@metaobjectsdev/codegen-ts 0.20.9 → 0.20.10

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 (114) hide show
  1. package/dist/generators/api-model.d.ts.map +1 -1
  2. package/dist/generators/api-model.js +9 -5
  3. package/dist/generators/api-model.js.map +1 -1
  4. package/dist/generators/barrel.js +1 -1
  5. package/dist/generators/barrel.js.map +1 -1
  6. package/dist/generators/entity-file.d.ts.map +1 -1
  7. package/dist/generators/entity-file.js +7 -1
  8. package/dist/generators/entity-file.js.map +1 -1
  9. package/dist/generators/extractor-file.d.ts.map +1 -1
  10. package/dist/generators/extractor-file.js +6 -1
  11. package/dist/generators/extractor-file.js.map +1 -1
  12. package/dist/generators/output-parser-file.d.ts.map +1 -1
  13. package/dist/generators/output-parser-file.js +6 -1
  14. package/dist/generators/output-parser-file.js.map +1 -1
  15. package/dist/generators/queries-file.d.ts +1 -1
  16. package/dist/generators/queries-file.d.ts.map +1 -1
  17. package/dist/generators/queries-file.js +11 -6
  18. package/dist/generators/queries-file.js.map +1 -1
  19. package/dist/generators/routes-file-hono.d.ts +4 -0
  20. package/dist/generators/routes-file-hono.d.ts.map +1 -1
  21. package/dist/generators/routes-file-hono.js +6 -1
  22. package/dist/generators/routes-file-hono.js.map +1 -1
  23. package/dist/generators/routes-file.d.ts +4 -0
  24. package/dist/generators/routes-file.d.ts.map +1 -1
  25. package/dist/generators/routes-file.js +6 -1
  26. package/dist/generators/routes-file.js.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/naming/collision-names.d.ts +19 -0
  32. package/dist/naming/collision-names.d.ts.map +1 -0
  33. package/dist/naming/collision-names.js +79 -0
  34. package/dist/naming/collision-names.js.map +1 -0
  35. package/dist/payload-codegen.d.ts.map +1 -1
  36. package/dist/payload-codegen.js +5 -71
  37. package/dist/payload-codegen.js.map +1 -1
  38. package/dist/render-context.d.ts +35 -3
  39. package/dist/render-context.d.ts.map +1 -1
  40. package/dist/render-context.js +23 -0
  41. package/dist/render-context.js.map +1 -1
  42. package/dist/runner.d.ts.map +1 -1
  43. package/dist/runner.js +36 -2
  44. package/dist/runner.js.map +1 -1
  45. package/dist/source-detect.d.ts +10 -0
  46. package/dist/source-detect.d.ts.map +1 -1
  47. package/dist/source-detect.js +22 -0
  48. package/dist/source-detect.js.map +1 -1
  49. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  50. package/dist/templates/drizzle-schema.js +14 -3
  51. package/dist/templates/drizzle-schema.js.map +1 -1
  52. package/dist/templates/entity-file.d.ts +1 -1
  53. package/dist/templates/entity-file.d.ts.map +1 -1
  54. package/dist/templates/entity-file.js +13 -3
  55. package/dist/templates/entity-file.js.map +1 -1
  56. package/dist/templates/extract-delegate-emitter.d.ts +9 -4
  57. package/dist/templates/extract-delegate-emitter.d.ts.map +1 -1
  58. package/dist/templates/extract-delegate-emitter.js +46 -30
  59. package/dist/templates/extract-delegate-emitter.js.map +1 -1
  60. package/dist/templates/extractor.d.ts +2 -1
  61. package/dist/templates/extractor.d.ts.map +1 -1
  62. package/dist/templates/extractor.js +48 -33
  63. package/dist/templates/extractor.js.map +1 -1
  64. package/dist/templates/inferred-types.d.ts +1 -1
  65. package/dist/templates/inferred-types.d.ts.map +1 -1
  66. package/dist/templates/inferred-types.js +39 -16
  67. package/dist/templates/inferred-types.js.map +1 -1
  68. package/dist/templates/output-parser.d.ts +2 -1
  69. package/dist/templates/output-parser.d.ts.map +1 -1
  70. package/dist/templates/output-parser.js +37 -12
  71. package/dist/templates/output-parser.js.map +1 -1
  72. package/dist/templates/projection-decl.d.ts +1 -1
  73. package/dist/templates/projection-decl.d.ts.map +1 -1
  74. package/dist/templates/projection-decl.js +20 -9
  75. package/dist/templates/projection-decl.js.map +1 -1
  76. package/dist/templates/value-object-file.d.ts.map +1 -1
  77. package/dist/templates/value-object-file.js +5 -1
  78. package/dist/templates/value-object-file.js.map +1 -1
  79. package/dist/templates/view-decl.d.ts +12 -2
  80. package/dist/templates/view-decl.d.ts.map +1 -1
  81. package/dist/templates/view-decl.js +21 -15
  82. package/dist/templates/view-decl.js.map +1 -1
  83. package/dist/templates/zod-validators.d.ts +1 -1
  84. package/dist/templates/zod-validators.d.ts.map +1 -1
  85. package/dist/templates/zod-validators.js +35 -22
  86. package/dist/templates/zod-validators.js.map +1 -1
  87. package/package.json +6 -6
  88. package/src/generators/api-model.ts +9 -5
  89. package/src/generators/barrel.ts +1 -1
  90. package/src/generators/entity-file.ts +7 -1
  91. package/src/generators/extractor-file.ts +6 -1
  92. package/src/generators/output-parser-file.ts +6 -1
  93. package/src/generators/queries-file.ts +12 -7
  94. package/src/generators/routes-file-hono.ts +7 -1
  95. package/src/generators/routes-file.ts +6 -1
  96. package/src/index.ts +1 -1
  97. package/src/naming/collision-names.ts +87 -0
  98. package/src/payload-codegen.ts +4 -79
  99. package/src/reference/barrel.ts +1 -1
  100. package/src/reference/queries.ts +14 -6
  101. package/src/reference/routes.ts +8 -1
  102. package/src/render-context.ts +51 -3
  103. package/src/runner.ts +36 -4
  104. package/src/source-detect.ts +20 -0
  105. package/src/templates/drizzle-schema.ts +14 -3
  106. package/src/templates/entity-file.ts +14 -6
  107. package/src/templates/extract-delegate-emitter.ts +52 -27
  108. package/src/templates/extractor.ts +49 -32
  109. package/src/templates/inferred-types.ts +39 -17
  110. package/src/templates/output-parser.ts +40 -12
  111. package/src/templates/projection-decl.ts +20 -10
  112. package/src/templates/value-object-file.ts +5 -1
  113. package/src/templates/view-decl.ts +30 -18
  114. package/src/templates/zod-validators.ts +35 -23
