@metaobjectsdev/codegen-ts 1.0.3 → 1.0.4

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 (70) hide show
  1. package/dist/catalog-gates.d.ts +52 -0
  2. package/dist/catalog-gates.d.ts.map +1 -0
  3. package/dist/catalog-gates.js +183 -0
  4. package/dist/catalog-gates.js.map +1 -0
  5. package/dist/constants.d.ts +24 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/constants.js +26 -0
  8. package/dist/constants.js.map +1 -1
  9. package/dist/generator-registry.d.ts +73 -0
  10. package/dist/generator-registry.d.ts.map +1 -1
  11. package/dist/generator-registry.js +174 -40
  12. package/dist/generator-registry.js.map +1 -1
  13. package/dist/generator.d.ts +17 -0
  14. package/dist/generator.d.ts.map +1 -1
  15. package/dist/generator.js.map +1 -1
  16. package/dist/generators/trace-helper-file.d.ts.map +1 -1
  17. package/dist/generators/trace-helper-file.js +54 -11
  18. package/dist/generators/trace-helper-file.js.map +1 -1
  19. package/dist/index.d.ts +5 -4
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -3
  22. package/dist/index.js.map +1 -1
  23. package/dist/metaobjects-config.d.ts +0 -15
  24. package/dist/metaobjects-config.d.ts.map +1 -1
  25. package/dist/metaobjects-config.js.map +1 -1
  26. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  27. package/dist/projection/extract-view-spec.js +111 -4
  28. package/dist/projection/extract-view-spec.js.map +1 -1
  29. package/dist/relation-resolver.d.ts.map +1 -1
  30. package/dist/relation-resolver.js +11 -9
  31. package/dist/relation-resolver.js.map +1 -1
  32. package/dist/routes-expose.d.ts +30 -0
  33. package/dist/routes-expose.d.ts.map +1 -1
  34. package/dist/routes-expose.js +46 -0
  35. package/dist/routes-expose.js.map +1 -1
  36. package/dist/runner.d.ts +20 -0
  37. package/dist/runner.d.ts.map +1 -1
  38. package/dist/runner.js +23 -0
  39. package/dist/runner.js.map +1 -1
  40. package/dist/templates/entity-file.js +2 -2
  41. package/dist/templates/entity-file.js.map +1 -1
  42. package/dist/templates/queries-file.js +5 -5
  43. package/dist/templates/queries-file.js.map +1 -1
  44. package/dist/templates/routes-file-hono.d.ts.map +1 -1
  45. package/dist/templates/routes-file-hono.js +10 -3
  46. package/dist/templates/routes-file-hono.js.map +1 -1
  47. package/dist/templates/routes-file.d.ts.map +1 -1
  48. package/dist/templates/routes-file.js +21 -4
  49. package/dist/templates/routes-file.js.map +1 -1
  50. package/dist/templates/value-object-file.js +2 -2
  51. package/dist/templates/value-object-file.js.map +1 -1
  52. package/package.json +6 -6
  53. package/src/catalog-gates.ts +222 -0
  54. package/src/constants.ts +27 -0
  55. package/src/generator-registry.ts +245 -44
  56. package/src/generator.ts +17 -0
  57. package/src/generators/trace-helper-file.ts +55 -12
  58. package/src/index.ts +17 -3
  59. package/src/metaobjects-config.ts +0 -15
  60. package/src/projection/extract-view-spec.ts +126 -5
  61. package/src/reference/entity.ts +2 -2
  62. package/src/reference/queries.ts +2 -2
  63. package/src/relation-resolver.ts +14 -8
  64. package/src/routes-expose.ts +57 -0
  65. package/src/runner.ts +49 -0
  66. package/src/templates/entity-file.ts +2 -2
  67. package/src/templates/queries-file.ts +5 -5
  68. package/src/templates/routes-file-hono.ts +10 -3
  69. package/src/templates/routes-file.ts +21 -4
  70. package/src/templates/value-object-file.ts +2 -2
@@ -42,16 +42,19 @@ import {
42
42
  SORT_ORDER_DESC,
43
43
  RELATIONSHIP_ATTR_OBJECT_REF,
44
44
  RELATIONSHIP_ATTR_CARDINALITY,
45
+ RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
45
46
  CARDINALITY_ONE,
46
47
  IDENTITY_SUBTYPE_REFERENCE,
47
48
  IDENTITY_REFERENCE_ATTR_REFERENCES,
48
49
  FIELD_ATTR_COLUMN,
49
50
  OBJECT_PROJECTION_ATTR_FILTER,
50
- findReferenceBetween,
51
+ findReferencesBetween,
52
+ resolveRelationshipReference,
51
53
  resolveObjectRef,
52
54
  type AggregateFunction,
55
+ type ReferenceLookup,
53
56
  } from "@metaobjectsdev/metadata";
54
- import { type MetaData, type MetaField, type MetaRoot, type MetaSource, MetaObject } from "@metaobjectsdev/metadata";
57
+ import { type MetaData, type MetaField, type MetaReferenceIdentity, type MetaRoot, type MetaSource, MetaObject } from "@metaobjectsdev/metadata";
55
58
  import { intValueMapOf } from "../enum-meta.js";
