/** * Phase 4c — Resource repo generator. * * Given a `ParsedResource` with `options.persistence` set, emit the full * TypeScript source of `.mandu/generated/server/repos/{name}.repo.ts` — a * typed CRUD bundle (`findById`, `findMany`, `create`, `update`, `delete`) * sitting on top of `@mandujs/core/db`. * * # Invariants * * 1. PURE FUNCTION. No I/O, no filesystem, no `Bun.write`. The orchestrator * in `generator.ts` handles writes. * 2. The Row type is emitted INLINE as a TypeScript interface matching * the resource's fields. We do NOT import the Zod schema from the * contract file — the existing contract generator emits `XSchema` * as a module-private const, not a named export, and Appendix B TC-3 * prohibits changing the contract's public surface in this phase. * 3. Column names in SQL are `snake_case` — sourced from * `snapshotFromResources` normalization. We import `toSnakeCase` from * `ddl/snapshot.ts` (DRY; single canonical transform). * 4. Dialect branching is RESOLVED AT GENERATION TIME from * `persistence.provider`. The emitted file never runtime-switches on * provider — there's one code path per file. * 5. All identifiers emitted into SQL strings flow through `quoteIdent` * (imported from `ddl/emit.ts`). Table/column names come from the * (author-validated) resource definition; `quoteIdent` adds defense * in depth. * 6. `// @generated by Mandu — do not edit.` banner at the top so * humans + CI tooling recognize the file as derived. * * # Emit shape * * export interface User { id: string; email: string; ... } * export function createUsersRepo(db: Db) { * return { * async findById(id): User | null { ... }, * async findMany(limit = 100, offset = 0): User[] { ... }, * async create(row): User { ... }, // PG/SQLite: RETURNING *; MySQL: INSERT + re-select * async update(id, patch): User | null { ... }, * async delete(id): boolean { ... }, * }; * } * * The repo name `createUsersRepo` uses the table's plural form (what * `snapshotFromResources` resolves to) rather than the singular * `resource.name`. This matches what developers naturally type when * calling it: `createUsersRepo`, `createPostsRepo`, etc. * * # Non-goals * * - Implicit transaction context (Appendix D.3 — v2). * - JOIN helpers / multi-table queries — use raw `db` directly in slots. * - Partial column selection — always `SELECT *` for type stability. * - Custom error types — thrown errors bubble; users handle them. * * # References * * - docs/rfcs/0001-db-resource-layer.md §D1 (5th artifact) * - docs/rfcs/0001-db-resource-layer.md §D2 (Zod-inferred row type) * - docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence) * - DNA/drizzle-orm/drizzle-kit/src/sqlgenerator.ts (reference templates) */ import type { ParsedResource } from "./parser"; import type { ResourceField } from "./schema"; import { quoteIdent } from "./ddl/emit"; import { toSnakeCase } from "./ddl/snapshot"; import { asPersistence, type ExtendedResourcePersistence, type FieldOverride } from "./ddl/persistence-types"; import type { SqlProvider } from "./ddl/types"; // ============================================ // Public API // ============================================ /** * Options controlling `generateRepoSource` behavior. * * All fields are optional. Defaults are chosen so the generator "just works" * when called from `generateResourceArtifacts` with no explicit options. */ export interface RepoGenerationOptions { /** * When `false`, return `null` for non-persistent resources instead of * throwing. Defaults to `true` (throw on non-persistent) — the * orchestrator in `generator.ts` guards against this path by checking * `options.persistence` before calling us. Advanced callers can pass * `enable: false` to treat missing persistence as a no-op. */ enable?: boolean; /** * Import specifier for `Db` type. Default `@mandujs/core/db`. Useful in * monorepo tests that need to point at a local build. */ dbImport?: string; /** * Override the contract import path. When not set, the generator uses * `../contracts/{resource.name}.contract` which matches the output of * `resolveGeneratedPaths()`. Pass this only when you're generating the * repo into a non-standard location. */ contractImport?: string; } /** * Return the full TypeScript source of a resource's repo module. * * Pure — no filesystem, no side effects. Caller writes the result. * * @throws TypeError when the resource has no `options.persistence` and * `options.enable` is not explicitly `false`. */ export function generateRepoSource( resource: ParsedResource, options: RepoGenerationOptions = {}, ): string | null { const enable = options.enable ?? true; const rawPersistence = ( resource.definition.options as Record | undefined )?.persistence; const persistence = asPersistence(rawPersistence); if (!persistence) { if (enable === false) return null; throw new TypeError( `generateRepoSource: resource "${resource.resourceName}" has no options.persistence. ` + `Either add a persistence block or pass { enable: false } to skip repo generation.`, ); } const dbImport = options.dbImport ?? "@mandujs/core/db"; const contractImport = options.contractImport ?? `../contracts/${resource.definition.name}.contract`; return renderRepoFile(resource, persistence, dbImport, contractImport); } /** * Back-compat predicate exported for `generator.ts` to decide whether to * emit a repo without duplicating the `asPersistence` narrowing logic. */ export function shouldEmitRepo(resource: ParsedResource): boolean { const rawPersistence = ( resource.definition.options as Record | undefined )?.persistence; return asPersistence(rawPersistence) !== undefined; } // ============================================ // Renderer — top-level composition // ============================================ function renderRepoFile( resource: ParsedResource, persistence: ExtendedResourcePersistence, dbImport: string, contractImport: string, ): string { const { definition } = resource; const pascalName = toPascalCase(definition.name); // Plural name from persistence.tableName > auto-plural. Matches what // `snapshotFromResources` resolves to — keeps the repo name + table // name aligned so stack traces are readable. const table = resolveTableName(resource, persistence); const repoFactoryName = `create${toPascalCase(table)}Repo`; const provider = persistence.provider; // Canonical column list — keys are the author's field names // (e.g., `passwordHash`), columns are the normalized SQL names // (`password_hash`). Column overrides from `fieldOverrides` take // precedence, matching `snapshot.ts` behavior. const columns = resolveColumns(resource, persistence); const primaryKeyColumn = columns.find((c) => c.primary)?.column ?? "id"; const primaryKeyField = columns.find((c) => c.primary)?.field ?? "id"; const insertColumns = columns.filter((c) => !c.omitOnInsert); const createInputType = createMethodInputType(pascalName, columns); // Quoted identifiers — precomputed so each builder reads cleanly. const q = (name: string) => escapeSqlTemplateText(quoteIdent(name, provider)); const qTable = q(table); // Column list for SELECT: we don't use `*` because aliasing // snake_case → camelCase is needed to return the Row type the contract // expects. The `AS` alias is emitted only when column != field (avoid // `name AS name` noise). const selectList = columns .map((c) => c.column === c.field ? q(c.column) : `${q(c.column)} AS ${q(c.field)}`, ) .join(", "); const header = renderHeader(definition.name, repoFactoryName, pascalName); const imports = renderImports(dbImport, contractImport, pascalName); const rowTypeBlock = renderRowType(pascalName, Object.entries(definition.fields)); const body = [ findByIdMethod(pascalName, qTable, selectList, q, primaryKeyColumn, primaryKeyField), findManyMethod(pascalName, qTable, selectList), createMethod(pascalName, table, qTable, selectList, insertColumns, createInputType, primaryKeyColumn, primaryKeyField, q, provider), updateMethod(pascalName, table, qTable, selectList, columns, primaryKeyColumn, primaryKeyField, q, provider), deleteMethod(pascalName, qTable, q, primaryKeyColumn, primaryKeyField, provider), ].join(",\n\n"); return `${header}${imports} ${rowTypeBlock} export function ${repoFactoryName}(db: Db) { return { ${body}, }; } `; } // ============================================ // Headers / imports / row type // ============================================ function renderHeader( resourceName: string, repoFactoryName: string, pascalName: string, ): string { return `// @generated by Mandu — do not edit. // Regenerate with \`mandu generate\` or \`mandu db plan\`. // // Resource: ${resourceName} // Factory: ${repoFactoryName}(db) → typed CRUD bundle // Row type: ${pascalName} (inline interface mirroring the contract schema) // // IMPLEMENTATION NOTES // - Column names in SQL are snake_case; the Row type keeps camelCase by // aliasing in the SELECT list (\`AS "fieldName"\`). // - The repo never constructs a Db itself — callers (slots, scripts) pass // in either \`ctx.deps.db\` or a module-level singleton. // - Provider-specific SQL (INSERT ... RETURNING * on PG/SQLite vs // INSERT + re-select on MySQL) is resolved at generation // time; the generated file has one code path per provider. `; } function renderImports(dbImport: string, _contractImport: string, _pascalName: string): string { // The repo's Row type is emitted INLINE (as a TypeScript interface) // rather than inferred from the contract's Zod schema. // // Why not `z.infer`? The existing contract generator // (`generators/contract.ts:77`) emits `XSchema` as a module-private // `const`, not a named export. Touching the contract emitter to export // it is a cross-artifact API change that Appendix B TC-3 is explicitly // protecting against. Inlining the Row type keeps the repo artifact // self-contained and preserves the contract's existing public surface. // // The inline interface mirrors Mandu's `ResourceField` → TS mapping // exactly; correctness is guarded by the repo's unit tests. return ` import type { Db } from "${dbImport}"; `; } function renderRowType(pascalName: string, fields: Array<[string, ResourceField]>): string { const lines = fields.map(([name, field]) => { const tsType = fieldToTsType(field); // Fields are REQUIRED only when explicitly set. Matches the contract // generator's behavior (`generators/contract.ts:144`: appends // `.optional()` to the Zod schema when `required` is falsy). const optional = field.required === true ? "" : "?"; return ` ${name}${optional}: ${tsType};`; }); // Index signature satisfies `Db`'s `Row = Record` // constraint so the generated repo type-checks when the callers pass // `${pascalName}` as the row type param to `db<${pascalName}>` / `db.one<${pascalName}>`. // Concrete fields above retain their precise types — the signature // only widens unknown keys. return `/** * Row shape for the \`${pascalName}\` resource. Mirrors the contract's * Zod schema — if you add or remove fields in the resource definition, * regenerate to pick up the change. */ export interface ${pascalName} { ${lines.join("\n")} [key: string]: unknown; }`; } /** * Map a Mandu `ResourceField` to its TypeScript row type. * * Keep in sync with `generators/contract.ts:generateZodSchema` — the two * must agree or the Row type will diverge from the contract. Primitive * types only; complex shapes fall back to `unknown` which is explicit * and easy to spot at call sites. */ function fieldToTsType(field: ResourceField): string { switch (field.type) { case "string": case "uuid": case "email": case "url": case "date": return "string"; case "number": return "number"; case "boolean": return "boolean"; case "array": return "unknown[]"; case "json": case "object": return "Record"; default: return "unknown"; } } // ============================================ // Method emitters — each returns the method text // ============================================ function findByIdMethod( pascalName: string, qTable: string, selectList: string, q: (n: string) => string, pkColumn: string, pkField: string, ): string { return ` /** * Look up a single row by primary key. * Returns \`null\` when no row matches — never throws for "not found". */ async findById(${pkField}: ${pascalName}["${pkField}"]): Promise<${pascalName} | null> { return db.one<${pascalName}>\`SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}\`; }`; } function findManyMethod(pascalName: string, qTable: string, selectList: string): string { return ` /** * List rows with basic pagination. Default: 100 rows, no offset. * Ordering is \` ASC\` — callers that need custom ordering * should drop to raw \`db\` in the slot. */ async findMany(limit: number = 100, offset: number = 0): Promise<${pascalName}[]> { return db<${pascalName}>\`SELECT ${selectList} FROM ${qTable} LIMIT \${limit} OFFSET \${offset}\`; }`; } function createMethod( pascalName: string, tableName: string, qTable: string, selectList: string, insertColumns: ResolvedColumn[], createInputType: string, pkColumn: string, pkField: string, q: (n: string) => string, provider: SqlProvider, ): string { // Build INSERT column list + VALUES placeholder list. Each value is // sourced from the `input` object and bound via Bun.SQL's tagged-template // parameter mechanism — never concatenated into the SQL string. const columnList = insertColumns.map((c) => q(c.column)).join(", "); const valuePlaceholders = insertColumns .map((c) => `\${input.${c.field}}`) .join(", "); if (provider === "postgres" || provider === "sqlite") { // RETURNING * is native on both dialects (SQLite >= 3.35 — Bun's bundled // SQLite is modern enough per Agent A's emit.ts note). We reuse the // precomputed `selectList` on RETURNING so newly-generated DB-side // defaults come back with the inserted row and snake_case → camelCase // aliasing is consistent with SELECT. return ` /** * Insert a new row and return the inserted record. Errors from * constraint violations (UNIQUE, NOT NULL, FK) bubble unchanged. */ async create(input: ${createInputType}): Promise<${pascalName}> { const row = await db.one<${pascalName}>\` INSERT INTO ${qTable} (${columnList}) VALUES (${valuePlaceholders}) RETURNING ${selectList} \`; if (!row) throw new Error("${tableName} create: INSERT RETURNING produced no row"); return row; }`; } const mysqlLookup = insertColumns.some((c) => c.field === pkField) ? `${q(pkColumn)} = \${input.${pkField}}` : `${q(pkColumn)} = LAST_INSERT_ID()`; // MySQL: no RETURNING. INSERT then SELECT by the supplied primary key when // the caller provides it; if the primary key is DB-generated, fall back to // LAST_INSERT_ID(). return ` /** * Insert a new row and return the inserted record via a follow-up SELECT. * MySQL lacks RETURNING; the generator uses LAST_INSERT_ID() when the * primary key is server-generated, otherwise it re-selects by the * provided primary key value. */ async create(input: ${createInputType}): Promise<${pascalName}> { await db\` INSERT INTO ${qTable} (${columnList}) VALUES (${valuePlaceholders}) \`; const row = await db.one<${pascalName}>\` SELECT ${selectList} FROM ${qTable} WHERE ${mysqlLookup} \`; if (!row) throw new Error("${tableName} create: follow-up SELECT returned no row"); return row; }`; } function updateMethod( pascalName: string, _tableName: string, qTable: string, selectList: string, allColumns: ResolvedColumn[], pkColumn: string, pkField: string, q: (n: string) => string, _provider: SqlProvider, ): string { // Strategy: per-column UPDATE statements inside a transaction so the SQL // remains a well-formed tagged template at all times (no dynamic SET // string-building). Trade-off: up to N round-trips for N patched fields, // but portable across all three dialects with no string concatenation. // Repo users who need a hot write path drop to raw `db` in the slot. // // We emit a per-column UPDATE whose table + column identifiers are // already baked into the generated source as string literals inside // the tagged template. Fields in `fieldToColumn` drive the runtime // iteration; the actual UPDATE statement for each column is a static // fragment selected by a switch on the field name. This keeps every // SQL string a true tagged template with no interpolated identifiers. const updateCases = allColumns .filter((c) => !c.primary) .map( (c) => ` case "${c.field}": await tx\`UPDATE ${qTable} SET ${q(c.column)} = \${value} WHERE ${q(pkColumn)} = \${${pkField}}\`; break;`, ) .join("\n"); return ` /** * Update an existing row by primary key. Fields left \`undefined\` * in \`patch\` are not modified. Returns the updated row, or * \`null\` when no row with the given primary key exists. * * Implementation detail: the dynamic SET is emitted as a sequence of * single-column UPDATEs within a transaction. This keeps the SQL a * well-formed tagged template across all three dialects at the cost * of N round-trips for N patched fields. For hot write paths, drop * to raw \`db\` in your slot. */ async update( ${pkField}: ${pascalName}["${pkField}"], patch: Partial>, ): Promise<${pascalName} | null> { return db.transaction(async (tx) => { const existing = await tx.one<${pascalName}>\` SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}} \`; if (!existing) return null; for (const [field, value] of Object.entries(patch as Record)) { if (value === undefined) continue; switch (field) { ${updateCases} default: // Unknown key — silently ignore. Contract validation at the // slot layer should prevent this from reaching the repo. break; } } return tx.one<${pascalName}>\` SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}} \`; }); }`; } function deleteMethod( pascalName: string, qTable: string, q: (n: string) => string, pkColumn: string, pkField: string, _provider: SqlProvider, ): string { // All three providers support DELETE; we return true iff at least one // row was removed. Rather than rely on driver-specific affected-row // reporting (`count` on PG, `changes()` on SQLite, `affectedRows` on // MySQL) we do a cheap SELECT-then-DELETE so behavior is identical // across dialects. Same correctness-over-perf trade-off as `update`: // drop to raw `db` in a slot for hot delete paths. return ` /** * Delete a row by primary key. Returns \`true\` if a row was deleted, * \`false\` if no row matched. */ async delete(${pkField}: ${pascalName}["${pkField}"]): Promise { const existing = await db.one\`SELECT 1 AS ok FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}} LIMIT 1\`; if (!existing) return false; await db\`DELETE FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}\`; return true; }`; } // ============================================ // Column + table resolution // ============================================ interface ResolvedColumn { /** Field key the author wrote in `definition.fields` (e.g. `passwordHash`). */ field: string; /** Column name in SQL (e.g. `password_hash`). */ column: string; primary: boolean; /** PK auto-generated by the DB → skip from INSERT column list. */ omitOnInsert: boolean; } function resolveColumns( resource: ParsedResource, persistence: ExtendedResourcePersistence, ): ResolvedColumn[] { const { definition } = resource; const overrides = persistence.fieldOverrides ?? {}; const declaredPk = resolveDeclaredPrimaryKey(persistence.primaryKey); const columns: ResolvedColumn[] = []; let pkCount = 0; for (const [fieldKey, field] of Object.entries(definition.fields)) { const override = overrides[fieldKey]; const column = override?.columnName ?? toSnakeCase(fieldKey); // `primary` resolution mirrors `snapshot.ts`: // - named by persistence.primaryKey, OR // - field-level `primary: true` (via cast — Mandu's public // ResourceField type predates this flag). const declaredPkMatch = declaredPk !== undefined && declaredPk === fieldKey; const fieldLevelPk = Boolean( (field as { primary?: boolean }).primary, ); const primary = declaredPkMatch || fieldLevelPk; if (primary) pkCount++; const omitOnInsert = primary && hasDbDefault(field, override); columns.push({ field: fieldKey, column, primary, omitOnInsert }); } if (pkCount === 0) { throw new TypeError( `Resource "${resource.resourceName}" has no primary-key field. Mark one with \`primary: true\` (via fieldOverrides) or declare \`options.persistence.primaryKey\`.`, ); } if (pkCount > 1) { throw new TypeError( `Resource "${resource.resourceName}" has ${pkCount} primary-key fields. Composite primary keys are not supported in v1.`, ); } return columns; } function resolveDeclaredPrimaryKey( declared: ExtendedResourcePersistence["primaryKey"], ): string | undefined { if (declared === undefined) return undefined; if (typeof declared === "string") return declared; return declared[0]; } function createMethodInputType( pascalName: string, columns: ResolvedColumn[], ): string { const omittedFields = columns .filter((c) => c.omitOnInsert) .map((c) => JSON.stringify(c.field)); if (omittedFields.length === 0) return pascalName; return `Omit<${pascalName}, ${omittedFields.join(" | ")}>`; } function hasDbDefault( field: ResourceField, override: FieldOverride | undefined, ): boolean { return override?.default !== undefined || field.default !== undefined; } function escapeSqlTemplateText(text: string): string { return text.replace(/`/g, "\\`"); } function resolveTableName( resource: ParsedResource, persistence: ExtendedResourcePersistence, ): string { if (persistence.tableName) return persistence.tableName; const options = resource.definition.options; if (options?.pluralName) return options.pluralName; if (options?.autoPlural === false) return resource.definition.name; return pluralize(resource.definition.name); } /** * Shared pluralizer — MUST match `snapshot.ts`. If the two diverge the * repo's table name won't match the actual table → runtime SQL errors. * Kept as a private copy (vs import) because `snapshot.ts` doesn't export * its pluralizer; duplication is guarded by a test that compares both. */ function pluralize(singular: string): string { if (/[^aeiou]y$/i.test(singular)) { return singular.slice(0, -1) + "ies"; } if (/(?:s|x|z|ch|sh)$/i.test(singular)) { return singular + "es"; } return singular + "s"; } function toPascalCase(str: string): string { return str .split(/[-_\s]/) .map((part) => (part.length === 0 ? "" : part[0]!.toUpperCase() + part.slice(1))) .join(""); }