@metaobjectsdev/codegen-ts 0.10.0 → 0.11.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/dist/column-mapper.d.ts +12 -6
  2. package/dist/column-mapper.d.ts.map +1 -1
  3. package/dist/column-mapper.js +48 -24
  4. package/dist/column-mapper.js.map +1 -1
  5. package/dist/enum-shared.js +1 -1
  6. package/dist/enum-shared.js.map +1 -1
  7. package/dist/generators/docs-data-builder.js +14 -14
  8. package/dist/generators/docs-data-builder.js.map +1 -1
  9. package/dist/generators/template-payload-tree.js +1 -1
  10. package/dist/generators/template-payload-tree.js.map +1 -1
  11. package/dist/import-path.d.ts +18 -0
  12. package/dist/import-path.d.ts.map +1 -1
  13. package/dist/import-path.js +21 -0
  14. package/dist/import-path.js.map +1 -1
  15. package/dist/metaobjects-config.d.ts +26 -0
  16. package/dist/metaobjects-config.d.ts.map +1 -1
  17. package/dist/metaobjects-config.js +3 -0
  18. package/dist/metaobjects-config.js.map +1 -1
  19. package/dist/naming.d.ts +20 -2
  20. package/dist/naming.d.ts.map +1 -1
  21. package/dist/naming.js +11 -3
  22. package/dist/naming.js.map +1 -1
  23. package/dist/payload-codegen.js +2 -2
  24. package/dist/payload-codegen.js.map +1 -1
  25. package/dist/pk-resolver.d.ts.map +1 -1
  26. package/dist/pk-resolver.js +4 -2
  27. package/dist/pk-resolver.js.map +1 -1
  28. package/dist/projection/extract-view-spec.js +1 -1
  29. package/dist/projection/extract-view-spec.js.map +1 -1
  30. package/dist/render-context.d.ts +21 -2
  31. package/dist/render-context.d.ts.map +1 -1
  32. package/dist/render-context.js +7 -0
  33. package/dist/render-context.js.map +1 -1
  34. package/dist/runner.d.ts.map +1 -1
  35. package/dist/runner.js +3 -0
  36. package/dist/runner.js.map +1 -1
  37. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  38. package/dist/templates/drizzle-schema.js +44 -23
  39. package/dist/templates/drizzle-schema.js.map +1 -1
  40. package/dist/templates/entity-constants.js +2 -2
  41. package/dist/templates/entity-constants.js.map +1 -1
  42. package/dist/templates/entity-file.js +1 -1
  43. package/dist/templates/entity-file.js.map +1 -1
  44. package/dist/templates/extract-delegate-emitter.js +1 -1
  45. package/dist/templates/extract-delegate-emitter.js.map +1 -1
  46. package/dist/templates/extractor.js +2 -2
  47. package/dist/templates/extractor.js.map +1 -1
  48. package/dist/templates/field-meta.js +1 -1
  49. package/dist/templates/field-meta.js.map +1 -1
  50. package/dist/templates/filter-allowlist.js +2 -2
  51. package/dist/templates/filter-allowlist.js.map +1 -1
  52. package/dist/templates/filter-shared.js +2 -2
  53. package/dist/templates/filter-shared.js.map +1 -1
  54. package/dist/templates/filter-type.js +1 -1
  55. package/dist/templates/filter-type.js.map +1 -1
  56. package/dist/templates/fr010-field-mapping.js +6 -6
  57. package/dist/templates/fr010-field-mapping.js.map +1 -1
  58. package/dist/templates/inferred-types.d.ts +1 -1
  59. package/dist/templates/inferred-types.d.ts.map +1 -1
  60. package/dist/templates/inferred-types.js +18 -7
  61. package/dist/templates/inferred-types.js.map +1 -1
  62. package/dist/templates/mermaid-er.js +1 -1
  63. package/dist/templates/mermaid-er.js.map +1 -1
  64. package/dist/templates/output-format-spec-emitter.js +1 -1
  65. package/dist/templates/output-format-spec-emitter.js.map +1 -1
  66. package/dist/templates/output-parser.js +1 -1
  67. package/dist/templates/output-parser.js.map +1 -1
  68. package/dist/templates/queries-file.js +3 -3
  69. package/dist/templates/queries-file.js.map +1 -1
  70. package/dist/templates/queries.d.ts +2 -2
  71. package/dist/templates/queries.d.ts.map +1 -1
  72. package/dist/templates/queries.js +9 -9
  73. package/dist/templates/queries.js.map +1 -1
  74. package/dist/templates/relations-block.d.ts.map +1 -1
  75. package/dist/templates/relations-block.js +3 -4
  76. package/dist/templates/relations-block.js.map +1 -1
  77. package/dist/templates/render-helper.js +1 -1
  78. package/dist/templates/render-helper.js.map +1 -1
  79. package/dist/templates/routes-file-hono.d.ts.map +1 -1
  80. package/dist/templates/routes-file-hono.js +1 -2
  81. package/dist/templates/routes-file-hono.js.map +1 -1
  82. package/dist/templates/routes-file.js +5 -5
  83. package/dist/templates/routes-file.js.map +1 -1
  84. package/dist/templates/value-object-file.js +2 -2
  85. package/dist/templates/value-object-file.js.map +1 -1
  86. package/dist/templates/zod-validators.d.ts +2 -2
  87. package/dist/templates/zod-validators.d.ts.map +1 -1
  88. package/dist/templates/zod-validators.js +29 -22
  89. package/dist/templates/zod-validators.js.map +1 -1
  90. package/package.json +6 -6
  91. package/src/column-mapper.ts +58 -28
  92. package/src/enum-shared.ts +1 -1
  93. package/src/generators/docs-data-builder.ts +14 -14
  94. package/src/generators/template-payload-tree.ts +1 -1
  95. package/src/import-path.ts +28 -0
  96. package/src/metaobjects-config.ts +29 -0
  97. package/src/naming.ts +25 -3
  98. package/src/payload-codegen.ts +2 -2
  99. package/src/pk-resolver.ts +4 -2
  100. package/src/projection/extract-view-spec.ts +1 -1
  101. package/src/render-context.ts +28 -2
  102. package/src/runner.ts +3 -0
  103. package/src/templates/drizzle-schema.ts +51 -29
  104. package/src/templates/entity-constants.ts +2 -2
  105. package/src/templates/entity-file.ts +1 -1
  106. package/src/templates/extract-delegate-emitter.ts +1 -1
  107. package/src/templates/extractor.ts +2 -2
  108. package/src/templates/field-meta.ts +1 -1
  109. package/src/templates/filter-allowlist.ts +2 -2
  110. package/src/templates/filter-shared.ts +2 -2
  111. package/src/templates/filter-type.ts +1 -1
  112. package/src/templates/fr010-field-mapping.ts +6 -6
  113. package/src/templates/inferred-types.ts +18 -7
  114. package/src/templates/mermaid-er.ts +1 -1
  115. package/src/templates/output-format-spec-emitter.ts +1 -1
  116. package/src/templates/output-parser.ts +1 -1
  117. package/src/templates/queries-file.ts +3 -3
  118. package/src/templates/queries.ts +8 -10
  119. package/src/templates/relations-block.ts +3 -4
  120. package/src/templates/render-helper.ts +1 -1
  121. package/src/templates/routes-file-hono.ts +1 -2
  122. package/src/templates/routes-file.ts +5 -5
  123. package/src/templates/value-object-file.ts +2 -2
  124. package/src/templates/zod-validators.ts +29 -22
  125. package/dist/templates/extract-schema-emitter.d.ts +0 -8
  126. package/dist/templates/extract-schema-emitter.d.ts.map +0 -1
  127. package/dist/templates/extract-schema-emitter.js +0 -85
  128. package/dist/templates/extract-schema-emitter.js.map +0 -1
  129. package/dist/templates/recover-schema-emitter.d.ts +0 -8
  130. package/dist/templates/recover-schema-emitter.d.ts.map +0 -1
  131. package/dist/templates/recover-schema-emitter.js +0 -64
  132. package/dist/templates/recover-schema-emitter.js.map +0 -1
