From ff339fbf270fb0eed5950786902098328489c386 Mon Sep 17 00:00:00 2001 From: Richard Ward Date: Tue, 30 Dec 2025 22:27:58 +0000 Subject: [PATCH] code: frontends,nextjs,schema (4 files) --- frontends/cli/src/commands/dbal_commands.cpp | 38 +- .../nextjs/src/app/api/dbal/ping/route.ts | 13 + .../nextjs/src/lib/schema/schema-registry.ts | 395 ++++++++++++++++++ .../nextjs/src/lib/schema/schema-scanner.ts | 83 ++++ 4 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 frontends/nextjs/src/app/api/dbal/ping/route.ts create mode 100644 frontends/nextjs/src/lib/schema/schema-registry.ts create mode 100644 frontends/nextjs/src/lib/schema/schema-scanner.ts diff --git a/frontends/cli/src/commands/dbal_commands.cpp b/frontends/cli/src/commands/dbal_commands.cpp index a9b8f8166..7534a526b 100644 --- a/frontends/cli/src/commands/dbal_commands.cpp +++ b/frontends/cli/src/commands/dbal_commands.cpp @@ -187,6 +187,10 @@ int dbal_schema(const HttpClient &client, const std::vector &args) std::cout << " dbal schema list List all registered schemas\n"; std::cout << " dbal schema pending Show pending migrations\n"; std::cout << " dbal schema entity Show schema for entity\n"; + std::cout << " dbal schema scan Scan packages for schema changes\n"; + std::cout << " dbal schema approve Approve a migration (or 'all')\n"; + std::cout << " dbal schema reject Reject a migration\n"; + std::cout << " dbal schema generate Generate Prisma fragment\n"; return 1; } @@ -198,7 +202,7 @@ int dbal_schema(const HttpClient &client, const std::vector &args) } if (subcommand == "pending") { - print_response(client.get("/api/dbal/schema/pending")); + print_response(client.get("/api/dbal/schema")); return 0; } @@ -207,6 +211,32 @@ int dbal_schema(const HttpClient &client, const std::vector &args) return 0; } + if (subcommand == "scan") { + std::cout << "Scanning packages for schema changes...\n"; + print_response(client.post("/api/dbal/schema", "{\"action\":\"scan\"}")); + return 0; + } + + if (subcommand == "approve" && args.size() >= 4) { + std::string id = args[3]; + std::cout << "Approving migration: " << id << "\n"; + print_response(client.post("/api/dbal/schema", "{\"action\":\"approve\",\"id\":\"" + id + "\"}")); + return 0; + } + + if (subcommand == "reject" && args.size() >= 4) { + std::string id = args[3]; + std::cout << "Rejecting migration: " << id << "\n"; + print_response(client.post("/api/dbal/schema", "{\"action\":\"reject\",\"id\":\"" + id + "\"}")); + return 0; + } + + if (subcommand == "generate") { + std::cout << "Generating Prisma fragment from approved migrations...\n"; + print_response(client.post("/api/dbal/schema", "{\"action\":\"generate\"}")); + return 0; + } + std::cout << "Unknown schema subcommand: " << subcommand << "\n"; return 1; } @@ -224,9 +254,15 @@ void print_dbal_help() { dbal delete Delete a record dbal list [filters...] List records with optional filters dbal execute [params...] Execute a DBAL operation + +Schema Management: dbal schema list List registered entity schemas dbal schema pending Show pending schema migrations dbal schema entity Show schema for an entity + dbal schema scan Scan packages for schema changes + dbal schema approve Approve a migration + dbal schema reject Reject a migration + dbal schema generate Generate Prisma fragment Filter syntax for list: where.field=value Filter by field value diff --git a/frontends/nextjs/src/app/api/dbal/ping/route.ts b/frontends/nextjs/src/app/api/dbal/ping/route.ts new file mode 100644 index 000000000..85580c747 --- /dev/null +++ b/frontends/nextjs/src/app/api/dbal/ping/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' + +/** + * GET /api/dbal/ping + * Health check for DBAL API + */ +export async function GET() { + return NextResponse.json({ + status: 'ok', + service: 'dbal', + timestamp: new Date().toISOString(), + }) +} diff --git a/frontends/nextjs/src/lib/schema/schema-registry.ts b/frontends/nextjs/src/lib/schema/schema-registry.ts new file mode 100644 index 000000000..7ce7016ca --- /dev/null +++ b/frontends/nextjs/src/lib/schema/schema-registry.ts @@ -0,0 +1,395 @@ +/** + * Package Schema Registry & Migration Queue (API-side) + * + * Re-exports from tools/codegen/schema-registry for use in API routes. + * This module provides schema management capabilities via the DBAL API. + */ + +import * as crypto from 'crypto' +import * as fs from 'fs' +import * as path from 'path' +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' + +// Types +export interface PackageSchemaField { + type: string + primary?: boolean + generated?: boolean + required?: boolean + nullable?: boolean + index?: boolean + unique?: boolean + default?: unknown + maxLength?: number + enum?: string[] + description?: string +} + +export interface PackageSchemaIndex { + fields: string[] + unique?: boolean + name?: string +} + +export interface PackageSchemaRelation { + kind: 'hasOne' | 'hasMany' | 'belongsTo' | 'manyToMany' + target: string + foreignKey?: string + through?: string + references?: string +} + +export interface PackageSchemaEntity { + name: string + fields: Record + indexes?: PackageSchemaIndex[] + relations?: Record +} + +export interface PackageSchema { + packageId: string + version: string + entities: PackageSchemaEntity[] +} + +export interface MigrationQueueItem { + id: string + packageId: string + status: 'pending' | 'approved' | 'rejected' | 'applied' + queuedAt: string + approvedAt?: string + appliedAt?: string + checksum: string + entities: PackageSchemaEntity[] + prismaFragment?: string +} + +export interface SchemaRegistry { + version: string + packages: Record + migrationQueue: MigrationQueueItem[] +} + +/** + * Convert snake_case package name to PascalCase prefix + */ +export const packageToPascalCase = (packageId: string): string => { + return packageId + .split('_') + .map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join('') +} + +/** + * Generate prefixed entity name to avoid package collisions + */ +export const getPrefixedEntityName = (packageId: string, entityName: string): string => { + const prefix = packageToPascalCase(packageId) + return `Pkg_${prefix}_${entityName}` +} + +/** + * Load registry from disk + */ +export const loadSchemaRegistry = (registryPath: string): SchemaRegistry => { + if (!fs.existsSync(registryPath)) { + return { + version: '1.0.0', + packages: {}, + migrationQueue: [], + } + } + + const content = fs.readFileSync(registryPath, 'utf-8') + return JSON.parse(content) +} + +/** + * Save registry to disk + */ +export const saveSchemaRegistry = (registry: SchemaRegistry, registryPath: string): void => { + fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2)) +} + +/** + * Get pending migrations + */ +export const getPendingMigrations = (registry: SchemaRegistry): MigrationQueueItem[] => { + return registry.migrationQueue.filter(m => m.status === 'pending') +} + +/** + * Get approved migrations + */ +export const getApprovedMigrations = (registry: SchemaRegistry): MigrationQueueItem[] => { + return registry.migrationQueue.filter(m => m.status === 'approved') +} + +/** + * Approve a migration + */ +export const approveMigration = (id: string, registry: SchemaRegistry): boolean => { + const migration = registry.migrationQueue.find(m => m.id === id) + if (!migration || migration.status !== 'pending') { + return false + } + + migration.status = 'approved' + migration.approvedAt = new Date().toISOString() + return true +} + +/** + * Reject a migration + */ +export const rejectMigration = (id: string, registry: SchemaRegistry): boolean => { + const migration = registry.migrationQueue.find(m => m.id === id) + if (!migration || migration.status !== 'pending') { + return false + } + + migration.status = 'rejected' + return true +} + +/** + * Compute checksum for schema + */ +export const computeSchemaChecksum = (schema: PackageSchema): string => { + const normalized = JSON.stringify(schema.entities.sort((a, b) => a.name.localeCompare(b.name))) + return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16) +} + +/** + * Load package schema from YAML file + */ +export const loadPackageSchema = (packageId: string, packagePath: string): PackageSchema | null => { + const schemaPath = path.join(packagePath, 'seed', 'schema', 'entities.yaml') + + if (!fs.existsSync(schemaPath)) { + return null + } + + const content = fs.readFileSync(schemaPath, 'utf-8') + const parsed = parseYaml(content) as { version?: string; entities?: Record } + + if (!parsed?.entities) { + return null + } + + const entities: PackageSchemaEntity[] = [] + + for (const [name, def] of Object.entries(parsed.entities)) { + const entityDef = def as { + fields?: Record + indexes?: PackageSchemaIndex[] + relations?: Record + } + + entities.push({ + name, + fields: entityDef.fields || {}, + indexes: entityDef.indexes, + relations: entityDef.relations, + }) + } + + return { + packageId, + version: parsed.version || '1.0.0', + entities, + } +} + +/** + * Generate Prisma model from entity + */ +export const entityToPrisma = (entity: PackageSchemaEntity, packageId: string): string => { + const prefixedName = getPrefixedEntityName(packageId, entity.name) + const tableName = `${packageId}_${entity.name.toLowerCase()}` + + const lines: string[] = [] + lines.push(`model ${prefixedName} {`) + + // Fields + for (const [fieldName, field] of Object.entries(entity.fields)) { + let line = ` ${fieldName} ` + + // Type mapping + const typeMap: Record = { + 'String': 'String', + 'Int': 'Int', + 'Float': 'Float', + 'Boolean': 'Boolean', + 'DateTime': 'DateTime', + 'Json': 'Json', + 'BigInt': 'BigInt', + 'Decimal': 'Decimal', + 'Bytes': 'Bytes', + } + + line += typeMap[field.type] || 'String' + + if (field.nullable) { + line += '?' + } + + const attrs: string[] = [] + + if (field.primary) { + attrs.push('@id') + } + + if (field.generated) { + attrs.push('@default(cuid())') + } else if (field.default !== undefined) { + if (typeof field.default === 'string') { + if (field.default === 'now()') { + attrs.push('@default(now())') + } else { + attrs.push(`@default("${field.default}")`) + } + } else if (typeof field.default === 'boolean') { + attrs.push(`@default(${field.default})`) + } else if (typeof field.default === 'number') { + attrs.push(`@default(${field.default})`) + } + } + + if (field.unique) { + attrs.push('@unique') + } + + if (field.index) { + attrs.push('@index') + } + + if (attrs.length > 0) { + line += ' ' + attrs.join(' ') + } + + lines.push(line) + } + + // Relations + if (entity.relations) { + lines.push('') + for (const [relName, rel] of Object.entries(entity.relations)) { + const targetPrefixed = getPrefixedEntityName(packageId, rel.target) + + if (rel.kind === 'belongsTo') { + lines.push(` ${relName} ${targetPrefixed}? @relation(fields: [${rel.foreignKey}], references: [${rel.references || 'id'}])`) + } else if (rel.kind === 'hasMany') { + lines.push(` ${relName} ${targetPrefixed}[]`) + } else if (rel.kind === 'hasOne') { + lines.push(` ${relName} ${targetPrefixed}?`) + } + } + } + + // Table mapping + lines.push('') + lines.push(` @@map("${tableName}")`) + + // Indexes + if (entity.indexes) { + for (const idx of entity.indexes) { + const fields = idx.fields.join(', ') + if (idx.unique) { + lines.push(` @@unique([${fields}])`) + } else { + lines.push(` @@index([${fields}])`) + } + } + } + + lines.push('}') + + return lines.join('\n') +} + +/** + * Generate full Prisma fragment for approved migrations + */ +export const generatePrismaFragment = (registry: SchemaRegistry): string => { + const approved = getApprovedMigrations(registry) + + if (approved.length === 0) { + return '' + } + + const fragments: string[] = [ + '// Auto-generated from package schemas', + '// DO NOT EDIT MANUALLY', + `// Generated at: ${new Date().toISOString()}`, + '', + ] + + for (const migration of approved) { + fragments.push(`// Package: ${migration.packageId}`) + + for (const entity of migration.entities) { + fragments.push(entityToPrisma(entity, migration.packageId)) + fragments.push('') + } + } + + return fragments.join('\n') +} + +/** + * Validate and queue schema changes + */ +export const validateAndQueueSchema = ( + packageId: string, + packagePath: string, + registry: SchemaRegistry +): { success: boolean; message: string; migrationId?: string } => { + const schema = loadPackageSchema(packageId, packagePath) + + if (!schema) { + return { success: false, message: 'No schema found' } + } + + const checksum = computeSchemaChecksum(schema) + const currentPkg = registry.packages[packageId] + + // Check if schema has changed + if (currentPkg && currentPkg.currentChecksum === checksum) { + return { success: true, message: 'Schema unchanged' } + } + + // Check if already queued + const existingPending = registry.migrationQueue.find( + m => m.packageId === packageId && m.checksum === checksum && m.status === 'pending' + ) + + if (existingPending) { + return { success: true, message: 'Already queued', migrationId: existingPending.id } + } + + // Queue new migration + const migrationId = `${packageId}-${Date.now()}` + + registry.migrationQueue.push({ + id: migrationId, + packageId, + status: 'pending', + queuedAt: new Date().toISOString(), + checksum, + entities: schema.entities, + }) + + return { success: true, message: 'Queued for migration', migrationId } +} + +/** + * Check if container restart is needed + */ +export const needsContainerRestart = (registry: SchemaRegistry): boolean => { + return getApprovedMigrations(registry).length > 0 +} diff --git a/frontends/nextjs/src/lib/schema/schema-scanner.ts b/frontends/nextjs/src/lib/schema/schema-scanner.ts new file mode 100644 index 000000000..50ed993a3 --- /dev/null +++ b/frontends/nextjs/src/lib/schema/schema-scanner.ts @@ -0,0 +1,83 @@ +/** + * Package Schema Scanner + * + * Scans all packages for schema definitions and queues changes. + */ + +import * as fs from 'fs' +import * as path from 'path' + +import { + validateAndQueueSchema, + type SchemaRegistry, +} from './schema-registry' + +const PACKAGES_PATH = path.join(process.cwd(), '..', '..', '..', 'packages') + +export interface ScanResult { + scanned: number + queued: number + errors: string[] +} + +/** + * Scan all packages for schema definitions + */ +export const scanAllPackages = async (registry: SchemaRegistry): Promise => { + const result: ScanResult = { + scanned: 0, + queued: 0, + errors: [], + } + + if (!fs.existsSync(PACKAGES_PATH)) { + result.errors.push(`Packages directory not found: ${PACKAGES_PATH}`) + return result + } + + const packages = fs.readdirSync(PACKAGES_PATH) + .filter(p => { + const pkgPath = path.join(PACKAGES_PATH, p) + return fs.statSync(pkgPath).isDirectory() + }) + + for (const pkg of packages) { + const pkgPath = path.join(PACKAGES_PATH, pkg) + const schemaPath = path.join(pkgPath, 'seed', 'schema', 'entities.yaml') + + // Skip packages without schemas + if (!fs.existsSync(schemaPath)) { + continue + } + + result.scanned++ + + try { + const validateResult = validateAndQueueSchema(pkg, pkgPath, registry) + + if (validateResult.migrationId && validateResult.message === 'Queued for migration') { + result.queued++ + } + } catch (error) { + result.errors.push(`${pkg}: ${String(error)}`) + } + } + + return result +} + +/** + * Scan a specific package for schema changes + */ +export const scanPackage = ( + packageId: string, + registry: SchemaRegistry +): { success: boolean; message: string; migrationId?: string } => { + const pkgPath = path.join(PACKAGES_PATH, packageId) + + if (!fs.existsSync(pkgPath)) { + return { success: false, message: `Package not found: ${packageId}` } + } + + return validateAndQueueSchema(packageId, pkgPath, registry) +}