Files
metabuilder/tools/codegen/schema-registry.ts
2025-12-30 22:19:07 +00:00

473 lines
13 KiB
TypeScript

/**
* Package Schema Registry & Migration Queue
*
* Flow:
* 1. Package declares schema in seed/schema/entities.yaml
* 2. On package install/update, schema is validated & checksummed
* 3. If schema differs from current DB, added to migration queue
* 4. Admin reviews queue, approves migrations
* 5. On container restart, pending migrations are applied
*
* Entity Prefixing:
* - All entities are prefixed with package name to avoid collisions
* - Example: forum_forge.Post → Pkg_ForumForge_Post
* - Prefix format: Pkg_{PascalPackageName}_{EntityName}
* - Relations automatically use prefixed names
*/
import * as crypto from 'crypto'
import * as fs from 'fs'
import * as path from 'path'
/**
* Convert snake_case package name to PascalCase prefix
* Example: forum_forge → ForumForge
*/
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
* Format: Pkg_{PascalPackageName}_{EntityName}
* Example: forum_forge + Post → Pkg_ForumForge_Post
*/
export const getPrefixedEntityName = (packageId: string, entityName: string): string => {
const prefix = packageToPascalCase(packageId)
return `Pkg_${prefix}_${entityName}`
}
/**
* Check if an entity name is prefixed
*/
export const isPrefixedEntityName = (name: string): boolean => {
return name.startsWith('Pkg_')
}
/**
* Extract package ID from prefixed entity name
* Example: Pkg_ForumForge_Post → forum_forge
*/
export const extractPackageFromPrefix = (prefixedName: string): string | null => {
if (!isPrefixedEntityName(prefixedName)) return null
const match = prefixedName.match(/^Pkg_([A-Z][a-zA-Z]*)_/)
if (!match) return null
// Convert PascalCase back to snake_case
return match[1].replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase()
}
/**
* Extract original entity name from prefixed name
* Example: Pkg_ForumForge_Post → Post
*/
export const extractEntityFromPrefix = (prefixedName: string): string | null => {
if (!isPrefixedEntityName(prefixedName)) return null
const parts = prefixedName.split('_')
if (parts.length < 3) return null
return parts.slice(2).join('_')
}
// Use proper YAML parser
import { parse as parseYamlLib, stringify as stringifyYamlLib } from 'yaml'
const parseYaml = (content: string): unknown => {
return parseYamlLib(content)
}
const stringifyYaml = (obj: unknown): string => {
return stringifyYamlLib(obj)
}
// 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 {
name: string
type: 'belongsTo' | 'hasMany' | 'hasOne' | 'manyToMany'
entity: string
field?: string
foreignKey?: string
onDelete?: 'Cascade' | 'SetNull' | 'Restrict' | 'NoAction'
optional?: boolean
}
export interface PackageSchemaEntity {
name: string
version: string
description?: string
checksum?: string | null
fields: Record<string, PackageSchemaField>
indexes?: PackageSchemaIndex[]
relations?: PackageSchemaRelation[]
acl?: Record<string, string[]>
}
export interface PackageSchema {
entities: PackageSchemaEntity[]
}
export interface MigrationQueueItem {
id: string
packageId: string
entityName: string
action: 'create' | 'alter' | 'drop'
currentChecksum: string | null
newChecksum: string
schemaYaml: string
prismaPreview: string
status: 'pending' | 'approved' | 'rejected' | 'applied' | 'failed'
createdAt: number
reviewedAt?: number
reviewedBy?: string
appliedAt?: number
error?: string
}
export interface SchemaRegistry {
entities: Record<string, {
checksum: string
version: string
ownerPackage: string
prismaModel: string
appliedAt: number
}>
migrationQueue: MigrationQueueItem[]
}
/**
* Compute deterministic checksum for schema entity
* Ignores description/comments, only structural fields
*/
export const computeSchemaChecksum = (entity: PackageSchemaEntity): string => {
const structural = {
name: entity.name,
version: entity.version,
fields: entity.fields,
indexes: entity.indexes || [],
relations: entity.relations || [],
}
const json = JSON.stringify(structural, Object.keys(structural).sort())
return crypto.createHash('sha256').update(json).digest('hex').slice(0, 16)
}
/**
* Convert package schema field to Prisma field definition
*/
export const fieldToPrisma = (name: string, field: PackageSchemaField): string => {
const parts: string[] = []
// Type mapping
const typeMap: Record<string, string> = {
'string': 'String',
'int': 'Int',
'bigint': 'BigInt',
'float': 'Float',
'boolean': 'Boolean',
'datetime': 'DateTime',
'json': 'Json',
'cuid': 'String',
'uuid': 'String',
}
let prismaType = typeMap[field.type] || 'String'
// Nullable
if (field.nullable) {
prismaType += '?'
}
parts.push(` ${name}`)
parts.push(prismaType)
// Attributes
const attrs: string[] = []
if (field.primary) {
attrs.push('@id')
}
if (field.generated && field.type === 'cuid') {
attrs.push('@default(cuid())')
} else if (field.default !== undefined) {
if (typeof field.default === 'boolean') {
attrs.push(`@default(${field.default})`)
} else if (typeof field.default === 'number') {
attrs.push(`@default(${field.default})`)
} else if (typeof field.default === 'string') {
attrs.push(`@default("${field.default}")`)
}
}
if (field.unique) {
attrs.push('@unique')
}
if (attrs.length > 0) {
parts.push(attrs.join(' '))
}
return parts.join(' ')
}
/**
* Convert package schema entity to Prisma model
* Applies package prefix to entity name and relation targets
*/
export const entityToPrisma = (entity: PackageSchemaEntity, packageId?: string): string => {
const lines: string[] = []
// Use prefixed name if packageId provided
const modelName = packageId
? getPrefixedEntityName(packageId, entity.name)
: entity.name
lines.push(`model ${modelName} {`)
// Fields
for (const [name, field] of Object.entries(entity.fields)) {
lines.push(fieldToPrisma(name, field))
}
// Relations - also prefix target entities from same package
if (entity.relations) {
lines.push('')
for (const rel of entity.relations) {
// Prefix relation target entity if it's from the same package
const targetEntity = packageId
? getPrefixedEntityName(packageId, rel.entity)
: rel.entity
if (rel.type === 'belongsTo') {
const optional = rel.optional ? '?' : ''
lines.push(` ${rel.name} ${targetEntity}${optional} @relation(fields: [${rel.field}], references: [id]${rel.onDelete ? `, onDelete: ${rel.onDelete}` : ''})`)
} else if (rel.type === 'hasMany') {
lines.push(` ${rel.name} ${targetEntity}[]`)
}
}
}
// Indexes
if (entity.indexes && entity.indexes.length > 0) {
lines.push('')
for (const idx of entity.indexes) {
const fields = idx.fields.join(', ')
if (idx.unique) {
lines.push(` @@unique([${fields}])`)
} else {
lines.push(` @@index([${fields}])`)
}
}
}
// Map to original table name for cleaner DB
if (packageId) {
lines.push('')
lines.push(` @@map("${packageId}_${entity.name.toLowerCase()}")`)
}
lines.push('}')
return lines.join('\n')
}
/**
* Load schema registry from disk
*/
export const loadSchemaRegistry = (registryPath: string): SchemaRegistry => {
if (fs.existsSync(registryPath)) {
return JSON.parse(fs.readFileSync(registryPath, 'utf-8'))
}
return { entities: {}, migrationQueue: [] }
}
/**
* Save schema registry to disk
*/
export const saveSchemaRegistry = (registryPath: string, registry: SchemaRegistry): void => {
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2))
}
/**
* Load package schema from seed/schema/entities.yaml
*/
export const loadPackageSchema = (packagePath: string): PackageSchema | null => {
const schemaPath = path.join(packagePath, 'seed', 'schema', 'entities.yaml')
if (!fs.existsSync(schemaPath)) {
return null
}
return parseYaml(fs.readFileSync(schemaPath, 'utf-8')) as PackageSchema
}
/**
* Validate and queue schema changes for a package
* Uses prefixed entity names to prevent cross-package collisions
*/
export const validateAndQueueSchema = (
packageId: string,
packagePath: string,
registry: SchemaRegistry
): { valid: boolean; errors: string[]; queued: MigrationQueueItem[] } => {
const errors: string[] = []
const queued: MigrationQueueItem[] = []
const schema = loadPackageSchema(packagePath)
if (!schema) {
return { valid: true, errors: [], queued: [] } // No schema = OK
}
for (const entity of schema.entities) {
// Use prefixed entity name to avoid collisions
const prefixedName = getPrefixedEntityName(packageId, entity.name)
const newChecksum = computeSchemaChecksum(entity)
const existing = registry.entities[prefixedName]
// Check for conflicts - with prefixes, only same package can conflict
if (existing && existing.ownerPackage !== packageId) {
// This should be rare with prefixes, but check anyway
errors.push(
`Entity "${prefixedName}" owned by "${existing.ownerPackage}" conflicts. ` +
`This is unexpected with prefixes - please report this bug.`
)
continue
}
// New entity or updated schema
if (!existing || existing.checksum !== newChecksum) {
const prismaPreview = entityToPrisma(entity, packageId)
const item: MigrationQueueItem = {
id: crypto.randomUUID(),
packageId,
entityName: prefixedName, // Store prefixed name
action: existing ? 'alter' : 'create',
currentChecksum: existing?.checksum || null,
newChecksum,
schemaYaml: stringifyYaml(entity),
prismaPreview,
status: 'pending',
createdAt: Date.now(),
}
queued.push(item)
registry.migrationQueue.push(item)
}
}
return { valid: errors.length === 0, errors, queued }
}
/**
* Get pending migrations for admin review
*/
export const getPendingMigrations = (registry: SchemaRegistry): MigrationQueueItem[] => {
return registry.migrationQueue.filter(m => m.status === 'pending')
}
/**
* Approve a migration (admin action)
*/
export const approveMigration = (
registry: SchemaRegistry,
migrationId: string,
adminUserId: string
): boolean => {
const migration = registry.migrationQueue.find(m => m.id === migrationId)
if (!migration || migration.status !== 'pending') {
return false
}
migration.status = 'approved'
migration.reviewedAt = Date.now()
migration.reviewedBy = adminUserId
return true
}
/**
* Reject a migration (admin action)
*/
export const rejectMigration = (
registry: SchemaRegistry,
migrationId: string,
adminUserId: string
): boolean => {
const migration = registry.migrationQueue.find(m => m.id === migrationId)
if (!migration || migration.status !== 'pending') {
return false
}
migration.status = 'rejected'
migration.reviewedAt = Date.now()
migration.reviewedBy = adminUserId
return true
}
/**
* Generate combined Prisma schema fragment for approved migrations
*/
export const generatePrismaFragment = (registry: SchemaRegistry): string => {
const approved = registry.migrationQueue.filter(m => m.status === 'approved')
if (approved.length === 0) {
return ''
}
const lines = [
'// =============================================================================',
'// AUTO-GENERATED FROM PACKAGE SCHEMAS - DO NOT EDIT MANUALLY',
'// Generated: ' + new Date().toISOString(),
'// =============================================================================',
'',
]
for (const migration of approved) {
lines.push(`// From package: ${migration.packageId}`)
lines.push(migration.prismaPreview)
lines.push('')
}
return lines.join('\n')
}
/**
* Mark migrations as applied after successful Prisma migrate
*/
export const markMigrationsApplied = (registry: SchemaRegistry): void => {
const approved = registry.migrationQueue.filter(m => m.status === 'approved')
for (const migration of approved) {
migration.status = 'applied'
migration.appliedAt = Date.now()
// Update registry
registry.entities[migration.entityName] = {
checksum: migration.newChecksum,
version: '1.0', // Could extract from schema
ownerPackage: migration.packageId,
prismaModel: migration.prismaPreview,
appliedAt: Date.now(),
}
}
}
/**
* Check if container restart is needed (approved migrations waiting)
*/
export const needsContainerRestart = (registry: SchemaRegistry): boolean => {
return registry.migrationQueue.some(m => m.status === 'approved')
}