@metaobjectsdev/codegen-ts 0.24.4 → 0.24.5

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 (65) hide show
  1. package/dist/client-directive.d.ts +18 -0
  2. package/dist/client-directive.d.ts.map +1 -0
  3. package/dist/client-directive.js +37 -0
  4. package/dist/client-directive.js.map +1 -0
  5. package/dist/generators/routes-file-hono.d.ts.map +1 -1
  6. package/dist/generators/routes-file-hono.js +11 -6
  7. package/dist/generators/routes-file-hono.js.map +1 -1
  8. package/dist/index.d.ts +3 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +5 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/metaobjects-config.d.ts +29 -0
  13. package/dist/metaobjects-config.d.ts.map +1 -1
  14. package/dist/metaobjects-config.js +1 -0
  15. package/dist/metaobjects-config.js.map +1 -1
  16. package/dist/naming.d.ts +17 -0
  17. package/dist/naming.d.ts.map +1 -1
  18. package/dist/naming.js +21 -0
  19. package/dist/naming.js.map +1 -1
  20. package/dist/payload-codegen.d.ts.map +1 -1
  21. package/dist/payload-codegen.js +4 -3
  22. package/dist/payload-codegen.js.map +1 -1
  23. package/dist/prompt-generator-gate.d.ts +27 -0
  24. package/dist/prompt-generator-gate.d.ts.map +1 -0
  25. package/dist/prompt-generator-gate.js +62 -0
  26. package/dist/prompt-generator-gate.js.map +1 -0
  27. package/dist/reference-templates.d.ts +22 -5
  28. package/dist/reference-templates.d.ts.map +1 -1
  29. package/dist/reference-templates.js +55 -21
  30. package/dist/reference-templates.js.map +1 -1
  31. package/dist/render-context.d.ts +9 -1
  32. package/dist/render-context.d.ts.map +1 -1
  33. package/dist/render-context.js +1 -0
  34. package/dist/render-context.js.map +1 -1
  35. package/dist/runner.d.ts.map +1 -1
  36. package/dist/runner.js +6 -0
  37. package/dist/runner.js.map +1 -1
  38. package/dist/templates/output-parser.d.ts.map +1 -1
  39. package/dist/templates/output-parser.js +54 -8
  40. package/dist/templates/output-parser.js.map +1 -1
  41. package/dist/templates/render-helper.d.ts.map +1 -1
  42. package/dist/templates/render-helper.js +3 -1
  43. package/dist/templates/render-helper.js.map +1 -1
  44. package/dist/templates/requirement-test.d.ts.map +1 -1
  45. package/dist/templates/requirement-test.js +35 -19
  46. package/dist/templates/requirement-test.js.map +1 -1
  47. package/package.json +6 -6
  48. package/src/client-directive.ts +36 -0
  49. package/src/generators/routes-file-hono.ts +11 -9
  50. package/src/index.ts +6 -1
  51. package/src/metaobjects-config.ts +30 -0
  52. package/src/naming.ts +23 -0
  53. package/src/payload-codegen.ts +4 -3
  54. package/src/prompt-generator-gate.ts +73 -0
  55. package/src/reference/barrel.ts +3 -0
  56. package/src/reference/entity.ts +7 -0
  57. package/src/reference/queries.ts +5 -0
  58. package/src/reference/routes-hono.ts +103 -0
  59. package/src/reference/routes.ts +5 -0
  60. package/src/reference-templates.ts +61 -21
  61. package/src/render-context.ts +10 -1
  62. package/src/runner.ts +7 -0
  63. package/src/templates/output-parser.ts +54 -7
  64. package/src/templates/render-helper.ts +3 -1
  65. package/src/templates/requirement-test.ts +36 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/codegen-ts",
3
- "version": "0.24.4",
3
+ "version": "0.24.5",
4
4
  "description": "TypeScript codegen engine for MetaObjects — emits Drizzle, Zod, and Fastify artifacts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -111,8 +111,8 @@
111
111
  "access": "public"
112
112
  },
113
113
  "dependencies": {
114
- "@metaobjectsdev/metadata": "0.24.4",
115
- "@metaobjectsdev/render": "0.24.4",
114
+ "@metaobjectsdev/metadata": "0.24.5",
115
+ "@metaobjectsdev/render": "0.24.5",
116
116
  "@biomejs/js-api": "^0.7.0",
117
117
  "@biomejs/wasm-nodejs": "^1.9.4",
118
118
  "@toon-format/toon": "^2.3.0",
@@ -124,9 +124,9 @@
124
124
  },
