@metaobjectsdev/codegen-ts 1.0.0-rc.4 → 1.0.0-rc.6

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 (168) hide show
  1. package/dist/column-mapper.d.ts +12 -0
  2. package/dist/column-mapper.d.ts.map +1 -1
  3. package/dist/column-mapper.js +16 -0
  4. package/dist/column-mapper.js.map +1 -1
  5. package/dist/constants.d.ts +10 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/constants.js +10 -0
  8. package/dist/constants.js.map +1 -1
  9. package/dist/generator-registry.d.ts.map +1 -1
  10. package/dist/generator-registry.js +1 -0
  11. package/dist/generator-registry.js.map +1 -1
  12. package/dist/generator.d.ts +15 -0
  13. package/dist/generator.d.ts.map +1 -1
  14. package/dist/generator.js.map +1 -1
  15. package/dist/generators/agent-docs-file.d.ts.map +1 -1
  16. package/dist/generators/agent-docs-file.js +12 -3
  17. package/dist/generators/agent-docs-file.js.map +1 -1
  18. package/dist/generators/agent-schema-input.d.ts +11 -0
  19. package/dist/generators/agent-schema-input.d.ts.map +1 -1
  20. package/dist/generators/agent-schema-page.d.ts.map +1 -1
  21. package/dist/generators/agent-schema-page.js +13 -1
  22. package/dist/generators/agent-schema-page.js.map +1 -1
  23. package/dist/generators/agent-ui-page.d.ts.map +1 -1
  24. package/dist/generators/agent-ui-page.js +11 -9
  25. package/dist/generators/agent-ui-page.js.map +1 -1
  26. package/dist/generators/api-model.d.ts +2 -1
  27. package/dist/generators/api-model.d.ts.map +1 -1
  28. package/dist/generators/api-model.js +12 -6
  29. package/dist/generators/api-model.js.map +1 -1
  30. package/dist/generators/barrel.d.ts.map +1 -1
  31. package/dist/generators/barrel.js +2 -1
  32. package/dist/generators/barrel.js.map +1 -1
  33. package/dist/generators/callable-file.d.ts.map +1 -1
  34. package/dist/generators/callable-file.js +2 -1
  35. package/dist/generators/callable-file.js.map +1 -1
  36. package/dist/generators/docs-data-builder.d.ts.map +1 -1
  37. package/dist/generators/docs-data-builder.js +6 -5
  38. package/dist/generators/docs-data-builder.js.map +1 -1
  39. package/dist/generators/entity-file.d.ts.map +1 -1
  40. package/dist/generators/entity-file.js +2 -1
  41. package/dist/generators/entity-file.js.map +1 -1
  42. package/dist/generators/names-file.d.ts +22 -1
  43. package/dist/generators/names-file.d.ts.map +1 -1
  44. package/dist/generators/names-file.js +13 -18
  45. package/dist/generators/names-file.js.map +1 -1
  46. package/dist/generators/queries-file.d.ts.map +1 -1
  47. package/dist/generators/queries-file.js +2 -1
  48. package/dist/generators/queries-file.js.map +1 -1
  49. package/dist/generators/render-helper-file.js +1 -1
  50. package/dist/generators/render-helper-file.js.map +1 -1
  51. package/dist/generators/requirements-markdown.d.ts.map +1 -1
  52. package/dist/generators/requirements-markdown.js +26 -1
  53. package/dist/generators/requirements-markdown.js.map +1 -1
  54. package/dist/generators/routes-file-hono.d.ts.map +1 -1
  55. package/dist/generators/routes-file-hono.js +2 -1
  56. package/dist/generators/routes-file-hono.js.map +1 -1
  57. package/dist/generators/routes-file.d.ts.map +1 -1
  58. package/dist/generators/routes-file.js +2 -1
  59. package/dist/generators/routes-file.js.map +1 -1
  60. package/dist/index.d.ts +9 -7
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +16 -7
  63. package/dist/index.js.map +1 -1
  64. package/dist/metaobjects-config.d.ts +10 -0
  65. package/dist/metaobjects-config.d.ts.map +1 -1
  66. package/dist/metaobjects-config.js +14 -1
  67. package/dist/metaobjects-config.js.map +1 -1
  68. package/dist/names.d.ts.map +1 -1
  69. package/dist/names.js +5 -4
  70. package/dist/names.js.map +1 -1
  71. package/dist/overwrite-policy.d.ts +23 -9
  72. package/dist/overwrite-policy.d.ts.map +1 -1
  73. package/dist/overwrite-policy.js +48 -0
  74. package/dist/overwrite-policy.js.map +1 -1
  75. package/dist/runner.d.ts +24 -0
  76. package/dist/runner.d.ts.map +1 -1
  77. package/dist/runner.js +143 -13
  78. package/dist/runner.js.map +1 -1
  79. package/dist/templates/callable-file.d.ts.map +1 -1
  80. package/dist/templates/callable-file.js +2 -1
  81. package/dist/templates/callable-file.js.map +1 -1
  82. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  83. package/dist/templates/drizzle-schema.js +39 -14
  84. package/dist/templates/drizzle-schema.js.map +1 -1
  85. package/dist/templates/entity-file.d.ts.map +1 -1
  86. package/dist/templates/entity-file.js +4 -2
  87. package/dist/templates/entity-file.js.map +1 -1
  88. package/dist/templates/filter-allowlist.d.ts.map +1 -1
  89. package/dist/templates/filter-allowlist.js +10 -6
  90. package/dist/templates/filter-allowlist.js.map +1 -1
  91. package/dist/templates/filter-shared.d.ts +42 -1
  92. package/dist/templates/filter-shared.d.ts.map +1 -1
  93. package/dist/templates/filter-shared.js +68 -5
  94. package/dist/templates/filter-shared.js.map +1 -1
  95. package/dist/templates/inferred-types.d.ts.map +1 -1
  96. package/dist/templates/inferred-types.js +9 -6
  97. package/dist/templates/inferred-types.js.map +1 -1
  98. package/dist/templates/projection-decl.d.ts.map +1 -1
  99. package/dist/templates/projection-decl.js +4 -2
  100. package/dist/templates/projection-decl.js.map +1 -1
  101. package/dist/templates/queries-file.d.ts.map +1 -1
  102. package/dist/templates/queries-file.js +6 -5
  103. package/dist/templates/queries-file.js.map +1 -1
  104. package/dist/templates/relations-block.d.ts.map +1 -1
  105. package/dist/templates/relations-block.js +2 -1
  106. package/dist/templates/relations-block.js.map +1 -1
  107. package/dist/templates/render-helper.d.ts +12 -1
  108. package/dist/templates/render-helper.d.ts.map +1 -1
  109. package/dist/templates/render-helper.js +30 -7
  110. package/dist/templates/render-helper.js.map +1 -1
  111. package/dist/templates/routes-file-hono.d.ts.map +1 -1
  112. package/dist/templates/routes-file-hono.js +2 -1
  113. package/dist/templates/routes-file-hono.js.map +1 -1
  114. package/dist/templates/routes-file.d.ts.map +1 -1
  115. package/dist/templates/routes-file.js +18 -10
  116. package/dist/templates/routes-file.js.map +1 -1
  117. package/dist/templates/zod-validators.d.ts.map +1 -1
  118. package/dist/templates/zod-validators.js +7 -4
  119. package/dist/templates/zod-validators.js.map +1 -1
  120. package/dist/ui-tier-gate.d.ts +44 -0
  121. package/dist/ui-tier-gate.d.ts.map +1 -0
  122. package/dist/ui-tier-gate.js +68 -0
  123. package/dist/ui-tier-gate.js.map +1 -0
  124. package/package.json +6 -6
  125. package/src/column-mapper.ts +17 -0
  126. package/src/constants.ts +11 -0
  127. package/src/generator-registry.ts +1 -0
  128. package/src/generator.ts +15 -0
  129. package/src/generators/agent-docs-file.ts +11 -2
  130. package/src/generators/agent-schema-input.ts +11 -0
  131. package/src/generators/agent-schema-page.ts +14 -1
  132. package/src/generators/agent-ui-page.ts +10 -10
  133. package/src/generators/api-model.ts +14 -7
  134. package/src/generators/barrel.ts +2 -1
  135. package/src/generators/callable-file.ts +2 -1
  136. package/src/generators/docs-data-builder.ts +6 -5
  137. package/src/generators/entity-file.ts +2 -1
  138. package/src/generators/names-file.ts +34 -4
  139. package/src/generators/queries-file.ts +2 -1
  140. package/src/generators/render-helper-file.ts +1 -1
  141. package/src/generators/requirements-markdown.ts +28 -1
  142. package/src/generators/routes-file-hono.ts +2 -1
  143. package/src/generators/routes-file.ts +2 -1
  144. package/src/index.ts +23 -7
  145. package/src/metaobjects-config.ts +20 -1
  146. package/src/names.ts +5 -4
  147. package/src/overwrite-policy.ts +62 -11
  148. package/src/reference/barrel.ts +2 -1
  149. package/src/reference/entity.ts +2 -2
  150. package/src/reference/names.ts +30 -4
  151. package/src/reference/queries.ts +3 -3
  152. package/src/reference/routes-hono.ts +2 -1
  153. package/src/reference/routes.ts +2 -1
  154. package/src/runner.ts +162 -12
  155. package/src/templates/callable-file.ts +2 -1
  156. package/src/templates/drizzle-schema.ts +43 -13
  157. package/src/templates/entity-file.ts +4 -2
  158. package/src/templates/filter-allowlist.ts +9 -6
  159. package/src/templates/filter-shared.ts +91 -6
  160. package/src/templates/inferred-types.ts +9 -6
  161. package/src/templates/projection-decl.ts +4 -2
  162. package/src/templates/queries-file.ts +6 -5
  163. package/src/templates/relations-block.ts +2 -1
  164. package/src/templates/render-helper.ts +31 -6
  165. package/src/templates/routes-file-hono.ts +2 -1
  166. package/src/templates/routes-file.ts +18 -10
  167. package/src/templates/zod-validators.ts +7 -4
  168. package/src/ui-tier-gate.ts +79 -0