56
59
  import {
57
60
  columnNameFromField,
@@ -330,6 +333,43 @@ function resolveHop(
330
333
  return undefined;
331
334
  }
332
335
 
336
+ /**
337
+ * #368: which identity.reference does a resolved `@via` hop actually mean?
338
+ *
339
+ * `resolveHop` already found the EXACT node the hop segment named — a
340
+ * reference hop names the reference itself (nothing to disambiguate: two
341
+ * references onto the same target can coexist, but the hop picked one of them
342
+ * by name), and a relationship hop names a relationship whose backing
343
+ * reference `resolveRelationshipReference` resolves via the same SSOT ladder
344
+ * (unique candidate -> @sourceRefField -> unique name-pairing) that
345
+ * relation-resolver.ts already uses for the identical question elsewhere.
346
+ * Discarding `hop` and re-deriving purely from `holder`/`target` — as this
347
+ * file did before — throws away that specificity and reintroduces the exact
348
+ * ambiguity a named hop exists to resolve.
349
+ *
350
+ * Returns a single `ReferenceLookup` once resolved unambiguously, or the full
351
+ * candidate list when even the ladder cannot choose (a relationship hop whose
352
+ * name pairs with none of its candidates and no `@sourceRefField`) — the
353
+ * caller reports that list as a genuine ambiguity.
354
+ */
355
+ function resolveHopReference(
356
+ holder: MetaObject,
357
+ hop: MetaData,
358
+ hopName: string,
359
+ target: MetaObject,
360
+ ): ReferenceLookup | ReferenceLookup[] {
361
+ if (hop.type === TYPE_IDENTITY && hop.subType === IDENTITY_SUBTYPE_REFERENCE) {
362
+ return { holder, other: target, referenceIdentity: hop as unknown as MetaReferenceIdentity };
363
+ }
364
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends.
365
+ const sourceRefField = hop.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
366
+ const matching = resolveRelationshipReference(holder, hopName, target.name, sourceRefField);
367
+ if (matching) {
368
+ return { holder, other: target, referenceIdentity: matching };
369
+ }
370
+ return findReferencesBetween(holder, target);
371
+ }
372
+
333
373
  function viewName(projection: MetaObject, ctx: ExtractContext): string {
334
374
  // The read-only source carries the physical view name. FR-016: physicalName
335
375
  // implements the four-step rule (kind-matching alias → legacy @table →
@@ -750,14 +790,62 @@ function buildJoinTree(
750
790
  // a traversed relationship/reference may inherit its target via extends.
751
791
  const resolved = resolveHop(currentObj, relName);
752
792
  if (!resolved) break;
753
- const { targetName, cardinality } = resolved;
793
+ const { hop, targetName, cardinality } = resolved;
754
794
  // @objectRef/@references may be package-qualified ("pkg::Entity"); resolve it
755
795
  // package-aware relative to the hop's source entity (the loader qualifies a
756
796
  // same-package ref even when authored bare), so the join binds the exact target.
757
797
  const target = resolveEntityRef(root, targetName, packageOf(currentObj));
758
798
  if (!target) break;
759
799
 
760
- const ref = findReferenceBetween(currentObj as MetaObject, target);
800
+ // #368: two identity.reference declarations onto the same target are legal
801
+ // (e.g. Match.homeTeamRef/awayTeamRef -> Team) — resolveHopReference prefers
802
+ // the SPECIFIC reference/relationship the hop already named over re-deriving
803
+ // one from the target alone, so an explicit `@via: "Match.homeTeamRef"` (or a
804
+ // relationship disambiguated by @sourceRefField/name-pairing) resolves cleanly.
805
+ // Only a relationship hop that even the ladder cannot choose reaches the throw.
806
+ const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target);
807
+ let ref: ReferenceLookup | undefined;
808
+ if (Array.isArray(resolvedRef)) {
809
+ if (resolvedRef.length > 1) {
810
+ // #368 round 2: @sourceRefField cannot fix this, but WHY differs by shape, and
811
+ // asserting the wrong reason for a given shape is itself a bug (fix round 1 of
812
+ // this cleanup caught exactly that). resolveRelationshipReference's ladder reads
813
+ // ONLY the hop's own entity's candidates (referenceCandidatesFor(currentObj, ...));
814
+ // it never even looks at `target`'s references. So:
815
+ // - If `currentObj` itself holds one of the ambiguous candidates, resolution
816
+ // already tried @sourceRefField/name-pairing against it and failed — and that
817
+ // is only reachable at all when @cardinality isn't "one": a @cardinality "one"
818
+ // relationship with 2+ own-side candidates is rejected at LOAD by rule (e)
819
+ // (validateOneSideReferenceResolution) using this exact same ladder, so if we
820
+ // got this far with an own-side candidate, @cardinality is provably not "one",
821
+ // and @sourceRefField is provably illegal here (rule (d)).
822
+ // - If NONE of the candidates are `currentObj`'s own, @sourceRefField could not
823
+ // have mattered regardless of @cardinality — it only ever consults the hop's
824
+ // OWN identity.reference children, and it has none targeting `target`. This is
825
+ // rule (e)'s zero-candidate gap (validation-passes.ts:2226, `<= 1` skips 0 too):
826
+ // a @cardinality "one" relationship can reach here with the FK entirely on the
827
+ // far side, so @cardinality itself must NOT be asserted in this branch.
828
+ const holderName = (currentObj as MetaObject).name;
829
+ const holderOwnsACandidate = resolvedRef.some((r) => r.holder.name === holderName);
830
+ const whySourceRefFieldCannotHelp = holderOwnsACandidate
831
+ ? `it only disambiguates a @cardinality "${CARDINALITY_ONE}" relationship, and this ` +
832
+ `relationship's @cardinality is not "${CARDINALITY_ONE}" (declaring @sourceRefField on it ` +
833
+ `is itself a load error)`
834
+ : `it only consults "${holderName}"'s own identity.reference children, and "${holderName}" ` +
835
+ `declares none targeting "${target.name}" -- every candidate above belongs to the other side ` +
836
+ `of this join`;
837
+ throw new Error(
838
+ `projection join hop "${relName}" from "${holderName}" to "${target.name}" is ambiguous: ` +
839
+ `${resolvedRef.map((r) => r.referenceIdentity.name).join(", ")}. ` +
840
+ `@sourceRefField cannot resolve this: ${whySourceRefFieldCannotHelp}. There is no attribute ` +
841
+ `that disambiguates a hop like this -- remove the extra identity.reference between these two ` +
842
+ `entities, or restructure the model so only one remains.`,
843
+ );
844
+ }
845
+ ref = resolvedRef[0];
846
+ } else {
847
+ ref = resolvedRef;
848
+ }
761
849
  if (!ref) break;
762
850
 
763
851
  const fkField = ref.referenceIdentity.fields[0];
@@ -1090,7 +1178,40 @@ function buildSelectSpec(
1090
1178
  // The base↔child correlation FK — resolved exactly as buildJoinTree resolves a
1091
1179
  // single hop (the identity.reference is the FK-direction SSOT). Single-hop @via;
1092
1180
  // a multi-hop @via on origin.first is not lowered here (rare, and validated away).
1093
- const ref = findReferenceBetween(base, childEntity);
1181
+ // #368: two identity.reference declarations onto the same target are legal, so
1182
+ // this correlation cannot silently take the first — refuse rather than guess.
1183
+ // Unlike buildJoinTree's @via hop (a named relationship/reference this file can
1184
+ // resolve via resolveHopReference), origin.first's OWN @via (ORIGIN_FIRST_ATTR_VIA)
1185
+ // is never read anywhere in this file — buildJoinTree explicitly skips it
1186
+ // (ORIGIN_SUBTYPE_FIRST falls through to `continue` there) and this branch derives
1187
+ // childEntity from @of alone, so there is no hop name here to prefer. Fixing that is
1188
+ // a separate, larger change (wiring @via/single-hop-unique inference into this
1189
+ // branch to match _validateViaPath/_inferViaSingleHop) — out of scope for #368's
1190
+ // silent-first-match fix; the message below reflects the real, narrower remedy.
1191
+ const refs = findReferencesBetween(base, childEntity);
1192
+ // findReferencesBetween walks BOTH directions ([base,child] then [child,base]),
1193
+ // so ">1 entry" is not by itself #368 ambiguity: a mutual 1:1 —
1194
+ // Customer.primaryAddressRef -> Address PLUS Address.customerRef -> Customer —
1195
+ // returns two entries pointing in OPPOSITE directions. That shape is legal
1196
+ // (findReferenceBetween's own contract calls it "rare, but legal", and the
1197
+ // referenceHolder: "source" | "target" branch below exists to serve it), and
1198
+ // refusing it broke codegen for a model #368 says nothing about. The real
1199
+ // ambiguity is two references declared by the SAME holder onto the other side —
1200
+ // that is what the first-match below cannot choose between.
1201
+ const ambiguousHolder = [base, childEntity].find(
1202
+ (holder) => refs.filter((r) => r.holder === holder).length > 1,
1203
+ );
1204
+ if (ambiguousHolder !== undefined) {
1205
+ const ambiguous = refs.filter((r) => r.holder === ambiguousHolder);
1206
+ throw new Error(
1207
+ `origin.first correlation from "${base.name}" to "${childEntity.name}" is ambiguous: ` +
1208
+ `"${ambiguousHolder.name}" declares ${ambiguous.length} identity.reference nodes ` +
1209
+ `onto the other side (${ambiguous.map((r) => r.referenceIdentity.name).join(", ")}). ` +
1210
+ `origin.first's own @via is not consulted for this correlation — reduce to a ` +
1211
+ `single identity.reference between these two entities.`,
1212
+ );
1213
+ }
1214
+ const ref = refs[0];
1094
1215
  if (!ref) continue;
1095
1216
  const fkField = ref.referenceIdentity.fields[0];
1096
1217
  if (!fkField) continue;
@@ -70,6 +70,7 @@ import {
70
70
  namesRef,
71
71
  namesConstArg,
72
72
  GENERATED_HEADER,
73
+ sidecarLine,
73
74
  } from "@metaobjectsdev/codegen-ts";
74
75
 
75
76
  export interface RenderEntityOpts {
@@ -143,8 +144,7 @@ function renderEntity(entity: MetaObject, ctx: RenderContext, opts?: RenderEntit
143
144
  const body = joinCode(sections, { on: "\n" }).toString();
144
145
  const header =
145
146
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
146
- `// Source metadata: ${entity.name} (${entity.fqn()})\n` +
147
- `// Customize via ${entity.name}.extra.ts in this directory.\n`;
147
+ `// Source metadata: ${entity.name} (${entity.fqn()})\n${sidecarLine(`${entity.name}.extra.ts`)}`;
148
148
  return header + body;
149
149
  }
150
150
 
@@ -56,6 +56,7 @@ import {
56
56
  formatTs,
57
57
  entityOutputPath,
58
58
  GENERATED_HEADER,
59
+ sidecarLine,
59
60
  } from "@metaobjectsdev/codegen-ts";
60
61
 
61
62
  // --- composition (OWNED for the common case) ---
@@ -128,8 +129,7 @@ import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}I
128
129
  const body = joinCode(sections, { on: "\n" }).toString();
129
130
  const header =
130
131
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
131
- `// Source metadata: ${entityName} (${obj.fqn()})\n` +
132
- `// Customize via ${entityName}.extra.ts in this directory (additional queries, custom logic).\n`;
132
+ `// Source metadata: ${entityName} (${obj.fqn()})\n${sidecarLine(`${entityName}.extra.ts`)}`;
133
133
  return header + body;
134
134
  }
135
135
 
@@ -10,9 +10,11 @@ import {
10
10
  RELATIONSHIP_ATTR_CARDINALITY,
11
11
  RELATIONSHIP_ATTR_OBJECT_REF,
12
12
  RELATIONSHIP_ATTR_THROUGH,
13
+ RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
13
14
  CARDINALITY_ONE,
14
15
  CARDINALITY_MANY,
15
16
  deriveM2MFields,
17
+ resolveRelationshipReference,
16
18
  stripPackage,
17
19
  } from "@metaobjectsdev/metadata";
18
20
  import { variableNameFromEntity } from "./naming.js";
@@ -89,16 +91,20 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
89
91
  if (!targetEntityRaw) continue;
90
92
  const targetEntity = stripPackage(targetEntityRaw);
91
93
 
92
- // Find an identity.reference on `obj` whose @references targets this relationship's target.
93
- // Compare against package-stripped names since both relationship @objectRef and
94
- // identity.reference @references may carry package-qualified entity names.
95
- const refs = obj.referenceIdentities();
96
- const matching = refs.find((r) => stripPackage(r.targetEntity ?? "") === targetEntity);
94
+ // #368: an entity may hold more than one identity.reference onto the same
95
+ // target, so the target alone does not identify the FK. Resolve through the
96
+ // shared ladder (unique candidate -> @sourceRefField -> name pairing); the
97
+ // loader has already refused anything it cannot resolve, so a miss here
98
+ // means an unloadable model reached codegen skip rather than guess.
99
+ // ADR-0039: resolving — @sourceRefField may be inherited via extends.
100
+ const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
101
+ const matching = resolveRelationshipReference(
102
+ obj, child.name, targetEntity, declaredRefField,
103
+ );
97
104
  if (!matching) continue;
98
105
 
99
- const fkFields = matching.fields;
100
- if (fkFields.length === 0) continue;
101
- const fkField = fkFields[0]!;
106
+ const fkField = matching.fields[0];
107
+ if (!fkField) continue;
102
108
 
103
109
  ensure(obj.name).push({
104
110
  name: child.name,
@@ -84,3 +84,60 @@ export function exposeLine(verbs: readonly CrudVerb[] | undefined, indent: strin
84
84
  if (verbs === undefined) return "";
85
85
  return `\n${indent}expose: [${verbs.map((v) => JSON.stringify(v)).join(", ")}],`;
86
86
  }
87
+
88
+ /**
89
+ * The auth-seam paragraph in a generated routes handler's JSDoc.
90
+ *
91
+ * Stock CRUD is unauthenticated, and until #367 the only thing a generated routes file
92
+ * said about that was a header line pointing at `<Entity>.extra.ts` "(e.g., auth)" — a
93
+ * sibling module nothing imports. An agent building a real API read it, went looking for
94
+ * the seam, found none, and deleted `routesFile()` from its config rather than mount five
95
+ * open endpoints over a password-hash table. The seam it needed already existed in both
96
+ * frameworks; nothing told it where.
97
+ *
98
+ * Both recipes are gated by `runtime-ts/test/route-auth-seam.test.ts` against the real
99
+ * mount helpers, because a recipe in generated output that does not work is the defect
100
+ * this replaces, not a smaller version of it. Hono's trailing wildcard is load-bearing
101
+ * and counter-intuitive: `"/users/*"` matches the collection path `/users` itself, so one
102
+ * `app.use` covers list, get and every write.
103
+ *
104
+ * The closing sentence is the part an adopter most needs and no knob can supply: a
105
+ * row-ownership rule ("only the owner may read this row") is not expressible in a mount,
106
+ * so the honest move is to hand-write those verbs and narrow the generated file with
107
+ * `expose` — not to mount them and hope.
108
+ */
109
+ export function authSeamJsDoc(opts: {
110
+ framework: "fastify" | "hono";
111
+ /** The emitted handler's name, so the worked example is callable as written. */
112
+ handlerName: string;
113
+ /** Hono only: the source expression for this file's mount path, e.g. "`/api${User.$path}`". */
114
+ mountPathExpr?: string;
115
+ /** False for a read-only mount, which `expose` cannot narrow further. */
116
+ narrowable: boolean;
117
+ }): string {
118
+ const recipe =
119
+ opts.framework === "fastify"
120
+ ? ` * app.register(async (s) => {\n` +
121
+ ` * s.addHook("preHandler", requireAuth);\n` +
122
+ ` * await ${opts.handlerName}(s);\n` +
123
+ ` * });`
124
+ : ` * app.use(${opts.mountPathExpr}, requireAuth); // matches the collection path too\n` +
125
+ ` * ${opts.handlerName}(app, { db });`;
126
+ const guarded =
127
+ opts.framework === "fastify"
128
+ ? `Register this inside a scope that\n` +
129
+ ` * carries your hook and every verb below is guarded — routes outside that scope\n` +
130
+ ` * are not:`
131
+ : `Guard the mount path with\n` + ` * middleware before calling this:`;
132
+ // Name the generator that actually emitted this file — `routesFile` does not exist in
133
+ // a Hono project's config, and a recipe naming the wrong symbol is the same defect
134
+ // one size smaller.
135
+ const generatorName = opts.framework === "fastify" ? "routesFile" : "routesFileHono";
136
+ const narrowNote = opts.narrowable
137
+ ? `\n *\n * A row-ownership rule ("only the owner may read this row") is not expressible in a\n` +
138
+ ` * mount. Hand-write those verbs and narrow this file with\n` +
139
+ ` * ${generatorName}({ expose: ["list", "get"] }) rather than mounting them open.`
140
+ : `\n *\n * A row-ownership rule ("only the owner may read this row") is not expressible in a\n` +
141
+ ` * mount. Hand-write those reads rather than exposing this one open.`;
142
+ return ` *\n * Auth: these endpoints are unauthenticated. ${guarded}\n *\n${recipe}${narrowNote}`;
143
+ }
package/src/runner.ts CHANGED
@@ -36,6 +36,11 @@ import {
36
36
  type BaselineMode,
37
37
  type DecideAndWriteOpts,
38
38
  } from "./overwrite-policy.js";
39
+ import {
40
+ warnUnsatisfiedRequires, warnMixedApiFrameworks, warnLibrarySelectionMismatch,
41
+ } from "./catalog-gates.js";
42
+ import { libraryManifests, splitLayerToken } from "@metaobjectsdev/metadata/library";
43
+ import { generatorRegistry, type GeneratorRegistryEntry } from "./generator-registry.js";
39
44
 
40
45
  /** JS-identifier-shape only. Prevents filesystem traversal when metadata comes
41
46
  * from untrusted sources (e.g. MCP). Mirrors the guard in legacy generate.ts. */
@@ -74,6 +79,15 @@ export interface RunGenOpts {
74
79
  * `--dry-run`, and watching it reappear.
75
80
  */
76
81
  dryRun?: boolean;
82
+ /**
83
+ * The catalog the `requires` / api-framework gates check against.
84
+ *
85
+ * Injected because the COMPOSITION lives in the CLI: `codegen-ts` cannot import its
86
+ * own dependents, so it cannot see the react and tanstack registry slices. Defaults
87
+ * to this package's own slice, which keeps a programmatic embedder that never
88
+ * composes correct — it simply has nothing to say about client-tier generators.
89
+ */
90
+ catalog?: Record<string, GeneratorRegistryEntry>;
77
91
  /**
78
92
  * Output scope — an object is generated only when this predicate returns true
79
93
  * for its fully-qualified name (`obj.resolutionKey()`, `<package>::<name>`).
@@ -118,6 +132,16 @@ export interface RunGenOpts {
118
132
  * option explicitly) can omit this with no behavior change elsewhere.
119
133
  */
120
134
  sourceFiles?: readonly string[];
135
+ /**
136
+ * FR-043 §6 — the project's shipped-library selection TOKENS (`Collection.libraries`,
137
+ * e.g. `["ai", "ai/db"]`), resolved to manifests and threaded onto
138
+ * `GenContext.libraries`, and read by the post-selection audit.
139
+ *
140
+ * Omitted means the caller never said; `[]` means it looked and this project opted
141
+ * into none. The two differ for a generator keying on a library anchor — see
142
+ * `GenContext.libraries`.
143
+ */
144
+ libraries?: readonly string[];
121
145
  }
122
146
 
123
147
  export interface RunGenResult {
@@ -386,6 +410,16 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
386
410
  // 2. Resolve targets + entity-module target.
387
411
  const config = normalizeConfig(opts.config);
388
412
 
413
+ // FR-043 §6 — the opted-in manifests, resolved once per run. A token names its
414
+ // library whether or not it carries a layer, and an unknown name contributes nothing
415
+ // (the config reader is what refuses a typo, by name — see `knownLibraryTokens`).
416
+ const selectedManifests =
417
+ opts.libraries === undefined
418
+ ? undefined
419
+ : [...new Set(opts.libraries.map((t) => splitLayerToken(t)[0]))]
420
+ .map((name) => libraryManifests()[name])
421
+ .filter((m): m is NonNullable<typeof m> => m !== undefined);
422
+
389
423
  // FR-023 §4.3 — the run's composed registry, threaded onto every GenContext.
390
424
  // Mirrors sdk's `loadMemory` exactly (`defaultLoadMemoryProviders` = core
391
425
  // providers, then the project's own `config.providers` appended) so
@@ -528,6 +562,20 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
528
562
  warnMissingPromptGenerators(root, config.generators, (m) => warnings.push(m));
529
563
  warnUnmarkedUiGenerators(config.generators, (m) => warnings.push(m));
530
564
 
565
+ // Codegen is opt-in, so `meta gen` is the post-selection audit: the two ways a legal
566
+ // selection still surprises you. Warnings only — see catalog-gates.ts.
567
+ const catalog = opts.catalog ?? generatorRegistry;
568
+ warnUnsatisfiedRequires(config.generators, catalog, (m) => warnings.push(m));
569
+ warnMixedApiFrameworks(config.generators, catalog, (m) => warnings.push(m));
570
+ // FR-043 §6 — only when the caller actually told us what was selected. A programmatic
571
+ // caller that never threads `libraries` gets no library warnings at all, rather than
572
+ // "you opted into nothing", which would be a claim we cannot support.
573
+ if (opts.libraries !== undefined) {
574
+ warnLibrarySelectionMismatch(
575
+ config.generators, catalog, libraryManifests(), opts.libraries, (m) => warnings.push(m),
576
+ );
577
+ }
578
+
531
579
  // <Entity>Names is opt-in on TypeScript and an existing project gets no signal that
532
580
  // it exists. Fires ONCE, on the first gen after crossing the release that made it the
533
581
  // doctrine — see shouldNoteNamesArtifactAbsent for why it is keyed on the engine stamp
@@ -644,6 +692,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
644
692
  ...(projectRoot !== undefined && { projectRoot }),
645
693
  registry,
646
694
  ...(opts.sourceFiles !== undefined && { sourceFiles: opts.sourceFiles }),
695
+ ...(selectedManifests !== undefined && { libraries: selectedManifests }),
647
696
  warn: (msg) => warnings.push(`[${generator.name}] ${msg}`),
648
697
  };
649
698
 
@@ -17,7 +17,7 @@ import { renderEntityConstants } from "./entity-constants.js";
17
17
  import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
18
18
  import { renderFilterType } from "./filter-type.js";
19
19
  import { renderTphDiscriminatorUnion, isTphDiscriminatorBase } from "./tph-discriminator.js";
20
- import { GENERATED_HEADER } from "../constants.js";
20
+ import { GENERATED_HEADER, sidecarLine } from "../constants.js";
21
21
  import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
22
22
  import { renderProjectionDecl } from "./projection-decl.js";
23
23
  import {
@@ -224,6 +224,6 @@ ${docsPrefix}export type ${entity.name} = ${z}.infer<typeof ${entity.name}Schema
224
224
  const header =
225
225
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
226
226
  `// Source metadata: ${entity.name} (${entity.fqn()})\n` +
227
- `// Customize via ${entity.name}.extra.ts in this directory.\n`;
227
+ sidecarLine(`${entity.name}.extra.ts`);
228
228
  return header + body;
229
229
  }
@@ -22,7 +22,7 @@ import {
22
22
  getPkFields,
23
23
  } from "./queries.js";
24
24
  import { pluralize, findByIdFnName, listFnName, createFnName, insertPreservingFnName, updateFnName } from "../naming.js";
25
- import { GENERATED_HEADER } from "../constants.js";
25
+ import { GENERATED_HEADER, sidecarLine } from "../constants.js";
26
26
  import { isTphDiscriminatorBase, tphConcreteSubtypes } from "./tph-discriminator.js";
27
27
  import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
28
28
  import { hasAutoSetFields } from "./zod-validators.js";
@@ -147,7 +147,7 @@ import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}I
147
147
  const header =
148
148
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
149
149
  `// Source metadata: ${entityName} (${obj.fqn()})\n` +
150
- `// Customize via ${entityName}.extra.ts in this directory (additional queries, custom logic).\n`;
150
+ sidecarLine(`${entityName}.extra.ts`);
151
151
  return header + body;
152
152
  }
153
153
 
@@ -196,7 +196,7 @@ export async function ${listFnName(entityName)}(db: Db, opts?: { limit?: number;
196
196
  const header =
197
197
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
198
198
  `// Source metadata: ${entityName} (${obj.fqn()}) — projection (read-only)\n` +
199
- `// Customize via ${entityName}.extra.ts in this directory (additional queries, custom logic).\n`;
199
+ sidecarLine(`${entityName}.extra.ts`);
200
200
  return header + body;
201
201
  }
202
202
 
@@ -286,7 +286,7 @@ export async function ${updateFnName(entityName)}(db: Db, ${pkField}: ${pkType},
286
286
  const header =
287
287
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
288
288
  `// Source metadata: ${entityName} (${obj.fqn()}) — write-through entity read-view (reads → view, writes → table)\n` +
289
- `// Customize via ${entityName}.extra.ts in this directory (additional queries, custom logic).\n`;
289
+ sidecarLine(`${entityName}.extra.ts`);
290
290
  return header + body;
291
291
  }
292
292
 
@@ -396,6 +396,6 @@ ${dbTypeAlias}
396
396
  const header =
397
397
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
398
398
  `// Source metadata: ${baseName} (${base.fqn()}) — TPH discriminator base\n` +
399
- `// Customize via ${baseName}.extra.ts in this directory (additional queries, custom logic).\n`;
399
+ sidecarLine(`${baseName}.extra.ts`);
400
400
  return header + body;
401
401
  }
@@ -28,9 +28,9 @@ import { code, imp } from "ts-poet";
28
28
  import type { MetaObject } from "@metaobjectsdev/metadata";
29
29
  import { type RenderContext } from "../render-context.js";
30
30
  import { entityModuleSpecifier } from "../import-path.js";
31
- import { GENERATED_HEADER } from "../constants.js";
31
+ import { GENERATED_HEADER, sidecarLine } from "../constants.js";
32
32
  import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
33
- import { type CrudVerb, exposeLine } from "../routes-expose.js";
33
+ import { authSeamJsDoc, type CrudVerb, exposeLine } from "../routes-expose.js";
34
34
  import { effectivePackage } from "../docs-paths.js";
35
35
 
36
36
  export function renderRoutesFileHono(
@@ -58,12 +58,17 @@ export function renderRoutesFileHono(
58
58
  const header =
59
59
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
60
60
  `// Source metadata: ${entityName} (${entity.fqn()})\n` +
61
- `// Customize via ${entityName}.extra.ts in this directory (e.g., auth, additional handlers).\n`;
61
+ sidecarLine(`${entityName}.extra.ts`);
62
62
 
63
63
  // Path composition: apiPrefix is a literal string in the URL.
64
64
  const pathExpr = ctx.apiPrefix
65
65
  ? `\`${ctx.apiPrefix}\${${entityName}.$path}\``
66
66
  : `${entityName}.$path`;
67
+ // The same path with Hono's trailing wildcard, for the auth recipe in the JSDoc.
68
+ // `"/users/*"` matches `/users` itself, so one app.use covers list, get and writes.
69
+ const authPathExpr = ctx.apiPrefix
70
+ ? `\`${ctx.apiPrefix}\${${entityName}.$path}/*\``
71
+ : `\`\${${entityName}.$path}/*\``;
67
72
 
68
73
  // --- Projection path: read-only routes (GET list + GET :id) ---
69
74
  if (isProjection(entity)) {
@@ -89,6 +94,7 @@ import {
89
94
  * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
90
95
  * Customize: register this as-is, or import individual route helpers from
91
96
  * @metaobjectsdev/runtime-ts/hono.
97
+ ${authSeamJsDoc({ framework: "hono", handlerName, mountPathExpr: authPathExpr, narrowable: false })}
92
98
  */
93
99
  // biome-ignore lint/suspicious/noExplicitAny: consumer-defined Hono bindings/variables
94
100
  export function ${handlerName}(app: ${HonoSym}<any, any, any>, deps: { db: unknown }): void {
@@ -143,6 +149,7 @@ import {
143
149
  * helpers (mountListRoute, mountGetRoute, ...) from
144
150
  * @metaobjectsdev/runtime-ts/hono and mix with your own handlers
145
151
  * (auth, side effects, etc.).
152
+ ${authSeamJsDoc({ framework: "hono", handlerName, mountPathExpr: authPathExpr, narrowable: true })}
146
153
  */
147
154
  // biome-ignore lint/suspicious/noExplicitAny: consumer-defined Hono bindings/variables
148
155
  export function ${handlerName}(app: ${HonoSym}<any, any, any>, deps: { db: unknown }): void {
@@ -24,12 +24,12 @@ import {
24
24
  import { type RenderContext } from "../render-context.js";
25
25
  import { crossEntitySpecifier, entityModuleSpecifier, relativeModuleSpecifier } from "../import-path.js";
26
26
  import { namesRef, columnExpr } from "../names.js";
27
- import { GENERATED_HEADER } from "../constants.js";
27
+ import { GENERATED_HEADER, sidecarLine } from "../constants.js";
28
28
  import { routesHandlerName } from "../naming.js";
29
29
  import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
30
30
  import type { RelationEntry } from "../relation-resolver.js";
31
31
  import { isTphDiscriminatorBase, tphPlan } from "./tph-discriminator.js";
32
- import { type CrudVerb, exposeLine, intersectExpose, TPH_POLYMORPHIC_VERBS } from "../routes-expose.js";
32
+ import { authSeamJsDoc, type CrudVerb, exposeLine, intersectExpose, TPH_POLYMORPHIC_VERBS } from "../routes-expose.js";
33
33
  import { effectivePackage } from "../docs-paths.js";
34
34
 
35
35
  export function renderRoutesFile(
@@ -65,7 +65,7 @@ export function renderRoutesFile(
65
65
  const header =
66
66
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
67
67
  `// Source metadata: ${entityName} (${entity.fqn()})\n` +
68
- `// Customize via ${entityName}.extra.ts in this directory (e.g., auth, additional handlers).\n`;
68
+ sidecarLine(`${entityName}.extra.ts`);
69
69
 
70
70
  // --- Projection path: read-only routes (GET list + GET :id) ---
71
71
  if (isProjection(entity)) {
@@ -74,6 +74,13 @@ export function renderRoutesFile(
74
74
  const mountReadOnlyCrudRoutesSym = imp(
75
75
  "mountReadOnlyCrudRoutes@@metaobjectsdev/runtime-ts/drizzle-fastify",
76
76
  );
77
+ // A projection mount is read-only by construction, so `expose` cannot narrow it —
78
+ // the paragraph drops the narrowing advice and keeps the auth seam.
79
+ const readOnlyAuthJsDoc = authSeamJsDoc({
80
+ framework: "fastify",
81
+ handlerName,
82
+ narrowable: false,
83
+ });
77
84
 
78
85
  const literalImports = code`
79
86
  import { db } from ${JSON.stringify(dbImportSpec)};
@@ -93,6 +100,7 @@ import {
93
100
  * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
94
101
  * Customize: register this as-is, or import individual route helpers from
95
102
  * @metaobjectsdev/runtime-ts/drizzle-fastify.
103
+ ${readOnlyAuthJsDoc}
96
104
  */
97
105
  export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
98
106
  await fastify.register(async (instance) => {
@@ -115,6 +123,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
115
123
  * Exposes GET list + GET :id only. POST/PATCH/DELETE return 405.
116
124
  * Customize: register this as-is, or import individual route helpers from
117
125
  * @metaobjectsdev/runtime-ts/drizzle-fastify.
126
+ ${readOnlyAuthJsDoc}
118
127
  */
119
128
  export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
120
129
  ${mountReadOnlyCrudRoutesSym}({
@@ -147,6 +156,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
147
156
  const readViewLineFlat = writeThrough ? `\n readView: ${camelName}View,` : "";
148
157
  const exposeLinePrefixed = exposeLine(expose, " ");
149
158
  const exposeLineFlat = exposeLine(expose, " ");
159
+ const crudAuthJsDoc = authSeamJsDoc({ framework: "fastify", handlerName, narrowable: true });
150
160
 
151
161
  const FastifyInstanceSym = imp("t:FastifyInstance@fastify");
152
162
  const mountCrudRoutesSym = imp("mountCrudRoutes@@metaobjectsdev/runtime-ts/drizzle-fastify");
@@ -185,6 +195,7 @@ import {
185
195
  * helpers (mountListRoute, mountGetRoute, ...) from
186
196
  * @metaobjectsdev/runtime-ts/drizzle-fastify and mix with your own handlers
187
197
  * (auth, side effects, etc.).
198
+ ${crudAuthJsDoc}
188
199
  */
189
200
  export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
190
201
  await fastify.register(async (instance) => {
@@ -210,6 +221,7 @@ ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} });
210
221
  * helpers (mountListRoute, mountGetRoute, ...) from
211
222
  * @metaobjectsdev/runtime-ts/drizzle-fastify and mix with your own handlers
212
223
  * (auth, side effects, etc.).
224
+ ${crudAuthJsDoc}
213
225
  */
214
226
  export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
215
227
  ${mountCrudRoutesSym}({
@@ -444,6 +456,9 @@ function renderTphRoutesFile(
444
456
  });
445
457
 
446
458
  const mounts = joinCode([polymorphic, ...subtypeMounts], { on: "\n" });
459
+ // The base path is read-only by construction (TPH_POLYMORPHIC_VERBS), but the
460
+ // per-subtype mounts below it are full CRUD — so `expose` does narrow this file.
461
+ const tphAuthJsDoc = authSeamJsDoc({ framework: "fastify", handlerName, narrowable: true });
447
462
 
448
463
  const fn = ctx.apiPrefix
449
464
  ? code`
@@ -452,6 +467,7 @@ function renderTphRoutesFile(
452
467
  *
453
468
  * GET ${baseName}.$path (+ /:id) lists/gets the discriminated union; each
454
469
  * /${baseName}.$path/<subtype> path is a full per-subtype CRUD set.
470
+ ${tphAuthJsDoc}
455
471
  */
456
472
  export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
457
473
  await fastify.register(async (instance) => {
@@ -465,6 +481,7 @@ ${mounts}
465
481
  *
466
482
  * GET ${baseName}.$path (+ /:id) lists/gets the discriminated union; each
467
483
  * /${baseName}.$path/<subtype> path is a full per-subtype CRUD set.
484
+ ${tphAuthJsDoc}
468
485
  */
469
486
  export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
470
487
  ${mounts}
@@ -474,6 +491,6 @@ ${mounts}
474
491
  const header =
475
492
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
476
493
  `// Source metadata: ${baseName} (${base.fqn()}) — TPH discriminator base\n` +
477
- `// Customize via ${baseName}.extra.ts in this directory (e.g., auth, additional handlers).\n`;
494
+ sidecarLine(`${baseName}.extra.ts`);
478
495
  return header + fn.toString();
479
496
  }
@@ -22,7 +22,7 @@ import {
22
22
  import { renderEntityConstants } from "./entity-constants.js";
23
23
  import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
24
24
  import { renderFilterType } from "./filter-type.js";
25
- import { GENERATED_HEADER } from "../constants.js";
25
+ import { GENERATED_HEADER, sidecarLine } from "../constants.js";
26
26
  import { namesRef, namesConstArg } from "../names.js";
27
27
 
28
28
  export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: RenderContext): string {
@@ -84,6 +84,6 @@ export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: Ren
84
84
  const header =
85
85
  `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
86
86
  `// Source metadata: ${obj.name} (${obj.fqn()})\n` +
87
- `// Customize via ${emittedName}.extra.ts in this directory.\n`;
87
+ sidecarLine(`${emittedName}.extra.ts`);
88
88
  return header + body;
89
89
  }