@metaobjectsdev/codegen-ts 1.0.2 → 1.0.4-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) 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/enum-shared.d.ts +9 -2
  10. package/dist/enum-shared.d.ts.map +1 -1
  11. package/dist/enum-shared.js +12 -3
  12. package/dist/enum-shared.js.map +1 -1
  13. package/dist/generator-registry.d.ts +73 -0
  14. package/dist/generator-registry.d.ts.map +1 -1
  15. package/dist/generator-registry.js +182 -38
  16. package/dist/generator-registry.js.map +1 -1
  17. package/dist/generator.d.ts +48 -1
  18. package/dist/generator.d.ts.map +1 -1
  19. package/dist/generator.js.map +1 -1
  20. package/dist/generators/entity-file.d.ts.map +1 -1
  21. package/dist/generators/entity-file.js +4 -1
  22. package/dist/generators/entity-file.js.map +1 -1
  23. package/dist/generators/index.d.ts +1 -0
  24. package/dist/generators/index.d.ts.map +1 -1
  25. package/dist/generators/index.js +5 -0
  26. package/dist/generators/index.js.map +1 -1
  27. package/dist/generators/shared-model-file.d.ts +23 -0
  28. package/dist/generators/shared-model-file.d.ts.map +1 -0
  29. package/dist/generators/shared-model-file.js +235 -0
  30. package/dist/generators/shared-model-file.js.map +1 -0
  31. package/dist/generators/trace-helper-file.d.ts.map +1 -1
  32. package/dist/generators/trace-helper-file.js +54 -11
  33. package/dist/generators/trace-helper-file.js.map +1 -1
  34. package/dist/index.d.ts +5 -4
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/metaobjects-config.d.ts +0 -15
  39. package/dist/metaobjects-config.d.ts.map +1 -1
  40. package/dist/metaobjects-config.js.map +1 -1
  41. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  42. package/dist/projection/extract-view-spec.js +111 -4
  43. package/dist/projection/extract-view-spec.js.map +1 -1
  44. package/dist/relation-resolver.d.ts.map +1 -1
  45. package/dist/relation-resolver.js +11 -9
  46. package/dist/relation-resolver.js.map +1 -1
  47. package/dist/routes-expose.d.ts +30 -0
  48. package/dist/routes-expose.d.ts.map +1 -1
  49. package/dist/routes-expose.js +46 -0
  50. package/dist/routes-expose.js.map +1 -1
  51. package/dist/runner.d.ts +29 -0
  52. package/dist/runner.d.ts.map +1 -1
  53. package/dist/runner.js +38 -1
  54. package/dist/runner.js.map +1 -1
  55. package/dist/templates/entity-file.js +2 -2
  56. package/dist/templates/entity-file.js.map +1 -1
  57. package/dist/templates/enums-file.d.ts +11 -1
  58. package/dist/templates/enums-file.d.ts.map +1 -1
  59. package/dist/templates/enums-file.js +10 -2
  60. package/dist/templates/enums-file.js.map +1 -1
  61. package/dist/templates/queries-file.js +5 -5
  62. package/dist/templates/queries-file.js.map +1 -1
  63. package/dist/templates/routes-file-hono.d.ts.map +1 -1
  64. package/dist/templates/routes-file-hono.js +10 -3
  65. package/dist/templates/routes-file-hono.js.map +1 -1
  66. package/dist/templates/routes-file.d.ts.map +1 -1
  67. package/dist/templates/routes-file.js +21 -4
  68. package/dist/templates/routes-file.js.map +1 -1
  69. package/dist/templates/value-object-file.js +2 -2
  70. package/dist/templates/value-object-file.js.map +1 -1
  71. package/package.json +6 -6
  72. package/src/catalog-gates.ts +222 -0
  73. package/src/constants.ts +27 -0
  74. package/src/enum-shared.ts +17 -3
  75. package/src/generator-registry.ts +253 -41
  76. package/src/generator.ts +48 -1
  77. package/src/generators/entity-file.ts +7 -1
  78. package/src/generators/index.ts +11 -0
  79. package/src/generators/shared-model-file.ts +292 -0
  80. package/src/generators/trace-helper-file.ts +55 -12
  81. package/src/index.ts +17 -3
  82. package/src/metaobjects-config.ts +0 -15
  83. package/src/projection/extract-view-spec.ts +126 -5
  84. package/src/reference/entity.ts +12 -3
  85. package/src/reference/queries.ts +2 -2
  86. package/src/relation-resolver.ts +14 -8
  87. package/src/routes-expose.ts +57 -0
  88. package/src/runner.ts +77 -1
  89. package/src/templates/entity-file.ts +2 -2
  90. package/src/templates/enums-file.ts +13 -2
  91. package/src/templates/queries-file.ts +5 -5
  92. package/src/templates/routes-file-hono.ts +10 -3
  93. package/src/templates/routes-file.ts +21 -4
  94. package/src/templates/value-object-file.ts +2 -2