package/src/runner.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join, relative, resolve, isAbsolute, dirname } from "node:path";
2
2
  import { warnMissingPromptGenerators } from "./prompt-generator-gate.js";
3
+ import { runEmitsHonoRoutes, runEmitsUiTier, warnUnmarkedUiGenerators } from "./ui-tier-gate.js";
3
4
  import { warnRetiredCodegenAttrs } from "./retired-codegen-attrs.js";
4
5
  import { tmpdir } from "node:os";
5
6
  import { fileURLToPath } from "node:url";
@@ -19,6 +20,7 @@ import { buildRelationMap } from "./relation-resolver.js";
19
20
  import { makeRenderContext } from "./render-context.js";
20
21
  import { sweepOrphans, type OrphanJob } from "./orphan-sweep.js";
21
22
  import { refusedOrphanMessage } from "./reconcile-orphans.js";
23
+ import { effectivePackage } from "./docs-paths.js";
22
24
  import {
23
25
  decideAndWrite,
24
26
  previewWriteStatus,
@@ -140,6 +142,20 @@ function orderable(v: string | undefined): [number, number, number] | undefined
140
142
  return m === null ? undefined : [Number(m[1]), Number(m[2]), Number(m[3])];
141
143
  }
142
144
 
145
+ /** True when `a` sorts strictly before `b` under `[major, minor, patch]` order. The
146
+ * shared comparison the two "did we just cross a release boundary" predicates below
147
+ * reduce to once both sides are `orderable()` — their SILENCE rules (what makes a
148
+ * boundary crossing worth noting at all) stay separate; only this pure ordering is
149
+ * common between them. */
150
+ function versionBefore(a: readonly [number, number, number], b: readonly [number, number, number]): boolean {
151
+ for (let i = 0; i < 3; i++) {
152
+ const ai = a[i] as number;
153
+ const bi = b[i] as number;
154
+ if (ai !== bi) return ai < bi;
155
+ }
156
+ return false;
157
+ }
158
+
143
159
  /**
144
160
  * True exactly on the FIRST gen after upgrading past the release that moved the API base
145
161
  * URL out of the entity descriptor, and only for a project that actually had a prefix to
@@ -166,12 +182,49 @@ export function shouldNoteBaseUrlMove(
166
182
  const was = orderable(recordedEngine);
167
183
  const move = orderable(moveVersion);
168
184
  if (was === undefined || move === undefined) return false;
169
- for (let i = 0; i < 3; i++) {
170
- const a = was[i] as number;
171
- const b = move[i] as number;
172
- if (a !== b) return a < b;
173
- }
174
- return false;
185
+ return versionBefore(was, move);
186
+ }
187
+
188
+ /** The release in which `<Entity>Names` became the way a physical name is spelled once. */
189
+ const NAMES_ARTIFACT_VERSION = "1.0.0";
190
+
191
+ /**
192
+ * True exactly on the FIRST gen after crossing into the release that made
193
+ * `<Entity>Names` the doctrine, for a project that has DB-backed objects and has not
194
+ * wired `namesFile()`. Exported for test.
195
+ *
196
+ * The problem it addresses was reported from three separate adopter estates: on
197
+ * TypeScript `generators: [...]` IS the complete suite — there is no default set to
198
+ * inherit — so an EXISTING project emits no names artifact until the line is added, and
199
+ * `meta gen` said nothing about it. One estate carried ~200 physical names as string
200
+ * literals in hand-written SQL and the whole run contained zero occurrences of the word
201
+ * "names". `meta init` scaffolds the generator, so the gap is only ever for projects that
202
+ * already exist — which is exactly the population that cannot be reached by a scaffold.
203
+ *
204
+ * Keyed on the #232 engine stamp for the same reason `shouldNoteBaseUrlMove` is: whether
205
+ * an adopter has DECIDED against the artifact is not something `meta gen` can see, so a
206
+ * condition-only trigger would nag for ever with no way to satisfy it — the cry-wolf
207
+ * failure that got the `timestampMode` warning deleted from this file. Crossing the
208
+ * version boundary is an event that happens once.
209
+ *
210
+ * Silence is the default for an absent or unorderable recorded version, matching
211
+ * `shouldNoteBaseUrlMove`: no gen history means nothing has been generated the old way,
212
+ * and a project generating for the first time under 1.0 is not migrating.
213
+ */
214
+ export function shouldNoteNamesArtifactAbsent(
215
+ namesWired: boolean,
216
+ hasDbBackedObject: boolean,
217
+ recordedEngine: string | undefined,
218
+ sinceVersion: string = NAMES_ARTIFACT_VERSION,
219
+ ): boolean {
220
+ if (namesWired) return false;
221
+ // Nothing with a physical name to spell — an all-value / sourceless model has no
222
+ // artifact to miss, and saying so would be noise rather than a finding.
223
+ if (!hasDbBackedObject) return false;
224
+ const was = orderable(recordedEngine);
225
+ const since = orderable(sinceVersion);
226
+ if (was === undefined || since === undefined) return false;
227
+ return versionBefore(was, since);
175
228
  }