@@ -0,0 +1,87 @@
1
+ // ADR-0044 collision-scoped naming — shared across every codegen tier that emits
2
+ // declarations from a reference closure (payload records today; the entity +
3
+ // extract/output-parser tiers per issue #228).
4
+ //
5
+ // `assignEmittedNames` is a PURE function of the closure: a bare short name
6
+ // unique in the closure emits bare; a collision emits EVERY member under its
7
+ // package-qualified derived name (PascalCase each package segment + short
8
+ // name). A still-colliding derived name fails loud (ERR_PAYLOAD_NAME_COLLISION).
9
+
10
+ import { type ErrorCode, type MetaData, PACKAGE_SEPARATOR } from "@metaobjectsdev/metadata";
11
+
12
+ // ADR-0044 backstop error code — a codegen-time (not loader) error, peer of
13
+ // @metaobjectsdev/render's ERR_VAR_NOT_ON_PAYLOAD. Already promoted to the shared
14
+ // cross-language error-code ledger in 0.19.3 (packages/metadata/src/errors.ts's
15
+ // ERROR_CODES, fixtures/conformance/ERROR-CODES.json, server/python/src/metaobjects/errors.py).
16
+ // That ledger has no per-code named export (only the ERROR_CODES array + the ErrorCode
17
+ // union type), so this stays a local literal — but `satisfies ErrorCode` binds it to the
18
+ // shared ledger's type: a future rename/removal there fails this file's typecheck instead
19
+ // of silently drifting.
20
+ export const ERR_PAYLOAD_NAME_COLLISION = "ERR_PAYLOAD_NAME_COLLISION" satisfies ErrorCode;
21
+
22
+ function pascalSegment(s: string): string {
23
+ return s.length > 0 ? s[0]!.toUpperCase() + s.slice(1) : s;
24
+ }
25
+
26
+ /** ADR-0044 — package-qualified derived name for a collision member: PascalCase
27
+ * each `::`-segment of the node's effective package, concatenated, then the
28
+ * bare short name (`acme::alpha::Note` -> `AcmeAlphaNote`). A root-level
29
+ * (no-package) node has nothing to qualify with and keeps its bare name — two
30
+ * root-level VOs can never share a name (the loader's own-package uniqueness
31
+ * already rejects that), so this can't silently under-qualify. */
32
+ export function packageQualifiedName(pkg: string, shortName: string): string {
33
+ if (pkg === "") return shortName;
34
+ return (
35
+ pkg
36
+ .split(PACKAGE_SEPARATOR)
37
+ .map(pascalSegment)
38
+ .join("") + shortName
39
+ );
40
+ }
41
+
42
+ /**
43
+ * ADR-0044 pass 2 — assign the emitted TS name for every VO in the closure. A
44
+ * PURE function of the closure's (fqn, bareName, package) triples — never of
45
+ * traversal order: bare short name unique in the closure -> bare name; a
46
+ * collision -> EVERY member gets its package-qualified derived name. If two
47
+ * DISTINCT fqns still derive the same name after qualification, throws
48
+ * (ERR_PAYLOAD_NAME_COLLISION) — never silently wrong.
49
+ */
50
+ export function assignEmittedNames(closure: ReadonlyMap<string, MetaData>): Map<string, string> {
51
+ const byShortName = new Map<string, string[]>();
52
+ for (const [fqn, node] of closure) {
53
+ const bucket = byShortName.get(node.name);
54
+ if (bucket) bucket.push(fqn);
55
+ else byShortName.set(node.name, [fqn]);
56
+ }
57
+
58
+ const nameMap = new Map<string, string>();
59
+ for (const [shortName, fqns] of byShortName) {
60
+ if (fqns.length === 1) {
61
+ nameMap.set(fqns[0]!, shortName);
62
+ continue;
63
+ }
64
+ for (const fqn of fqns) {
65
+ const node = closure.get(fqn)!;
66
+ const pkg = node.package ?? node.fileDefaultPackage ?? "";
67
+ nameMap.set(fqn, packageQualifiedName(pkg, shortName));
68
+ }
69
+ }
70
+
71
+ // Backstop — sorted by fqn so which pair the message names (and whether the
72
+ // set of colliding names is non-empty) is a pure function of the closure, not
73
+ // of Map insertion/traversal order.
74
+ const ownerOf = new Map<string, string>();
75
+ for (const fqn of [...nameMap.keys()].sort()) {
76
+ const emitted = nameMap.get(fqn)!;
77
+ const existing = ownerOf.get(emitted);
78
+ if (existing !== undefined && existing !== fqn) {
79
+ throw new Error(
80
+ `${ERR_PAYLOAD_NAME_COLLISION}: payload record name collision: "${emitted}" derives from both "${existing}" and "${fqn}" — rename one value-object or move it to a package that derives a distinct name`,
81
+ );
82
+ }
83
+ ownerOf.set(emitted, fqn);
84
+ }
85
+
86
+ return nameMap;
87
+ }
@@ -19,7 +19,9 @@
19
19
  // unique in the closure emits bare; a collision emits
