/** * Phase 4c — Schema snapshot normalization + serialization. * * Pure functions, no I/O beyond the Bun-native SHA-256 hasher. Given the * same `ParsedResource[]`, these functions MUST produce byte-identical * `Snapshot` objects and byte-identical serialized JSON — this guarantee * is what makes the snapshot file usable as a git-checked-in artifact and * what makes checksum-based tamper detection (Agent C) meaningful. * * Pipeline: * * ParsedResource[] --snapshotFromResources--> Snapshot * | * serializeSnapshot | parseSnapshot * v * JSON string (committed to .mandu/schema/applied.json) * * Nothing here touches the filesystem — the CLI (Agent E) and generator * (Agent D) are responsible for where the snapshot lives on disk. * * Normalization rules (documented in detail on each helper below): * 1. Only resources with a well-formed `options.persistence` are included. * 2. All resources must target the same `SqlProvider`; mixing throws. * 3. Table name: `options.persistence.tableName` > auto-pluralized * `definition.name`. `options.autoPlural === false` keeps it singular. * 4. Column name: `fieldOverrides[key].columnName` > `camelCase → snake_case`. * 5. Primary key: `options.persistence.primaryKey` (string|[string]) > * field with `primary: true` > error. Composite PK is v2+. * 6. `DdlDefault` derived from `field.default` with the string-magic * "now" / "current_timestamp" shortcut. See `normalizeDefault`. * * References: * docs/bun/phase-4c-team-plan.md §3 Agent B * docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence) * docs/rfcs/0001-db-resource-layer.md §4 D5 (opt-in `persistence` field) */ import type { ParsedResource } from "../parser"; import type { ResourceField, ResourceOptions } from "../schema"; import type { DdlDefault, DdlFieldDef, DdlFieldType, DdlIndex, DdlResource, Snapshot, SqlProvider, } from "./types"; import { asPersistence, type ExtendedResourcePersistence, type FieldOverride } from "./persistence-types"; // ============================================ // Public API // ============================================ /** * Normalize `ParsedResource[]` into a provider-tagged `Snapshot`. * * Resources whose `options.persistence` is missing/empty are silently * dropped — the resource generator emits contract/types/slot/client but * no DDL for such resources. * * Insertion order of fields (the order keys appear in the source * `definition.fields` object) is preserved; this is the author's intent * and the emit order for `CREATE TABLE`. * * Top-level `snapshot.resources` is sorted alphabetically by `name` — * this is what makes `serializeSnapshot` output stable across runs even * if the caller scans files in a different order. * * @throws TypeError on: * - conflicting providers across persistent resources * - zero primary-key fields on a persistent resource * - more than one primary-key field (composite PK is v2) * - duplicate table name after pluralization * - invalid `field.default` value (functions, symbols, objects) * - structurally broken `options.persistence` (see `asPersistence`) */ export function snapshotFromResources(resources: readonly ParsedResource[]): Snapshot { const ddlResources: DdlResource[] = []; let provider: SqlProvider | undefined; const seenTableNames = new Map(); // tableName -> first resource that claimed it for (const parsed of resources) { // `options.persistence` is not declared on the public ResourceOptions — // it's an opt-in additive field (see persistence-types.ts for rationale). // Read it via an unknown cast and narrow with `asPersistence`. const rawPersistence = (parsed.definition.options as Record | undefined)?.persistence; const persistence = asPersistence(rawPersistence); if (!persistence) continue; // non-persistent resource — skip if (provider === undefined) { provider = persistence.provider; } else if (provider !== persistence.provider) { throw new TypeError( `Mixed SQL providers in resource set: resource "${parsed.resourceName}" declares provider "${persistence.provider}" but the snapshot is already building for "${provider}". All persistent resources in a project must share one provider.` ); } const ddlResource = normalizeResource(parsed, persistence); const prior = seenTableNames.get(ddlResource.name); if (prior !== undefined) { throw new TypeError( `Duplicate table name "${ddlResource.name}" — resources "${prior}" and "${parsed.resourceName}" both map to it. Use options.persistence.tableName to disambiguate.` ); } seenTableNames.set(ddlResource.name, parsed.resourceName); ddlResources.push(ddlResource); } ddlResources.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); return { version: 1, provider: provider ?? "postgres", // empty set → default to postgres; it's meaningless for an empty resource list resources: ddlResources, generatedAt: new Date().toISOString(), }; } /** * Deterministic JSON serialization — 2-space indent, sorted object keys, * stable array order. Byte-for-byte stable for the same `Snapshot`, * modulo `generatedAt` which the caller controls. * * We intentionally sort object keys rather than trusting the property * insertion order of the callers' objects — this guards against subtle * non-determinism when `Snapshot` is built by code that constructs * objects in different orders across refactors. * * Arrays are NOT sorted — their order is semantically load-bearing * (field emit order, sorted resource order). */ export function serializeSnapshot(s: Snapshot): string { return stringifyWithSortedKeys(s, 2); } /** * Parse a snapshot JSON string. * * @throws TypeError on invalid JSON, missing required fields, or * `version` that this build does not understand. */ export function parseSnapshot(raw: string): Snapshot { let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err) { throw new TypeError( `Invalid snapshot JSON: ${err instanceof Error ? err.message : String(err)}`, { cause: err } ); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new TypeError(`Snapshot must be a JSON object, got ${typeof parsed}`); } const obj = parsed as Record; if (obj.version !== 1) { throw new TypeError( `Unsupported snapshot version ${JSON.stringify(obj.version)}. This build understands version 1 only.` ); } if (obj.provider !== "postgres" && obj.provider !== "mysql" && obj.provider !== "sqlite") { throw new TypeError(`Snapshot has invalid provider ${JSON.stringify(obj.provider)}`); } if (!Array.isArray(obj.resources)) { throw new TypeError(`Snapshot.resources must be an array`); } if (typeof obj.generatedAt !== "string") { throw new TypeError(`Snapshot.generatedAt must be an ISO string`); } // Deeper structural validation of resources is deferred to the diff // engine; tampering with fields of a stored snapshot would be caught // by the migration runtime's checksum before diff ever runs. return parsed as Snapshot; } /** * SHA-256 of the canonical serialization of a snapshot. Useful for quick * change detection ("has the committed schema drifted from the applied * snapshot?"). * * Note: `generatedAt` is part of the canonical serialization, so two * snapshots with the same resources but different generation times will * hash differently. Callers that want a time-stable hash should zero out * `generatedAt` before calling. */ export function hashSnapshot(s: Snapshot): string { const canonical = serializeSnapshot(s); const hasher = new Bun.CryptoHasher("sha256"); hasher.update(canonical); return hasher.digest("hex"); } // ============================================ // Internals — resource normalization // ============================================ function normalizeResource( parsed: ParsedResource, persistence: ExtendedResourcePersistence ): DdlResource { const { definition } = parsed; const tableName = resolveTableName(definition.name, definition.options, persistence); const overrides = persistence.fieldOverrides ?? {}; // Respect insertion order — Object.entries preserves the author's key order. const fieldEntries = Object.entries(definition.fields); if (fieldEntries.length === 0) { // parser.ts already validates this, but guard defensively. throw new TypeError(`Resource "${parsed.resourceName}" has no fields`); } const declaredPk = resolveDeclaredPrimaryKey(persistence.primaryKey); const fields: DdlFieldDef[] = []; const pkFieldKeys: string[] = []; // Author field key (e.g. "passwordHash") → resolved column name (e.g. "password_hash"). // Used by index normalization below to resolve declared index fields. const keyToColumn = new Map(); for (const [fieldKey, field] of fieldEntries) { const override = overrides[fieldKey]; const ddlField = normalizeField(fieldKey, field, override, declaredPk, parsed.resourceName); if (ddlField.primary) pkFieldKeys.push(fieldKey); fields.push(ddlField); keyToColumn.set(fieldKey, ddlField.name); } if (pkFieldKeys.length === 0) { throw new TypeError( `Resource "${parsed.resourceName}" must have exactly one primary key field. Mark a field with \`primary: true\` (via fieldOverrides) or declare \`options.persistence.primaryKey\`.` ); } if (pkFieldKeys.length > 1) { throw new TypeError( `Resource "${parsed.resourceName}" has ${pkFieldKeys.length} primary key fields (${pkFieldKeys.join(", ")}). Composite primary keys are not supported in v1.` ); } const indexes = normalizeIndexes(persistence.indexes, keyToColumn, parsed.resourceName); return { name: tableName, fields, indexes }; } function resolveTableName( resourceName: string, options: ResourceOptions | undefined, persistence: ExtendedResourcePersistence ): string { if (persistence.tableName) return persistence.tableName; // `options.pluralName` from the existing schema takes precedence over auto-plural, // but `tableName` trumps both — this preserves backward compat for users who // had a `pluralName` before Phase 4c. if (options?.pluralName) return options.pluralName; if (options?.autoPlural === false) return resourceName; return pluralize(resourceName); } /** * Conservative v1 pluralizer. * * Rules (in order): * 1. ends with `y` preceded by a consonant → `ies` ("city" → "cities") * 2. ends with `s` / `x` / `z` / `ch` / `sh` → `+es` ("box" → "boxes") * 3. default → `+s` ("user" → "users") * * English has irregular plurals the framework cannot infer — that's the * escape hatch `options.persistence.tableName` exists for. */ 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 resolveDeclaredPrimaryKey(declared: ExtendedResourcePersistence["primaryKey"]): string | undefined { if (declared === undefined) return undefined; if (typeof declared === "string") return declared; return declared[0]; } // ============================================ // Internals — field normalization // ============================================ function normalizeField( fieldKey: string, field: ResourceField, override: FieldOverride | undefined, declaredPk: string | undefined, resourceName: string ): DdlFieldDef { const name = override?.columnName ?? toSnakeCase(fieldKey); const nullable = override?.nullable ?? !(field.required ?? false); // A field is a primary key if: // - the explicit `persistence.primaryKey` names it, OR // - the field's declaration carries `primary: true` (an opt-in, not the // default on Mandu's ResourceField type; accessed via a best-effort // cast because `ResourceField` predates this feature). const declaredPkMatch = declaredPk !== undefined && declaredPk === fieldKey; const fieldLevelPk = Boolean((field as ResourceField & { primary?: boolean }).primary); const primary = declaredPkMatch || fieldLevelPk; const unique = override?.unique ?? Boolean((field as ResourceField & { unique?: boolean }).unique); const indexed = override?.indexed ?? Boolean((field as ResourceField & { indexed?: boolean }).indexed); const def = override?.default ?? normalizeDefault(field.default, fieldKey, resourceName); const result: DdlFieldDef = { name, type: field.type as DdlFieldType, nullable, primary, unique, indexed, }; if (def !== undefined) result.default = def; const maxLength = override?.maxLength ?? (field as ResourceField & { maxLength?: number }).maxLength; if (typeof maxLength === "number") result.maxLength = maxLength; return result; } /** * `camelCase` / `PascalCase` → `snake_case`. Leaves already-snake names * untouched. Runs of consecutive capitals are treated as a single word * (`HTTPRequest` → `http_request`) which matches PostgreSQL/Drizzle * conventions. */ export function toSnakeCase(input: string): string { if (input.length === 0) return input; // Insert underscores at run-of-caps/start-of-word boundaries, then lowercase. return input .replace(/([a-z0-9])([A-Z])/g, "$1_$2") // camelCase → camel_Case .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2") // HTTPServer → HTTP_Server .toLowerCase(); } /** * Normalize `ResourceField.default` to a `DdlDefault` discriminated union. * * Rules: * undefined → no DEFAULT (returns undefined) * null → { kind: "null" } * "now" | "current_timestamp" → { kind: "now" } * string (other) → { kind: "literal", value } * number | boolean → { kind: "literal", value } * function | symbol | object | array → throws TypeError * * Arrays/objects are explicitly rejected: their JSON representation depends * on the receiving dialect's JSON column semantics, and the v1 contract * is that DEFAULT values are scalar. Users with JSON defaults should use * `{ kind: "sql", expr: "'[]'" }` via `fieldOverrides[key].default`. */ function normalizeDefault( raw: unknown, fieldKey: string, resourceName: string ): DdlDefault | undefined { if (raw === undefined) return undefined; if (raw === null) return { kind: "null" }; if (typeof raw === "string") { if (raw === "now" || raw === "current_timestamp") return { kind: "now" }; return { kind: "literal", value: raw }; } if (typeof raw === "number") { if (!Number.isFinite(raw)) { throw new TypeError( `Field "${resourceName}.${fieldKey}" has non-finite default (${String(raw)}). DEFAULT must be a finite number.` ); } return { kind: "literal", value: raw }; } if (typeof raw === "boolean") return { kind: "literal", value: raw }; // Functions, symbols, objects, arrays, BigInt — none are representable // as a portable SQL DEFAULT literal. Force the user to be explicit via // `fieldOverrides[key].default = { kind: "sql", expr: ... }`. throw new TypeError( `Field "${resourceName}.${fieldKey}" has unsupported default type (${typeof raw}). Use a string, number, boolean, or null — or override via options.persistence.fieldOverrides.${fieldKey}.default with an explicit DdlDefault.` ); } // ============================================ // Internals — index normalization // ============================================ /** * Normalize user-declared indexes to DDL shape. The author's `idx.fields` * entries are field KEYS (as written in `definition.fields`); we resolve * each to the post-snake_case column name via `keyToColumn`. Entries * that don't match a known field key are passed through verbatim — * that supports the escape hatch of referencing a column directly. */ function normalizeIndexes( declared: DdlIndex[] | undefined, keyToColumn: ReadonlyMap, resourceName: string ): DdlIndex[] { if (!declared || declared.length === 0) return []; const list = declared.map((idx) => { if (!idx.name) throw new TypeError(`Index on "${resourceName}" is missing a name`); if (!Array.isArray(idx.fields) || idx.fields.length === 0) { throw new TypeError(`Index "${resourceName}.${idx.name}" must declare at least one field`); } const columns = idx.fields.map((key) => keyToColumn.get(key) ?? key); return { name: idx.name, fields: columns, unique: Boolean(idx.unique) }; }); // Stable sort by name — makes snapshot output deterministic. list.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); // Detect duplicate index names after normalization. for (let i = 1; i < list.length; i++) { if (list[i].name === list[i - 1].name) { throw new TypeError(`Duplicate index name "${list[i].name}" on resource "${resourceName}"`); } } return list; } // ============================================ // Internals — deterministic JSON // ============================================ /** * `JSON.stringify` that sorts object keys at every depth. Arrays are * preserved in order. `undefined` values (and their keys) are dropped. */ function stringifyWithSortedKeys(value: unknown, indent: number): string { return JSON.stringify(sortKeys(value), null, indent); } function sortKeys(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeys); if (value && typeof value === "object") { const entries = Object.entries(value as Record).filter( ([, v]) => v !== undefined ); entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); const out: Record = {}; for (const [k, v] of entries) out[k] = sortKeys(v); return out; } return value; }