176
229
 
177
230
  export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
@@ -411,13 +464,26 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
411
464
  const key = o.subType === OBJECT_SUBTYPE_VALUE
412
465
  ? (valueObjectNames.get(o.resolutionKey()) ?? o.name)
413
466
  : o.name;
414
- packageOf.set(key, o.package);
467
+ packageOf.set(key, effectivePackage(o));
415
468
  }
416
469
 
417
470
  // Auto-detect: is the OPT-IN Hono routes generator in the active suite? If so,
418
471
  // surface it on every generator's ctx.config so api-docs documents the Hono
419
472
  // CRUD surface it actually emits (rather than silently omitting it).
420
- const includeHonoRoutes = config.generators.some((g) => g.emitsHonoRoutes === true);
473
+ // The explicit config key WINS over the marker aggregation, at both doors. `meta docs`
474
+ // already read `loadedConfig?.includeHonoRoutes ?? …`, and the runner did not — so an
475
+ // adopter who set the key got the surface documented and not emitted, or the reverse.
476
+ // The key is settable because `MetaobjectsGenConfig extends Omit<ResolvedGenConfig,…>`,
477
+ // and it is the escape hatch for an owned generator whose name was changed; an escape
478
+ // hatch honoured by one of two doors is the defect these flags exist to close.
479
+ const includeHonoRoutes = config.includeHonoRoutes ?? runEmitsHonoRoutes(config.generators);
480
+
481
+ // Same auto-detection for the CLIENT UI tier. `agent/ui.md` describes forms, grids
482
+ // and the endpoints their hooks call; whether any of that is emitted is a generator
483
+ // fact, and the page's own gate could only see metadata. Run-scoped like
484
+ // includeHonoRoutes — the page asks "is this surface in the run?", never "does it
485
+ // land in my target?".
486
+ const includeUiTier = config.includeUiTier ?? runEmitsUiTier(config.generators);
421
487
 
422
488
  // §A6 — same auto-detection for the OPT-IN names generator. The entity tier may only
423
489
  // REFERENCE `<Entity>Names` when something in this run actually emits it; the names
@@ -441,6 +507,30 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
441
507
  // this, said nothing — while `meta verify` reported the template "clean". See
442
508
  // prompt-generator-gate.ts. Self-extinguishing; warning only.
443
509
  warnMissingPromptGenerators(root, config.generators, (m) => warnings.push(m));
510
+ warnUnmarkedUiGenerators(config.generators, (m) => warnings.push(m));
511
+
512
+ // <Entity>Names is opt-in on TypeScript and an existing project gets no signal that
513
+ // it exists. Fires ONCE, on the first gen after crossing the release that made it the
514
+ // doctrine — see shouldNoteNamesArtifactAbsent for why it is keyed on the engine stamp
515
+ // rather than on the condition alone.
516
+ if (
517
+ shouldNoteNamesArtifactAbsent(
518
+ namesTargets.size > 0,
519
+ safeEntities.some((e) => hasAnyRdbSource(e)),
520
+ recordedEngine,
521
+ )
522
+ ) {
523
+ warnings.push(
524
+ `no <Entity>Names artifact: namesFile() is not in this run's generators, so every ` +
525
+ `physical table/column name your hand-written SQL, upserts and fixtures spell is a ` +
526
+ `string literal that nothing checks against the metadata. Add it — ` +
527
+ `import { namesFile } from "@metaobjectsdev/codegen-ts/generators" (or the copy ` +
528
+ `'meta eject names' writes) — then reference <Entity>Names instead of the literals. ` +
529
+ `A typed ORM handle already in a name position is CORRECT and should stay; the ` +
530
+ `constants are for where no handle exists (raw SQL, excluded.<column>). ` +
531
+ `This note appears once.`,
532
+ );
533
+ }
444
534
 