20
20
  // EVERY member under its package-qualified derived
21
21
  // name (PascalCase each package segment + short name).
22
- // A still-colliding derived name fails loud.
22
+ // A still-colliding derived name fails loud. Lives in
23
+ // ./naming/collision-names.js — shared with the
24
+ // entity + extract/output-parser tiers (#228).
23
25
  // 3. emitClosureDeclarations — emit each declaration + every reference through
24
26
  // the name map.
25
27
 
@@ -35,12 +37,12 @@ import {
35
37
  TEMPLATE_ATTR_PAYLOAD_REF,
36
38
  TEMPLATE_ATTR_TEXT_REF,
37
39
  TEMPLATE_ATTR_FORMAT,
38
- PACKAGE_SEPARATOR,
39
40
  resolveObjectRef,
40
41
  stripPackage,
41
42
  } from "@metaobjectsdev/metadata";
42
43
  import { enumValues } from "./enum-meta.js";
43
44
  import { enumUnionAliasName, enumUnionString } from "./templates/inferred-types.js";
45
+ import { assignEmittedNames } from "./naming/collision-names.js";
44
46
 
45
47
  const SCALAR_TS: Record<string, string> = {
46
48
  string: "string",
@@ -59,16 +61,6 @@ const SCALAR_TS: Record<string, string> = {
59
61
  timestamp: "string",
60
62
  };
61
63
 
62
- // ADR-0044 backstop error code — a codegen-time (not loader) error, peer of
63
- // @metaobjectsdev/render's ERR_VAR_NOT_ON_PAYLOAD. Declared LOCALLY rather than
64
- // added to packages/metadata/src/errors.ts's ERROR_CODES ledger: that ledger is
65
- // checked for FULL cross-port agreement against fixtures/conformance/ERROR-CODES.json
66
- // (packages/metadata/test/errors.test.ts) and, on the Python side, for corpus-code
67
- // coverage — registering it there before every port implements the ADR-0044 fix
68
- // would turn those OTHER ports' tests red. It moves into the shared ledger once the
69
- // Java/Kotlin/Python follow-up (ADR-0044 §4, items 3-4) lands alongside this code.
70
- const ERR_PAYLOAD_NAME_COLLISION = "ERR_PAYLOAD_NAME_COLLISION";
71
-
72
64
  // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
73
65
  // ADR-0042: resolveObjectRef gives package-local-before-root-level precedence for a bare ref, FQN-exact otherwise.
74
66
  function findObject(root: MetaData, name: string, referrerPkg = ""): MetaData | undefined {
@@ -108,73 +100,6 @@ function collectClosure(
108
100
  }
109
101
  }
110
102
 
111
- function pascalSegment(s: string): string {
112
- return s.length > 0 ? s[0]!.toUpperCase() + s.slice(1) : s;
113
- }
114
-
115
- /** ADR-0044 — package-qualified derived name for a collision member: PascalCase
116
- * each `::`-segment of the node's effective package, concatenated, then the
117
- * bare short name (`acme::alpha::Note` -> `AcmeAlphaNote`). A root-level
118
- * (no-package) node has nothing to qualify with and keeps its bare name — two
119
- * root-level VOs can never share a name (the loader's own-package uniqueness
120
- * already rejects that), so this can't silently under-qualify. */
121
- function packageQualifiedName(pkg: string, shortName: string): string {
122
- if (pkg === "") return shortName;
123
- return (
124
- pkg
125
- .split(PACKAGE_SEPARATOR)
126
- .map(pascalSegment)
127
- .join("") + shortName
128
- );
129
- }
130
-
131
- /**
132
- * ADR-0044 pass 2 — assign the emitted TS name for every VO in the closure. A
133
- * PURE function of the closure's (fqn, bareName, package) triples — never of
134
- * traversal order: bare short name unique in the closure -> bare name; a
135
- * collision -> EVERY member gets its package-qualified derived name. If two
136
- * DISTINCT fqns still derive the same name after qualification, throws
137
- * (ERR_PAYLOAD_NAME_COLLISION) — never silently wrong.
138
- */
139
- function assignEmittedNames(closure: ReadonlyMap<string, MetaData>): Map<string, string> {
140
- const byShortName = new Map<string, string[]>();
141
- for (const [fqn, node] of closure) {
142
- const bucket = byShortName.get(node.name);
143
- if (bucket) bucket.push(fqn);
144
- else byShortName.set(node.name, [fqn]);
145
- }
146
-
147
- const nameMap = new Map<string, string>();
148
- for (const [shortName, fqns] of byShortName) {
149
- if (fqns.length === 1) {
150
- nameMap.set(fqns[0]!, shortName);
151
- continue;
152
- }
153
- for (const fqn of fqns) {
154
- const node = closure.get(fqn)!;
155
- const pkg = node.package ?? node.fileDefaultPackage ?? "";
156
- nameMap.set(fqn, packageQualifiedName(pkg, shortName));
157
- }
158
- }
159
-
160
- // Backstop — sorted by fqn so which pair the message names (and whether the
161
- // set of colliding names is non-empty) is a pure function of the closure, not
162
- // of Map insertion/traversal order.
163
- const ownerOf = new Map<string, string>();
164
- for (const fqn of [...nameMap.keys()].sort()) {
165
- const emitted = nameMap.get(fqn)!;
166
- const existing = ownerOf.get(emitted);
167
- if (existing !== undefined && existing !== fqn) {
168
- throw new Error(
169
- `${ERR_PAYLOAD_NAME_COLLISION}: payload record name collision: "${emitted}" derives from both "${existing}" and "${fqn}" — rename one value-object or move it to a package that derives a distinct name`,
170
- );
171
- }
172
- ownerOf.set(emitted, fqn);
173
- }
174
-
175
- return nameMap;
176
- }
177
-
178
103
  /** Resolve `ref`'s emitted TS interface name under the ADR-0044 naming rule,
179
104
  * scoped to `ref`'s OWN reference closure (the same closure
180
105
  * `generatePayloadInterfaces(root, ref, referrerPkg)` would emit). Returns
@@ -51,7 +51,7 @@ export const barrel = function barrel(opts?: BarrelOpts): Generator {
51
51
  path: "index.ts",
52
52
  content: await formatTs(
53
53
  renderBarrel(
54
- entities.map((e) => ({ name: e.name, package: e.package })),
54
+ entities.map((e) => ({ name: ctx.renderContext!.valueObjectEmittedName(e), package: e.package })),
55
55
  ctx.renderContext!.extStyle,
56
56
  ctx.renderContext!.selfTarget,
57
57
  ctx.renderContext!.entityModuleTarget,
@@ -4,7 +4,9 @@
4
4
  //
5
5
  // use-when: you want generated typed CRUD finders (find<E>ById, list<E>s, create/update/delete)
6
6
  // over Drizzle. Drop it if you hand-write your data access.
7
- // emits: <target>/<Entity>.queries.ts per write-through entity.
7
+ // emits: <target>/<Entity>.queries.ts per source-backed object (any source.rdb kind,
8
+ // incl. read-only projections) — skipped for sourceless objects (incl. every
9
+ // object.value, source-less by value purity) and TPH subtypes (#248 R2).
8
10
  // customize: the vanilla CRUD assembly below is OWNED — reorder, drop verbs (e.g. no delete),
9
11
  // change the Db type alias, add your own finders. The render<Verb>Fn primitives emit
10
12
  // each block; call your own instead to change a verb's body.
@@ -15,7 +17,7 @@
15
17
  // out of the package source. The vanilla path here is byte-identical to the built-in.
16
18
 
17
19
  import { code, joinCode, type Code } from "ts-poet";
18
- import { OBJECT_SUBTYPE_VALUE, type MetaObject } from "@metaobjectsdev/metadata";
20
+ import type { MetaObject } from "@metaobjectsdev/metadata";
19
21
  import {
20
22
  perEntity,
21
23
  type Generator,
@@ -33,6 +35,7 @@ import {
33
35
  isProjection,
34
36
  isWriteThrough,
35
37
  isTphSubtype,
38
+ hasAnyRdbSource,
36
39
  renderQueriesFile, // engine composer — used for the delegated variants
37
40
  formatTs,
38
41
  entityOutputPath,
@@ -105,10 +108,15 @@ export interface QueriesFileOpts {
105
108
  target?: string;
106
109
  }
107
110
 
108
- // value objects have no identity (findById/updateById would target a non-existent column),
109
- // and TPH subtypes emit no standalone queries file both are skipped unconditionally.
110
- const skipNonQueryable = (e: MetaObject): boolean =>
111
- e.subType !== OBJECT_SUBTYPE_VALUE && !isTphSubtype(e);
111
+ // #248 R2: persistability derives from declared/inherited source, never subtype.
112
+ // An object with no source.rdb (of ANY kind) isn't backed by any store — the
113
+ // rendered queries module would emit findById/updateById/deleteById against
114
+ // Drizzle table/schema exports the entity file never emits for it (value
115
+ // objects are subsumed here too: value purity bans sources on them, so no
116
+ // loadable value ever has hasAnyRdbSource === true). TPH subtypes emit no
117
+ // standalone queries file either — their per-subtype CRUD helpers live in the
118
+ // discriminator base's queries file (which targets the single shared table).
119
+ const skipNonQueryable = (e: MetaObject): boolean => hasAnyRdbSource(e) && !isTphSubtype(e);
112
120
 
113
121
  export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
114
122
  const userFilter = opts?.filter;
@@ -6,6 +6,9 @@
6
6
  // if you need bespoke endpoints — or keep it and add handlers via <Entity>.extra.ts.
7
7
  // emits: <target>/<Entity>.routes.ts — full CRUD for write-through entities, read-only
8
8
  // (GET list + GET :id) for projections, polymorphic + per-subtype for TPH bases.
9
+ // Skipped for any sourceless object (incl. every object.value, source-less by
10
+ // value purity) and for TPH subtypes — no source.rdb means no table/allowlist
11
+ // for a routes file to import (#248 R2).
9
12
  // customize: this generator (filter, output path, per-entity @emitRoutes opt-out, target) is
10
13
  // YOURS — edit it freely. The route *composition* itself is richer than the others
11
14
  // (M:N junction traversal, TPH per-subtype route sets), so it stays in the engine via
@@ -23,6 +26,7 @@ import {
23
26
  type GeneratorFactory,
24
27
  renderRoutesFile,
25
28
  isTphSubtype,
29
+ hasAnyRdbSource,
26
30
  formatTs,
27
31
  entityOutputPath,
28
32
  CODEGEN_ATTR_EMIT_ROUTES,
@@ -39,9 +43,12 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
39
43
  name: "routes-file",
40
44
  // per-entity opt-out via `@emitRoutes: false`; TPH subtypes get no standalone routes
41
45
  // file (their routes live in the discriminator base's); AND-composed with your filter.
46
+ // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
47
+ // backed by any store — routes against it would import Drizzle table/allowlist
48
+ // exports the entity file never emits. Gated by hasAnyRdbSource.
42
49
  filter: (e: MetaObject) =>
43
50
  // ADR-0039: resolving — a concrete entity may inherit its @emit* opt-out flag via extends.
44
- e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false && !isTphSubtype(e) && userFilter(e),
51
+ e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false && hasAnyRdbSource(e) && !isTphSubtype(e) && userFilter(e),
45
52
  generate: perEntity(async (entity, ctx) => {
46
53
  if (!ctx.renderContext) {
47
54
  throw new Error("routes-file: renderContext is required (provided by runGen)");
@@ -1,6 +1,7 @@
1
1
  // RenderContext — cross-cutting state passed to every template.
2
2
 
3
- import type { MetaRoot } from "@metaobjectsdev/metadata";
3
+ import type { MetaRoot, MetaData, MetaField } from "@metaobjectsdev/metadata";
4
+ import { resolveObjectRef, stripPackage } from "@metaobjectsdev/metadata";
4
5
  import type { Dialect } from "./column-mapper.js";
5
6
  import type { PkInfo } from "./pk-resolver.js";
6
7
  import type { RelationMap } from "./relation-resolver.js";
@@ -67,8 +68,28 @@ export interface RenderContext {
67
68
  pkMap: Map<string, PkInfo>;
68
69
  /** Pre-pass relation map for FK + relations() block emission. */
69
70
  relationMap: RelationMap;
70
- /** Entity name → its metadata package (undefined if the entity has no package). Built once per run. */
71
+ /** Object name → its metadata package (undefined if the object has no package).
72
+ * Built once per run. Value objects are keyed by their ADR-0044 EMITTED name
73
+ * (bare when unique, package-qualified on a cross-package short-name collision;
74
+ * #228) so `valueObjectModuleSpecifier` resolves the right module; entities and
75
+ * other objects are keyed by their bare name. */
71
76
  packageOf: Map<string, string | undefined>;
77
+ /** ADR-0044/#228 — `resolutionKey()` → emitted TS name for every emitted
78
+ * `object.value` in the run. A PURE function of the run's value-object set
79
+ * (collision-scoped): a bare short name unique in the set stays bare; a
80
+ * cross-package short-name collision qualifies EVERY member. Empty by default
81
+ * (bare names — byte-identical to pre-#228 output). */
82
+ valueObjectNames: ReadonlyMap<string, string>;
83
+ /** The ADR-0044 emitted name for a value object being DECLARED (its interface,
84
+ * Zod schema, and module filename). Non-value objects (entities) are never in
85
+ * the collision set, so this returns their bare `name`. */
86
+ valueObjectEmittedName: (obj: MetaData) => string;
87
+ /** The ADR-0044 emitted name for a REFERENCE to a value object (`@objectRef`,
88
+ * bare or FQN), resolved package-locally (ADR-0042) from `referrerPkg`. Falls
89
+ * back to the bare (package-stripped) ref when it resolves to no emitted value
90
+ * object — which is also the byte-identical result whenever there is no
91
+ * collision. */
92
+ resolveValueObjectName: (ref: string, referrerPkg: string | undefined) => string;
72
93
  /** FR-019: module specifier to import externally-PROVIDED shared enums from
73
94
  * (`@provided: true` declarations). Undefined when unset — referencing a
74
95
  * provided enum without it is a codegen-time error. */
@@ -76,7 +97,7 @@ export interface RenderContext {
76
97
  }
77
98
 
78
99
  /** 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). */
79
- export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "timestampMode" | "apiPrefix" | "emitAbstractShapes" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget" | "collectionName"> & {
100
+ export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "timestampMode" | "apiPrefix" | "emitAbstractShapes" | "outputLayout" | "packageOf" | "valueObjectNames" | "valueObjectEmittedName" | "resolveValueObjectName" | "selfTarget" | "entityModuleTarget" | "collectionName"> & {
80
101
  extStyle?: ExtStyle;
81
102
  omImport?: string;
82
103
  columnNamingStrategy?: ColumnNamingStrategy;
@@ -85,6 +106,10 @@ export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "
85
106
  emitAbstractShapes?: boolean;
86
107
  outputLayout?: OutputLayout;
87
108
  packageOf?: Map<string, string | undefined>;
109
+ /** ADR-0044/#228 value-object emitted-name map (resolutionKey → emitted name).
110
+ * Defaults to an empty Map — bare names, byte-identical to pre-#228 output.
111
+ * `runGen` always provides the real map. */
112
+ valueObjectNames?: ReadonlyMap<string, string>;
88
113
  selfTarget?: ResolvedTarget;
89
114
  entityModuleTarget?: ResolvedTarget;
90
115
  /** Auto-pluralize collection (table) variable names. Default true. */
@@ -93,6 +118,17 @@ export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "
93
118
  collectionNameOverrides?: Record<string, string>;
94
119
  };
95
120
 
121
+ /** ADR-0042/#228 — the package a field's `@objectRef` resolves in: the FIELD's OWN
122
+ * declaring package (which differs from the referring object's when the field is
123
+ * inherited via `extends` from an abstract node in another package), falling back
124
+ * to `fallbackPkg` (the referring object's package). THE single source of truth for
125
+ * the referrer package passed to `RenderContext.resolveValueObjectName`, so every
126
+ * value-object reference site resolves a cross-package short-name collision
127
+ * identically (they cannot drift). Mirrors payload-codegen's `collectClosure`. */
128
+ export function fieldDeclaringPackage(field: MetaField, fallbackPkg: string | undefined): string | undefined {
129
+ return field.parent?.package ?? field.parent?.fileDefaultPackage ?? fallbackPkg;
130
+ }
131
+
96
132
  /** Append the configured extension to a cross-entity module specifier (which is
97
133
  * always a bare, extension-less relative path like `./Foo`). */
98
134
  export function withExt(spec: string, style: ExtStyle): string {
@@ -129,6 +165,11 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
129
165
  pluralize: opts.pluralizeCollections ?? true,
130
166
  overrides: opts.collectionNameOverrides ?? {},
131
167
  };
168
+ // ADR-0044/#228 — the value-object emitted-name map + its two accessors. When
169
+ // absent (bare template unit-tests), the map is empty, so both accessors return
170
+ // bare names and every consumer is byte-identical to pre-#228 output.
171
+ const valueObjectNames = opts.valueObjectNames ?? new Map<string, string>();
172
+ const loadedRoot = opts.loadedRoot;
132
173
  return {
133
174
  ...opts,
134
175
  extStyle: opts.extStyle ?? "js",
@@ -139,6 +180,13 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
139
180
  emitAbstractShapes: opts.emitAbstractShapes ?? true,
140
181
  outputLayout,
141
182
  packageOf: opts.packageOf ?? new Map(),
183
+ valueObjectNames,
184
+ valueObjectEmittedName: (obj: MetaData) => valueObjectNames.get(obj.resolutionKey()) ?? obj.name,
185
+ resolveValueObjectName: (ref: string, referrerPkg: string | undefined) => {
186
+ const { node } = resolveObjectRef(loadedRoot, ref, referrerPkg ?? "");
187
+ const emitted = node !== undefined ? valueObjectNames.get(node.resolutionKey()) : undefined;
188
+ return emitted ?? stripPackage(ref);
189
+ },
142
190
  selfTarget: defaultTarget,
143
191
  entityModuleTarget: opts.entityModuleTarget ?? defaultTarget,
144
192
  collectionName: (entityName: string) => variableNameFromEntity(entityName, collectionNameOpts),
package/src/runner.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { join, relative, resolve, isAbsolute } from "node:path";
2
2
  import { tmpdir } from "node:os";
3
3
  import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
4
- import { MetaRoot } from "@metaobjectsdev/metadata";
4
+ import { MetaRoot, OBJECT_SUBTYPE_VALUE } from "@metaobjectsdev/metadata";
5
+ import { assignEmittedNames } from "./naming/collision-names.js";
6
+ import { isAbstract } from "./instance-artifacts.js";
5
7
  import type { Generator, GenContext, EmittedFile } from "./generator.js";
6
8
  import type { MetaobjectsGenConfig } from "./metaobjects-config.js";
7
9
  import { normalizeConfig, DEFAULT_TARGET_NAME } from "./metaobjects-config.js";
@@ -143,9 +145,38 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
143
145
  // 3. Build shared render state once.
144
146
  const pkMap = buildPkMap(root);
145
147
  const relationMap = buildRelationMap(root);
146
- const packageOf = new Map<string, string | undefined>(
147
- root.objects().map((o) => [o.name, o.package]),
148
- );
148
+ // ADR-0044/#228 the ENTITY-tier collision domain is the run's EMITTED
149
+ // `object.value` SET (NOT any per-payload closure): value-object module
150
+ // filenames + `packageOf` are per-run/global, so the emitted-name map is built
151
+ // ONCE over every top-level `object.value` that actually produces a file, keyed
152
+ // by `resolutionKey()`. A bare short name unique across the set stays bare
153
+ // (byte-identical to pre-#228 output); a cross-package short-name collision
154
+ // qualifies every member (`AcmeAlphaNote`), and a still-colliding derived name
155
+ // fails loud (ERR_PAYLOAD_NAME_COLLISION, thrown by assignEmittedNames). A
156
+ // NON-emitted abstract value object (abstract + emitAbstractShapes off) produces
157
+ // no file/reference and is excluded — the entity-file generator's own emit gate
158
+ // (`isAbstract && !emitAbstractShapes` ⇒ skip) — so it can't over-qualify a
159
+ // concrete value object that merely shares its bare name in another package.
160
+ const isEmittedValueObject = (o: MetaObject): boolean =>
161
+ o.subType === OBJECT_SUBTYPE_VALUE && (!isAbstract(o) || config.emitAbstractShapes);
162
+ const valueObjectClosure = new Map<string, MetaData>();
163
+ for (const o of root.objects()) {
164
+ if (isEmittedValueObject(o)) valueObjectClosure.set(o.resolutionKey(), o);
165
+ }
166
+ const valueObjectNames = assignEmittedNames(valueObjectClosure);
167
+ // `packageOf` keys value objects by their EMITTED name (unique by construction
168
+ // via the backstop) so `valueObjectModuleSpecifier` resolves the right module
169
+ // even when two same-bare-named value objects live in different packages (the
170
+ // #244 misbinding disease). Non-value objects keep their bare name. With no
171
+ // collision, every emitted name equals its bare name, so this map is
172
+ // byte-identical to the pre-#228 `[o.name, o.package]` map.
173
+ const packageOf = new Map<string, string | undefined>();
174
+ for (const o of root.objects()) {
175
+ const key = o.subType === OBJECT_SUBTYPE_VALUE
176
+ ? (valueObjectNames.get(o.resolutionKey()) ?? o.name)
177
+ : o.name;
178
+ packageOf.set(key, o.package);
179
+ }
149
180
 
150
181
  // Auto-detect: is the OPT-IN Hono routes generator in the active suite? If so,
151
182
  // surface it on every generator's ctx.config so api-docs documents the Hono
@@ -192,6 +223,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
192
223
  pkMap,
193
224
  relationMap,
194
225
  packageOf,
226
+ valueObjectNames,
195
227
  selfTarget,
196
228
  entityModuleTarget,
197
229
  ...(config.providedEnumModule !== undefined && { providedEnumModule: config.providedEnumModule }),
@@ -29,3 +29,23 @@ export function hasWritableRdbSource(entity: MetaObject): boolean {
29
29
  }
30
30
  return false;
31
31
  }
32
+
33
+ /**
34
+ * True when the object declares (or inherits via extends — ADR-0039 resolving)
35
+ * at least one source.rdb child of ANY kind (writable OR read-only). Zero
36
+ * sources means "not backed by any store" (loader contract,
37
+ * validate-source-roles: zero sources is allowed, means not persisted) — the
38
+ * DB-artifact tier (queries/routes/api-model) must emit nothing for it, exactly
39
+ * as the table tier already refuses to emit a Drizzle table for it
40
+ * (hasWritableRdbSource, above — this is its any-kind sibling).
41
+ */
42
+ export function hasAnyRdbSource(entity: MetaObject): boolean {
43
+ // ADR-0039: resolving — same rationale as hasWritableRdbSource.
44
+ for (const child of entity.children()) {
45
+ if (child.type !== TYPE_SOURCE) continue;
46
+ if (child.subType !== SOURCE_SUBTYPE_RDB) continue;
47
+ if (!(child instanceof MetaSource)) continue;
48
+ return true;
49
+ }
50
+ return false;
51
+ }
@@ -9,8 +9,9 @@ import {
9
9
  IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION,
10
10
  GENERATION_INCREMENT, GENERATION_UUID,
11
11
  FIELD_ATTR_AUTO_SET,
12
+ FIELD_ATTR_OBJECT_REF,
12
13
  } from "@metaobjectsdev/metadata";
13
- import { type RenderContext } from "../render-context.js";
14
+ import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
14
15
  import { crossEntitySpecifier, valueObjectModuleSpecifier } from "../import-path.js";
15
16
  import { mapColumnType, type ColumnSpec } from "../column-mapper.js";
16
17
  import { tableNameFromEntity, columnNameFromField } from "../naming.js";
@@ -386,8 +387,18 @@ function renderColumn(
386
387
  // first."
387
388
  // Resolve a VO name → an imported type symbol (shared layout/package/extStyle-aware
388
389
  // helper, so the .$type<VO> import matches the field's TS type + Zod schema).
389
- const voSym = (name: string) =>
390
- imp(`${name}@${valueObjectModuleSpecifier(name, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`);
390
+ // ADR-0044/#228 resolve the field's @objectRef to the value object's EMITTED
391
+ // name (bare when unique in the run, package-qualified on a cross-package
392
+ // short-name collision), resolved package-locally from the FIELD's declaring
393
+ // package. `name` (the bare dollarTypeRef name) is the byte-identical fallback
394
+ // when the ref doesn't resolve to an emitted value object.
395
+ const voSym = (name: string) => {
396
+ const refRaw = field.attr(FIELD_ATTR_OBJECT_REF);
397
+ const emitted = typeof refRaw === "string"
398
+ ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage))
399
+ : name;
400
+ return imp(`${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`);
401
+ };
391
402
 