125
125
  "devDependencies": {
126
126
  "@biomejs/biome": "^1.9.0",
127
- "@metaobjectsdev/codegen-ts-react": "0.24.4",
128
- "@metaobjectsdev/migrate-ts": "0.24.4",
129
- "@metaobjectsdev/runtime-ts": "0.24.4",
127
+ "@metaobjectsdev/codegen-ts-react": "0.24.5",
128
+ "@metaobjectsdev/migrate-ts": "0.24.5",
129
+ "@metaobjectsdev/runtime-ts": "0.24.5",
130
130
  "bun-types": "latest",
131
131
  "drizzle-orm": "^0.38.0",
132
132
  "hono": "^4.6.0",
@@ -0,0 +1,36 @@
1
+ // FR-040 §6.4 — the module-level client-component directive on generated CLIENT
2
+ // artifacts (forms, hooks, grid columns, grid hooks).
3
+ //
4
+ // One implementation in the shared engine rather than a string literal in each of the
5
+ // four UI generators: the directive must be the FIRST statement in the module to have
6
+ // any effect, and "prepend a line" is the kind of thing four copies get subtly
7
+ // different (a missing blank line, a single-quoted variant, or — the one that actually
8
+ // breaks — emitted after the generated header comment in one and before it in another).
9
+ //
10
+ // Why this is a config knob and not metadata, and why it defaults OFF, is on
11
+ // `MetaobjectsGenConfig.clientDirective`.
12
+
13
+ /** The directive itself. Double-quoted to match the emitted code's own quote style. */
14
+ export const CLIENT_DIRECTIVE = '"use client";';
15
+
16
+ /**
17
+ * Prepend the client directive to a generated module body when the project asked for it.
18
+ *
19
+ * Placement is the whole point: a directive prologue is only honoured before any other
20
+ * statement, and — unlike a runtime statement — it must also precede the `@generated`
21
+ * header comment for some bundlers to see it, so this goes at absolute position 0.
22
+ * Idempotent: a body that already opens with the directive (a hand edit preserved
23
+ * through three-way merge, or an owned generator that adds its own) is returned
24
+ * unchanged rather than given a second copy.
25
+ *
26
+ * @param body the rendered module source.
27
+ * @param enabled `ctx.clientDirective` — false (the default) returns `body` untouched,
28
+ * so output is byte-identical for every project that does not opt in.
29
+ */
30
+ export function withClientDirective(body: string, enabled: boolean): string {
31
+ if (!enabled) return body;
32
+ // Tolerate either quote style when checking, so an adopter's own `'use client'`
33
+ // is not doubled — but always EMIT the canonical form.
34
+ if (/^\s*['"]use client['"];?/.test(body)) return body;
35
+ return `${CLIENT_DIRECTIVE}\n\n${body}`;
36
+ }
@@ -31,6 +31,15 @@ export interface RoutesFileHonoOpts {
31
31
  */
32
32
  export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts): Generator {
33
33
  const userFilter = opts?.filter ?? (() => true);
34
+ // Eligibility, minus the TPH question — stated ONCE so the emit set and the warn set
35
+ // below cannot drift apart. They differ only by `!isTphSubtype` vs `isTphSubtype`, and
36
+ // written out twice a later edit to one silently makes an entity either stop emitting
37
+ // without being named as held back, or get warned about while still emitting.
38
+ const passesOtherGates = (e: MetaObject): boolean =>
39
+ // ADR-0039: resolving — a concrete entity may inherit @emitRoutes via extends.
40
+ e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false
41
+ && hasAnyRdbSource(e)
42
+ && userFilter(e);
34
43
  const generator: Generator = {
35
44
  name: "routes-file-hono",
36
45
  // Marks this as the Hono routes generator so the runner can aggregate
@@ -46,18 +55,11 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
46
55
  // these to a discriminator-aware renderer; the Hono runtime has no discriminator
47
56
  // support yet, so this fails CLOSED and the run says so (see the warning below)
48
57
  // rather than shipping an artifact that returns the wrong rows.
49
- filter: (e: MetaObject) =>
50
- e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false
51
- && hasAnyRdbSource(e)
52
- && !isTphSubtype(e)
53
- && userFilter(e),
58
+ filter: (e: MetaObject) => passesOtherGates(e) && !isTphSubtype(e),
54
59
  generate: async (ctx) => {
55
60
  // One note per run naming every TPH subtype held back, so the gap is visible at
56
61
  // `meta gen` time rather than discovered as missing endpoints in production.
57
- const skipped = ctx.entities.filter(
58
- (e) => e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false
59
- && hasAnyRdbSource(e) && isTphSubtype(e) && userFilter(e),
60
- );
62
+ const skipped = ctx.entities.filter((e) => passesOtherGates(e) && isTphSubtype(e));
61
63
  if (skipped.length > 0) {
62
64
  ctx.warn(
63
65
  `no Hono routes emitted for the TPH subtype(s) ${skipped.map((e) => e.name).join(", ")} — ` +
package/src/index.ts CHANGED
@@ -110,7 +110,7 @@ export { renderSharedEnumsFile, SHARED_ENUMS_BASENAME } from "./templates/enums-
110
110
 
111
111
  // ADR-0034 scaffold-and-own — reader for the copyable reference generators in
112
112
  // `src/reference/*.ts`. `meta init` uses this to copy them into the consumer's repo.
113
- export { resolveReferenceRoot, readReferenceTemplate, REFERENCE_GENERATOR_NAMES } from "./reference-templates.js";
113
+ export { resolveReferenceRoot, readReferenceTemplate, REFERENCE_GENERATOR_NAMES, makeReferenceReader } from "./reference-templates.js";
114
114
  export type { ReferenceGeneratorName } from "./reference-templates.js";
115
115
 
116
116
  // ts-poet composition primitives, re-exported from THIS package's own ts-poet
@@ -161,6 +161,7 @@ export { renderFilterAllowlist, renderSortAllowlist } from "./templates/filter-a
161
161
  export { renderEntityConstants, resourcePath } from "./templates/entity-constants.js";
162
162
  export { renderQueriesFile } from "./templates/queries-file.js";
163
163
  export { renderRoutesFile } from "./templates/routes-file.js";
164
+ export { renderRoutesFileHono } from "./templates/routes-file-hono.js";
164
165
  export { renderValueObjectFile } from "./templates/value-object-file.js";
165
166
  export { renderProjectionDecl } from "./templates/projection-decl.js";
166
167
  export type { ProjectionDeclOpts } from "./templates/projection-decl.js";
@@ -251,3 +252,7 @@ export type {
251
252
  SweepOrphansArgs,
252
253
  SweepOrphansResult,
253
254
  } from "./orphan-sweep.js";
255
+
256
+ // FR-040 §6.4 — the client-component directive for generated CLIENT artifacts.
257
+ // Public so an OWNED generator applies it the same way the built-ins do.
258
+ export { withClientDirective, CLIENT_DIRECTIVE } from "./client-directive.js";
@@ -108,6 +108,34 @@ export interface MetaobjectsGenConfig extends Omit<ResolvedGenConfig, "dbImport"
108
108
  * (e.g. `{ AuditLog: "auditLog", LlmTierConfig: "llmTierConfig" }`).
109
109
  */
110
110
  collectionNameOverrides?: Record<string, string>;
111
+ /**
112
+ * Prepend a module-level client-component directive — `"use client";` — to every
113
+ * generated CLIENT artifact (forms, hooks, grid columns, grid hooks). Defaults to
114
+ * `false`. FR-040 §6.4.
115
+ *
116
+ * **Why this is config and not a metadata attribute.** It is a fact about the
117
+ * adopter's BUNDLER TOPOLOGY, not about the entity — the metamodel contains nothing
118
+ * that could derive it, and every non-TS port would carry a registration it can never
119
+ * dispatch on, which is the `source.rdb @role` mistake that retired four members in
120
+ * 0.21.0. It sits here beside `extStyle` and `columnNamingStrategy` for the same
121
+ * reason `pluralizeCollections` does: a per-port codegen concern (ADR-0001), so
122
+ * config, and no cross-port conformance cost.
123
+ *
124
+ * **Why it defaults to `false`.** The generated form and hook modules genuinely ARE
125
+ * client components, so the directive is a true statement about them — but it is only
126
+ * REQUIRED by frameworks that compile server and client from one tree (React Server
127
+ * Components). Elsewhere it is inert and provokes module-level-directive warnings in
128
+ * some bundlers, so defaulting it on would put noise in every generated UI file for
129
+ * the majority to save the minority one line. The asymmetry that would argue for
130
+ * defaulting on — a runtime error for RSC adopters versus a build warning for
131
+ * everyone else — is what FR-040 itself removed: before it, an RSC adopter had no
132
+ * seam at all; now it is this flag, or `meta eject form` and a one-line prepend.
133
+ *
134
+ * Flipping the default needs EVIDENCE, not a guess about majorities — the standard
135
+ * `extStyle` was held to in 0.20.1, where the default moved because the documented
136
+ * quickstart provably failed under a stock `tsc --init`.
137
+ */
138
+ clientDirective?: boolean;
111
139
  /**
112
140
  * Drizzle timestamp column mode. "string" (default) types timestamp columns as
113
141
  * ISO-8601 strings (matches the generated Zod + cross-port wire contract); "date"
@@ -186,6 +214,7 @@ export interface NormalizedMetaobjectsGenConfig
186
214
  pluralizeCollections: boolean;
187
215
  collectionNameOverrides: Record<string, string>;
188
216
  timestampMode: "date" | "string";
217
+ clientDirective: boolean;
189
218
  apiPrefix: string;
190
219
  emitAbstractShapes: boolean;
191
220
  outputLayout: OutputLayout;
@@ -322,6 +351,7 @@ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobj
322
351
  // normalize to "string" on sqlite/D1 at this one choke point so the option
323
352
  // can never silently emit a non-compiling column + a disagreeing Zod schema.
324
353
  timestampMode: dialect === "sqlite" ? "string" : (config.timestampMode ?? "string"),
354
+ clientDirective: config.clientDirective ?? false,
325
355
  apiPrefix: config.apiPrefix ?? "",
326
356
  emitAbstractShapes: config.emitAbstractShapes ?? true,
327
357
  outputLayout: config.outputLayout ?? "flat",
package/src/naming.ts CHANGED
@@ -191,5 +191,28 @@ export function routesHandlerName(entityName: string): string {
191
191
  return `${entityName.charAt(0).toLowerCase()}${entityName.slice(1)}Routes`;
192
192
  }
193
193
 
194
+
195
+ /**
196
+ * The PascalCase base every generated symbol for a `template.*` node is built from —
197
+ * `render<Base>`, `parse<Base>`, `safeParse<Base>`, `<Base>Schema`, `<Base>Data`,
198
+ * `<Base>ValidationError`, `extractLenient<Base>WithLoader`.
199
+ *
200
+ * Kept here, and used by ALL of the template emitters, because they disagreed. Three
201
+ * generators named the same node three ways: `promptRender()`'s handle applied a private
202
+ * `pascal()` (`renderTriageTicket`), while `outputParser()` and the render-helper
203
+ * concatenated the raw name (`parsetriageTicket`, `type triageTicketData`). One `meta gen`
204
+ * run, one template, two conventions — and the skill's own examples use the lower-camel
205
+ * spelling that triggers it, so following the documentation is what produced it.
206
+ *
207
+ * An UpperCamel template name is unaffected (`pascal("TriageTicket") === "TriageTicket"`),
208
+ * so this only moves symbols for lower-camel names, which are exactly the ones that were
209
+ * spelled inconsistently.
210
+ */
211
+ export function templateSymbolBase(templateName: string): string {
212
+ return templateName.length > 0
213
+ ? templateName[0]!.toUpperCase() + templateName.slice(1)
214
+ : templateName;
215
+ }
216
+
194
217
  // Re-exported here for callers that import from codegen-ts's naming module.
195
218
  export { toKebabCase };
@@ -41,6 +41,7 @@ import {
41
41
  stripPackage,
42
42
  } from "@metaobjectsdev/metadata";
43
43
  import { enumValues } from "./enum-meta.js";
44
+ import { templateSymbolBase } from "./naming.js";
44
45
  import { enumUnionAliasName, enumUnionString } from "./templates/inferred-types.js";
45
46
  import { assignEmittedNames } from "./naming/collision-names.js";
46
47
 
@@ -258,9 +259,9 @@ export function generatePayloadInterfacesBatch(
258
259
  return out.length === 0 ? "" : out.join("\n\n") + "\n";
259
260
  }
260
261
 
261
- function pascal(s: string): string {
262
- return s.length > 0 ? s[0]!.toUpperCase() + s.slice(1) : s;
263
- }
262
+ // Delegates to the shared authority so this emitter cannot drift from the parser and
263
+ // render-helper again (naming.ts / templateSymbolBase).
264
+ const pascal = templateSymbolBase;
264
265
 
265
266
  /** Emit a typed render handle binding a template's @textRef + @format and typing its payload. */
266
267
  export function generateRenderHandle(root: MetaData, templateName: string): string {
@@ -0,0 +1,73 @@
1
+ import type { MetaData } from "@metaobjectsdev/metadata";
2
+ import { TYPE_TEMPLATE, TEMPLATE_SUBTYPE_PROMPT, TEMPLATE_ATTR_RESPONSE_REF } from "@metaobjectsdev/metadata";
3
+ import type { Generator } from "./generator.js";
4
+
5
+ /**
6
+ * Generators that turn a declared `template.*` into runnable prompt code. Names, not
7
+ * identities, because an adopter may wrap or re-export them (ADR-0034 scaffold-and-own
8
+ * makes owning a copy the encouraged path) and a wrapper keeps the name.
9
+ */
10
+ const PROMPT_GENERATOR_NAMES: ReadonlySet<string> = new Set([
11
+ "prompt-render",
12
+ "output-parser",
13
+ "render-helper",
14
+ "output-prompt",
15
+ ]);
16
+
17
+ /**
18
+ * A declared prompt with no prompt generator wired produced NOTHING and said NOTHING.
19
+ *
20
+ * `meta gen` emitted the payload value objects (they are `object.value` nodes, which the
21
+ * entity generator picks up) and stopped there: no `render<Name>()`, no parser, no
22
+ * response-format fragment. `meta verify` then reported "1 template(s) clean", which reads
23
+ * as confirmation that the prompt is fine. So the fourth pillar produced two type files and
24
+ * a green gate, and the adopter had neither a send side nor a receive side.
25
+ *
26
+ * The wiring IS documented — but only in the prompts skill's per-language reference
27
+ * fragment, which SKILL.md points at in its final line, while the skill body walks the
28
+ * entire declaration without once saying a generator is required. Found by declaring a
29
+ * `template.prompt` in a from-scratch app exactly as that skill teaches.
30
+ *
31
+ * This follows the `layout.dataGrid` precedent (#287, data-grid-gate.ts): tell the adopter
32
+ * at `meta gen` time rather than adding a doc line that gets missed the same way. It is a
33
+ * WARNING — the exit code is untouched — and it is **self-extinguishing**: wire any one
34
+ * prompt generator and it goes quiet forever, so a project that has made this choice
35
+ * deliberately is never nagged.
36
+ *
37
+ * It must live in the runner rather than in a generator, because the whole condition is
38
+ * that the generator which would speak up is not running.
39
+ */
40
+ export function warnMissingPromptGenerators(
41
+ root: MetaData,
42
+ generators: readonly Generator[],
43
+ warn: (msg: string) => void,
44
+ ): void {
45
+ if (generators.some((g) => PROMPT_GENERATOR_NAMES.has(g.name))) return;
46
+
47
+ // ADR-0039: resolving children — a template may arrive through an overlay or extends.
48
+ const templates = root
49
+ .children()
50
+ .filter((c) => c.type === TYPE_TEMPLATE && c.subType === TEMPLATE_SUBTYPE_PROMPT);
51
+ if (templates.length === 0) return;
52
+
53
+ const names = templates.map((t) => t.name).join(", ");
54
+ // A @responseRef is what asks for the inbound tier (ADR-0052), so a responding prompt
55
+ // is missing strictly more than an outbound-only one. Name that, rather than making the
56
+ // adopter infer which half is absent.
57
+ const responding = templates.filter((t) => typeof t.attr(TEMPLATE_ATTR_RESPONSE_REF) === "string");
58
+ const receiveHalf =
59
+ responding.length > 0
60
+ ? ` ${responding.length === templates.length ? "All" : `${responding.length} of them`} ` +
61
+ `declare a @responseRef, so the parser and response-format fragment are missing too — ` +
62
+ `add outputParser().`
63
+ : "";
64
+
65
+ warn(
66
+ `${templates.length} declared template.prompt (${names}) generated no prompt code: ` +
67
+ `no prompt generator is wired, so there is no render function to send one and nothing ` +
68
+ `read a reply. Add promptRender() to \`generators\` in metaobjects.config.ts ` +
69
+ `(import it from "@metaobjectsdev/codegen-ts/generators").${receiveHalf} ` +
70
+ `The payload value objects were emitted regardless — those are object.value nodes, ` +
71
+ `which is why this looked like it had worked.`,
72
+ );
73
+ }
@@ -6,6 +6,9 @@
6
6
  // shebang is `#!/usr/bin/env node` — so it runs under NODE even in a Bun project. Do not
7
7
  // reach for `Bun.*` globals here; they are undefined and take the whole run down with
8
8
  // `Bun is not defined`. Use `node:` builtins instead.
9
+ // targets: nothing framework-specific — it re-exports whatever the other generators
10
+ // emitted. `extStyle` decides whether the re-export specifiers carry a
11
+ // `.js` extension.
9
12
  // use-when: you want a single `index.ts` re-exporting every generated entity module.
10
13
  // emits: <target>/index.ts with one `export * from "./<Entity>"` per entity, alphabetical.
11
14
  // customize: the export form (star vs named), ordering, grouping by package, what to include/exclude.
@@ -6,6 +6,13 @@
6
6
  // shebang is `#!/usr/bin/env node` — so it runs under NODE even in a Bun project. Do not
7
7
  // reach for `Bun.*` globals here; they are undefined and take the whole run down with
8
8
  // `Bun is not defined`. Use `node:` builtins instead.
9
+ // targets: Drizzle ORM + Zod. The emitted module is a Drizzle table plus Zod
10
+ // insert/update schemas; the column mapping follows `dialect`. On the
11
+ // default (vanilla) path, replace the `renderDrizzleSchema` /
12
+ // `renderZodValidators` calls to target a different ORM or validator — the
13
+ // metadata walk that feeds them is ORM-neutral. (The `isWriteThrough`
14
+ // branch calls `renderEntityFile` instead — a narrow #214 read-view case,
15
+ // not the default.)
9
16
  // use-when: ALWAYS — this is the entity-module generator. It owns the shape of each
10
17
  // generated <Entity>.ts (the Drizzle table, Zod schemas, inferred types,
11
18
  // constants, filter allowlists). Start here and adapt the assembly.
@@ -6,6 +6,11 @@
6
6
  // shebang is `#!/usr/bin/env node` — so it runs under NODE even in a Bun project. Do not
7
7
  // reach for `Bun.*` globals here; they are undefined and take the whole run down with
8
8
  // `Bun is not defined`. Use `node:` builtins instead.
9
+ // targets: Drizzle. Emitted helpers take `db` as a PARAMETER rather than importing
10
+ // a module singleton, so they compose with any caller that already holds a
11
+ // connection — including a server-rendered component. Swap the
12
+ // `render<Verb>Fn` primitives (findById/list/create/update/deleteById) and
13
+ // the inline `Db` type block above them to emit for another query builder.
9
14
  // use-when: you want generated typed CRUD finders (find<E>ById, list<E>s, create/update/delete)
10
15
  // over Drizzle. Drop it if you hand-write your data access.
11
16
  // emits: <target>/<Entity>.queries.ts per source-backed object (any source.rdb kind,
@@ -0,0 +1,103 @@
1
+ // REFERENCE TEMPLATE — copy this into your repo (e.g. codegen/generators/routes-hono.ts) and own it.
2
+ // Then import it LOCALLY in metaobjects.config.ts:
3
+ // import { routesFileHono } from "./codegen/generators/routes-hono.js";
4
+ //
5
+ // RUNTIME: this file executes under whatever runs `meta gen`, and the published CLI's
6
+ // shebang is `#!/usr/bin/env node` — so it runs under NODE even in a Bun project. Do not
7
+ // reach for `Bun.*` globals here; they are undefined and take the whole run down with
8
+ // `Bun is not defined`.
9
+ // targets: Hono. The emitted file imports `mountCrudRoutes` from
10
+ // `@metaobjectsdev/runtime-ts/hono` and takes its persistence client as
11
+ // INJECTED DEPS (`register<Entity>Routes(app, { db })`) rather than a
12
+ // module-singleton import — which is what makes it portable to any host
13
+ // that can hand Hono a request. If your framework is not Hono, THIS is the
14
+ // file to retarget: swap the mount helper and the exported signature; the
15
+ // metadata walk above it is framework-neutral and stays as-is.
16
+ // use-when: you want generated Hono CRUD routes per entity.
17
+ // emits: <target>/<Entity>.routes.hono.ts — full CRUD for write-through entities,
18
+ // read-only (GET list + GET :id) for projections. Skipped for any sourceless
19
+ // object and for TPH subtypes.
20
+ // customize: this generator is YOURS — edit it freely. For the emitted route
21
+ // composition, call `renderRoutesFileHono` (exported from the engine) and
22
+ // wrap its result, or replace the call entirely.
23
+ // composes-with: entity.ts (imports the table/schemas/allowlists), queries.ts.
24
+
25
+ import { type MetaObject } from "@metaobjectsdev/metadata";
26
+ import {
27
+ perEntity,
28
+ type Generator,
29
+ type GeneratorFactory,
30
+ renderRoutesFileHono,
31
+ isTphSubtype,
32
+ hasAnyRdbSource,
33
+ formatTs,
34
+ entityOutputPath,
35
+ CODEGEN_ATTR_EMIT_ROUTES,
36
+ } from "@metaobjectsdev/codegen-ts";
37
+
38
+ export interface RoutesFileHonoOpts {
39
+ filter?: (entity: MetaObject) => boolean;
40
+ target?: string;
41
+ }
42
+
43
+ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts): Generator {
44
+ const userFilter = opts?.filter ?? (() => true);
45
+ // Eligibility, minus the TPH question — stated ONCE so the emit set and the warn set
46
+ // below cannot drift apart. They differ only by `!isTphSubtype` vs `isTphSubtype`, and
47
+ // written out twice a later edit to one silently makes an entity either stop emitting
48
+ // without being named as held back, or get warned about while still emitting.
49
+ // (Same shape as tanstack's grid generator, which factors it the same way.)
50
+ const passesOtherGates = (e: MetaObject): boolean =>
51
+ // ADR-0039: resolving — a concrete entity may inherit @emitRoutes via extends.
52
+ e.attr(CODEGEN_ATTR_EMIT_ROUTES) !== false
53
+ && hasAnyRdbSource(e)
54
+ && userFilter(e);
55
+ const generator: Generator = {
56
+ name: "routes-file-hono",
57
+ // Marks this as the Hono routes generator so the runner can aggregate
58
+ // `ctx.config.includeHonoRoutes` and api-docs auto-documents the Hono surface.
59
+ emitsHonoRoutes: true,
60
+ // ADR-0039: resolving — a concrete entity may inherit @emitRoutes via extends.
61
+ //
62
+ // TPH subtypes are EXCLUDED, matching the Fastify generator. A TPH subtype shares
63
+ // its base's table, so mounting vanilla CRUD for it produced routes with no
64
+ // discriminator scoping at all: the list returned EVERY subtype's rows, and
65
+ // get/patch/delete by id happily operated on rows belonging to a different
66
+ // subtype. Silently wrong data, which is worse than no route. Fastify dispatches
67
+ // these to a discriminator-aware renderer; the Hono runtime has no discriminator
68
+ // support yet, so this fails CLOSED and the run says so (see the warning below)
69
+ // rather than shipping an artifact that returns the wrong rows.
70
+ filter: (e: MetaObject) => passesOtherGates(e) && !isTphSubtype(e),
71
+ generate: async (ctx) => {
72
+ // One note per run naming every TPH subtype held back, so the gap is visible at
73
+ // `meta gen` time rather than discovered as missing endpoints in production.
74
+ const skipped = ctx.entities.filter((e) => passesOtherGates(e) && isTphSubtype(e));
75
+ if (skipped.length > 0) {
76
+ ctx.warn(
77
+ `no Hono routes emitted for the TPH subtype(s) ${skipped.map((e) => e.name).join(", ")} — ` +
78
+ "the Hono adapter has no discriminator scoping yet, so per-subtype CRUD would " +
79
+ "return and mutate OTHER subtypes' rows. Use routesFile() (Fastify), which " +
80
+ "dispatches TPH correctly, or hand-write the scoped routes.",
81
+ );
82
+ }
83
+ return emit(ctx);
84
+ },
85
+ };
86
+ const emit = perEntity(async (entity, ctx) => {
87
+ if (!ctx.renderContext) {
88
+ throw new Error("routes-file-hono: renderContext is required (provided by runGen)");
89
+ }
90
+ return {
91
+ path: entityOutputPath(
92
+ ctx.config.outputLayout ?? "flat",
93
+ entity.package,
94
+ `${entity.name}.routes.hono.ts`,
95
+ ),
96
+ content: await formatTs(renderRoutesFileHono(entity, ctx.renderContext)),
97
+ };
98
+ });
99
+ if (opts?.target) {
100
+ generator.target = opts.target;
101
+ }
102
+ return generator;
103
+ } as GeneratorFactory<RoutesFileHonoOpts>;
@@ -6,6 +6,11 @@
6
6
  // shebang is `#!/usr/bin/env node` — so it runs under NODE even in a Bun project. Do not
7
7
  // reach for `Bun.*` globals here; they are undefined and take the whole run down with
8
8
  // `Bun is not defined`. Use `node:` builtins instead.
9
+ // targets: Fastify. The emitted file imports `mountCrudRoutes` from
10
+ // `@metaobjectsdev/runtime-ts/drizzle-fastify` and binds a
11
+ // module-singleton `db`. THIS is the file to retarget for another HTTP
12
+ // framework; see also the routes-hono template, whose deps-injected shape
13
+ // ports more easily to hosts that hand you a request.
9
14
  // use-when: you want generated Fastify REST routes per entity. Drop it and hand-write routes
10
15
  // if you need bespoke endpoints — or keep it and add handlers via <Entity>.extra.ts.
11
16
  // emits: <target>/<Entity>.routes.ts — full CRUD for write-through entities, read-only
@@ -13,37 +13,77 @@ import { dirname, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
 
15
15
  /** Basenames (no extension) of the copyable reference generators shipped in `src/reference/`. */
16
- export const REFERENCE_GENERATOR_NAMES = ["entity", "queries", "routes", "barrel"] as const;
16
+ export const REFERENCE_GENERATOR_NAMES = ["entity", "queries", "routes", "routes-hono", "barrel"] as const;
17
17
  export type ReferenceGeneratorName = (typeof REFERENCE_GENERATOR_NAMES)[number];
18
18
 
19
- /** A directory is the reference root iff it holds the entity reference template. */
20
- function isReferenceRoot(dir: string): boolean {
21
- return existsSync(join(dir, "entity.ts"));
19
+ /** A directory is a reference root iff it holds the first template the reader was told to expect. */
20
+ function isReferenceRoot(dir: string, sentinel: string): boolean {
21
+ return existsSync(join(dir, `${sentinel}.ts`));
22
22
  }
23
23
 
24
24
  /**
25
- * Resolve the `src/reference/` directory holding the copyable reference generators.
26
- * Works in dev (this module runs from `src/`, templates at `./reference/`) and in a
27
- * published install (this module runs from `dist/`, templates at `../src/reference/`,
28
- * since `src/` ships alongside `dist/`). Walks up checking both layouts at each level.
25
+ * FR-040 §4.1 — build a reference-template reader for ONE package. `moduleUrl` is the
26
+ * calling module's `import.meta.url`, so each package resolves its OWN `src/reference/`.
27
+ * `names[0]` is the sentinel that identifies the directory.
28
+ *
29
+ * Works in dev (module runs from `src/`, templates at `./reference/`) and in a published
30
+ * install (module runs from `dist/`, templates at `../src/reference/`, since `src/` ships
31
+ * alongside `dist/`). Walks up checking both layouts at each level.
29
32
  */
30
- export function resolveReferenceRoot(): string {
31
- let dir = dirname(fileURLToPath(import.meta.url));
32
- for (let i = 0; i < 8; i++) {
33
- for (const candidate of [join(dir, "reference"), join(dir, "src", "reference")]) {
34
- if (isReferenceRoot(candidate)) return candidate;
33
+ export function makeReferenceReader(moduleUrl: string, names: readonly [string, ...string[]]) {
34
+ // A non-empty tuple type, so `names[0]` is `string` outright — this used to need a
35
+ // runtime empty-check plus a re-bound const, because `noUncheckedIndexedAccess`
36
+ // narrowing does not survive into the closures below. Every call site already passes
37
+ // an `as const` tuple or an array literal, so the type costs nothing and deletes both.
38
+ const sentinel: string = names[0];
39
+
40
+ function resolveReferenceRoot(): string {
41
+ let dir = dirname(fileURLToPath(moduleUrl));
42
+ for (let i = 0; i < 8; i++) {
43
+ for (const candidate of [join(dir, "reference"), join(dir, "src", "reference")]) {
44
+ if (isReferenceRoot(candidate, sentinel)) return candidate;
45
+ }
46
+ const parent = dirname(dir);
47
+ if (parent === dir) break;
48
+ dir = parent;
35
49
  }
36
- const parent = dirname(dir);
37
- if (parent === dir) break;
38
- dir = parent;
50
+ throw new Error(
51
+ "reference templates not found — looked for `reference/` and `src/reference/` " +
52
+ `walking up from ${dir}.`,
53
+ );
39
54
  }
40
- throw new Error(
41
- "codegen-ts reference templates not found — looked for `reference/` and `src/reference/` " +
42
- "walking up from the codegen-ts module.",
43
- );
55
+
56
+ return {
57
+ resolveReferenceRoot,
58
+ /**
59
+ * Read one template by name. The name is checked against `names` FIRST: this is a
60
+ * public export, so `name` is an untrusted `string` at runtime (the per-package
61
+ * wrappers narrow to a literal union, but types are erased). Without the check it
62
+ * interpolates straight into a path, so any caller threading a CLI argument through
63
+ * reads whatever file the string points at. The allowlist is already a parameter —
64
+ * enforcing it here makes the guarantee structural instead of something every call
65
+ * site has to remember, and it is the reason `meta eject` needs no check of its own.
66
+ */
67
+ readReferenceTemplate: (name: string): string => {
68
+ if (!names.includes(name)) {
69
+ throw new Error(
70
+ `unknown reference template "${name}". Available: ${names.join(", ")}.`,
71
+ );
72
+ }
73
+ return readFileSync(join(resolveReferenceRoot(), `${name}.ts`), "utf8");
74
+ },
75
+ };
76
+ }
77
+
78
+ // This package's own reader — the back-compatible named exports `meta init` uses.
79
+ const ownReader = makeReferenceReader(import.meta.url, REFERENCE_GENERATOR_NAMES);
80
+
81
+ /** Resolve the `src/reference/` directory holding this package's reference generators. */
82
+ export function resolveReferenceRoot(): string {
83
+ return ownReader.resolveReferenceRoot();
44
84
  }
45
85
 
46
86
  /** Read the raw source of one reference generator (e.g. `"entity"` → the text of `entity.ts`). */
47
87
  export function readReferenceTemplate(name: ReferenceGeneratorName): string {
48
- return readFileSync(join(resolveReferenceRoot(), `${name}.ts`), "utf8");
88
+ return ownReader.readReferenceTemplate(name);
49
89
  }
@@ -52,6 +52,13 @@ export interface RenderContext {
52
52
  * doc comment on `MetaobjectsGenConfig.timestampMode` in metaobjects-config.ts.
53
53
  */
54
54
  timestampMode: "date" | "string";
55
+ /**
56
+ * Prepend `"use client";` to generated CLIENT artifacts (forms, hooks, grid
57
+ * columns, grid hooks). Defaults to false. A bundler-topology fact the adopter
58
+ * declares — see `MetaobjectsGenConfig.clientDirective` for why it is config
59
+ * rather than metadata, and why the default is off. FR-040 §6.4.
60
+ */
61
+ clientDirective: boolean;
55
62
  /** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
56
63
  apiPrefix: string;
57
64
  /** Whether abstract entities emit their shape artifact (type-only interface / value-object file). Defaults to true. Instance/write artifacts are never emitted for abstract entities regardless. */
@@ -101,11 +108,12 @@ export interface RenderContext {
101
108
  }
102
109
 
103
110
  /** 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). */
104
- export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "timestampMode" | "apiPrefix" | "emitAbstractShapes" | "outputLayout" | "packageOf" | "valueObjectNames" | "valueObjectEmittedName" | "resolveValueObjectName" | "selfTarget" | "entityModuleTarget" | "collectionName"> & {
111
+ export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "timestampMode" | "clientDirective" | "apiPrefix" | "emitAbstractShapes" | "outputLayout" | "packageOf" | "valueObjectNames" | "valueObjectEmittedName" | "resolveValueObjectName" | "selfTarget" | "entityModuleTarget" | "collectionName"> & {
105
112
  extStyle?: ExtStyle;
106
113
  omImport?: string;
107
114
  columnNamingStrategy?: ColumnNamingStrategy;
108
115
  timestampMode?: "date" | "string";
116
+ clientDirective?: boolean;
109
117
  apiPrefix?: string;
110
118
  emitAbstractShapes?: boolean;
111
119
  outputLayout?: OutputLayout;
@@ -184,6 +192,7 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
184
192
  // a unit test or a generator invoked outside `runGen`, must get the same
185
193
  // safe-no-op guarantee). See MetaobjectsGenConfig.timestampMode's doc comment.
186
194
  timestampMode: opts.dialect === "sqlite" ? "string" : (opts.timestampMode ?? "string"),
195
+ clientDirective: opts.clientDirective ?? false,
187
196
  apiPrefix: opts.apiPrefix ?? "",
188
197
  emitAbstractShapes: opts.emitAbstractShapes ?? true,
189
198
  outputLayout,
package/src/runner.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join, relative, resolve, isAbsolute, dirname } from "node:path";
2
+ import { warnMissingPromptGenerators } from "./prompt-generator-gate.js";
2
3
  import { tmpdir } from "node:os";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { existsSync, readFileSync } from "node:fs";
@@ -366,6 +367,11 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
366
367
  // CRUD surface it actually emits (rather than silently omitting it).
367
368
  const includeHonoRoutes = config.generators.some((g) => g.emitsHonoRoutes === true);
368
369
 
370
+ // A declared template.prompt with no prompt generator wired emits nothing and, before
371
+ // this, said nothing — while `meta verify` reported the template "clean". See
372
+ // prompt-generator-gate.ts. Self-extinguishing; warning only.
373
+ warnMissingPromptGenerators(root, config.generators, (m) => warnings.push(m));
374
+
369
375
  // 4. Run each generator with a per-target render context; collect with full path.
370
376
  const emitted: { fullPath: string; content: string; generatedBy: string }[] = [];
371
377
  // FR-038 §8 — generators that opted into orphan reconciliation, paired with the
@@ -411,6 +417,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
411
417
  pluralizeCollections: config.pluralizeCollections,
412
418
  collectionNameOverrides: config.collectionNameOverrides,
413
419
  timestampMode: config.timestampMode,
420
+ clientDirective: config.clientDirective,
414
421
  apiPrefix: config.apiPrefix,
415
422
  emitAbstractShapes: config.emitAbstractShapes,
416
423
  outputLayout: selfTarget.outputLayout,