@@ -82,7 +82,7 @@ export interface BuildDocDataOpts {
82
82
  * the documented model-field optionality. Exported so the field-shape builder
83
83
  * reuses the EXACT same rule rather than re-deriving it. */
84
84
  export function isFieldRequired(field: MetaField): boolean {
85
- if (field.ownAttr(FIELD_ATTR_REQUIRED) === true) return true;
85
+ if (field.attr(FIELD_ATTR_REQUIRED) === true) return true;
86
86
  return field.validators().some((v) => v.subType === VALIDATOR_SUBTYPE_REQUIRED);
87
87
  }
88
88
 
@@ -104,7 +104,7 @@ interface ValidatorParts {
104
104
  }
105
105
 
106
106
  function collectValidatorParts(field: MetaField): ValidatorParts {
107
- const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
107
+ const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH);
108
108
  const regexParts: string[] = [];
109
109
  const lengthParts: string[] = [];
110
110
  const numericParts: string[] = [];
@@ -143,7 +143,7 @@ function collectValidatorParts(field: MetaField): ValidatorParts {
143
143
  export function neutralTypeStr(field: MetaField): string {
144
144
  let base: string;
145
145
  if (field.subType === FIELD_SUBTYPE_OBJECT) {
146
- const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
146
+ const ref = field.attr(FIELD_ATTR_OBJECT_REF);
147
147
  base = typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : "object";
148
148
  } else {
149
149
  base = field.subType;
@@ -165,7 +165,7 @@ function neutralTypeCell(field: MetaField): string {
165
165
  * uses. Deliberately does NOT derive ANSI/ORM SQL so it can't drift vs the
166
166
  * migrate engine or re-introduce language-specific DDL. Wrapped in backticks. */
167
167
  function storageTypeCell(field: MetaField): string {
168
- const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
168
+ const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE);
169
169
  if (typeof dbColumnType === "string" && dbColumnType.length > 0) {
170
170
  return `\`${dbColumnType.toUpperCase()}\``;
171
171
  }
@@ -190,7 +190,7 @@ function buildConstraintRow(
190
190
  const rules: string[] = [];
191
191
 
192
192
  if (isPk) rules.push("primary key");
193
- if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
193
+ if (field.attr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
194
194
 
195
195
  if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) {
196
196
  const values = enumValues(field);
@@ -212,7 +212,7 @@ function buildConstraintRow(
212
212
  rules.push(`references \`${fk.targetEntity}.${fk.targetField}\``);
213
213
  }
214
214
 
215
- const def = field.ownAttr(FIELD_ATTR_DEFAULT);
215
+ const def = field.attr(FIELD_ATTR_DEFAULT);
216
216
  if (def !== undefined) rules.push(`default: \`${String(def)}\``);
217
217
 
218
218
  const sup = field.resolveSuper();
@@ -271,7 +271,7 @@ function buildFieldRow(
271
271
  // Otherwise empty. Keeps the column noise-free for the 90% case where
272
272
  // field name and column name agree.
273
273
  const columnName = field.column;
274
- const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
274
+ const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE);
275
275
  const columnDiffers = typeof columnName === "string" && columnName !== field.name;
276
276
  const hasPhysicalOverride = typeof dbColumnType === "string" && dbColumnType.length > 0;
277
277
  let storageCell = "";
@@ -287,7 +287,7 @@ function buildFieldRow(
287
287
  // column, plus the maxLength/length/numeric limits that used to live in
288
288
  // the separate Limits cell (collapsed in to keep the table to 5 columns).
289
289
  const rules: string[] = [];
290
- if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
290
+ if (field.attr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
291
291
 
292
292
  if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) {
293
293
  const values = enumValues(field);
@@ -303,7 +303,7 @@ function buildFieldRow(
303
303
  rules.push(...lengthParts, ...numericParts);
304
304
 
305
305
  // The FK reference is already encoded in typeCell — don't repeat it in rules.
306
- const def = field.ownAttr(FIELD_ATTR_DEFAULT);
306
+ const def = field.attr(FIELD_ATTR_DEFAULT);
307
307
  if (def !== undefined) rules.push(`default: \`${String(def)}\``);
308
308
 
309
309
  const sup = field.resolveSuper();
@@ -340,10 +340,10 @@ function buildFieldDetail(
340
340
  const hasSummary = typeof summary === "string" && summary.length > 0;
341
341
  const sup = field.resolveSuper();
342
342
  const fk = fkMap.get(field.name);
343
- const def = field.ownAttr(FIELD_ATTR_DEFAULT);
343
+ const def = field.attr(FIELD_ATTR_DEFAULT);
344
344
  const columnName = field.column;
345
- const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
346
- const isUnique = field.ownAttr(FIELD_ATTR_UNIQUE) === true;
345
+ const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE);
346
+ const isUnique = field.attr(FIELD_ATTR_UNIQUE) === true;
347
347
  const isEnum = field.subType === FIELD_SUBTYPE_ENUM && !field.isArray;
348
348
  const enumVals = isEnum ? enumValues(field) : undefined;
349
349
  const validators = field.validators();
@@ -352,7 +352,7 @@ function buildFieldDetail(
352
352
  || v.subType === VALIDATOR_SUBTYPE_REGEX
353
353
  || v.subType === VALIDATOR_SUBTYPE_NUMERIC,
354
354
  );
355
- const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
355
+ const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH);
356
356
 
357
357
  // "Interesting enough to render a detail block" predicate. Plain typed
358
358
  // fields with no authored annotations get skipped — the at-a-glance Fields
@@ -503,7 +503,7 @@ function describeIdentity(id: MetaIdentity): string {
503
503
  : `(${fields.map((f) => `\`${f}\``).join(", ")})`;
504
504
 
505
505
  if (id.subType === IDENTITY_SUBTYPE_PRIMARY) {
506
- const gen = id.ownAttr(IDENTITY_ATTR_GENERATION);
506
+ const gen = id.attr(IDENTITY_ATTR_GENERATION);
507
507
  const genSuffix = typeof gen === "string" ? ` — generation: \`${gen}\`` : "";
508
508
  return `**Primary key:** ${fieldList}${genSuffix}`;
509
509
  }
@@ -58,7 +58,7 @@ export function buildEnrichedPayloadTree(
58
58
  required: isFieldRequired(f),
59
59
  };
60
60
  if (f.subType === FIELD_SUBTYPE_OBJECT) {
61
- const ref = f.ownAttr(FIELD_ATTR_OBJECT_REF);
61
+ const ref = f.attr(FIELD_ATTR_OBJECT_REF);
62
62
  if (typeof ref === "string" && ref.length > 0) {
63
63
  // Owner switches to the nested VO for its fields (the recursion below
64
64
  // re-derives `owner` from the resolved VO's own name).
@@ -49,6 +49,34 @@ export function crossEntitySpecifier(
49
49
  return withExt(`${prefix}${toEntity}`, extStyle);
50
50
  }
51
51
 
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
+
52
80
  /** Barrel (at outDir root) re-export specifier for an entity.
53
81
  * Equivalent to crossEntitySpecifier with fromPkg=undefined (barrel is always at root). */
54
82
  export function barrelEntrySpecifier(
@@ -68,6 +68,29 @@ export interface MetaobjectsGenConfig extends ResolvedGenConfig {
68
68
  generators: GeneratorSpec[];
69
69
  /** How field names map to DB column names when @dbColumn is omitted. Defaults to "snake_case". */
70
70
  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";
71
94
  /** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
72
95
  apiPrefix?: string;
73
96
  /**
@@ -102,6 +125,9 @@ export interface NormalizedMetaobjectsGenConfig extends Omit<MetaobjectsGenConfi
102
125
  /** Fully resolved — every string spec has been mapped to its factory result. */
103
126
  generators: Generator[];
104
127
  columnNamingStrategy: ColumnNamingStrategy;
128
+ pluralizeCollections: boolean;
129
+ collectionNameOverrides: Record<string, string>;
130
+ timestampMode: "date" | "string";
105
131
  apiPrefix: string;
106
132
  emitAbstractShapes: boolean;
107
133
  outputLayout: OutputLayout;
@@ -222,6 +248,9 @@ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobj
222
248
  ...config,
223
249
  generators: resolveGenerators(config.generators),
224
250
  columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY,
251
+ pluralizeCollections: config.pluralizeCollections ?? true,
252
+ collectionNameOverrides: config.collectionNameOverrides ?? {},
253
+ timestampMode: config.timestampMode ?? "string",
225
254
  apiPrefix: config.apiPrefix ?? "",
226
255
  emitAbstractShapes: config.emitAbstractShapes ?? true,
227
256
  outputLayout: config.outputLayout ?? "flat",
package/src/naming.ts CHANGED
@@ -58,9 +58,31 @@ export function viewNameFromProjection(
58
58
  return "v" + sep + applyColumnNamingStrategy(projectionName, strategy);
59
59
  }
60
60
 
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);
64
86
  }
65
87
 
66
88
  // ---------------------------------------------------------------------------
@@ -65,7 +65,7 @@ function fieldTsType(
65
65
  ownerName: string,
66
66
  ): { type: string; refVo?: string; enumAlias?: { name: string; decl: string } } {
67
67
  if (field.subType === FIELD_SUBTYPE_OBJECT) {
68
- const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
68
+ const ref = field.attr(FIELD_ATTR_OBJECT_REF);
69
69
  const refName = typeof ref === "string" ? ref : "unknown";
70
70
  // isArray is a structural property on MetaData, not an attr.
71
71
  const isArray = field.isArray;
@@ -96,7 +96,7 @@ function fieldTsType(
96
96
 
97
97
  /** True iff the field's @required is explicitly set to true. */
98
98
  function isFieldRequired(field: MetaData): boolean {
99
- return field.ownAttr(FIELD_ATTR_REQUIRED) === true;
99
+ return field.attr(FIELD_ATTR_REQUIRED) === true;
100
100
  }
101
101
 
102
102
  function emitInterface(
@@ -30,7 +30,9 @@ export function buildPkMap(root: MetaRoot): Map<string, PkInfo> {
30
30
  // primaryIdentity() resolves the primary identity across the super-chain.
31
31
  const primary = obj.primaryIdentity();
32
32
  if (!primary) continue;
33
- const fields = primary.ownAttr(IDENTITY_ATTR_FIELDS);
33
+ // attr() (effective) not ownAttr() — @fields/@generation can be inherited when the
34
+ // identity node-level `extends` a base identity without restating them (#56).
35
+ const fields = primary.attr(IDENTITY_ATTR_FIELDS);
34
36
  if (!Array.isArray(fields) && typeof fields !== "string") continue;
35
37
  const fieldsList = Array.isArray(fields) ? fields : [fields];
36
38
  if (fieldsList.length === 0) continue;
@@ -38,7 +40,7 @@ export function buildPkMap(root: MetaRoot): Map<string, PkInfo> {
38
40
  // findField() resolves the field across the super-chain (handles extends:).
39
41
  const pkField = obj.findField(pkFieldName);
40
42
  const fieldSubType = pkField?.subType ?? FIELD_SUBTYPE_LONG; // sane default
41
- const generation = primary.ownAttr(IDENTITY_ATTR_GENERATION);
43
+ const generation = primary.attr(IDENTITY_ATTR_GENERATION);
42
44
  const info: PkInfo = { fieldName: pkFieldName, fieldSubType };
43
45
  if (typeof generation === "string") info.generation = generation;
44
46
  result.set(obj.name, info);
@@ -135,7 +135,7 @@ function sourceColumnNameFor(
135
135
  entityField: MetaData,
136
136
  ctx: ExtractContext,
137
137
  ): string {
138
- const col = entityField.ownAttr(FIELD_ATTR_COLUMN);
138
+ const col = entityField.attr(FIELD_ATTR_COLUMN);
139
139
  if (typeof col === "string" && col !== "") return col;
140
140
  return columnNameFromField(entityField.name, ctx.columnNamingStrategy);
141
141
  }
@@ -6,6 +6,7 @@ import type { PkInfo } from "./pk-resolver.js";
6
6
  import type { RelationMap } from "./relation-resolver.js";
7
7
  import type { ColumnNamingStrategy } from "./metaobjects-config.js";
8
8
  import type { OutputLayout, ResolvedTarget } from "./import-path.js";
9
+ import { variableNameFromEntity } from "./naming.js";
9
10
 
10
11
  /**
11
12
  * How to format cross-entity import specifiers in generated files.
@@ -37,12 +38,26 @@ export interface RenderContext {
37
38
  extStyle: ExtStyle;
38
39
  /** Column naming strategy: how field names map to DB column names. Defaults to "snake_case". */
39
40
  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";
40
48
  /** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
41
49
  apiPrefix: string;
42
50
  /** 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. */
43
51
  emitAbstractShapes: boolean;
44
52
  /** Output layout mode: "flat" (default) — all files in outDir; "package" — sub-paths from entity metadata package. */
45
53
  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;
46
61
  /** The target THIS generator emits to (drives path layout + same-target imports). */
47
62
  selfTarget: ResolvedTarget;
48
63
  /** Where entity files live (drives cross-target entity imports). */
@@ -58,17 +73,22 @@ export interface RenderContext {
58
73
  providedEnumModule?: string;
59
74
  }
60
75
 
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"> & {
63
78
  extStyle?: ExtStyle;
64
79
  omImport?: string;
65
80
  columnNamingStrategy?: ColumnNamingStrategy;
81
+ timestampMode?: "date" | "string";
66
82
  apiPrefix?: string;
67
83
  emitAbstractShapes?: boolean;
68
84
  outputLayout?: OutputLayout;
69
85
  packageOf?: Map<string, string | undefined>;
70
86
  selfTarget?: ResolvedTarget;
71
87
  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>;
72
92
  };
73
93
 
74
94
  /** Append the configured extension to a cross-entity module specifier. */
@@ -86,16 +106,22 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
86
106
  outputLayout,
87
107
  dbImport: opts.dbImport,
88
108
  };
109
+ const collectionNameOpts = {
110
+ pluralize: opts.pluralizeCollections ?? true,
111
+ overrides: opts.collectionNameOverrides ?? {},
112
+ };
89
113
  return {
90
114
  ...opts,
91
115
  extStyle: opts.extStyle ?? "none",
92
116
  omImport: opts.omImport ?? "../index",
93
117
  columnNamingStrategy: opts.columnNamingStrategy ?? "snake_case",
118
+ timestampMode: opts.timestampMode ?? "string",
94
119
  apiPrefix: opts.apiPrefix ?? "",
95
120
  emitAbstractShapes: opts.emitAbstractShapes ?? true,
96
121
  outputLayout,
97
122
  packageOf: opts.packageOf ?? new Map(),
98
123
  selfTarget: defaultTarget,
99
124
  entityModuleTarget: opts.entityModuleTarget ?? defaultTarget,
125
+ collectionName: (entityName: string) => variableNameFromEntity(entityName, collectionNameOpts),
100
126
  };
101
127
  }
package/src/runner.ts CHANGED
@@ -173,6 +173,9 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
173
173
  dbImport: selfTarget.dbImport,
174
174
  extStyle: config.extStyle,
175
175
  columnNamingStrategy: config.columnNamingStrategy,
176
+ pluralizeCollections: config.pluralizeCollections,
177
+ collectionNameOverrides: config.collectionNameOverrides,
178
+ timestampMode: config.timestampMode,
176
179
  apiPrefix: config.apiPrefix,
177
180
  emitAbstractShapes: config.emitAbstractShapes,
178
181
  outputLayout: selfTarget.outputLayout,
@@ -3,7 +3,7 @@
3
3
  // plus the relations() block auto-emitted at the end.
4
4
 
5
5
  import { code, imp, joinCode, type Code } from "ts-poet";
6
- import { MetaObject, MetaField } from "@metaobjectsdev/metadata";
6
+ import { MetaObject, MetaField, stripPackage } from "@metaobjectsdev/metadata";
7
7
  import {
8
8
  IDENTITY_SUBTYPE_SECONDARY, FIELD_SUBTYPE_LONG,
9
9
  IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, IDENTITY_ATTR_UNIQUE,
@@ -11,9 +11,9 @@ import {
11
11
  FIELD_ATTR_AUTO_SET,
12
12
  } from "@metaobjectsdev/metadata";
13
13
  import { type RenderContext } from "../render-context.js";
14
- import { crossEntitySpecifier } from "../import-path.js";
14
+ import { crossEntitySpecifier, valueObjectModuleSpecifier } from "../import-path.js";
15
15
  import { mapColumnType, type ColumnSpec } from "../column-mapper.js";
16
- import { tableNameFromEntity, variableNameFromEntity, columnNameFromField } from "../naming.js";
16
+ import { tableNameFromEntity, columnNameFromField } from "../naming.js";
17
17
  import { renderRelationsBlock } from "./relations-block.js";
18
18
  import { renderDocsFor } from "./jsdoc.js";
19
19
  import { collectTphSubtypeFields } from "./tph-discriminator.js";
@@ -33,17 +33,17 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
33
33
  const tableFnSym = imp(`${tableFn}@${importModule}`);
34
34
 
35
35
  const tableName = obj.dbTable ?? tableNameFromEntity(obj.name, ctx.columnNamingStrategy);
36
- const varName = variableNameFromEntity(obj.name);
36
+ const varName = ctx.collectionName(obj.name);
37
37
 
38
38
  const primary = obj.primaryIdentity();
39
- const rawPkFields = primary?.ownAttr(IDENTITY_ATTR_FIELDS);
39
+ const rawPkFields = primary?.attr(IDENTITY_ATTR_FIELDS);
40
40
  const pkFieldsList: string[] = Array.isArray(rawPkFields)
41
41
  ? rawPkFields as string[]
42
42
  : typeof rawPkFields === "string"
43
43
  ? rawPkFields.split(",").map((f) => f.trim()).filter(Boolean)
44
44
  : [];
45
45
  const pkFieldNames = new Set<string>(pkFieldsList);
46
- const pkGeneration = primary?.ownAttr(IDENTITY_ATTR_GENERATION) as string | undefined;
46
+ const pkGeneration = primary?.attr(IDENTITY_ATTR_GENERATION) as string | undefined;
47
47
 
48
48
  const fkMap = buildFkMapForEntity(obj, ctx);
49
49
 
@@ -56,9 +56,9 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
56
56
  const secondaryIdentities = obj.secondaryIdentities();
57
57
  const uniqueFieldNames = new Set<string>();
58
58
  for (const sec of secondaryIdentities) {
59
- const uniqueAttr = sec.ownAttr(IDENTITY_ATTR_UNIQUE);
59
+ const uniqueAttr = sec.attr(IDENTITY_ATTR_UNIQUE);
60
60
  if (uniqueAttr === false) continue; // explicit non-unique → don't mark column
61
- const fields = sec.ownAttr(IDENTITY_ATTR_FIELDS) as string[] | undefined;
61
+ const fields = sec.attr(IDENTITY_ATTR_FIELDS) as string[] | undefined;
62
62
  if (!Array.isArray(fields) || fields.length !== 1) continue; // multi-col uniques use a callback index, not a column flag
63
63
  uniqueFieldNames.add(fields[0]!);
64
64
  }
@@ -72,9 +72,9 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
72
72
  const fkInfo = fkMap.get(child.name);
73
73
  // Compute the column spec once per field and reuse it for both the column
74
74
  // line and the CHECK collection.
75
- const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy);
75
+ const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode);
76
76
  const fieldDocs = renderDocsFor(child);
77
- const columnLine = renderColumn(spec, child, ctx, isPk, pkGeneration, fkInfo, isComposite, isUnique, obj.package);
77
+ const columnLine = renderColumn(spec, child, ctx, isPk, pkGeneration, fkInfo, isComposite, isUnique, obj.package, obj.name);
78
78
  columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine);
79
79
  if (spec.checkConstraint !== undefined) {
80
80
  checkConstraints.push({
@@ -91,10 +91,10 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
91
91
  // stamp onto other-subtype inserts), regardless of the field's @required.
92
92
  // Subtype entities emit no table of their own (the value-object path).
93
93
  for (const child of collectTphSubtypeFields(obj, ctx.loadedRoot)) {
94
- const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy);
94
+ const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode);
95
95
  const fieldDocs = renderDocsFor(child);
96
96
  const columnLine = renderColumn(
97
- spec, child, ctx, false, undefined, fkMap.get(child.name), isComposite, false, obj.package, true,
97
+ spec, child, ctx, false, undefined, fkMap.get(child.name), isComposite, false, obj.package, obj.name, true,
98
98
  );
99
99
  columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine);
100
100
  // Enum CHECK constraints stay valid under TPH: `NULL IN (...)` is NULL
@@ -115,13 +115,13 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
115
115
  }
116
116
 
117
117
  for (const sec of secondaryIdentities) {
118
- const fields = sec.ownAttr(IDENTITY_ATTR_FIELDS) as string[] | undefined;
118
+ const fields = sec.attr(IDENTITY_ATTR_FIELDS) as string[] | undefined;
119
119
  if (!Array.isArray(fields) || fields.length === 0) continue;
120
120
  const indexName = `idx_${tableName}_${fields.map((f) => columnNameFromField(f, ctx.columnNamingStrategy)).join("_")}`;
121
121
  // @unique on the identity defaults to true (preserves back-compat with
122
122
  // foundations fixtures that assumed secondary identities were always
123
123
  // unique). Explicit @unique: false → ordinary non-unique index.
124
- const uniqueAttr = sec.ownAttr(IDENTITY_ATTR_UNIQUE);
124
+ const uniqueAttr = sec.attr(IDENTITY_ATTR_UNIQUE);
125
125
  const isUnique = uniqueAttr !== false;
126
126
  const indexFn = isUnique ? "uniqueIndex" : "index";
127
127
  const indexSym = imp(`${indexFn}@${importModule}`);
@@ -184,11 +184,15 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map<string, F
184
184
  const fkField = fkFieldNames[0]!;
185
185
  const targetName = ref.targetEntity;
186
186
  if (!targetName) continue;
187
- const targetObj = ctx.loadedRoot.findObject(targetName);
187
+ // @references may be authored bare OR package-qualified, and the loader can
188
+ // resolve it to an FQN (e.g. the YAML front-end qualifies it). Strip the
189
+ // package so the lookup matches the object's bare name — mirrors the
190
+ // relation-resolver, which already does this.
191
+ const targetObj = ctx.loadedRoot.findObject(stripPackage(targetName));
188
192
  if (!targetObj) continue;
189
193
  const targetPkField = ref.resolvedTargetPkField(ctx.loadedRoot) ?? "id";
190
194
  result.set(fkField, {
191
- targetVarName: variableNameFromEntity(targetObj.name),
195
+ targetVarName: ctx.collectionName(targetObj.name),
192
196
  targetEntityName: targetObj.name,
193
197
  targetPkField,
194
198
  });
@@ -236,6 +240,9 @@ function renderColumn(
236
240
  isComposite: boolean,
237
241
  isUnique: boolean = false,
238
242
  entityPackage: string | undefined = undefined,
243
+ // Name of the entity this column belongs to — used to detect a self-referential
244
+ // FK (target entity === this entity), which Drizzle emits without a self-import.
245
+ currentEntityName: string = "",
239
246
  // FR-017 Tier 2 — TPH subtype-only column: force nullable (drop .notNull())
240
247
  // and suppress any DB default (other-subtype rows must stay NULL here).
241
248
  forceNullable: boolean = false,
@@ -319,21 +326,30 @@ function renderColumn(
319
326
  // FK .references() uses imp() so ts-poet tracks the cross-entity import.
320
327
  let fkRefSegment: Code | null = null;
321
328
  if (fkInfo !== undefined && !isPk) {
322
- const targetSpec = crossEntitySpecifier(
323
- ctx.outputLayout,
324
- entityPackage,
325
- ctx.packageOf.get(fkInfo.targetEntityName),
326
- fkInfo.targetEntityName,
327
- ctx.extStyle,
328
- );
329
- const targetVarSym = imp(`${fkInfo.targetVarName}@${targetSpec}`);
330
- fkRefSegment = code`.references(() => ${targetVarSym}.${fkInfo.targetPkField})`;
329
+ if (fkInfo.targetEntityName === currentEntityName) {
330
+ // Self-referential FK (e.g. createdBy → this same table). Drizzle requires
331
+ // referencing the local table const directly — NOT a self-import — with an
332
+ // explicit `Any*Column` return type to break the circular type inference.
333
+ const anyColType = ctx.dialect === "sqlite" ? "AnySQLiteColumn" : "AnyPgColumn";
334
+ const anyColSym = imp(`${anyColType}@${spec.importModule}`);
335
+ fkRefSegment = code`.references((): ${anyColSym} => ${fkInfo.targetVarName}.${fkInfo.targetPkField})`;
336
+ } else {
337
+ const targetSpec = crossEntitySpecifier(
338
+ ctx.outputLayout,
339
+ entityPackage,
340
+ ctx.packageOf.get(fkInfo.targetEntityName),
341
+ fkInfo.targetEntityName,
342
+ ctx.extStyle,
343
+ );
344
+ const targetVarSym = imp(`${fkInfo.targetVarName}@${targetSpec}`);
345
+ fkRefSegment = code`.references(() => ${targetVarSym}.${fkInfo.targetPkField})`;
346
+ }
331
347
  }
332
348
 
333
349
  // @autoSet fields: emit .$defaultFn(() => new Date().toISOString()) so Drizzle
334
350
  // inserts stamp the server-side timestamp automatically. This means callers don't
335
351
  // need to supply createdAt / updatedAt in INSERT calls — Drizzle fills them in.
336
- const autoSet = field.ownAttr(FIELD_ATTR_AUTO_SET);
352
+ const autoSet = field.attr(FIELD_ATTR_AUTO_SET);
337
353
  const autoSetSuffix = (autoSet === "onCreate" || autoSet === "onUpdate")
338
354
  ? `.$defaultFn(() => new Date().toISOString())`
339
355
  : "";
@@ -346,11 +362,17 @@ function renderColumn(
346
362
  let dollarTypeSegment: Code | string = "";
347
363
  if (spec.dollarTypeRef !== undefined) {
348
364
  const ref = spec.dollarTypeRef;
365
+ const suffix = ref.array ? "[]" : "";
349
366
  if (ref.kind === "scalar") {
350
- dollarTypeSegment = `.$type<${ref.tsType}[]>()`;
367
+ dollarTypeSegment = `.$type<${ref.tsType}${suffix}>()`;
351
368
  } else {
352
- const refSym = imp(`${ref.name}@${ref.module}`);
353
- dollarTypeSegment = code`.$type<${refSym}[]>()`;
369
+ // Resolve the VO module through the shared layout/package/extStyle-aware
370
+ // helper so the .$type<VO> import matches the field's TS type + Zod schema.
371
+ const moduleSpec = valueObjectModuleSpecifier(
372
+ ref.name, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle,
373
+ );
374
+ const refSym = imp(`${ref.name}@${moduleSpec}`);
375
+ dollarTypeSegment = ref.array ? code`.$type<${refSym}[]>()` : code`.$type<${refSym}>()`;
354
376
  }
355
377
  }
356
378
 
@@ -160,12 +160,12 @@ function renderFieldRules(field: MetaField): string | undefined {
160
160
  }
161
161
 
162
162
  // Field-level @required attr (if not already covered by validator).
163
- if (!hasRequired && field.ownAttr(FIELD_ATTR_REQUIRED) === true) {
163
+ if (!hasRequired && field.attr(FIELD_ATTR_REQUIRED) === true) {
164
164
  ruleParts.push(`required: ${JSON.stringify(`${humanize(field.name)} is required`)}`);
165
165
  }
166
166
 
167
167
  // Field-level @maxLength attr (if not already covered).
168
- const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
168
+ const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH);
169
169
  if (!hasMaxLength && typeof maxLenAttr === "number") {
170
170
  ruleParts.push(
171
171
  `maxLength: { value: ${maxLenAttr}, message: ${JSON.stringify(`Must be ${maxLenAttr} characters or fewer`)} }`,
@@ -89,7 +89,7 @@ export function renderEntityFile(
89
89
  const tphBase = tphBlock !== null && isTphDiscriminatorBase(entity, ctx.loadedRoot);
90
90
  const sections: Code[] = [
91
91
  renderDrizzleSchema(entity, ctx),
92
- renderInferredTypes(entity, tphBase),
92
+ renderInferredTypes(entity, tphBase, ctx),
93
93
  ...(enumAliases !== null ? [enumAliases] : []),
94
94
  renderZodValidators(entity, ctx),
95
95
  renderEntityConstants(entity, ctx.apiPrefix),
@@ -37,7 +37,7 @@ function findObject(root: MetaData, name: string): MetaData | undefined {
37
37
 
38
38
  /** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */
39
39
  function refVo(field: MetaData, root: MetaData): MetaData | undefined {
40
- const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
40
+ const ref = field.attr(FIELD_ATTR_OBJECT_REF);
41
41
  if (typeof ref !== "string") return undefined;
42
42
  const direct = findObject(root, ref);
43
43
  if (direct !== undefined) return direct;
@@ -50,7 +50,7 @@ function findTemplate(root: MetaData, name: string): MetaData | undefined {
50
50
 
51
51
  /** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */
52
52
  function refVo(field: MetaData, root: MetaData): MetaData | undefined {
53
- const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
53
+ const ref = field.attr(FIELD_ATTR_OBJECT_REF);
54
54
  if (typeof ref !== "string") return undefined;
55
55
  const direct = findObject(root, ref);
56
56
  if (direct !== undefined) return direct;
@@ -85,7 +85,7 @@ function enumAlias(field: MetaData, ownerName: string): string | undefined {
85
85
  * required test (boolean `true` only) so the two never skew.
86
86
  */
87
87
  function isFieldRequired(field: MetaData): boolean {
88
- return field.ownAttr(FIELD_ATTR_REQUIRED) === true;
88
+ return field.attr(FIELD_ATTR_REQUIRED) === true;
89
89
  }
90
90
 
91
91
  /** The mirror→strict mapper name for a value-object (`toStrict<Name>`). */
@@ -114,7 +114,7 @@ export function zodTypeFor(field: MetaField): string {
114
114
  export function currencyMetaFor(field: MetaField): { currency: string; locale: string } | null {
115
115
  if (field.subType !== FIELD_SUBTYPE_CURRENCY) return null;
116
116
  const currency =
117
- (field.ownAttr(FIELD_ATTR_CURRENCY) as string | undefined) ?? FIELD_ATTR_CURRENCY_DEFAULT;
117
+ (field.attr(FIELD_ATTR_CURRENCY) as string | undefined) ?? FIELD_ATTR_CURRENCY_DEFAULT;
118
118
  const viewChild = field.views().find((c) => c.subType === VIEW_SUBTYPE_CURRENCY);
119
119
  const locale =
120
120
  (viewChild?.ownAttr(VIEW_CURRENCY_ATTR_LOCALE) as string | undefined) ??