445
535
  // A retired `@emit*` codegen flag still sitting in the metadata suppresses nothing now.
446
536
  // Named here rather than left to be discovered as a file that reappeared. Scoped to
@@ -455,6 +545,10 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
455
545
  // directory their policy's relative paths are measured from. Collected here
456
546
  // because `writeOutDir` is resolved per generator inside this loop.
457
547
  const orphanJobs: OrphanJob[] = [];
548
+ // Generators that ran and produced no file. See the note at the `files.length === 0`
549
+ // check below. A Set because a generator may run once per target.
550
+ const emptyGenerators = new Set<string>();
551
+
458
552
  for (const generator of config.generators) {
459
553
  // ADR-0025: `meta docs` is the single docs door. A `meta gen` config that
460
554
  // still lists a deprecated doc generator is warned + skipped, not run — the
@@ -519,6 +613,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
519
613
  outputLayout: selfTarget.outputLayout,
520
614
  includeHonoRoutes,
521
615
  includeNames: namesTargets.has(selfTarget.name),
616
+ includeUiTier,
522
617
  },
523
618
  renderContext,
524
619
  ...(projectRoot !== undefined && { projectRoot }),
@@ -547,6 +642,17 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
547
642
  let files: EmittedFile[];
548
643
  try {
549
644
  files = await generator.generate(ctx);
645
+ // A generator the author DELIBERATELY WIRED that matched nothing is a fact
646
+ // worth one line. `namesFile()` on a model with no DB-backed object emitted
647
+ // zero files with no line, no count and no warning, and an adopter could not
648
+ // tell that from a run that had done the work. 0.21.4 gave `tanstackGrid()`
649
+ // a self-extinguishing warning for exactly this shape.
650
+ //
651
+ // Collected rather than warned per generator: one aggregated line names every
652
+ // silent generator, so a suite with several inapplicable ones costs one line,
653
+ // not five. Emitting nothing is often CORRECT — a form generator on a model
654
+ // with no forms — so this reports, it never fails.
655
+ if (files.length === 0) emptyGenerators.add(generator.name);
550
656
  } catch (err) {
551
657
  const msg = err instanceof Error ? err.message : String(err);
552
658
  // `cause` preserves the original throw. Without it a `runGen` caller sees a
@@ -579,6 +685,21 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
579
685
  }
580
686
  }
581
687
 
688
+ // One line for every wired generator that matched nothing — see the note at the
689
+ // collection site. Deliberately AFTER the loop so the message is a single line
690
+ // naming all of them, and deliberately a warning: emitting nothing is frequently
691
+ // the correct outcome, and a gate that failed on it would be wrong more often
692
+ // than right.
693
+ if (emptyGenerators.size > 0) {
694
+ const names = [...emptyGenerators].sort();
695
+ warnings.push(
696
+ `${names.length} wired generator(s) matched nothing and wrote no file: ` +
697
+ `${names.join(", ")}. That is often correct — a generator whose metadata this ` +
698
+ `model does not declare has nothing to emit. It is reported because a run that ` +
699
+ `emitted nothing and a run that did the work otherwise look identical.`,
700
+ );
701
+ }
702
+
582
703
  // 5. Write phase.
583
704
  const writes: WriteResult[] = [];
584
705
  const conflicts: WriteResult[] = [];
@@ -614,10 +735,15 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
614
735
  `no codegen hash manifest — so 'meta gen' cannot tell your edits from its own ` +
615
736
  `stale output, and it will not guess. This is the expected first run for a ` +
616
737
  `project created before the manifest was committed. ` +
617
- `ONE-TIME FIX: commit '.metaobjects/.gen-state/.hashes.json' (un-ignore it in ` +
618
- `.metaobjects/.gitignore with '.gen-state/*' + '!.gen-state/.hashes.json'), ` +
619
- `then re-run. To adopt fresh output and DISCARD any hand edits in these files ` +
620
- `instead, re-run with --baseline=fresh. Files: ${names.join(", ")}${more}.`,
738
+ `ONE-TIME FIX: re-run with --baseline=adopt — it records the files you have as ` +
739
+ `the merge base and writes NOTHING — then commit ` +
740
+ `'.metaobjects/.gen-state/.hashes.json' (un-ignore it in .metaobjects/.gitignore ` +
741
+ `with '.gen-state/*' + '!.gen-state/.hashes.json') and run 'meta gen' again, where ` +
742
+ `the regeneration arrives as its own reviewable diff. Adopting DECLARES these files ` +
743
+ `to be generated output, so an edit already inside one of them is part of the ` +
744
+ `baseline and that regeneration will replace it — commit before you run it. ` +
745
+ `To write fresh output NOW and DISCARD any hand edits in these files, ` +
746
+ `--baseline=fresh does both in one step. Files: ${names.join(", ")}${more}.`,
621
747
  );
622
748
  return;
623
749
  }
@@ -657,6 +783,28 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
657
783
  );
658
784
  };
659
785
 
