/** * Phase 4c — DDL emission engine. * * Pure, deterministic translation from `DdlResource` / `Change` objects * into dialect-correct SQL strings for Postgres, MySQL, and SQLite. * * Design invariants (Agents B/C/D/E/G rely on these): * 1. NO I/O. Every function is synchronous and takes / returns strings * or plain objects. No filesystem, no database connection. * 2. NO dependency on `Bun.SQL` or any driver. We only emit SQL text — * Agent C's runner handles execution. * 3. Every identifier — table names, column names, index names — MUST * flow through `quoteIdent`. Direct string interpolation of * identifiers into SQL is a security audit failure (Agent G). * 4. Value literals in `DEFAULT` clauses flow through `resolveDefault` * which handles quote escaping. `kind: "sql"` is the explicit * escape hatch — caller's responsibility. * 5. Unsupported changes (v1 scope — `alter-column-type`, and some * dialect-specific ALTER gaps) emit a `-- TODO:` comment block plus * a deliberate failing sentinel statement. The generated migration * is reviewable/editable, but `mandu db apply` will not record it * until the operator replaces the TODO with real SQL. * * Determinism: * - `emitCreateTable` emits columns in `DdlFieldDef` array order — the * snapshot layer (Agent B) is responsible for stabilizing that order * (see §"Deterministic ordering" in docs/bun/phase-4c-team-plan.md). * - `emitChanges` emits in the order received. Agent B guarantees * deterministic change order; emit does not re-sort. * * References: * - docs/bun/phase-4c-team-plan.md §2 (types) + §3 Agent A * - docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence) * - DNA/drizzle-orm/drizzle-kit/src/sqlgenerator.ts (reference emitter) */ import { nowExpr, resolveColumnType, resolveDefault, } from "./type-map"; import type { Change, DdlFieldDef, DdlIndex, DdlResource, SqlProvider, } from "./types"; // ===================================================================== // Identifier quoting — security-critical (audited by Agent G). // ===================================================================== /** * Maximum length for an identifier before we refuse to emit. * * Postgres allows 63 bytes (NAMEDATALEN - 1), MySQL 64. We pick the * tighter bound so identity rules are uniform across dialects and * cross-dialect migration files don't surprise a user switching providers. */ const MAX_IDENT_LENGTH = 63; /** * Dialect-correct identifier quoter. * * - Postgres / SQLite: wraps in ANSI double quotes. Rejects names * containing `"` — there is no portable escape (`""` works on PG but * complicates downstream tooling and is not idiomatic for generated * code). * - MySQL: wraps in backticks. Rejects names containing `` ` ``. * * All providers additionally reject: * - empty string * - names longer than 63 chars (PG limit — stricter than MySQL's 64) * - NUL bytes (`\0`) — a driver-level crash on several Bun.SQL backends * * @throws {Error} with a clear message when any rule is violated. */ export function quoteIdent(name: string, provider: SqlProvider): string { if (typeof name !== "string") { throw new Error(`identifier must be a string, got ${typeof name}`); } if (name.length === 0) { throw new Error("identifier must not be empty"); } if (name.length > MAX_IDENT_LENGTH) { throw new Error( `identifier too long (${name.length} > ${MAX_IDENT_LENGTH}): ${name.slice(0, 32)}...`, ); } if (name.includes("\0")) { throw new Error("identifier contains NUL byte"); } if (provider === "mysql") { if (name.includes("`")) { throw new Error(`identifier contains unquotable character: ${JSON.stringify(name)}`); } return `\`${name}\``; } // Postgres, SQLite if (name.includes('"')) { throw new Error(`identifier contains unquotable character: ${JSON.stringify(name)}`); } return `"${name}"`; } // ===================================================================== // Column + index emission helpers. // ===================================================================== /** * Emit a single column definition line (no leading whitespace, no trailing * comma). Used inside `CREATE TABLE` parens and by `emitChange("add-column")`. * * Column order of modifiers (chosen to match Drizzle / Atlas output for * familiarity): * [PRIMARY KEY] [NOT NULL] [UNIQUE] [DEFAULT ] * * Rationale: * - `PRIMARY KEY` before `NOT NULL` mirrors Postgres/MySQL docs. * - `DEFAULT` last because some drivers (SQLite) parse `NOT NULL DEFAULT` * more reliably than the reverse. */ function emitColumnDef(field: DdlFieldDef, provider: SqlProvider): string { const ident = quoteIdent(field.name, provider); const type = resolveColumnType(field, provider); const parts: string[] = [ident, type]; if (field.primary) parts.push("PRIMARY KEY"); // PRIMARY KEY implies NOT NULL everywhere; skip the explicit marker. if (!field.nullable && !field.primary) parts.push("NOT NULL"); if (field.unique && !field.primary) parts.push("UNIQUE"); if (field.default) { parts.push(`DEFAULT ${resolveDefault(field.default, provider)}`); } return parts.join(" "); } /** * Stable single-column index name. Used when a field is flagged * `indexed: true` but no explicit DdlIndex entry exists for it. * * Format: `idx__` — shortened to fit within * MAX_IDENT_LENGTH when either name is long. */ function autoIndexName(table: string, column: string): string { const raw = `idx_${table}_${column}`; if (raw.length <= MAX_IDENT_LENGTH) return raw; // Truncate by taking prefix of table + suffix of column — preserves some // readability while guaranteeing length compliance. Deterministic. const budget = MAX_IDENT_LENGTH - "idx_".length - 1; const halfBudget = Math.floor(budget / 2); return `idx_${table.slice(0, halfBudget)}_${column.slice(-halfBudget)}`; } /** * Emit a `CREATE INDEX` statement for either: * - an automatic single-column index on an `indexed: true` field, or * - an explicit multi-column `DdlIndex`. */ function emitCreateIndex( table: string, name: string, columns: readonly string[], unique: boolean, provider: SqlProvider, ): string { const uniqueKw = unique ? "UNIQUE " : ""; const tbl = quoteIdent(table, provider); const idx = quoteIdent(name, provider); const cols = columns.map((c) => quoteIdent(c, provider)).join(", "); return `CREATE ${uniqueKw}INDEX ${idx} ON ${tbl} (${cols});`; } // ===================================================================== // Public API — resource-level emitters. // ===================================================================== /** * Emit the full initial-migration SQL for a single resource: * one `CREATE TABLE` followed by zero or more `CREATE INDEX` statements. * * Output format: * - No leading / trailing blank lines (caller composes). * - `CREATE TABLE` spans multiple lines, one column per line, two-space * indent — matches Drizzle-kit's output for diff readability. * - Each statement ends with `;`. Statements separated by `\n`. * * Determinism: column order matches `resource.fields` array order, index * order matches: all `indexed: true` fields (in field array order) first, * then explicit `resource.indexes` (in array order). */ export function emitCreateTable(resource: DdlResource, provider: SqlProvider): string { if (!Array.isArray(resource.fields) || resource.fields.length === 0) { throw new Error(`resource "${resource.name}" has no fields — cannot emit CREATE TABLE`); } const tableIdent = quoteIdent(resource.name, provider); // also validates const columnLines = resource.fields.map((f) => ` ${emitColumnDef(f, provider)}`); const createTable = [ `CREATE TABLE ${tableIdent} (`, columnLines.join(",\n"), `);`, ].join("\n"); const indexStatements: string[] = []; // Auto-index from `field.indexed` (single column, non-unique). for (const field of resource.fields) { if (!field.indexed) continue; // Skip if field is already unique (UNIQUE column constraint creates // an implicit index on PG/MySQL; on SQLite a UNIQUE column also has // an implicit index). if (field.unique || field.primary) continue; indexStatements.push( emitCreateIndex( resource.name, autoIndexName(resource.name, field.name), [field.name], false, provider, ), ); } // Explicit multi-column indexes. for (const index of resource.indexes ?? []) { validateIndex(resource, index); indexStatements.push( emitCreateIndex(resource.name, index.name, index.fields, index.unique, provider), ); } return indexStatements.length > 0 ? `${createTable}\n${indexStatements.join("\n")}` : createTable; } /** * Emit a `DROP TABLE IF EXISTS` statement. `IF EXISTS` is present on all * three dialects and makes migrations idempotent on re-run. */ export function emitDropTable(resourceName: string, provider: SqlProvider): string { return `DROP TABLE IF EXISTS ${quoteIdent(resourceName, provider)};`; } /** * Emit the full initial schema for many resources. Each resource becomes a * `CREATE TABLE` (+ its indexes); blocks are separated by one blank line * for readability. * * Deterministic in resource array order — the snapshot layer is * responsible for stable ordering. */ export function emitSchema(resources: DdlResource[], provider: SqlProvider): string { if (resources.length === 0) return ""; return resources.map((r) => emitCreateTable(r, provider)).join("\n\n"); } /** * Emit a sequence of `Change` objects as a single migration SQL string. * Each change becomes one or more statements, separated by newlines. * * Empty input returns empty string (not whitespace) — makes it safe to * concatenate with a header or leave out when unused. */ export function emitChanges(changes: readonly Change[], provider: SqlProvider): string { if (changes.length === 0) return ""; return changes.map((c) => emitChange(c, provider)).join("\n"); } // ===================================================================== // Change dispatch — one branch per `Change.kind`. // ===================================================================== /** * Dispatch a single `Change` to the corresponding SQL statement(s). * * Unknown `kind` throws (defensive against future `Change` variants added * to `types.ts` without a matching emitter update). */ export function emitChange(change: Change, provider: SqlProvider): string { switch (change.kind) { case "create-table": return emitCreateTable(change.resource, provider); case "drop-table": return emitDropTable(change.resourceName, provider); case "add-column": return emitAddColumn(change.resourceName, change.field, provider); case "drop-column": return emitDropColumn(change.resourceName, change.fieldName, provider); case "alter-column-type": return emitAlterColumnTypeStub( change.resourceName, change.fieldName, change.fromType, change.toType, ); case "alter-column-nullable": return emitAlterColumnNullable( change.resourceName, change.fieldName, change.nullable, provider, ); case "alter-column-default": return emitAlterColumnDefault( change.resourceName, change.fieldName, change.default, provider, ); case "add-index": return emitAddIndex(change.resourceName, change.index, provider); case "drop-index": return emitDropIndex(change.resourceName, change.indexName, provider); case "rename-table": return emitRenameTable(change.oldName, change.newName, provider); case "rename-column": return emitRenameColumn( change.resourceName, change.oldName, change.newName, provider, ); default: { // `never` check — keeps this function exhaustive with types.ts. const _exhaustive: never = change; throw new Error(`unknown Change.kind: ${JSON.stringify(_exhaustive)}`); } } } // ===================================================================== // Change emitters (internal — called only via emitChange dispatch). // ===================================================================== function emitAddColumn( resourceName: string, field: DdlFieldDef, provider: SqlProvider, ): string { validateAddColumn(resourceName, field, provider); const table = quoteIdent(resourceName, provider); const columnDef = emitColumnDef(field, provider); const addColumn = `ALTER TABLE ${table} ADD COLUMN ${columnDef};`; if (!field.indexed || field.unique || field.primary) return addColumn; return [ addColumn, emitCreateIndex( resourceName, autoIndexName(resourceName, field.name), [field.name], false, provider, ), ].join("\n"); } function validateAddColumn( resourceName: string, field: DdlFieldDef, provider: SqlProvider, ): void { if (provider !== "sqlite") return; if (field.primary) { throw new Error( `SQLite cannot add PRIMARY KEY column "${field.name}" to existing table "${resourceName}" via ALTER TABLE. ` + `Create a manual table-rebuild migration instead.`, ); } if (field.unique) { throw new Error( `SQLite cannot add UNIQUE column "${field.name}" to existing table "${resourceName}" via ALTER TABLE. ` + `Add a nullable/non-unique column first, backfill it, then create a unique index manually.`, ); } const defaultIssue = sqliteAddColumnDefaultIssue(field); if (defaultIssue) { throw new Error( `SQLite cannot add column "${field.name}" to existing table "${resourceName}" with this DEFAULT: ` + `${defaultIssue} Use a scalar literal default or write a manual backfill migration.`, ); } if (field.nullable) return; if (field.default !== undefined && !isSqliteNullDefault(field.default)) return; throw new Error( `SQLite cannot add required column "${field.name}" to existing table "${resourceName}" without a non-NULL constant DEFAULT. ` + `Add a scalar default, set required:false, or write a manual backfill migration.`, ); } function sqliteAddColumnDefaultIssue(field: DdlFieldDef): string | null { const def = field.default; if (!def) return null; if (def.kind === "now") { return `default "now" maps to CURRENT_TIMESTAMP, which SQLite rejects in ADD COLUMN.`; } if (def.kind !== "sql") return null; const expr = def.expr.trim(); if (/^CURRENT_(?:TIME|DATE|TIMESTAMP)\b/i.test(expr)) { return `SQLite rejects CURRENT_TIME/CURRENT_DATE/CURRENT_TIMESTAMP in ADD COLUMN.`; } if (/\b[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(expr)) { return `SQLite rejects non-constant function defaults in ADD COLUMN.`; } if (!isSqliteAddColumnConstantSqlDefault(expr)) { return `SQLite ADD COLUMN only accepts literal constant defaults; raw SQL expression ${JSON.stringify(expr)} is not safe to emit automatically.`; } return null; } function isSqliteNullDefault(def: NonNullable): boolean { return def.kind === "null" || (def.kind === "sql" && /^NULL$/i.test(def.expr.trim())); } function isSqliteAddColumnConstantSqlDefault(expr: string): boolean { let value = expr.trim(); while (value.startsWith("(") && value.endsWith(")")) { value = value.slice(1, -1).trim(); } return ( /^NULL$/i.test(value) || /^(?:TRUE|FALSE)$/i.test(value) || /^[+-]?(?:\d+|\d+\.\d+|\.\d+)(?:e[+-]?\d+)?$/i.test(value) || /^'(?:''|[^'])*'$/.test(value) || /^X'(?:[0-9a-f]{2})*'$/i.test(value) ); } /** * DROP COLUMN. * * Note on SQLite: DROP COLUMN landed in SQLite 3.35 (March 2021). Bun's * bundled SQLite is modern (3.46+ as of Bun 1.3), so we emit the standard * `ALTER TABLE ... DROP COLUMN ...` and assume the target engine is >=3.35. * Downstream environments pinning an older SQLite will need to manually * use the `recreate table` dance — Agent F's matrix tests cover 3.46. */ function emitDropColumn( resourceName: string, fieldName: string, provider: SqlProvider, ): string { const table = quoteIdent(resourceName, provider); const col = quoteIdent(fieldName, provider); return `ALTER TABLE ${table} DROP COLUMN ${col};`; } /** * Emit a stub `-- TODO:` block for v1-unsupported `alter-column-type`. * * Output: a multi-line SQL comment block naming the resource + field + * fromType → toType, followed by the literal TODO message and a no-op * a deliberate failing sentinel so the migration runner refuses to mark * this TODO as applied until the operator edits the migration manually. */ function emitAlterColumnTypeStub( resourceName: string, fieldName: string, fromType: string, toType: string, ): string { return [ `-- ================================================================`, `-- Column type change detected: ${resourceName}.${fieldName}`, `-- from: ${fromType}`, `-- to: ${toType}`, `-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.`, `-- Please write the migration manually, then re-run \`mandu db apply\`.`, `-- ================================================================`, manualMigrationRequiredStatement( `Mandu cannot auto-generate ALTER COLUMN TYPE for ${resourceName}.${fieldName}`, ), ].join("\n"); } function emitAlterColumnNullable( resourceName: string, fieldName: string, nullable: boolean, provider: SqlProvider, ): string { const table = quoteIdent(resourceName, provider); const col = quoteIdent(fieldName, provider); if (provider === "postgres") { // PG supports direct SET/DROP NOT NULL on existing columns. return nullable ? `ALTER TABLE ${table} ALTER COLUMN ${col} DROP NOT NULL;` : `ALTER TABLE ${table} ALTER COLUMN ${col} SET NOT NULL;`; } if (provider === "sqlite") { // SQLite cannot toggle NOT NULL on an existing column without a full // table recreate. Emit a failing stub so the user handles it manually. return [ `-- ================================================================`, `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`, `-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`, `-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`, `-- ================================================================`, manualMigrationRequiredStatement( `SQLite nullability change requires manual table rebuild for ${resourceName}.${fieldName}`, ), ].join("\n"); } // MySQL requires the full column spec; we cannot reconstruct it here. // Emit a stub — Agent B's diff emits `alter-column-nullable` only when // everything else matches, so this is narrow-scope. return [ `-- ================================================================`, `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`, `-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`, `-- cannot emit this automatically. Please edit this migration to use`, `-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} ${nullable ? "NULL" : "NOT NULL"}\``, `-- ================================================================`, manualMigrationRequiredStatement( `MySQL nullability change requires manual MODIFY COLUMN for ${resourceName}.${fieldName}`, ), ].join("\n"); } function emitAlterColumnDefault( resourceName: string, fieldName: string, def: DdlFieldDef["default"] | undefined, provider: SqlProvider, ): string { const table = quoteIdent(resourceName, provider); const col = quoteIdent(fieldName, provider); if (provider === "postgres") { return def ? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};` : `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`; } if (provider === "sqlite") { // Same constraint as nullability — SQLite needs a table recreate. return [ `-- ================================================================`, `-- Default change: ${resourceName}.${fieldName}`, `-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`, `-- ================================================================`, manualMigrationRequiredStatement( `SQLite default change requires manual table rebuild for ${resourceName}.${fieldName}`, ), ].join("\n"); } // MySQL — ALTER COLUMN ... SET DEFAULT / DROP DEFAULT is actually supported. return def ? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};` : `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`; } function emitAddIndex( resourceName: string, index: DdlIndex, provider: SqlProvider, ): string { if (!index.fields || index.fields.length === 0) { throw new Error(`DdlIndex "${index.name}" has no fields`); } return emitCreateIndex(resourceName, index.name, index.fields, index.unique, provider); } function emitDropIndex( resourceName: string, indexName: string, provider: SqlProvider, ): string { const idx = quoteIdent(indexName, provider); if (provider === "mysql") { // MySQL requires the table name — DROP INDEX ... ON table. const table = quoteIdent(resourceName, provider); return `DROP INDEX ${idx} ON ${table};`; } // Postgres + SQLite use standalone DROP INDEX (index names are unique // per-schema in PG, per-database in SQLite). return `DROP INDEX ${idx};`; } function emitRenameTable( oldName: string, newName: string, provider: SqlProvider, ): string { const from = quoteIdent(oldName, provider); const to = quoteIdent(newName, provider); return `ALTER TABLE ${from} RENAME TO ${to};`; } function emitRenameColumn( resourceName: string, oldName: string, newName: string, provider: SqlProvider, ): string { const table = quoteIdent(resourceName, provider); const from = quoteIdent(oldName, provider); const to = quoteIdent(newName, provider); // All three dialects use `ALTER TABLE ... RENAME COLUMN ... TO ...` in // their modern versions (PG >=9.2, MySQL >=8.0.3, SQLite >=3.25). return `ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to};`; } function manualMigrationRequiredStatement(message: string): string { return `SELECT mandu_manual_migration_required(${sqlStringLiteral(message)});`; } function sqlStringLiteral(value: string): string { return `'${value.replace(/'/g, "''")}'`; } // ===================================================================== // Validation helpers. // ===================================================================== function validateIndex(resource: DdlResource, index: DdlIndex): void { if (!index.name) { throw new Error(`resource "${resource.name}" has an unnamed index`); } if (!index.fields || index.fields.length === 0) { throw new Error(`index "${index.name}" on "${resource.name}" has no fields`); } const fieldNames = new Set(resource.fields.map((f) => f.name)); for (const f of index.fields) { if (!fieldNames.has(f)) { throw new Error( `index "${index.name}" on "${resource.name}" references unknown field "${f}"`, ); } } } // Internal exports for white-box tests ONLY. Not part of the public API // (consumers should never rely on these names being stable). export const _internal = { emitColumnDef, autoIndexName, validateIndex, MAX_IDENT_LENGTH, }; // Re-export the type-map functions so consumers can import from a single // entry point. No logic added — just pass-through. export { nowExpr, resolveColumnType, resolveDefault } from "./type-map";