mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 14:25:02 +00:00
473 lines
13 KiB
TypeScript
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')
|
|
}
|