786
+ // `--baseline=adopt` — the counterpart of the aggregate above, and aggregated for the
787
+ // same reason: every adopted file has one cause and one next step. Reported even though
788
+ // nothing failed, because a run that writes nothing and exits 0 is otherwise
789
+ // indistinguishable from a no-op, and the file it DID produce (`.hashes.json`) is the
790
+ // one the adopter now has to commit.
791
+ const reportAdoptions = (): void => {
792
+ const adopted = writes.filter((w) => w.status === "adopted");
793
+ if (adopted.length === 0) return;
794
+ const names = adopted.slice(0, MAX_NAMED).map((w) => relativeForDisplay(w.path));
795
+ const more = adopted.length > MAX_NAMED ? `, and ${adopted.length - MAX_NAMED} more` : "";
796
+ warnings.push(
797
+ `Recorded ${adopted.length} existing file(s) as the codegen baseline and wrote ` +
798
+ `nothing. NEXT: commit '.metaobjects/.gen-state/.hashes.json' (un-ignore it in ` +
799
+ `.metaobjects/.gitignore with '.gen-state/*' + '!.gen-state/.hashes.json'), then run ` +
800
+ `'meta gen' — these files regenerate normally from here, as their own reviewable ` +
801
+ `diff. Adopting DECLARED them to be generated output: an edit already inside one is ` +
802
+ `part of the baseline and that regeneration will replace it, so check the diff ` +
803
+ `against git. Edits you make FROM NOW ON merge, because the base now exists. ` +
804
+ `Files: ${names.join(", ")}${more}.`,
805
+ );
806
+ };
807
+
660
808
  const sweep = (dryRun: boolean): void => {
661
809
  if (projectRoot === undefined || orphanJobs.length === 0) return;
662
810
 
@@ -758,6 +906,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
758
906
  });
759
907
  }
760
908
  reportRefusals();
909
+ reportAdoptions();
761
910
  // A preview that hides a pending deletion is worse than no preview at all, so
762
911
  // the sweep still runs — in decide-and-report mode, touching nothing.
763
912
  sweep(true);
@@ -791,6 +940,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
791
940
  }
792
941
 
793
942
  reportRefusals();
943
+ reportAdoptions();
794
944
 
795
945
  // Sweep AFTER the writes: writing is the primary job, and a deletion that runs
796
946
  // first would be unrecoverable if a later write threw. Ordering cannot change
@@ -35,6 +35,7 @@ import { GENERATED_HEADER } from "../constants.js";
35
35
  import { crossEntitySpecifier } from "../import-path.js";
36
36
  import { resolveObjectNames } from "../names.js";
37
37
  import type { RenderContext } from "../render-context.js";
38
+ import { effectivePackage } from "../docs-paths.js";
38
39
 