392
403
  let dollarTypeSegment: Code | string = "";
393
404
  if (spec.dollarTypeRef !== undefined) {
@@ -7,8 +7,9 @@
7
7
  // vanilla / write-through entity → Drizzle table path
8
8
 
9
9
  import { code, imp, joinCode, type Code } from "ts-poet";
10
- import type { MetaObject } from "@metaobjectsdev/metadata";
11
- import type { RenderContext } from "../render-context.js";
10
+ import type { MetaObject, MetaField } from "@metaobjectsdev/metadata";
11
+ import { FIELD_ATTR_OBJECT_REF } from "@metaobjectsdev/metadata";
12
+ import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
12
13
  import { renderDrizzleSchema } from "./drizzle-schema.js";
13
14
  import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js";
14
15
  import { renderZodValidators, isTphSubtype } from "./zod-validators.js";
@@ -23,7 +24,6 @@ import { projectionViewName } from "../projection/extract-view-spec.js";
23
24
  import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js";
24
25
  import { renderDocsFor } from "./jsdoc.js";
25
26
  import { valueObjectModuleSpecifier } from "../import-path.js";
26
- import { stripPackage } from "@metaobjectsdev/metadata";
27
27
  import { hasWritableRdbSource } from "../source-detect.js";
28
28
  import { renderValueObjectFile } from "./value-object-file.js";
29
29
  import { isAbstract } from "../instance-artifacts.js";
@@ -131,10 +131,18 @@ export function renderEntityFile(
131
131
  if (writeThrough) {
132
132
  const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1);
133
133
  const fields = entity.fields();
134
- const voModule = (refBase: string): string =>
135
- valueObjectModuleSpecifier(stripPackage(refBase), ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle);
134
+ // ADR-0044/#228 resolve a view column's `@objectRef` to the value object's
135
+ // EMITTED name + module TOGETHER (lock-step), so the read-view artifact imports
136
+ // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`)
137
+ // under a cross-package short-name collision.
138
+ const voRef = (field: MetaField): { name: string; module: string } => {
139
+ const ref = field.attr(FIELD_ATTR_OBJECT_REF);
140
+ const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entity.package));
141
+ const module = valueObjectModuleSpecifier(name, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle);
142
+ return { name, module };
143
+ };
136
144
  const viewOpts = {
137
- dialect: ctx.dialect, columnNamingStrategy: ctx.columnNamingStrategy, timestampMode: ctx.timestampMode, voModule,
145
+ dialect: ctx.dialect, columnNamingStrategy: ctx.columnNamingStrategy, timestampMode: ctx.timestampMode, voRef,
138
146
  };
139
147
  const z = imp("z@zod");
140
148
  const docs = renderDocsFor(entity);