@@ -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
@@ -6,7 +6,10 @@ import { tmpdir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
9
- import { isMetaRoot, OBJECT_SUBTYPE_VALUE, FIELD_SUBTYPE_TIMESTAMP, FIELD_ATTR_FILTERABLE } from "@metaobjectsdev/metadata";
9
+ import {
10
+ isMetaRoot, OBJECT_SUBTYPE_VALUE, FIELD_SUBTYPE_TIMESTAMP, FIELD_ATTR_FILTERABLE,
11
+ composeRegistry, coreProviders,
12
+ } from "@metaobjectsdev/metadata";
10
13
  import { assignEmittedNames } from "./naming/collision-names.js";
11
14
  import { isAbstract } from "./instance-artifacts.js";
12
15
  import { dbEmittingObjects, missingDialectMessage } from "./db-emitting.js";
@@ -33,6 +36,11 @@ import {
33
36
  type BaselineMode,
34
37
  type DecideAndWriteOpts,
35
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";
36
44
 
37
45
  /** JS-identifier-shape only. Prevents filesystem traversal when metadata comes
38
46
  * from untrusted sources (e.g. MCP). Mirrors the guard in legacy generate.ts. */
@@ -71,6 +79,15 @@ export interface RunGenOpts {
71
79
  * `--dry-run`, and watching it reappear.
72
80
  */
73
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>;
74
91
  /**
75
92
  * Output scope — an object is generated only when this predicate returns true
76
93
  * for its fully-qualified name (`obj.resolutionKey()`, `<package>::<name>`).
@@ -106,6 +123,25 @@ export interface RunGenOpts {
106
123
  * they pass straight through.
107
124
  */
108
125
  scope?: (fqn: string) => boolean;
126
+ /**
127
+ * FR-023 §4.3 — the collection's own source files (`Collection.ownFiles`; never
128
+ * a dependency's snapshot artifact), threaded onto `GenContext.sourceFiles` for
129
+ * `sharedModelFile()`'s default `files` selection. The CLI's `gen` command
130
+ * always passes `genCollection.ownFiles`; a programmatic caller that never
131
+ * wires `sharedModelFile()` (or always passes that generator's own `files`
132
+ * option explicitly) can omit this with no behavior change elsewhere.
133
+ */
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[];
109
145
  }
110
146
 
111
147
  export interface RunGenResult {
@@ -374,6 +410,23 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
374
410
  // 2. Resolve targets + entity-module target.
375
411
  const config = normalizeConfig(opts.config);
376
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
+
423
+ // FR-023 §4.3 — the run's composed registry, threaded onto every GenContext.
424
+ // Mirrors sdk's `loadMemory` exactly (`defaultLoadMemoryProviders` = core
425
+ // providers, then the project's own `config.providers` appended) so
426
+ // `sharedModelFile()`'s standalone re-load of a `files` subset sees the SAME
427
+ // vocabulary `opts.metadata` was originally loaded with.
428
+ const registry = composeRegistry([...coreProviders, ...(config.providers ?? [])]);
429
+
377
430
  // The compile break (`value` → `fetcher` on the provider) sends an adopter to the right
378
431
  // line; this names the value to put there, once, for the projects that had one.
379
432
  if (shouldNoteBaseUrlMove(config.apiPrefix, recordedEngine)) {
@@ -509,6 +562,20 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
509
562
  warnMissingPromptGenerators(root, config.generators, (m) => warnings.push(m));
510
563
  warnUnmarkedUiGenerators(config.generators, (m) => warnings.push(m));
511
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
+
512
579
  // <Entity>Names is opt-in on TypeScript and an existing project gets no signal that
513
580
  // it exists. Fires ONCE, on the first gen after crossing the release that made it the
514
581
  // doctrine — see shouldNoteNamesArtifactAbsent for why it is keyed on the engine stamp
@@ -605,6 +672,12 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
605
672
  entities: safeEntities,
606
673
  loadedRoot: root,
607
674
  matches: (e) => generator.filter?.(e) ?? true,
675
+ // FR-023 §11.1 item 2 — the SAME `scope` predicate that already narrowed
676
+ // `entities` above, re-exposed for a template that renders once over the
677
+ // WHOLE loaded root (the shared-enums module, so far the only one) rather
678
+ // than per matched entity, where `matches` never runs. Absent scope ⇒ no
679
+ // `select` at all, byte-identical to a project with no `scope` declared.
680
+ ...(scope !== undefined && { select: scope }),
608
681
  config: {
609
682
  outDir: selfTarget.outDir,
610
683
  extStyle: config.extStyle,
@@ -617,6 +690,9 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
617
690
  },
618
691
  renderContext,
619
692
  ...(projectRoot !== undefined && { projectRoot }),
693
+ registry,
694
+ ...(opts.sourceFiles !== undefined && { sourceFiles: opts.sourceFiles }),
695
+ ...(selectedManifests !== undefined && { libraries: selectedManifests }),
620
696
  warn: (msg) => warnings.push(`[${generator.name}] ${msg}`),
621
697
  };
622
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
  }
@@ -37,9 +37,20 @@ export const ${sharedEnumZodConstName(e.name)} = ${z}.enum([${members}]);
37
37
  /**
38
38
  * The full shared-enums module body, or null when the model has no materialized
39
39
  * shared enums (so the generator emits no file at all).
40
+ *
41
+ * `opts.select` (FR-023 §11.1 item 2) — the model-wide scope predicate over a
42
+ * CONSUMING entity's `resolutionKey()`: an enum extended only by entities
43
+ * `select` excludes is not materialized. This module renders once per run from
44
+ * the WHOLE loaded root (never per-generator-filtered — see #266), so it is the
45
+ * one shared-enums call site that must be threaded the scope predicate
46
+ * separately from `ctx.matches`. Omitted ⇒ every entity counts, byte-identical
47
+ * to a caller that never passes `opts` at all.
40
48
  */
41
- export function renderSharedEnumsFile(root: MetaRoot): string | null {
42
- const enums = materializedSharedEnums(root);
49
+ export function renderSharedEnumsFile(
50
+ root: MetaRoot,
51
+ opts?: { select?: (fqn: string) => boolean },
52
+ ): string | null {
53
+ const enums = materializedSharedEnums(root, opts?.select);
43
54
  if (enums.length === 0) return null;
44
55
 
45
56
  const body = joinCode(enums.map(renderOneSharedEnum), { on: "\n" }).toString();
@@ -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
  }