39
40
  const CALLABLE_KINDS: ReadonlySet<string> = new Set([
40
41
  SOURCE_KIND_STORED_PROC,
@@ -128,7 +129,7 @@ export function renderCallableFile(entity: MetaObject, ctx?: RenderContext): str
128
129
  const namesImport = namesConst === undefined
129
130
  ? ""
130
131
  : `import { ${namesConst} } from "${crossEntitySpecifier(
131
- ctx!.selfTarget.outputLayout, entity.package, entity.package,
132
+ ctx!.selfTarget.outputLayout, effectivePackage(entity), effectivePackage(entity),
132
133
  `${entity.name}.names`, ctx!.extStyle,
133
134
  )}";\n`;
134
135
  // The identifier must stay an IDENTIFIER. A bare interpolation into drizzle's `sql` tag
@@ -5,7 +5,6 @@
5
5
  import { code, imp, joinCode, type Code } from "ts-poet";
6
6
  import { MetaObject, MetaField, MetaIndex, isMetaObject, stripPackage } from "@metaobjectsdev/metadata";
7
7
  import {
8
- FIELD_SUBTYPE_LONG,
9
8
  IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION,
10
9
  IDENTITY_ATTR_EXPR, IDENTITY_ATTR_USING, IDENTITY_ATTR_ORDERS, IDENTITY_ATTR_WHERE,
11
10
  GENERATION_INCREMENT, GENERATION_UUID,
@@ -14,7 +13,10 @@ import {
14
13
  } from "@metaobjectsdev/metadata";
15
14
  import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
16
15
  import { crossEntitySpecifier, valueObjectModuleSpecifier } from "../import-path.js";
17
- import { mapColumnType, type ColumnSpec, type EnumIntCustomType } from "../column-mapper.js";
16
+ import {
17
+ mapColumnType, PG_IDENTITY_CAPABLE_FNS,
18
+ type ColumnSpec, type EnumIntCustomType,
19
+ } from "../column-mapper.js";
18
20
  import { tableNameFromEntity } from "../naming.js";
19
21
  import {
20
22
  namesRef, physicalNameExpr, sourceSchemaExpr, indexNameExpr, columnExpr,
@@ -23,6 +25,7 @@ import { resolveTableSchema } from "@metaobjectsdev/metadata";
23
25
  import { renderRelationsBlock } from "./relations-block.js";
24
26
  import { renderDocsFor } from "./jsdoc.js";
25
27
  import { collectTphSubtypeFields } from "./tph-discriminator.js";
28
+ import { effectivePackage } from "../docs-paths.js";
26
29
 
27
30
  /**
28
31
  * Render the Drizzle table definition for one entity, including:
@@ -102,6 +105,10 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
102
105
  // `isMetaObject`, never `instanceof`: two physical copies of @metaobjectsdev/metadata in
103
106
  // one process give the class object and the instance different identities, so the check
104
107
  // would return false for a real node — silently.
108
+ // `obj` never changes across this function, so its effective package is computed once
109
+ // and reused everywhere below (the `fromPackage` passed to `namesRef` here, and the
110
+ // per-field `renderColumn` calls in both the own-field and TPH-subtype-fold loops).
111
+ const pkg = effectivePackage(obj);
105
112
  const columnNameExpr = (field: MetaField, dbName: string): Code => {
106
113
  if (names === undefined || names.resolved.fields[field.name] !== undefined) {
107
114
  return columnExpr(names, field.name, dbName);
@@ -111,7 +118,7 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
111
118
  // base's), not to the subtype's own package.
112
119
  const ownerNames =
113
120
  owner !== undefined && isMetaObject(owner) && owner !== obj
114
- ? namesRef(owner, ctx, obj.package)
121
+ ? namesRef(owner, ctx, pkg)
115
122
  : undefined;
116
123
  return columnExpr(ownerNames, field.name, dbName);
117
124
  };
@@ -202,7 +209,7 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
202
209
  enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType);
203
210
  }
204
211
  const fieldDocs = renderDocsFor(child);
205
- const columnLine = renderColumn(spec, columnNameExpr(child, spec.dbName), child, ctx, isPk, pkGeneration, fkInfo, isComposite, obj.package, obj.name);
212
+ const columnLine = renderColumn(spec, columnNameExpr(child, spec.dbName), child, ctx, isPk, pkGeneration, fkInfo, isComposite, pkg, obj.name);
206
213
  columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine);
207
214
  if (spec.checkConstraint !== undefined) checkConstraints.push(checkEntry(child, spec));
208
215
  }
@@ -222,7 +229,7 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
222
229
  }
223
230
  const fieldDocs = renderDocsFor(child);
224
231
  const columnLine = renderColumn(
225
- spec, columnNameExpr(child, spec.dbName), child, ctx, false, undefined, fkMap.get(child.name), isComposite, obj.package, obj.name, true,
232
+ spec, columnNameExpr(child, spec.dbName), child, ctx, false, undefined, fkMap.get(child.name), isComposite, pkg, obj.name, true,
226
233
  );
227
234
  columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine);
228
235
  // Enum CHECK constraints stay valid under TPH: `NULL IN (...)` is NULL
@@ -532,15 +539,38 @@ function renderColumn(
532
539
  // Composite PKs don't use .primaryKey() per-column; table callback owns it.
533
540
  pkSuffix = isComposite ? "" : ".primaryKey({ autoIncrement: true })";
534
541
  } else {
535
- // Postgres: bigserial for long (8-byte), serial for int (4-byte).
536
- if (field.subType === FIELD_SUBTYPE_LONG) {
537
- const bigserialSym = imp(`bigserial@${spec.importModule}`);
538
- baseCall = code`${bigserialSym}(${dbNameExpr}, { mode: "number" })`;
539
- } else {
540
- const serialSym = imp(`serial@${spec.importModule}`);
541
- baseCall = code`${serialSym}(${dbNameExpr})`;
542
- }
542
+ // Postgres: the column KEEPS the type the column-mapper derived and
543
+ // GAINS the identity clause. It does not get a second spelling.
544
+ //
545
+ // This used to substitute `serial`/`bigserial` for the mapped
546
+ // `integer`/`bigint`, and that was the 1.0 defect wearing another hat:
547
+ // migrate emits `… GENERATED BY DEFAULT AS IDENTITY` (emit/postgres.ts
548
+ // renderColumn) for the same field, and `serial` is NOT that column.
549
+ // `serial` is historical sugar for integer + an owned sequence + a
550
+ // genuine DEFAULT nextval(...); an identity column carries neither a
551
+ // default nor a droppable sequence. So the generated Drizzle schema
552
+ // misrepresented the live PK of every integer-keyed table migrate had
553
+ // created — one question, two doors, two answers, and nothing compared
554
+ // them. migrate owns the DDL (ADR-0015), so codegen is what changes —
555
+ // the same call #293 made when the two named a CHECK constraint
556
+ // differently.
557
+ //
558
+ // The clause is gated on the MAPPED column function rather than on the
559
+ // field subtype, so the two cannot drift apart again: only Drizzle's
560
+ // int family (integer/bigint/smallint, via PgIntColumnBaseBuilder)
561
+ // carries .generatedByDefaultAsIdentity(). A subtype outside that
562
+ // family with @generation:"increment" is a combination Postgres cannot
563
+ // express at all — migrate emits `TEXT GENERATED BY DEFAULT AS
564
+ // IDENTITY` and fails at apply — and the honest thing here is to leave
565
+ // the declared type alone and let that failure be loud, rather than
566
+ // silently substituting an integer column for the declared one.
543
567
  pkSuffix = isComposite ? "" : ".primaryKey()";
568
+ if (
569
+ spec.enumIntCustomType === undefined &&
570
+ PG_IDENTITY_CAPABLE_FNS.has(spec.fnName)
571
+ ) {
572
+ pkSuffix += ".generatedByDefaultAsIdentity()";
573
+ }
544
574
  }
545
575
  } else if (pkGeneration === GENERATION_UUID) {
546
576
  pkSuffix = isComposite
@@ -30,6 +30,7 @@ import { namesRef, namesConstArg, physicalNameExpr, sourceSchemaExpr } from "../
30
30
  import { hasWritableRdbSource } from "../source-detect.js";
31
31
  import { renderValueObjectFile } from "./value-object-file.js";
32
32
  import { isAbstract } from "../instance-artifacts.js";
33
+ import { effectivePackage } from "../docs-paths.js";
33
34
 
34
35
  /**
35
36
  * Render-time options for the entity-file composer.
@@ -139,14 +140,15 @@ export function renderEntityFile(
139
140
  if (writeThrough) {
140
141
  const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1);
141
142
  const fields = entity.fields();
143
+ const entityPkg = effectivePackage(entity);
142
144
  // ADR-0044/#228 — resolve a view column's `@objectRef` to the value object's
143
145
  // EMITTED name + module TOGETHER (lock-step), so the read-view artifact imports
144
146
  // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`)
145
147
  // under a cross-package short-name collision.
146
148
  const voRef = (field: MetaField): { name: string; module: string } => {
147
149
  const ref = field.attr(FIELD_ATTR_OBJECT_REF);
148
- const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entity.package));
149
- const module = valueObjectModuleSpecifier(name, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle);
150
+ const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entityPkg));
151
+ const module = valueObjectModuleSpecifier(name, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle);
150
152
  return { name, module };
151
153
  };
152
154
  // ONE selection of the replica source: `projectionViewName` names it, and this is the
@@ -2,7 +2,6 @@ import { code, type Code } from "ts-poet";
2
2
  import { MetaField, MetaObject } from "@metaobjectsdev/metadata";
3
3
  import {
4
4
  FIELD_ATTR_FILTERABLE,
5
- FIELD_ATTR_SORTABLE_DEFAULT_ORDER,
6
5
  FIELD_SUBTYPE_BOOLEAN,
7
6
  FIELD_SUBTYPE_INT,
8
7
  FIELD_SUBTYPE_LONG,
@@ -15,7 +14,7 @@ import {
15
14
  FIELD_SUBTYPE_CURRENCY,
16
15
  opsForField,
17
16
  } from "@metaobjectsdev/metadata";
18
- import { sortableFields } from "./filter-shared.js";
17
+ import { sortableFields, declaredSortDefaultOrder } from "./filter-shared.js";
19
18
  import type { RenderContext } from "../render-context.js";
20
19
 
21
20
  const NUMBER_SUBTYPES = new Set<string>([
@@ -108,11 +107,15 @@ export const ${entity.name}SortAllowlist = {} as const satisfies SortAllowlist;
108
107
  }
109
108
  const rows = sortable
110
109
  .map((f) => {
111
- const defaultOrder = f.attr(FIELD_ATTR_SORTABLE_DEFAULT_ORDER) as string | undefined;
110
+ // The DECLARED order only an undeclared field stays `{}` so the runtime's own
111
+ // `?? "asc"` remains the single place the fallback is spelled. Read through the
112
+ // shared resolver, never off the attr here, so this allowlist and the grid tier
113
+ // cannot answer one declaration two ways.
114
+ const defaultOrder = declaredSortDefaultOrder(f);
112
115
  const rule =
113
- defaultOrder === "asc" || defaultOrder === "desc"
114
- ? `{ defaultOrder: ${JSON.stringify(defaultOrder)} as const }`
115
- : `{}`;
116
+ defaultOrder === undefined
117
+ ? `{}`
118
+ : `{ defaultOrder: ${JSON.stringify(defaultOrder)} as const }`;
116
119
  return ` ${f.name}: ${rule}`;
117
120
  })
118
121
  .join(",\n");
@@ -1,10 +1,31 @@
1
- // Shared predicate for "is this field sortable?" — used by both filter-allowlist.ts
2
- // (SortAllowlist generation) and filter-type.ts (sort union generation).
3
- // Both must agree on which fields are sortable; keeping them in sync via this shared
4
- // helper prevents client/server mismatches.
1
+ // Shared answers to "how does this entity sort?" — used by every tier that has to
2
+ // answer it, so none of them can answer it differently.
3
+ //
4
+ // Two questions live here:
5
+ // WHICH fields sort — isSortableField / sortableFields, read by the generated
6
+ // <Entity>SortAllowlist (filter-allowlist.ts), the client
7
+ // <Entity>Filter sort union (filter-type.ts) and the agent
8
+ // UI page.
9
+ // WHICH WAY one sorts — declaredSortDefaultOrder / sortDefaultOrder /
10
+ // resolveGridDefaultSort, read by the same allowlist, the
11
+ // generated grid const + grid hook, and the agent UI page.
12
+ //
13
+ // Keeping both here is what prevents a client/server mismatch: a header the grid
14
+ // renders clickable for a field the endpoint rejects, or a grid whose initial sort
15
+ // runs the opposite way from the endpoint it queries.
5
16
 
6
- import { MetaField, MetaObject } from "@metaobjectsdev/metadata";
7
- import { FIELD_ATTR_FILTERABLE, FIELD_ATTR_SORTABLE } from "@metaobjectsdev/metadata";
17
+ import { MetaField, MetaObject, type MetaData } from "@metaobjectsdev/metadata";
18
+ import {
19
+ FIELD_ATTR_FILTERABLE,
20
+ FIELD_ATTR_SORTABLE,
21
+ FIELD_ATTR_SORTABLE_DEFAULT_ORDER,
22
+ LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD,
23
+ LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER,
24
+ } from "@metaobjectsdev/metadata";
25
+
26
+ /** A resolved sort direction. Never widened to `string`: every door below either
27
+ * returns one of these two or says it has no answer. */
28
+ export type SortOrder = "asc" | "desc";
8
29
 
9
30
  /**
10
31
  * Returns true if the given field should be included in sort operations.
@@ -28,3 +49,67 @@ export function sortableFields(entity: MetaObject): MetaField[] {
28
49
  // fields() returns effective fields, so inherited fields (from extends:/super:) are included in sort ops.
29
50
  return entity.fields().filter(isSortableField);
30
51
  }
52
+
53
+ /**
54
+ * The direction `@sortableDefaultOrder` DECLARES on this field, or undefined when
55
+ * the field declares none.
56
+ *
57
+ * The declared value is what the generated `<Entity>SortAllowlist` carries, so an
58
+ * undeclared field stays `{}` there and the runtime's own `?? "asc"` remains the
59
+ * single place the fallback is spelled. Callers that need a concrete direction
60
+ * rather than a declaration use `sortDefaultOrder`.
61
+ *
62
+ * ADR-0039: resolving — `@sortableDefaultOrder` may be inherited via `extends`.
63
+ */
64
+ export function declaredSortDefaultOrder(field: MetaField): SortOrder | undefined {
65
+ const v = field.attr(FIELD_ATTR_SORTABLE_DEFAULT_ORDER);
66
+ return v === "asc" || v === "desc" ? v : undefined;
67
+ }
68
+
69
+ /** The direction a sort runs when it names this field without giving one. */
70
+ export function sortDefaultOrder(field: MetaField): SortOrder {
71
+ return declaredSortDefaultOrder(field) ?? "asc";
72
+ }
73
+
74
+ /** A grid's initial sort: the field it runs on and the direction it runs. */
75
+ export interface GridDefaultSort {
76
+ field: string;
77
+ order: SortOrder;
78
+ }
79
+
80
+ /**
81
+ * A `layout.dataGrid`'s initial sort, resolved once for every tier that renders it.
82
+ *
83
+ * `@defaultSortOrder` wins when the layout declares one. When it does not, the
84
+ * NAMED FIELD's `@sortableDefaultOrder` applies — that is what the attribute has
85
+ * always been registered to mean ("the direction a sort takes when it names this
86
+ * field but omits the order"), and it is the same rule the runtime applies to
87
+ * `?sort=<field>` with no `:order`. Without this the two ends of one declaration
88
+ * disagree: the endpoint returns the field's declared order while the grid that
89
+ * queries it renders the opposite.
90
+ *
91
+ * Returns undefined when the layout names no field — a grid with no initial sort.
92
+ *
93
+ * The field lookup cannot miss on a LOADED model: `validateDataGridSortFields`
94
+ * refuses `@defaultSortField` naming a field the entity does not have
95
+ * (`ERR_BAD_DEFAULT_SORT_FIELD`). "asc" is the answer for a hand-built node that
96
+ * never went through the loader, so codegen degrades rather than throwing.
97
+ */
98
+ // `layout` is typed as the base MetaData rather than MetaLayout because the three
99
+ // call sites hold the node at different static types (`entity.layouts()` gives
100
+ // MetaLayout, the agent page's own `dataGrids()` gives MetaData) and this reads
101
+ // nothing but `attr()`, which is declared there. Widening beats a cast per caller.
102
+ export function resolveGridDefaultSort(
103
+ entity: MetaObject,
104
+ layout: MetaData,
105
+ ): GridDefaultSort | undefined {
106
+ // ADR-0039: resolving — a layout may inherit its grid attrs via extends.
107
+ const fieldName = layout.attr(LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD);
108
+ if (typeof fieldName !== "string" || fieldName === "") return undefined;
109
+
110
+ const declared = layout.attr(LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER);
111
+ if (declared === "asc" || declared === "desc") return { field: fieldName, order: declared };
112
+
113
+ const field = entity.fields().find((f) => f.name === fieldName);
114
+ return { field: fieldName, order: field === undefined ? "asc" : sortDefaultOrder(field) };
115
+ }
@@ -42,6 +42,7 @@ import { renderDocsFor } from "./jsdoc.js";
42
42
  import { sharedEnumForField } from "../enum-shared.js";
43
43
  import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "../enum-import.js";
44
44
  import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
45
+ import { effectivePackage } from "../docs-paths.js";
45
46
 
46
47
  /**
47
48
  * Emit Drizzle's InferSelectModel / InferInsertModel aliases for an entity.
@@ -179,7 +180,7 @@ export function renderEnumTypeAliases(entity: MetaObject, ctx?: RenderContext):
179
180
  // configured external module; never redeclare the union here.
180
181
  const spec = shared.provided
181
182
  ? providedEnumImportSpecifier(ctx!, shared.name)
182
- : sharedEnumImportSpecifier(ctx!, entity.package);
183
+ : sharedEnumImportSpecifier(ctx!, effectivePackage(entity));
183
184
  // Re-export the runtime constant beside the type for a MATERIALIZED shared enum, so a
184
185
  // consumer reaches its members the same way it reaches an inline enum's, without having
185
186
  // to know which kind it is or which module the shared one landed in.
@@ -305,9 +306,10 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render
305
306
  // layout/package/extStyle-aware helper (the SAME one the Zod schema +
306
307
  // Drizzle .$type<> use) so all three agree. Without a ctx (bare unit-test
307
308
  // calls) fall back to the bare name + flat same-dir specifier.
308
- const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entity.package)) : stripPackage(ref);
309
+ const entityPkg = effectivePackage(entity);
310
+ const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref);
309
311
  const moduleSpec = ctx
310
- ? valueObjectModuleSpecifier(refName, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle)
312
+ ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle)
311
313
  : `./${refName}.js`;
312
314
  const refImp = imp(`${refName}@${moduleSpec}`);
313
315
  return field.resolvedIsArray() ? code`${refImp}[]` : code`${refImp}`;
@@ -319,9 +321,10 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render
319
321
  if (field.subType === FIELD_SUBTYPE_MAP) {
320
322
  const ref = field.attr(FIELD_ATTR_OBJECT_REF);
321
323
  if (typeof ref === "string" && ref.length > 0) {
322
- const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entity.package)) : stripPackage(ref);
324
+ const entityPkg = effectivePackage(entity);
325
+ const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref);
323
326
  const moduleSpec = ctx
324
- ? valueObjectModuleSpecifier(refName, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle)
327
+ ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle)
325
328
  : `./${refName}.js`;
326
329
  const refImp = imp(`${refName}@${moduleSpec}`);
327
330
  return code`Record<string, ${refImp}>`;
@@ -349,7 +352,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render
349
352
  if (shared !== undefined) {
350
353
  const spec = shared.provided
351
354
  ? providedEnumImportSpecifier(ctx, shared.name)
352
- : sharedEnumImportSpecifier(ctx, entity.package);
355
+ : sharedEnumImportSpecifier(ctx, effectivePackage(entity));
353
356
  const sym = imp(`t:${shared.name}@${spec}`);
354
357
  return field.resolvedIsArray() ? code`${sym}[]` : code`${sym}`;
355
358
  }
@@ -23,6 +23,7 @@ import { renderFilterType } from "./filter-type.js";
23
23
  import { buildUiFieldDescriptor, resourcePath } from "./entity-ui-descriptor.js";
24
24
  import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js";
25
25
  import { primaryIdentityFieldNames } from "./zod-validators.js";
26
+ import { effectivePackage } from "../docs-paths.js";
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
29
  // Public interface
@@ -103,14 +104,15 @@ export function renderProjectionDecl(
103
104
  // package-qualified on a cross-package short-name collision, so the projection's
104
105
  // VO import matches the entity's. Layout/package/extStyle-aware when a render
105
106
  // context is present, else a flat same-dir import (zodFieldExpr's fallback).
107
+ const projectionPkg = effectivePackage(projection);
106
108
  const voRef = (field: MetaField): { name: string; module: string } => {
107
109
  const ref = field.attr(FIELD_ATTR_OBJECT_REF);
108
110
  const rawRef = typeof ref === "string" ? ref : "";
109
111
  const name = ctx
110
- ? ctx.resolveValueObjectName(rawRef, fieldDeclaringPackage(field, projection.package))
112
+ ? ctx.resolveValueObjectName(rawRef, fieldDeclaringPackage(field, projectionPkg))
111
113
  : stripPackage(rawRef);
112
114
  const module = ctx
113
- ? valueObjectModuleSpecifier(name, ctx.packageOf, projection.package, ctx.outputLayout, ctx.extStyle)
115
+ ? valueObjectModuleSpecifier(name, ctx.packageOf, projectionPkg, ctx.outputLayout, ctx.extStyle)
114
116
  : `./${name}.js`;
115
117
  return { name, module };
116
118
  };