// Inferred types template — emits Drizzle's InferSelectModel / InferInsertModel type aliases, // plus named union types for field.enum fields. // // Also emits the structural TS interface for value-only objects (metaobjects // with no writable source.rdb). That path side-steps Drizzle entirely: the // interface is computed directly from the field tree, with `name?: T` for // optional fields (matching Zod's `.optional()` inference — `T | undefined` — // without the superfluous `| null` that Drizzle nullable columns introduce). import { code, imp, joinCode, type Code } from "ts-poet"; import type { MetaObject, MetaField } from "@metaobjectsdev/metadata"; import { FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, FIELD_ATTR_REQUIRED, FIELD_ATTR_OBJECT_REF, FIELD_ATTR_VALUE_TYPE, FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_JSONB, } from "@metaobjectsdev/metadata"; import { variableNameFromEntity, toPascalCase } from "../naming.js"; import { valueObjectModuleSpecifier } from "../import-path.js"; import { stripPackage } from "@metaobjectsdev/metadata"; import { enumValues } from "../enum-meta.js"; import { renderDocsFor } from "./jsdoc.js"; import { sharedEnumForField } from "../enum-shared.js"; import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "../enum-import.js"; import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; /** * Emit Drizzle's InferSelectModel / InferInsertModel aliases for an entity. * * `tphBase` (FR-017): when this entity is a TPH discriminator base, the * discriminated-union type (emitted by the tph-discriminator template) owns the * bare `` name, so the raw single-table row type is emitted as `Row` * to avoid a duplicate `export type `. Insert/Update keep their names * (no collision); they describe the physical TPH table row shape. */ export function renderInferredTypes( entity: MetaObject, tphBase = false, ctx?: RenderContext, skipRow = false, ): Code { // The inferred Row/Insert types reference the Drizzle table var, so they must // resolve to the SAME (possibly overridden) collection name the schema emits. // ctx is optional for bare unit-test calls — those fall back to the default // always-pluralize spelling. const varName = ctx ? ctx.collectionName(entity.name) : variableNameFromEntity(entity.name); // Type-only symbols (t: prefix) so ts-poet emits `import type` — otherwise a // value import of these types fails tsc under `verbatimModuleSyntax` (TS1484). (#165) const selectSym = imp("t:InferSelectModel@drizzle-orm"); const insertSym = imp("t:InferInsertModel@drizzle-orm"); const docs = renderDocsFor(entity); const docsPrefix = docs ? `${docs}\n` : ""; const rowName = tphBase ? `${entity.name}Row` : entity.name; // #214 — a write-through entity read-view routes READS to the replica view, so the // read row type is emitted from the VIEW's read schema (z.infer, carrying // the derived fields) by the entity-file composer, NOT here. `skipRow` suppresses // the table-inferred Row so there is no duplicate `export type ` (selectSym // then goes unreferenced and ts-poet drops its import). Insert/Update always stay // inferred from the write TABLE (derived-free, #213). if (skipRow) { return code` export type ${entity.name}Insert = ${insertSym}; export type ${entity.name}Update = Partial<${entity.name}Insert>; `; } return code` ${docsPrefix}export type ${rowName} = ${selectSym}; export type ${entity.name}Insert = ${insertSym}; export type ${entity.name}Update = Partial<${entity.name}Insert>; `; } /** * The string-literal-union type-alias name for a `field.enum` field — the SINGLE * source of truth for enum-union naming (reused by the entity inferred-types * emitter AND the payload-VO emitter so both agree byte-for-byte). * * - If the field extends an abstract field.enum (super), use the super field's * PascalCase name (so multiple fields sharing one abstract enum collapse to a * single alias). * - Otherwise use `` for inline enums, where `` is the * owning object's name (entity OR payload value-object). */ export function enumUnionAliasName(ownerName: string, field: MetaField): string { const superField = field.resolveSuper(); return superField !== undefined ? toPascalCase(superField.name) : `${ownerName}${toPascalCase(field.name)}`; } /** The `"A" | "B"` union string for a set of enum member values. */ export function enumUnionString(values: string[]): string { return values.map((v) => JSON.stringify(v)).join(" | "); } /** * Emit the enum type-alias section for an entity file. Three cases per field: * * • inline enum (members declared directly on the field; no root-abstract super) * → `export type = "A" | "B";` — UNCHANGED (byte-identical). * • shared materialized enum (extends a NON-@provided root-level abstract * field.enum) → re-export the materialized type from the shared `./enums` * module (`export { type E } from "./enums"`) instead of redeclaring it. The * type is materialized ONCE in enums.ts (FR-019). * • provided enum (extends a @provided root-level abstract field.enum) → * re-export the type from the configured external module * (`export { type E } from ""`); metaobjects emits no * declaration for it. A missing config is a codegen-time error. * * `ctx` is required to compute the shared/provided import specifiers. Returns * null when the entity has no enum-alias lines to emit. */ export function renderEnumTypeAliases(entity: MetaObject, ctx?: RenderContext): Code | null { // De-duplicate by type-alias name — multiple fields can extend the same abstract enum. const seen = new Set(); const lines: string[] = []; // ADR-0044/#228 — an inline enum's alias is ``; `` is this // object's EMITTED name so a collision-qualified value object declares (and its // interface references) `AcmeAlphaNoteStatus`, not a bare `NoteStatus`. Entities // and non-colliding value objects keep their bare name (byte-identical). const ownerName = ctx ? ctx.valueObjectEmittedName(entity) : entity.name; for (const field of entity.fields()) { if (field.subType !== FIELD_SUBTYPE_ENUM) continue; const values = enumValues(field); if (values === undefined) continue; const typeName = enumUnionAliasName(ownerName, field); if (seen.has(typeName)) continue; seen.add(typeName); // Without a RenderContext (bare unit-test calls) the shared/provided import // specifiers can't be computed — fall back to inline emission. Real runs // always pass ctx (entity-file template), so shared materialization applies. const shared = ctx !== undefined ? sharedEnumForField(field) : undefined; if (shared === undefined) { // Inline enum — emit the literal union exactly as before. lines.push(`export type ${typeName} = ${enumUnionString(values)};`); continue; } // Shared / provided enum — re-export from the materialized module or the // configured external module; never redeclare the union here. const spec = shared.provided ? providedEnumImportSpecifier(ctx!, shared.name) : sharedEnumImportSpecifier(ctx!, entity.package); lines.push(`export { type ${shared.name} } from ${JSON.stringify(spec)};`); } return lines.length > 0 ? code`${lines.join("\n")}` : null; } // --------------------------------------------------------------------------- // Value-object interface emitter // --------------------------------------------------------------------------- const SCALAR_TS_BY_SUBTYPE: Record = { [FIELD_SUBTYPE_STRING]: "string", [FIELD_SUBTYPE_UUID]: "string", // ADR-0036/0037 Wave 3: uri/inet bind to TS `string` (TS has no native URI/IP // type, same as uuid). Other ports bind to their native URI/IP type. [FIELD_SUBTYPE_URI]: "string", [FIELD_SUBTYPE_INET]: "string", [FIELD_SUBTYPE_INT]: "number", [FIELD_SUBTYPE_LONG]: "number", [FIELD_SUBTYPE_DOUBLE]: "number", [FIELD_SUBTYPE_FLOAT]: "number", // field.decimal is precision-exact: Drizzle's pg `numeric` column infers as // `string` (the driver returns NUMERIC as a string to avoid float rounding), // so the value-object/structural-interface scalar mapping must match. [FIELD_SUBTYPE_DECIMAL]: "string", [FIELD_SUBTYPE_CURRENCY]: "number", [FIELD_SUBTYPE_BOOLEAN]: "boolean", [FIELD_SUBTYPE_DATE]: "string", [FIELD_SUBTYPE_TIME]: "string", [FIELD_SUBTYPE_TIMESTAMP]: "string", }; /** * The PLAIN-STRING TS type expression for a field — the SINGLE source of truth * for "what TS type does the codegen give this field". `valueObjectFieldType` * (which returns a `Code` so cross-module `field.object` refs hoist via * `imp(...)`) makes the SAME per-branch decisions; this string form exists for * consumers (the api-docs field-shape builder) that need the type name as text, * not a hoisting `Code`. The branch logic MUST stay in lock-step with * `valueObjectFieldType` below. * * • field.object → the referenced object's bare (package-stripped) name, `[]` * when an array; `unknown` / `unknown[]` when the @objectRef is missing. * • field.enum → the same enum-union alias `enumUnionAliasName` emits * (`` or the abstract super's PascalCase), `string` fallback. * • scalar → SCALAR_TS_BY_SUBTYPE (else `unknown`), `[]` when an array. */ export function fieldTsTypeString(ownerName: string, field: MetaField): string { // `@dbColumnType: jsonb` (open JSON bag, legal only on field.string) → `unknown`: // the column is a bare `jsonb()` returning any parsed JSON value, so the TS type // stays in lock-step with the `z.unknown()` Zod emission (NOT `string`). if (field.attr(FIELD_ATTR_DB_COLUMN_TYPE) === DB_COLUMN_TYPE_JSONB) { return field.resolvedIsArray() ? "unknown[]" : "unknown"; } if (field.subType === FIELD_SUBTYPE_OBJECT) { const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { // #228: docs-tier bare name under collision — this is the deprecated `meta docs` // TEXT-shape helper (no ctx/root in scope; callers api-field-shape run under // api-model's `{ pkMap } as RenderContext` shim), so it can't resolve the ADR-0044 // emitted name. Byte-identical to codegen in every non-colliding model; on a // cross-package collision it documents the bare `Note` while codegen emits // `AcmeAlphaNote`. Threading a real RenderContext into api-docs is out of scope. const base = stripPackage(ref); return field.resolvedIsArray() ? `${base}[]` : base; } return field.resolvedIsArray() ? "unknown[]" : "unknown"; } if (field.subType === FIELD_SUBTYPE_ENUM) { const values = enumValues(field); if (values !== undefined) { // The emitted TS type is an enum-union ALIAS (``), but its // definition IS this literal union — inline it so the documented shape is // self-contained (an agent sees the exact allowed values, not an opaque // alias name). Array enums wrap the parenthesized union: `(A | B)[]`. const union = enumUnionString(values); return field.resolvedIsArray() ? `(${union})[]` : union; } return field.resolvedIsArray() ? "string[]" : "string"; } const scalar = SCALAR_TS_BY_SUBTYPE[field.subType] ?? "unknown"; return field.resolvedIsArray() ? `${scalar}[]` : scalar; } /** * One-line TS type expression for a field on a value-only object. * Returns a `Code` so cross-module `field.object` refs can be hoisted via * ts-poet `imp(...)` — matching how the Zod emitter hoists `InsertSchema`. */ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: RenderContext): Code { // ADR-0044/#228 — the owning value-object's EMITTED name (bare when unique in // the run, package-qualified on a cross-package short-name collision). Drives // the inline enum-union alias so it matches the alias declared for this object. const ownerName = ctx ? ctx.valueObjectEmittedName(entity) : entity.name; // `@dbColumnType: jsonb` (open JSON bag) → `unknown`, in lock-step with // fieldTsTypeString above and the `z.unknown()` Zod emission. if (field.attr(FIELD_ATTR_DB_COLUMN_TYPE) === DB_COLUMN_TYPE_JSONB) { return field.resolvedIsArray() ? code`unknown[]` : code`unknown`; } // field.object: import the referenced TS interface from its sibling module // so ts-poet hoists the import. Mirrors zod-validators.ts's `InsertSchema` // import strategy, just for the type alias instead of the schema constant. if (field.subType === FIELD_SUBTYPE_OBJECT) { const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { // @objectRef may be authored fully-qualified (acme::sales::Brief) or bare. // ADR-0044/#228 — the referenced interface is named by its EMITTED name // (bare when unique in the run, package-qualified on a cross-package // short-name collision), resolved package-locally from the FIELD's declaring // package. The import MODULE is resolved through the shared // layout/package/extStyle-aware helper (the SAME one the Zod schema + // Drizzle .$type<> use) so all three agree. Without a ctx (bare unit-test // calls) fall back to the bare name + flat same-dir specifier. const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entity.package)) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; const refImp = imp(`${refName}@${moduleSpec}`); return field.resolvedIsArray() ? code`${refImp}[]` : code`${refImp}`; } return field.resolvedIsArray() ? code`unknown[]` : code`unknown`; } // field.map: Record — V is a value-object (@objectRef) or a scalar (@valueType). if (field.subType === FIELD_SUBTYPE_MAP) { const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entity.package)) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; const refImp = imp(`${refName}@${moduleSpec}`); return code`Record`; } const vt = field.attr(FIELD_ATTR_VALUE_TYPE); const scalar = (typeof vt === "string" ? SCALAR_TS_BY_SUBTYPE[vt] : undefined) ?? "string"; return code`Record`; } // field.enum: use the same type-alias name as renderEnumTypeAliases emits. if (field.subType === FIELD_SUBTYPE_ENUM) { const values = enumValues(field); if (values !== undefined) { const alias = enumUnionAliasName(ownerName, field); // FR-019: a shared/provided enum's type lives in another module (./enums or // the provided module). The `t:` prefix is load-bearing (#341) — it is what // makes ts-poet hoist `import { type E }` rather than a VALUE import. Without // it the enum's TYPE and its Zod VALUE (`Enum`) merge into one value // import, which is a hard TS1484 under `verbatimModuleSyntax: true` — the // default in current Vite/TS templates — so generated code the adopter cannot // edit fails to compile. Invisible with the flag off, which is why it shipped. // Inline enums reference the locally-declared `` alias. if (ctx !== undefined) { const shared = sharedEnumForField(field); if (shared !== undefined) { const spec = shared.provided ? providedEnumImportSpecifier(ctx, shared.name) : sharedEnumImportSpecifier(ctx, entity.package); const sym = imp(`t:${shared.name}@${spec}`); return field.resolvedIsArray() ? code`${sym}[]` : code`${sym}`; } } return field.resolvedIsArray() ? code`${alias}[]` : code`${alias}`; } return field.resolvedIsArray() ? code`string[]` : code`string`; } const scalar = SCALAR_TS_BY_SUBTYPE[field.subType] ?? "unknown"; return field.resolvedIsArray() ? code`${scalar}[]` : code`${scalar}`; } /** * Emit a structural `interface { ... }` for a value-only object. * * Optional fields use `name?: T` (matching the Zod `.optional()` inference * `T | undefined`) instead of `name?: T | null`. Value objects never round- * trip through Drizzle nullable columns, so the null-bridge is unnecessary * here — and forces consumers into a residual cast at the call site. */ export function renderValueObjectInterface(entity: MetaObject, ctx?: RenderContext): Code { const docs = renderDocsFor(entity); const docsPrefix = docs ? `${docs}\n` : ""; // ADR-0044/#228 — the declared interface name is this value object's EMITTED // name (bare when unique in the run, package-qualified on a cross-package // short-name collision). Byte-identical (bare) when there is no collision. const objName = ctx ? ctx.valueObjectEmittedName(entity) : entity.name; const lines: Code[] = []; for (const field of entity.fields()) { const required = field.attr(FIELD_ATTR_REQUIRED) === true; const optional = required ? "" : "?"; const tsType = valueObjectFieldType(entity, field, ctx); lines.push(code` ${field.name}${optional}: ${tsType};`); } // joinCode with "\n" interpolates each Code segment on its own line and // keeps the imp() registrations intact so ts-poet hoists the imports. return code`${docsPrefix}export interface ${objName} { ${joinCode(lines, { on: "\n" })} } `; }