mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
code: frontends,nextjs,schema (4 files)
This commit is contained in:
@@ -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
|
||||
|
||||
13
frontends/nextjs/src/app/api/dbal/ping/route.ts
Normal file
13
frontends/nextjs/src/app/api/dbal/ping/route.ts
Normal 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(),
|
||||
})
|
||||
}
|
||||
395
frontends/nextjs/src/lib/schema/schema-registry.ts
Normal file
395
frontends/nextjs/src/lib/schema/schema-registry.ts
Normal 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
|
||||
}
|
||||
83
frontends/nextjs/src/lib/schema/schema-scanner.ts
Normal file
83
frontends/nextjs/src/lib/schema/schema-scanner.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user