/** * Emit dialect-specific Drizzle schema TypeScript from abstract declarations. * * Consumed as a pre-step inside `oke db push|generate|migrate` (and `oke dev` * watch) when abstract decls exist — not a third schema CLI. */ import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import type { TableContribution } from "../../kernel/plugin.ts"; import { isFieldBuilder, isSchemaColumnDecl, isSchemaTableDecl, relationsFromExports, schemaTable, schemaTableSqlName, tablesFromExports, type FieldTypeOptions, type RelationColumnRef, type SchemaColumnDecl, type SchemaColumnInput, type SchemaRelationEntry, type SchemaPolicyDecl, type SchemaRelationsDecl, type SchemaTableDecl, } from "./schema-decl.ts"; import { emitPgPolicySource, parseSqlPolicySpec } from "../../drivers/pg-rls.ts"; /** Supported Drizzle dialects for domain schema emit (Postgres-family only). */ export type SqlDialect = "postgres"; /** Header written at the top of generated files. */ export const GENERATED_SCHEMA_HEADER = "// generated by oke — do not edit"; /** Default generated Drizzle barrel (relative to project root). */ export const DEFAULT_GENERATED_SCHEMA_REL = "src/db/drizzle/index.ts"; /** * Prior single-file emit (`src/db/drizzle.ts`). Folder emit deletes this * sibling when it still carries the generated header. */ export const PREV_SINGLE_DRIZZLE_REL = "src/db/drizzle.ts"; /** * Prior emit filename (`src/db/schema.drizzle.ts`) — still resolved when * present and the new default does not exist yet. */ export const PREV_DRIZZLE_EMIT_REL = "src/db/schema.drizzle.ts"; /** Single-file declare module (takes priority when both layouts exist). */ export const DEFAULT_DECLARE_SCHEMA_REL = "src/db/schema.ts"; /** * Prior single-file declare (`src/db/schema.decl.ts`) — still resolved when * present and `schema.ts` does not exist yet. */ export const PREV_DECLARE_SCHEMA_REL = "src/db/schema.decl.ts"; /** * Folder declare module — `src/db/schema/index.ts`. * Used when {@link DEFAULT_DECLARE_SCHEMA_REL} is absent. */ export const DEFAULT_DECLARE_SCHEMA_DIR_REL = "src/db/schema/index.ts"; /** * Prior `src/db/` emit filename — still resolved when present and the new * default does not exist yet. */ export const PREV_GENERATED_SCHEMA_REL = "src/db/schema.generated.ts"; /** Legacy paths kept so pre-layout projects keep resolving. */ export const LEGACY_GENERATED_SCHEMA_REL = "src/schema.generated.ts"; /** @see LEGACY_GENERATED_SCHEMA_REL */ export const LEGACY_DECLARE_SCHEMA_REL = "src/schema.decl.ts"; /** * Map a store.sql driver id to an emit dialect. * * @param driverId - Protocol driver id (`postgres` · `pglite` · …) */ export function dialectFromDriverId(driverId: string | undefined): SqlDialect { if (driverId === undefined || driverId === "postgres" || driverId === "pglite") { return "postgres"; } throw new Error( `oke schema emit: store.sql driver "${driverId}" is not supported (postgres | pglite)`, ); } /** * Serialize one column to a Drizzle builder chain. * * Emits drizzle-exact calls for every {@link FieldSqlType}, carrying options * (`length`, `precision`, `scale`, `withTimezone`, `mode`, `fields`, * `enumValues`). Temporal modes are always pinned explicitly — rc.5 library * defaults differ from OKE's (bare `date()` = string, bare `timestamp()` = * Date). * * Postgres INTEGER is 32-bit; abstract `integer` (often ms clocks) → bigint. * drizzle-orm's `bigint()` requires `{ mode }` — omitting it throws on * `config.mode` when the generated schema is loaded by drizzle-kit push. * * @param col - Column decl * @param dialect - Target dialect (affects import names only at file level) */ export function emitColumnSource(col: SchemaColumnDecl, _dialect: SqlDialect = "postgres"): string { let expr = `${columnFactoryCall(col)}`; if (col.primaryKey) expr += ".primaryKey()"; else if (col.notNull) expr += ".notNull()"; if (col.unique) expr += ".unique()"; if (col.defaultValue !== undefined) { expr += `.default(${literalSource(col.defaultValue)})`; } if (col.defaultFnKind === "id") expr += ".$defaultFn(id)"; else if (col.defaultFnKind === "now") expr += ".$defaultFn(now)"; else if (col.defaultFnKind === "nowIso") expr += ".$defaultFn(nowIso)"; else if (col.defaultFnKind === "nowDate") expr += ".$defaultFn(nowDate)"; if (col.references) { const target = col.references.ref(); const targetTable = exportNameForTable(target.tableName ?? "unknown"); const targetCol = target.key; expr += `.references(() => ${targetTable}.${targetCol}`; const actions = col.references.actions; if (actions && (actions.onDelete || actions.onUpdate)) { const parts: string[] = []; if (actions.onDelete) parts.push(`onDelete: ${JSON.stringify(actions.onDelete)}`); if (actions.onUpdate) parts.push(`onUpdate: ${JSON.stringify(actions.onUpdate)}`); expr += `, { ${parts.join(", ")} }`; } expr += ")"; } return expr; } /** Factory call for a column type with its options, e.g. `varchar("x", { length: 256 })`. */ function columnFactoryCall(col: SchemaColumnDecl): string { const name = JSON.stringify(col.sqlName); const o = col.typeOptions ?? {}; switch (col.sqlType) { case "text": return enumConfig(o) !== undefined ? `text(${name}, { enum: ${JSON.stringify(o.enumValues)} })` : `text(${name})`; case "varchar": case "char": { const factory = col.sqlType; const configParts: string[] = []; if (o.length !== undefined) configParts.push(`length: ${o.length}`); const enumValues = enumConfig(o); if (enumValues !== undefined) configParts.push(`enum: ${enumValues}`); return configParts.length > 0 ? `${factory}(${name}, { ${configParts.join(", ")} })` : `${factory}(${name})`; } case "boolean": return `boolean(${name})`; case "smallint": return `smallint(${name})`; case "integer": // Postgres INTEGER is 32-bit — abstract integer (ms clocks) widens. return `bigint(${name}, { mode: "number" })`; case "bigint": return `bigint(${name}, { mode: ${JSON.stringify(o.mode ?? "number")} })`; case "serial": return `serial(${name})`; case "smallserial": return `smallserial(${name})`; case "bigserial": return `bigserial(${name}, { mode: ${JSON.stringify(o.mode ?? "number")} })`; case "numeric": { const configParts: string[] = []; if (o.precision !== undefined) configParts.push(`precision: ${o.precision}`); if (o.scale !== undefined) configParts.push(`scale: ${o.scale}`); if (o.mode !== undefined && o.mode !== "string") { configParts.push(`mode: ${JSON.stringify(o.mode)}`); } // rc.5 factory shape: numeric(name, config?) where the config is a // discriminated union requiring at least one of precision/scale/mode. return configParts.length > 0 ? `numeric(${name}, { ${configParts.join(", ")} })` : `numeric(${name})`; } case "real": return `real(${name})`; case "doublePrecision": return `doublePrecision(${name})`; case "json": return `json(${name})`; case "jsonb": return `jsonb(${name})`; case "uuid": return `uuid(${name})`; case "time": { const configParts: string[] = []; if (o.withTimezone !== undefined) configParts.push(`withTimezone: ${o.withTimezone}`); if (o.precision !== undefined) configParts.push(`precision: ${o.precision}`); return configParts.length > 0 ? `time(${name}, { ${configParts.join(", ")} })` : `time(${name})`; } case "timestamp": { const configParts: string[] = [`mode: ${JSON.stringify(o.mode ?? "date")}`]; if (o.precision !== undefined) configParts.push(`precision: ${o.precision}`); if (o.withTimezone !== undefined) configParts.push(`withTimezone: ${o.withTimezone}`); return `timestamp(${name}, { ${configParts.join(", ")} })`; } case "date": return `date(${name}, { mode: ${JSON.stringify(o.mode ?? "date")} })`; case "interval": { const configParts: string[] = []; if (o.fields !== undefined) configParts.push(`fields: ${JSON.stringify(o.fields)}`); if (o.precision !== undefined) configParts.push(`precision: ${o.precision}`); return configParts.length > 0 ? `interval(${name}, { ${configParts.join(", ")} })` : `interval(${name})`; } case "point": return o.mode !== undefined && o.mode !== "tuple" ? `point(${name}, { mode: ${JSON.stringify(o.mode)} })` : `point(${name})`; case "line": return o.mode !== undefined && o.mode !== "tuple" ? `line(${name}, { mode: ${JSON.stringify(o.mode)} })` : `line(${name})`; case "bytea": return `bytea(${name})`; case "inet": return `inet(${name})`; case "cidr": return `cidr(${name})`; case "macaddr": return `macaddr(${name})`; case "macaddr8": return `macaddr8(${name})`; } } function enumConfig(o: FieldTypeOptions): string | undefined { if (!o.enumValues || o.enumValues.length === 0) return undefined; return JSON.stringify(o.enumValues); } function literalSource(value: string | number | boolean | Date | Buffer | null): string { if (value === null) return "null"; if (value instanceof Date) return `new Date(${JSON.stringify(value.toISOString())})`; if (Buffer.isBuffer(value)) return `Buffer.from(${JSON.stringify(value.toString("base64"))}, "base64")`; if (typeof value === "string") return JSON.stringify(value); if (typeof value === "boolean") return value ? "true" : "false"; return String(value); } function emitColRef(ref: RelationColumnRef): string { return `r.${ref.table}.${ref.column}`; } function emitColRefs( value: RelationColumnRef | readonly RelationColumnRef[] | undefined, ): string | undefined { if (value === undefined) return undefined; if (Array.isArray(value)) { const refs = value as readonly RelationColumnRef[]; if (refs.length === 1) return emitColRef(refs[0]!); return `[${refs.map(emitColRef).join(", ")}]`; } return emitColRef(value as RelationColumnRef); } function emitRelationEntry(entry: SchemaRelationEntry): string { const helper = entry.kind === "one" ? "one" : "many"; const parts: string[] = []; const from = emitColRefs(entry.from); const to = emitColRefs(entry.to); if (from !== undefined) parts.push(`from: ${from}`); if (to !== undefined) parts.push(`to: ${to}`); if (entry.kind === "one" && entry.optional !== undefined) { parts.push(`optional: ${entry.optional ? "true" : "false"}`); } if (entry.alias !== undefined) parts.push(`alias: ${JSON.stringify(entry.alias)}`); const config = parts.length > 0 ? `{ ${parts.join(", ")} }` : ""; return `r.${helper}.${entry.target}(${config})`; } /** * Emit a `defineRelations(...)` block from abstract relations decls. * * @param relations - Relations declarations (typically one) */ export function emitRelationsSource(relations: readonly SchemaRelationsDecl[]): string { if (relations.length === 0) return ""; const blocks: string[] = []; for (const rel of relations) { const schemaKeys = rel.tableKeys.join(", "); const tableBlocks: string[] = []; for (const [tableKey, tableRels] of Object.entries(rel.config)) { const relLines = Object.entries(tableRels).map( ([relName, entry]) => ` ${relName}: ${emitRelationEntry(entry)},`, ); tableBlocks.push(` ${tableKey}: {\n${relLines.join("\n")}\n },`); } blocks.push( `export const relations = defineRelations({ ${schemaKeys} }, (r) => ({\n${tableBlocks.join("\n")}\n}));`, ); } return blocks.join("\n\n"); } /** Options for {@link emitDrizzleSource}. */ export interface EmitDrizzleSourceOptions { readonly relations?: readonly SchemaRelationsDecl[]; } function tableExportName(table: SchemaTableDecl): string { return exportNameForTable(schemaTableSqlName(table) ?? table.name); } function referencedExportNames(table: SchemaTableDecl): string[] { const self = tableExportName(table); const names = new Set(); for (const col of Object.values(table.columns)) { if (!col.references) continue; const target = col.references.ref(); const targetName = exportNameForTable(target.tableName ?? "unknown"); if (targetName !== self) names.add(targetName); } return [...names].sort(); } function emitImportLines( tables: readonly SchemaTableDecl[], options: { readonly relations?: boolean } = {}, ): string[] { const usedFactories = new Set(); for (const table of tables) { for (const col of Object.values(table.columns)) { // Abstract `integer` emits as drizzle `bigint` (32-bit widening). usedFactories.add(col.sqlType === "integer" ? "bigint" : col.sqlType); } } const pgIntImport = [...usedFactories].filter( (name) => name !== "pgTable" && name !== "pgPolicy", ); const needsPolicy = tables.some((t) => (t.policies?.length ?? 0) > 0); const coreNames = ["pgTable", ...pgIntImport, needsPolicy ? "pgPolicy" : null].filter( (name): name is string => name !== null, ); const lines = [`import { ${coreNames.join(", ")} } from "drizzle-orm/pg-core";`]; if (needsPolicy) lines.push(`import { sql } from "drizzle-orm";`); if (options.relations) lines.push(`import { defineRelations } from "drizzle-orm";`); const needsId = tables.some((t) => Object.values(t.columns).some((c) => c.defaultFnKind === "id"), ); const needsNow = tables.some((t) => Object.values(t.columns).some((c) => c.defaultFnKind === "now"), ); const needsNowIso = tables.some((t) => Object.values(t.columns).some((c) => c.defaultFnKind === "nowIso"), ); const needsNowDate = tables.some((t) => Object.values(t.columns).some((c) => c.defaultFnKind === "nowDate"), ); const helperNames = [ needsId ? "id" : null, needsNow ? "now" : null, needsNowIso ? "nowIso" : null, needsNowDate ? "nowDate" : null, ].filter((x): x is string => x !== null); if (helperNames.length > 0) { lines.push(`import { ${helperNames.join(", ")} } from "okengine/store";`); } return lines; } function joinGeneratedModule(parts: readonly string[]): string { return parts.filter((line, i, arr) => !(line === "" && arr[i - 1] === "")).join("\n"); } /** * Emit a full Drizzle schema module from abstract table decls. * * @param tables - App + plugin tables (already merged) * @param dialect - Always `postgres` (`pgTable`); retained for call-site clarity * @param options - Optional relations to emit as `defineRelations` */ export function emitDrizzleSource( tables: readonly SchemaTableDecl[], dialect: SqlDialect = "postgres", options: EmitDrizzleSourceOptions = {}, ): string { const relations = options.relations ?? []; const blocks = tables.map((table) => emitTableBlock(table, dialect)); const relationsBlock = emitRelationsSource(relations); return joinGeneratedModule([ GENERATED_SCHEMA_HEADER, ...emitImportLines(tables, { relations: relations.length > 0 }), "", blocks.join("\n\n"), relationsBlock ? `\n${relationsBlock}` : "", "", ]); } /** * One generated table module (`drizzle/.ts`). * * @param table - Table declaration * @param dialect - Always `postgres` */ export function emitDrizzleTableSource( table: SchemaTableDecl, dialect: SqlDialect = "postgres", ): string { const refs = referencedExportNames(table).map( (name) => `import { ${name} } from ${JSON.stringify(`./${name}.ts`)};`, ); return joinGeneratedModule([ GENERATED_SCHEMA_HEADER, ...emitImportLines([table]), ...refs, "", emitTableBlock(table, dialect), "", ]); } /** * Generated `drizzle/relations.ts` when declare relations exist. * * @param relations - Relations declarations */ export function emitDrizzleRelationsFileSource(relations: readonly SchemaRelationsDecl[]): string { if (relations.length === 0) return ""; const keys = new Set(); for (const rel of relations) { for (const k of rel.tableKeys) keys.add(k); } const imports = [...keys] .sort() .map((name) => `import { ${name} } from ${JSON.stringify(`./${name}.ts`)};`); return joinGeneratedModule([ GENERATED_SCHEMA_HEADER, `import { defineRelations } from "drizzle-orm";`, ...imports, "", emitRelationsSource(relations), "", ]); } /** * Generated `drizzle/index.ts` barrel. * * @param fileNames - Table (and relations) filenames to re-export */ export function emitDrizzleIndexSource(fileNames: readonly string[]): string { const exports = fileNames.map((name) => `export * from ${JSON.stringify(`./${name}`)};`); return `${GENERATED_SCHEMA_HEADER}\n${exports.join("\n")}\n`; } /** * Filename → source for a generated `drizzle/` folder (one table per file). * * @param tables - App + plugin tables (already merged) * @param dialect - Always `postgres` * @param options - Optional relations file */ export function emitDrizzleFolderSources( tables: readonly SchemaTableDecl[], dialect: SqlDialect = "postgres", options: EmitDrizzleSourceOptions = {}, ): ReadonlyMap { const files = new Map(); const tableFiles: string[] = []; for (const table of tables) { const name = `${tableExportName(table)}.ts`; files.set(name, emitDrizzleTableSource(table, dialect)); tableFiles.push(name); } const relations = options.relations ?? []; const extra: string[] = []; if (relations.length > 0) { files.set("relations.ts", emitDrizzleRelationsFileSource(relations)); extra.push("relations.ts"); } files.set("index.ts", emitDrizzleIndexSource([...tableFiles, ...extra])); return files; } /** * Whether `db.generated` is the folder barrel (`drizzle/index.ts`). * * @param rel - Path relative to project root */ export function isFolderGeneratedRel(rel: string): boolean { const n = rel.replace(/\\/g, "/").replace(/\/$/, ""); return /(?:^|\/)drizzle\/index\.tsx?$/.test(n) || /(?:^|\/)drizzle$/.test(n); } /** * Normalize `db.generated` so a directory `drizzle` becomes `drizzle/index.ts`. * * @param rel - Path relative to project root */ export function normalizeGeneratedRel(rel: string): string { const n = rel.replace(/\\/g, "/").replace(/\/$/, ""); if (n.endsWith("/drizzle") || n === "drizzle") return `${n}/index.ts`; return n; } function emitTableBlock(table: SchemaTableDecl, dialect: SqlDialect): string { const lines = Object.values(table.columns).map( (col) => ` ${col.key}: ${emitColumnSource(col, dialect)},`, ); const sqlName = schemaTableSqlName(table) ?? table.name; const exportName = exportNameForTable(sqlName); const cols = `{\n${lines.join("\n")}\n}`; const policies = table.policies ?? []; const ctor = table.rls === true ? "pgTable.withRLS" : "pgTable"; if (policies.length === 0) { return `export const ${exportName} = ${ctor}(${JSON.stringify(sqlName)}, ${cols});`; } const extras = policies.map((policy) => ` ${emitPolicyExtra(policy, sqlName)},`).join("\n"); return `export const ${exportName} = ${ctor}(${JSON.stringify(sqlName)}, ${cols}, (_t) => [\n${extras}\n]);`; } function emitPolicyExtra(policy: SchemaPolicyDecl, table: string): string { const roles = Array.isArray(policy.to) ? policy.to.join(", ") : typeof policy.to === "string" ? policy.to : "public"; const spec = parseSqlPolicySpec({ name: policy.name, table, as: policy.as, to: roles, for: policy.for, using: policy.using, withCheck: policy.withCheck, }); return emitPgPolicySource(spec).replaceAll("\n", "\n "); } function exportNameForTable(name: string): string { // Keep simple SQL names as-is; camelCase odd names. if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return name; return name.replace(/[^a-zA-Z0-9_]+/g, "_"); } /** * Build a {@link SchemaTableDecl} from a plugin column map (builders or decls). * * @param name - Table name * @param columns - Column map */ export function schemaTableFromPluginColumns( name: string, columns: Readonly>, ): SchemaTableDecl { return schemaTable(name, columns); } /** * Whether a record looks like an abstract column map (not `{ plane }` metadata). * * @param value - Unknown second arg to plugin.table */ export function isAbstractColumnMap( value: unknown, ): value is Readonly> { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const entries = Object.entries(value); if (entries.length === 0) return true; // Legacy `{ plane: "operator" }` — not columns. if (entries.length === 1 && entries[0]![0] === "plane" && typeof entries[0]![1] === "string") { return false; } return entries.every(([, v]) => isFieldBuilder(v) || isSchemaColumnDecl(v)); } /** * Extract schema tables from plugin contributions that supply `field.*` columns. * * Contributions without columns (name + plane only) are skipped — auth/console * still use hand-written SQL for those tables. * * @param contributions - Plugin table list */ export function tablesFromPluginContributions( contributions: readonly TableContribution[], ): SchemaTableDecl[] { const out: SchemaTableDecl[] = []; for (const c of contributions) { const columns = c.columns; if (!columns || Object.keys(columns).length === 0) continue; if (!isAbstractColumnMap(columns)) continue; out.push(schemaTableFromPluginColumns(c.name, columns)); } return out; } /** * Merge app + plugin abstract tables. Duplicate names throw. * * @param appTables - App declarations * @param pluginTables - Plugin contributions with columns */ export function mergeSchemaTables( appTables: readonly SchemaTableDecl[], pluginTables: readonly SchemaTableDecl[] = [], ): SchemaTableDecl[] { const byName = new Map(); for (const t of [...appTables, ...pluginTables]) { if (byName.has(t.name)) { throw new Error( `store schema: duplicate table "${t.name}" (plugins may add whole tables only — no column injection into app-owned tables)`, ); } byName.set(t.name, t); } return [...byName.values()]; } /** Options for {@link maybeEmitDomainSchema}. */ export interface EmitDomainSchemaOptions { readonly cwd: string; readonly dialect: SqlDialect; /** Declaration module path (absolute or cwd-relative). */ readonly declarePath?: string; /** Output path (absolute or cwd-relative). */ readonly outPath?: string; /** Live plugin registry table contributions (from a plugged `oke()` app). */ readonly pluginTables?: readonly TableContribution[]; readonly write?: (text: string) => void; } /** * When abstract decls and/or plugin `field.*` tables exist, write the generated * Drizzle schema. Returns whether a file was written. * * @param options - Paths / dialect / plugins */ export async function maybeEmitDomainSchema( options: EmitDomainSchemaOptions, ): Promise<{ readonly emitted: boolean; readonly path?: string; readonly tableCount?: number }> { const cwd = options.cwd; const resolved = resolveEmitPaths(cwd, { ...(options.declarePath !== undefined ? { declare: options.declarePath } : {}), ...(options.outPath !== undefined ? { generated: options.outPath } : {}), }); const declareAbs = resolved.declarePath; const outAbs = resolved.outPath; const declareRel = declareAbs.startsWith(`${cwd}/`) ? declareAbs.slice(cwd.length + 1) : declareAbs; const outRel = outAbs.startsWith(`${cwd}/`) ? outAbs.slice(cwd.length + 1) : outAbs; const declareFile = Bun.file(declareAbs); let appTables: SchemaTableDecl[] = []; let appRelations: SchemaRelationsDecl[] = []; if (await declareFile.exists()) { let mod: Record; try { mod = (await import(declareAbs)) as Record; } catch (err) { options.write?.( `oke db: failed to load schema declare module ${declareRel}: ${err instanceof Error ? err.message : String(err)}\n`, ); throw err; } appTables = tablesFromExports(mod); appRelations = relationsFromExports(mod); } const fromPlugins = tablesFromPluginContributions(options.pluginTables ?? []); if (appTables.length === 0 && fromPlugins.length === 0) { return { emitted: false }; } const tables = mergeSchemaTables(appTables, fromPlugins); if (isFolderGeneratedRel(outRel)) { const dirAbs = dirname(outAbs); const files = emitDrizzleFolderSources(tables, options.dialect, { relations: appRelations, }); await writeGeneratedFolder(dirAbs, files); await removeLegacySingleDrizzleFile(dirAbs); options.write?.( `oke db: emitted ${outRel} (${tables.length} table${tables.length === 1 ? "" : "s"}, ${options.dialect})\n`, ); return { emitted: true, path: outAbs, tableCount: tables.length }; } const source = emitDrizzleSource(tables, options.dialect, { relations: appRelations }); await mkdir(dirname(outAbs), { recursive: true }); await writeFile(outAbs, source, "utf8"); options.write?.( `oke db: emitted ${outRel} (${tables.length} table${tables.length === 1 ? "" : "s"}, ${options.dialect})\n`, ); return { emitted: true, path: outAbs, tableCount: tables.length }; } async function writeGeneratedFolder( dirAbs: string, files: ReadonlyMap, ): Promise { await mkdir(dirAbs, { recursive: true }); for (const [name, source] of files) { await writeFile(join(dirAbs, name), source, "utf8"); } let existing: string[] = []; try { existing = await readdir(dirAbs); } catch { return; } for (const name of existing) { if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue; if (files.has(name)) continue; const abs = join(dirAbs, name); try { const content = await readFile(abs, "utf8"); if (content.startsWith(GENERATED_SCHEMA_HEADER)) await unlink(abs); } catch { // ignore prune failures } } } async function removeLegacySingleDrizzleFile(dirAbs: string): Promise { if (basename(dirAbs) !== "drizzle") return; const sibling = join(dirname(dirAbs), basename(PREV_SINGLE_DRIZZLE_REL)); if (!existsSync(sibling)) return; try { const content = await readFile(sibling, "utf8"); if (content.startsWith(GENERATED_SCHEMA_HEADER)) await unlink(sibling); } catch { // ignore } } /** * Discover the declare module when `db.declare` is omitted. * * Order: `schema.ts` (single file) → `schema.decl.ts` (prior name) → * `schema/index.ts` (folder) → legacy `src/schema.decl.ts` → default * `schema.ts` (may not exist yet). * * @param cwd - Project root */ export function discoverDeclareRel(cwd: string): string { if (existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_REL))) { return DEFAULT_DECLARE_SCHEMA_REL; } if (existsSync(join(cwd, PREV_DECLARE_SCHEMA_REL))) { return PREV_DECLARE_SCHEMA_REL; } if (existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_DIR_REL))) { return DEFAULT_DECLARE_SCHEMA_DIR_REL; } if (existsSync(join(cwd, LEGACY_DECLARE_SCHEMA_REL))) { return LEGACY_DECLARE_SCHEMA_REL; } return DEFAULT_DECLARE_SCHEMA_REL; } /** * Discover the generated Drizzle emit path when `db.generated` is omitted. * * Order: `src/db/drizzle/index.ts`, except legacy `src/schema.generated.ts` * when the declare module is still on the old `src/` layout. Older single * files (`drizzle.ts`, `schema.drizzle.ts`) are not re-targeted — set * `db.generated` to keep emitting them. * * @param cwd - Project root * @param declareRel - Resolved declare relative path */ export function discoverGeneratedRel(cwd: string, declareRel: string): string { const preferSrcLegacy = declareRel === LEGACY_DECLARE_SCHEMA_REL || (!existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_REL)) && !existsSync(join(cwd, PREV_DECLARE_SCHEMA_REL)) && !existsSync(join(cwd, DEFAULT_DECLARE_SCHEMA_DIR_REL)) && existsSync(join(cwd, LEGACY_GENERATED_SCHEMA_REL))); if (preferSrcLegacy) return LEGACY_GENERATED_SCHEMA_REL; return DEFAULT_GENERATED_SCHEMA_REL; } /** * Resolve declare/out paths from an optional db config slice. * Prefers explicit `db.declare` / `db.generated`, then discovery. * * @param cwd - Project root * @param db - Config `db` block */ export function resolveEmitPaths( cwd: string, db?: { readonly declare?: string; readonly generated?: string }, ): { readonly declarePath: string; readonly outPath: string } { const declareRel = db?.declare ?? discoverDeclareRel(cwd); const outRel = normalizeGeneratedRel(db?.generated ?? discoverGeneratedRel(cwd, declareRel)); return { declarePath: join(cwd, declareRel), outPath: join(cwd, outRel), }; } /** Re-export for callers that only import the emitter. */ export { isSchemaTableDecl, tablesFromExports, relationsFromExports };