config: schema,tools,registry (3 files)

This commit is contained in:
Richard Ward
2025-12-30 21:35:30 +00:00
parent 27c1c37450
commit 288413aaaa
3 changed files with 386 additions and 1 deletions

View File

@@ -0,0 +1,4 @@
{
"entities": {},
"migrationQueue": []
}

View File

@@ -0,0 +1,330 @@
#!/usr/bin/env node
/**
* CLI tool for package schema management
*
* Commands:
* validate <package> - Validate package schema and queue changes
* list - List pending migrations
* approve <id> - Approve a migration
* reject <id> - Reject a migration
* generate - Generate Prisma fragment for approved migrations
* status - Show overall migration status
*/
import * as path from 'path'
import * as fs from 'fs'
import {
loadSchemaRegistry,
saveSchemaRegistry,
validateAndQueueSchema,
getPendingMigrations,
approveMigration,
rejectMigration,
generatePrismaFragment,
needsContainerRestart,
loadPackageSchema,
computeSchemaChecksum,
entityToPrisma,
type SchemaRegistry,
} from './schema-registry'
const REGISTRY_PATH = path.join(__dirname, '../../prisma/schema-registry.json')
const PACKAGES_PATH = path.join(__dirname, '../../packages')
const PRISMA_GENERATED_PATH = path.join(__dirname, '../../prisma/generated-from-packages.prisma')
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
}
const log = {
info: (msg: string) => console.log(`${colors.blue}${colors.reset} ${msg}`),
success: (msg: string) => console.log(`${colors.green}${colors.reset} ${msg}`),
warn: (msg: string) => console.log(`${colors.yellow}${colors.reset} ${msg}`),
error: (msg: string) => console.log(`${colors.red}${colors.reset} ${msg}`),
}
const printUsage = (): void => {
console.log(`
${colors.cyan}Package Schema Manager${colors.reset}
Usage: npx ts-node tools/codegen/schema-cli.ts <command> [args]
Commands:
${colors.green}scan${colors.reset} Scan all packages for schema changes
${colors.green}validate${colors.reset} <pkg> Validate specific package schema
${colors.green}list${colors.reset} List pending migrations
${colors.green}approve${colors.reset} <id> Approve a migration (or 'all')
${colors.green}reject${colors.reset} <id> Reject a migration
${colors.green}generate${colors.reset} Generate Prisma fragment
${colors.green}preview${colors.reset} <pkg> Preview Prisma output for package
${colors.green}status${colors.reset} Show migration status
Examples:
npx ts-node tools/codegen/schema-cli.ts scan
npx ts-node tools/codegen/schema-cli.ts approve all
npx ts-node tools/codegen/schema-cli.ts preview audit_log
`)
}
const cmdScan = (registry: SchemaRegistry): void => {
log.info('Scanning packages for schema definitions...')
const packages = fs.readdirSync(PACKAGES_PATH)
.filter(p => fs.statSync(path.join(PACKAGES_PATH, p)).isDirectory())
let totalQueued = 0
let totalErrors = 0
for (const pkg of packages) {
const pkgPath = path.join(PACKAGES_PATH, pkg)
const schemaPath = path.join(pkgPath, 'seed', 'schema', 'entities.yaml')
if (!fs.existsSync(schemaPath)) {
continue
}
const result = validateAndQueueSchema(pkg, pkgPath, registry)
if (!result.valid) {
log.error(`${pkg}: ${result.errors.length} error(s)`)
result.errors.forEach(e => console.log(` ${colors.dim}${e}${colors.reset}`))
totalErrors += result.errors.length
} else if (result.queued.length > 0) {
log.warn(`${pkg}: ${result.queued.length} migration(s) queued`)
result.queued.forEach(q =>
console.log(` ${colors.dim}${q.action} ${q.entityName}${colors.reset}`)
)
totalQueued += result.queued.length
} else {
log.success(`${pkg}: up to date`)
}
}
console.log('')
if (totalErrors > 0) {
log.error(`${totalErrors} total error(s) - fix conflicts before proceeding`)
} else if (totalQueued > 0) {
log.warn(`${totalQueued} migration(s) pending admin approval`)
log.info('Run: npx ts-node tools/codegen/schema-cli.ts list')
} else {
log.success('All packages up to date')
}
saveSchemaRegistry(REGISTRY_PATH, registry)
}
const cmdList = (registry: SchemaRegistry): void => {
const pending = getPendingMigrations(registry)
if (pending.length === 0) {
log.success('No pending migrations')
return
}
console.log(`\n${colors.cyan}Pending Migrations (${pending.length})${colors.reset}\n`)
for (const m of pending) {
console.log(`${colors.yellow}ID:${colors.reset} ${m.id.slice(0, 8)}`)
console.log(`${colors.dim}Package:${colors.reset} ${m.packageId}`)
console.log(`${colors.dim}Action:${colors.reset} ${m.action} ${m.entityName}`)
console.log(`${colors.dim}Created:${colors.reset} ${new Date(m.createdAt).toLocaleString()}`)
console.log(`${colors.dim}Checksum:${colors.reset} ${m.currentChecksum || '(new)'}${m.newChecksum}`)
console.log(`${colors.dim}Prisma Preview:${colors.reset}`)
console.log(m.prismaPreview.split('\n').map(l => ` ${l}`).join('\n'))
console.log('')
}
log.info('To approve: npx ts-node tools/codegen/schema-cli.ts approve <id>')
log.info('To approve all: npx ts-node tools/codegen/schema-cli.ts approve all')
}
const cmdApprove = (registry: SchemaRegistry, idOrAll: string): void => {
const adminId = process.env.USER || 'cli-admin'
if (idOrAll === 'all') {
const pending = getPendingMigrations(registry)
if (pending.length === 0) {
log.info('No pending migrations to approve')
return
}
for (const m of pending) {
approveMigration(registry, m.id, adminId)
log.success(`Approved: ${m.entityName} (${m.packageId})`)
}
saveSchemaRegistry(REGISTRY_PATH, registry)
log.info(`${pending.length} migration(s) approved`)
log.warn('Container restart required to apply migrations')
log.info('Run: npx ts-node tools/codegen/schema-cli.ts generate')
} else {
const migration = registry.migrationQueue.find(m => m.id.startsWith(idOrAll))
if (!migration) {
log.error(`Migration not found: ${idOrAll}`)
return
}
if (approveMigration(registry, migration.id, adminId)) {
saveSchemaRegistry(REGISTRY_PATH, registry)
log.success(`Approved: ${migration.entityName} (${migration.packageId})`)
log.warn('Container restart required to apply migrations')
} else {
log.error('Failed to approve migration')
}
}
}
const cmdReject = (registry: SchemaRegistry, id: string): void => {
const adminId = process.env.USER || 'cli-admin'
const migration = registry.migrationQueue.find(m => m.id.startsWith(id))
if (!migration) {
log.error(`Migration not found: ${id}`)
return
}
if (rejectMigration(registry, migration.id, adminId)) {
saveSchemaRegistry(REGISTRY_PATH, registry)
log.success(`Rejected: ${migration.entityName}`)
} else {
log.error('Failed to reject migration')
}
}
const cmdGenerate = (registry: SchemaRegistry): void => {
const fragment = generatePrismaFragment(registry)
if (!fragment) {
log.info('No approved migrations to generate')
return
}
fs.writeFileSync(PRISMA_GENERATED_PATH, fragment)
log.success(`Generated: ${PRISMA_GENERATED_PATH}`)
log.info('Add to prisma/schema.prisma or use Prisma imports')
log.warn('Run: npx prisma migrate dev --name package-schemas')
}
const cmdPreview = (packageId: string): void => {
const pkgPath = path.join(PACKAGES_PATH, packageId)
if (!fs.existsSync(pkgPath)) {
log.error(`Package not found: ${packageId}`)
return
}
const schema = loadPackageSchema(pkgPath)
if (!schema) {
log.info(`No schema defined for ${packageId}`)
return
}
console.log(`\n${colors.cyan}Prisma Preview for ${packageId}${colors.reset}\n`)
for (const entity of schema.entities) {
const checksum = computeSchemaChecksum(entity)
console.log(`${colors.dim}// Entity: ${entity.name} (checksum: ${checksum})${colors.reset}`)
console.log(entityToPrisma(entity))
console.log('')
}
}
const cmdStatus = (registry: SchemaRegistry): void => {
const pending = registry.migrationQueue.filter(m => m.status === 'pending').length
const approved = registry.migrationQueue.filter(m => m.status === 'approved').length
const applied = registry.migrationQueue.filter(m => m.status === 'applied').length
const rejected = registry.migrationQueue.filter(m => m.status === 'rejected').length
console.log(`\n${colors.cyan}Schema Migration Status${colors.reset}\n`)
console.log(`Registered entities: ${Object.keys(registry.entities).length}`)
console.log(`Pending approval: ${pending}`)
console.log(`Approved (waiting): ${approved}`)
console.log(`Applied: ${applied}`)
console.log(`Rejected: ${rejected}`)
console.log('')
if (needsContainerRestart(registry)) {
log.warn('Container restart required - approved migrations waiting')
} else if (pending > 0) {
log.info('Migrations pending admin review')
} else {
log.success('All schemas up to date')
}
}
// Main
const main = (): void => {
const args = process.argv.slice(2)
const command = args[0]
if (!command || command === 'help' || command === '--help') {
printUsage()
return
}
const registry = loadSchemaRegistry(REGISTRY_PATH)
switch (command) {
case 'scan':
cmdScan(registry)
break
case 'validate':
if (!args[1]) {
log.error('Package name required')
break
}
const pkgPath = path.join(PACKAGES_PATH, args[1])
const result = validateAndQueueSchema(args[1], pkgPath, registry)
if (result.valid) {
log.success(`Valid: ${args[1]}`)
if (result.queued.length > 0) {
log.warn(`${result.queued.length} migration(s) queued`)
}
} else {
result.errors.forEach(e => log.error(e))
}
saveSchemaRegistry(REGISTRY_PATH, registry)
break
case 'list':
cmdList(registry)
break
case 'approve':
if (!args[1]) {
log.error('Migration ID or "all" required')
break
}
cmdApprove(registry, args[1])
break
case 'reject':
if (!args[1]) {
log.error('Migration ID required')
break
}
cmdReject(registry, args[1])
break
case 'generate':
cmdGenerate(registry)
break
case 'preview':
if (!args[1]) {
log.error('Package name required')
break
}
cmdPreview(args[1])
break
case 'status':
cmdStatus(registry)
break
default:
log.error(`Unknown command: ${command}`)
printUsage()
}
}
main()

