Skip to content

Commit 92396d4

Browse files
dmealingclaude
andauthored
feat(codegen-ts): jsonb value-object typing + collection-name control + shared VO module resolver (#49)
Three independent codegen-ts adoption fixes that share the same render pipeline (so they land together to avoid intermingled-file rebases), plus a timestamp-mode config knob. 1. Postgres jsonb value objects - field.object no longer emits a native .array() — an object array lives in ONE jsonb column, not jsonb[]. Suppressed when subType === object. - jsonb object columns now carry .$type<VO>() / .$type<VO[]>() (the previous .$type path was sqlite-only, so Postgres object columns inferred `unknown`). Added an `array` flag to ColumnSpec.dollarTypeRef so the renderer picks the single vs collection form. Applies to single objects too, not just arrays. 2. Collection (table) variable naming control - New codegen config: `pluralizeCollections` (default true) and `collectionNameOverrides` (per-entity exact names). Surfaced as a single `ctx.collectionName(entity)` resolver used by every template that emits or references a table var (schema, queries, routes, relations, inferred types), so the declaration and all references stay consistent. Naming is a per-port codegen concern, so this is config — not a metadata attribute — and carries no cross-port conformance cost. 3. Shared value-object module resolver (consistency fix) - New `valueObjectModuleSpecifier()` is the single source of truth feeding all three places that reference a VO module — the field's TS type, its Zod schema, and the Drizzle .$type<>(). Previously each hardcoded `./<VO>.js`, which was wrong under package layout + cross-package refs and ignored extStyle. Now layout/package/extStyle-aware (matching FK-reference resolution); proven by a two-package end-to-end fixture. 4. `timestampMode` config ("date" | "string", default "string") applied uniformly to the plain timestamp + timestamptz override branches, keeping the Drizzle column aligned with the generated Zod + the cross-port wire contract. Default config reproduces byte-identical output, so all golden/conformance fixtures are unchanged. New coverage: jsonb-VO column cases, the pluralize/override resolver, the VO module resolver (flat/same-pkg/cross-pkg/ extStyle), and an end-to-end cross-package VO fixture asserting all three references resolve identically. Tests: codegen-ts 799/0, codegen-ts-react/angular/tanstack + conformance 134/0, migrate-ts 488/0, cli 277/0. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 95ed8a2 commit 92396d4

23 files changed

Lines changed: 507 additions & 78 deletions

server/typescript/packages/codegen-ts/src/column-mapper.ts

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,20 @@ export interface ColumnSpec {
134134
checkConstraint?: string;
135135
/**
136136
* Optional `.$type<...>()` chain target. Renderer (drizzle-schema.ts) emits
137-
* `.$type<TS[]>()` ahead of the modifiers chain, using ts-poet `imp()` for
138-
* objectRef variants so the cross-module type import auto-hoists.
137+
* it ahead of the modifiers chain, using ts-poet `imp()` for objectRef
138+
* variants so the cross-module type import auto-hoists. `array` controls the
139+
* `[]` suffix: a single value (`VO`) vs a collection (`VO[]`).
139140
* `kind: "scalar"` covers string[]/number[]/boolean[] — no import needed.
140-
* `kind: "objectRef"` covers SourceLens[]/Dissent[]/etc. — `module` is the
141-
* relative import path to that entity's emitted module.
141+
* `kind: "objectRef"` covers SourceLens/Dissent/etc. — only the bare VO `name`
142+
* is carried; the renderer resolves the import MODULE via the shared
143+
* `valueObjectModuleSpecifier` (layout/package/extStyle-aware, identical to the
144+
* field's TS type + Zod schema). A single Postgres jsonb object column
145+
* (`array: false`) gets `.$type<VO>()`; an array of VOs held in one jsonb
146+
* column gets `.$type<VO[]>()`.
142147
*/
143148
dollarTypeRef?:
144-
| { kind: "scalar"; tsType: "string" | "number" | "boolean" }
145-
| { kind: "objectRef"; name: string; module: string };
149+
| { kind: "scalar"; tsType: "string" | "number" | "boolean"; array: boolean }
150+
| { kind: "objectRef"; name: string; array: boolean };
146151
}
147152

148153
/**
@@ -163,6 +168,7 @@ export interface ColumnSpec {
163168
*/
164169
function pgColumnTypeOverride(
165170
field: MetaField,
171+
timestampMode: "date" | "string" = "string",
166172
): { fnName: string; fnOptions?: Record<string, unknown> } | undefined {
167173
const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
168174
if (typeof dbColumnType !== "string") return undefined;
@@ -173,9 +179,10 @@ function pgColumnTypeOverride(
173179
return { fnName: "jsonb" };
174180
case DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ:
175181
// Drizzle pg-core: timestamp(col, { withTimezone: true }) → timestamptz.
176-
// mode:"string" for the same reason as the plain-timestamp branch — the
177-
// schema + wire contract carry timestamps as strings.
178-
return { fnName: "timestamp", fnOptions: { mode: "string", withTimezone: true } };
182+
// mode defaults to "string" (ISO-8601 wire contract, matching the generated
183+
// Zod); a consumer can opt into "date" (drizzle's native mode) via
184+
// codegen.timestampMode when its hand-written code works with JS Dates.
185+
return { fnName: "timestamp", fnOptions: { mode: timestampMode, withTimezone: true } };
179186
default:
180187
return undefined;
181188
}
@@ -202,10 +209,20 @@ function isRequired(field: MetaField): boolean {
202209
return field.validators().some((child) => child.subType === VALIDATOR_SUBTYPE_REQUIRED);
203210
}
204211

212+
/** The bare (package-stripped) @objectRef name on a field.object, or undefined
213+
* when unset. Used as the `.$type<VO>()` target + its sibling-module import.
214+
* A fully-qualified ref (acme::ai::SourceLens) strips to the short name. */
215+
function objectRefBaseName(field: MetaField): string | undefined {
216+
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
217+
if (typeof ref === "string" && ref.length > 0) return stripPackage(ref);
218+
return undefined;
219+
}
220+
205221
export function mapColumnType(
206222
field: MetaField,
207223
dialect: Dialect,
208224
strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
225+
timestampMode: "date" | "string" = "string",
209226
): ColumnSpec {
210227
const dbName = field.column ?? columnNameFromField(field.name, strategy);
211228
const importModule = dialect === "sqlite" ? "drizzle-orm/sqlite-core" : "drizzle-orm/pg-core";
@@ -271,7 +288,7 @@ export function mapColumnType(
271288
// A physical @dbColumnType override wins over the subtype default (Postgres
272289
// only; SQLite has no native analogue and falls through above). Resolved
273290
// first so the override-precedence matches migrate-ts's expected-schema.
274-
const override = pgColumnTypeOverride(field);
291+
const override = pgColumnTypeOverride(field, timestampMode);
275292
if (override !== undefined) {
276293
// Override fully determines the physical type; skip the subtype switch.
277294
fnName = override.fnName;
@@ -310,7 +327,7 @@ export function mapColumnType(
310327
// inconsistent with the string-typed schema + wire contract and
311328
// throws on a string write. See SP-B api-contract-generated lane.
312329
fnName = "timestamp";
313-
fnOptions = { mode: "string" };
330+
fnOptions = { mode: timestampMode };
314331
break;
315332
case FIELD_SUBTYPE_UUID:
316333
// Postgres native uuid column; native TS binding stays `string`.
@@ -374,7 +391,11 @@ export function mapColumnType(
374391

375392
const modifiers: string[] = [];
376393

377-
if (dialect === "postgres" && isArray) {
394+
// Postgres native arrays (text[]/integer[]/…) apply to SCALAR array fields
395+
// only. An object-typed field is stored as a single jsonb column holding the
396+
// JSON array (storage jsonb/subdocument), so it gets NO native .array() — the
397+
// array-ness is carried by the .$type<VO[]>() annotation computed below.
398+
if (dialect === "postgres" && isArray && subType !== FIELD_SUBTYPE_OBJECT) {
378399
modifiers.push(".array()");
379400
}
380401

@@ -421,24 +442,33 @@ export function mapColumnType(
421442
}
422443
}
423444

424-
// SQLite isArray columns route through {mode:"json"} above; compute the
425-
// $type<E[]>() target so the renderer can hoist any cross-module imports.
445+
// jsonb / JSON-in-text columns infer as `unknown` in Drizzle without a
446+
// .$type<>() annotation. Carry the logical TS type so the column is typed:
447+
// - SQLite isArray: arrays serialize as JSON-in-text → .$type<E[]>() (scalar
448+
// element type, or the @objectRef VO for object arrays).
449+
// - Postgres field.object: a single jsonb column → .$type<VO>(), or
450+
// .$type<VO[]>() when isArray (the JSON array lives in the one column; no
451+
// native .array() is emitted for object storage — see modifiers above).
452+
// Scalar Postgres arrays use native .array() (already element-typed by
453+
// Drizzle) so they need no $type.
426454
let dollarTypeRef: ColumnSpec["dollarTypeRef"];
427455
if (dialect === "sqlite" && isArray) {
428456
if (subType === FIELD_SUBTYPE_OBJECT) {
429-
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
430-
if (typeof ref === "string" && ref.length > 0) {
431-
// @objectRef may be authored fully-qualified or bare — the $type<E[]>()
432-
// target interface + its sibling module use the BARE short name.
433-
const base = stripPackage(ref);
434-
dollarTypeRef = { kind: "objectRef", name: base, module: `./${base}.js` };
457+
const base = objectRefBaseName(field);
458+
if (base !== undefined) {
459+
dollarTypeRef = { kind: "objectRef", name: base, array: true };
435460
}
436461
} else {
437462
const scalar = sqliteJsonArrayElementTsType(subType);
438463
if (scalar !== undefined) {
439-
dollarTypeRef = { kind: "scalar", tsType: scalar as "string" | "number" | "boolean" };
464+
dollarTypeRef = { kind: "scalar", tsType: scalar as "string" | "number" | "boolean", array: true };
440465
}
441466
}
467+
} else if (dialect === "postgres" && subType === FIELD_SUBTYPE_OBJECT) {
468+
const base = objectRefBaseName(field);
469+
if (base !== undefined) {
470+
dollarTypeRef = { kind: "objectRef", name: base, array: isArray };
471+
}
442472
}
443473

444474
const result: ColumnSpec = {

server/typescript/packages/codegen-ts/src/import-path.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@ export function crossEntitySpecifier(
4949
return withExt(`${prefix}${toEntity}`, extStyle);
5050
}
5151

52+
/**
53+
* Module specifier to import a value-object's emitted module (`<VO>.ts`) from a
54+
* file that lives in `fromPkg`. Value objects are emitted by the entity-file
55+
* generator into the SAME target as entities, so this is the same same-target,
56+
* package- + extStyle-aware resolution that FK references use.
57+
*
58+
* This is the SINGLE source of truth shared by the three places that reference a
59+
* VO — the field's TS type (inferred-types), its runtime Zod schema
60+
* (zod-validators), and its Drizzle `.$type<>()` (drizzle-schema). They MUST
61+
* resolve a given VO to the identical module or they would import two distinct
62+
* symbols; routing all three through this function makes divergence impossible.
63+
*
64+
* `voPkg` is looked up from `packageOf` (which includes `object.value` nodes —
65+
* `root.objects()` returns every `TYPE_OBJECT` child). An unknown VO falls back
66+
* to `fromPkg`, yielding a same-dir `./<VO>` specifier (correct for flat layout
67+
* and for a same-package reference).
68+
*/
69+
export function valueObjectModuleSpecifier(
70+
voName: string,
71+
packageOf: ReadonlyMap<string, string | undefined>,
72+
fromPkg: string | undefined,
73+
layout: OutputLayout,
74+
extStyle: ExtStyle,
75+
): string {
76+
const voPkg = packageOf.has(voName) ? packageOf.get(voName) : fromPkg;
77+
return crossEntitySpecifier(layout, fromPkg, voPkg, voName, extStyle);
78+
}
79+
5280
/** Barrel (at outDir root) re-export specifier for an entity.
5381
* Equivalent to crossEntitySpecifier with fromPkg=undefined (barrel is always at root). */
5482
export function barrelEntrySpecifier(

server/typescript/packages/codegen-ts/src/metaobjects-config.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,29 @@ export interface MetaobjectsGenConfig extends ResolvedGenConfig {
6868
generators: GeneratorSpec[];
6969
/** How field names map to DB column names when @dbColumn is omitted. Defaults to "snake_case". */
7070
columnNamingStrategy?: ColumnNamingStrategy;
71+
/**
72+
* Auto-pluralize the Drizzle collection (table) variable name derived from
73+
* each entity (`AgentConfig` → `agentConfigs`). Defaults to `true`. Set
74+
* `false` to keep collection vars singular. Per-entity exceptions go in
75+
* {@link collectionNameOverrides}. Naming is a per-port codegen concern
76+
* (ADR-0001), so this is config — not a metadata attribute — and carries no
77+
* cross-port conformance cost.
78+
*/
79+
pluralizeCollections?: boolean;
80+
/**
81+
* Per-entity exact collection-var-name overrides, keyed by the bare entity
82+
* name. Wins over {@link pluralizeCollections} — the escape hatch for the
83+
* handful of tables a global rule gets wrong
84+
* (e.g. `{ AuditLog: "auditLog", LlmTierConfig: "llmTierConfig" }`).
85+
*/
86+
collectionNameOverrides?: Record<string, string>;
87+
/**
88+
* Drizzle timestamp column mode. "string" (default) types timestamp columns as
89+
* ISO-8601 strings (matches the generated Zod + cross-port wire contract); "date"
90+
* uses drizzle's native JS-Date mode (for consumers whose hand-written code works
91+
* with `Date`).
92+
*/
93+
timestampMode?: "date" | "string";
7194
/** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
7295
apiPrefix?: string;
7396
/**
@@ -102,6 +125,9 @@ export interface NormalizedMetaobjectsGenConfig extends Omit<MetaobjectsGenConfi
102125
/** Fully resolved — every string spec has been mapped to its factory result. */
103126
generators: Generator[];
104127
columnNamingStrategy: ColumnNamingStrategy;
128+
pluralizeCollections: boolean;
129+
collectionNameOverrides: Record<string, string>;
130+
timestampMode: "date" | "string";
105131
apiPrefix: string;
106132
emitAbstractShapes: boolean;
107133
outputLayout: OutputLayout;
@@ -222,6 +248,9 @@ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobj
222248
...config,
223249
generators: resolveGenerators(config.generators),
224250
columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY,
251+
pluralizeCollections: config.pluralizeCollections ?? true,
252+
collectionNameOverrides: config.collectionNameOverrides ?? {},
253+
timestampMode: config.timestampMode ?? "string",
225254
apiPrefix: config.apiPrefix ?? "",
226255
emitAbstractShapes: config.emitAbstractShapes ?? true,
227256
outputLayout: config.outputLayout ?? "flat",

server/typescript/packages/codegen-ts/src/naming.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,31 @@ export function viewNameFromProjection(
5858
return "v" + sep + applyColumnNamingStrategy(projectionName, strategy);
5959
}
6060

61-
/** PascalCase entity → camelCase plural for the Drizzle table variable. */
62-
export function variableNameFromEntity(entityName: string): string {
63-
return pluralize(toCamelCase(entityName.charAt(0).toLowerCase() + entityName.slice(1)));
61+
/** Codegen control over how an entity name lowers to its collection (table)
62+
* variable name. Both knobs are project-level codegen config (ADR-0001 —
63+
* naming is a per-port codegen concern, NOT a metadata attribute), so they
64+
* carry no cross-port conformance cost. */
65+
export interface CollectionNameOptions {
66+
/** Auto-pluralize the camelCase entity name. Default `true` (e.g.
67+
* `AgentConfig` → `agentConfigs`). Set `false` to keep it singular
68+
* (`agentConfig`). */
69+
pluralize?: boolean;
70+
/** Per-entity exact var-name overrides, keyed by the bare entity name. Wins
71+
* over `pluralize` — the escape hatch for the handful of tables a global
72+
* rule gets wrong (e.g. `{ AuditLog: "auditLog", LlmTierConfig: "llmTierConfig" }`). */
73+
overrides?: Record<string, string>;
74+
}
75+
76+
/** PascalCase entity → camelCase Drizzle table variable. Auto-pluralizes by
77+
* default; `opts` lets a project turn pluralization off globally and/or pin
78+
* exact names per entity. With no `opts` the behavior is the historical
79+
* always-pluralize (callers like the relation-resolver that only need the
80+
* cosmetic query-API member name pass nothing). */
81+
export function variableNameFromEntity(entityName: string, opts?: CollectionNameOptions): string {
82+
const override = opts?.overrides?.[entityName];
83+
if (override !== undefined && override.length > 0) return override;
84+
const camel = toCamelCase(entityName.charAt(0).toLowerCase() + entityName.slice(1));
85+
return opts?.pluralize === false ? camel : pluralize(camel);
6486
}
6587

6688
// ---------------------------------------------------------------------------

server/typescript/packages/codegen-ts/src/render-context.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { PkInfo } from "./pk-resolver.js";
66
import type { RelationMap } from "./relation-resolver.js";
77
import type { ColumnNamingStrategy } from "./metaobjects-config.js";
88
import type { OutputLayout, ResolvedTarget } from "./import-path.js";
9+
import { variableNameFromEntity } from "./naming.js";
910

1011
/**
1112
* How to format cross-entity import specifiers in generated files.
@@ -37,12 +38,26 @@ export interface RenderContext {
3738
extStyle: ExtStyle;
3839
/** Column naming strategy: how field names map to DB column names. Defaults to "snake_case". */
3940
columnNamingStrategy: ColumnNamingStrategy;
41+
/**
42+
* Drizzle timestamp column mode. "string" (default) types timestamp columns as
43+
* ISO-8601 strings (matching the generated Zod + cross-port wire contract);
44+
* "date" uses drizzle's native JS-Date mode (for consumers whose hand-written
45+
* code works with `Date`). Opt in via `codegen.timestampMode`.
46+
*/
47+
timestampMode: "date" | "string";
4048
/** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
4149
apiPrefix: string;
4250
/** Whether abstract entities emit their shape artifact (type-only interface / value-object file). Defaults to true. Instance/write artifacts are never emitted for abstract entities regardless. */
4351
emitAbstractShapes: boolean;
4452
/** Output layout mode: "flat" (default) — all files in outDir; "package" — sub-paths from entity metadata package. */
4553
outputLayout: OutputLayout;
54+
/**
55+
* Resolve an entity name to its Drizzle collection (table) variable name,
56+
* applying the project's pluralization config + per-entity overrides. Every
57+
* template that emits or references a table var goes through this so the
58+
* declaration and all references agree. Defaults to always-pluralize.
59+
*/
60+
collectionName: (entityName: string) => string;
4661
/** The target THIS generator emits to (drives path layout + same-target imports). */
4762
selfTarget: ResolvedTarget;
4863
/** Where entity files live (drives cross-target entity imports). */
@@ -58,17 +73,22 @@ export interface RenderContext {
5873
providedEnumModule?: string;
5974
}
6075

61-
/** Optional shape — `extStyle`, `omImport`, `columnNamingStrategy`, `apiPrefix`, `outputLayout`, and `packageOf` default if omitted. `packageOf` defaults to an empty Map (correct for flat layout; `runGen` always provides the real map). */
62-
export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "apiPrefix" | "emitAbstractShapes" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget"> & {
76+
/** Optional shape — `extStyle`, `omImport`, `columnNamingStrategy`, `apiPrefix`, `outputLayout`, and `packageOf` default if omitted. `packageOf` defaults to an empty Map (correct for flat layout; `runGen` always provides the real map). `collectionName` is built from `pluralizeCollections` + `collectionNameOverrides` (both default to always-pluralize). */
77+
export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "timestampMode" | "apiPrefix" | "emitAbstractShapes" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget" | "collectionName"> & {
6378
extStyle?: ExtStyle;
6479
omImport?: string;
6580
columnNamingStrategy?: ColumnNamingStrategy;
81+
timestampMode?: "date" | "string";
6682
apiPrefix?: string;
6783
emitAbstractShapes?: boolean;
6884
outputLayout?: OutputLayout;
6985
packageOf?: Map<string, string | undefined>;
7086
selfTarget?: ResolvedTarget;
7187
entityModuleTarget?: ResolvedTarget;
88+
/** Auto-pluralize collection (table) variable names. Default true. */
89+
pluralizeCollections?: boolean;
90+
/** Per-entity exact collection-var-name overrides, keyed by bare entity name. */
91+
collectionNameOverrides?: Record<string, string>;
7292
};
7393

7494
/** Append the configured extension to a cross-entity module specifier. */
@@ -86,16 +106,22 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
86106
outputLayout,
87107
dbImport: opts.dbImport,
88108
};
109+
const collectionNameOpts = {
110+
pluralize: opts.pluralizeCollections ?? true,
111+
overrides: opts.collectionNameOverrides ?? {},
112+
};
89113
return {
90114
...opts,
91115
extStyle: opts.extStyle ?? "none",
92116
omImport: opts.omImport ?? "../index",
93117
columnNamingStrategy: opts.columnNamingStrategy ?? "snake_case",
118+
timestampMode: opts.timestampMode ?? "string",
94119
apiPrefix: opts.apiPrefix ?? "",
95120
emitAbstractShapes: opts.emitAbstractShapes ?? true,
96121
outputLayout,
97122
packageOf: opts.packageOf ?? new Map(),
98123
selfTarget: defaultTarget,
99124
entityModuleTarget: opts.entityModuleTarget ?? defaultTarget,
125+
collectionName: (entityName: string) => variableNameFromEntity(entityName, collectionNameOpts),
100126
};
101127
}

server/typescript/packages/codegen-ts/src/runner.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
173173
dbImport: selfTarget.dbImport,
174174
extStyle: config.extStyle,
175175
columnNamingStrategy: config.columnNamingStrategy,
176+
pluralizeCollections: config.pluralizeCollections,
177+
collectionNameOverrides: config.collectionNameOverrides,
178+
timestampMode: config.timestampMode,
176179
apiPrefix: config.apiPrefix,
177180
emitAbstractShapes: config.emitAbstractShapes,
178181
outputLayout: selfTarget.outputLayout,

0 commit comments

Comments
 (0)