diff --git a/prisma/schema-registry.json b/prisma/schema-registry.json new file mode 100644 index 000000000..1df202e01 --- /dev/null +++ b/prisma/schema-registry.json @@ -0,0 +1,4 @@ +{ + "entities": {}, + "migrationQueue": [] +} diff --git a/tools/codegen/schema-cli.ts b/tools/codegen/schema-cli.ts index e69de29bb..0128af6ce 100644 --- a/tools/codegen/schema-cli.ts +++ b/tools/codegen/schema-cli.ts @@ -0,0 +1,330 @@ +#!/usr/bin/env node +/** + * CLI tool for package schema management + * + * Commands: + * validate - Validate package schema and queue changes + * list - List pending migrations + * approve - Approve a migration + * reject - 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 [args] + +Commands: + ${colors.green}scan${colors.reset} Scan all packages for schema changes + ${colors.green}validate${colors.reset} Validate specific package schema + ${colors.green}list${colors.reset} List pending migrations + ${colors.green}approve${colors.reset} Approve a migration (or 'all') + ${colors.green}reject${colors.reset} Reject a migration + ${colors.green}generate${colors.reset} Generate Prisma fragment + ${colors.green}preview${colors.reset} 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 ') + 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() diff --git a/tools/codegen/schema-registry.ts b/tools/codegen/schema-registry.ts index e84270fae..29b05222c 100644 --- a/tools/codegen/schema-registry.ts +++ b/tools/codegen/schema-registry.ts @@ -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 = {} + 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