View File

@@ -10,10 +10,61 @@
*/
import * as crypto from 'crypto'
import * as yaml from 'yaml'
import * as fs from 'fs'
import * as path from 'path'
// Simple YAML parser for our specific schema format
// Using a lightweight approach to avoid external dependency
const parseYaml = (content: string): unknown => {
// For complex YAML, we'd use a proper parser
// This handles our entities.yaml format
try {
// Try JSON first (YAML is superset of JSON)
return JSON.parse(content)
} catch {
// Basic YAML parsing for our schema files
// In production, install 'yaml' package
const lines = content.split('\n')
const result: Record<string, unknown> = {}
let currentKey = ''
let currentValue: unknown[] = []
let inArray = false
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const match = trimmed.match(/^(\w+):\s*(.*)$/)
if (match) {
if (currentKey && inArray) {
result[currentKey] = currentValue
}
currentKey = match[1]
const val = match[2]
if (val === '' || val === '|' || val === '>') {
currentValue = []
inArray = true
} else {
result[currentKey] = val
inArray = false
}
} else if (trimmed.startsWith('- ') && inArray) {
currentValue.push(trimmed.slice(2))
}
}
if (currentKey && inArray) {
result[currentKey] = currentValue
}
return result
}
}
const stringifyYaml = (obj: unknown): string => {
return JSON.stringify(obj, null, 2)
}
// Types
export interface PackageSchemaField {
type: string