code: frontends,nextjs,schema (4 files)

This commit is contained in:
Richard Ward
2025-12-30 22:27:58 +00:00
parent dc98ecf05c
commit ff339fbf27
4 changed files with 528 additions and 1 deletions

View File

@@ -187,6 +187,10 @@ int dbal_schema(const HttpClient &client, const std::vector<std::string> &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 <name> Show schema for entity\n";
std::cout << " dbal schema scan Scan packages for schema changes\n";
std::cout << " dbal schema approve <id> Approve a migration (or 'all')\n";
std::cout << " dbal schema reject <id> 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<std::string> &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<std::string> &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 <entity> <id> Delete a record
dbal list <entity> [filters...] List records with optional filters
dbal execute <operation> [params...] Execute a DBAL operation
Schema Management:
dbal schema list List registered entity schemas
dbal schema pending Show pending schema migrations
dbal schema entity <name> Show schema for an entity
dbal schema scan Scan packages for schema changes
dbal schema approve <id|all> Approve a migration
dbal schema reject <id> Reject a migration
dbal schema generate Generate Prisma fragment
Filter syntax for list:
where.field=value Filter by field value

View File

@@ -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(),
})
}

View File

@@ -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<string, PackageSchemaField>
indexes?: PackageSchemaIndex[]
relations?: Record<string, PackageSchemaRelation>
}
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<string, {
currentChecksum: string
appliedAt: string
entities: string[]
}>
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<string, unknown> }
if (!parsed?.entities) {
return null
}
const entities: PackageSchemaEntity[] = []
for (const [name, def] of Object.entries(parsed.entities)) {
const entityDef = def as {
fields?: Record<string, PackageSchemaField>
indexes?: PackageSchemaIndex[]
relations?: Record<string, PackageSchemaRelation>
}
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> = {
'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
}

View File

@@ -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<ScanResult> => {
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)
}