|
| 1 | +# Advanced modeling — the canonical spine |
| 2 | + |
| 3 | +**Unit 1** of the advanced modeling series (see |
| 4 | +`docs/superpowers/plans/2026-07-19-advanced-modeling-series-plan.md` and the |
| 5 | +design doc at `docs/superpowers/specs/2026-07-19-advanced-modeling-series-design.md`). |
| 6 | +A course/program publishing platform (package root `acme::learn`) that exercises |
| 7 | +four advanced metadata patterns **together, in one model** — the interaction |
| 8 | +between them is the thing the rest of the series teaches. This is the index |
| 9 | +Units 2–4 (developer deep-dives, agent-context decision rules, video scripts) |
| 10 | +all cite. |
| 11 | + |
| 12 | +## What this is — and isn't |
| 13 | + |
| 14 | +This directory is **authored metadata + `metaobjects.config.ts` + committed |
| 15 | +generated output (`src/generated/**`)**, verified by a drift gate. It is |
| 16 | +**not a runnable application** — there is no server, no database, no |
| 17 | +`package.json`/`node_modules` for the example itself. That is a deliberate |
| 18 | +scope boundary (see the design doc, Decision 3): a live app would buy demo |
| 19 | +polish at the cost of a permanent maintenance tax; metadata + committed |
| 20 | +output gives docs/agent-context/videos a real, current artifact to cite at a |
| 21 | +fraction of the upkeep. |
| 22 | + |
| 23 | +**The drift gate is a freshness + CLI-composition smoke check, not a |
| 24 | +correctness gate.** It proves the committed output still matches what |
| 25 | +`meta gen` produces right now, and that all four patterns still compose |
| 26 | +through the real CLI path. It does **not** re-prove the patterns' *behavior* |
| 27 | +— that is owned by `fixtures/*-conformance/` and the codegen golden tests in |
| 28 | +`server/typescript/packages/codegen-ts/test/`. Do not describe it otherwise. |
| 29 | + |
| 30 | +## The four patterns, and where to find them |
| 31 | + |
| 32 | +| # | Pattern | Metadata | Generated output | |
| 33 | +|---|---|---|---| |
| 34 | +| 1 | **Projections** (`object.projection`, `origin.*`) | `metaobjects/meta.catalog.yaml:127-165` — `ProgramSummary`: `origin.passthrough` (a join to `Author`, `:138`), `origin.aggregate @agg:count` (`:143-146`), `origin.aggregate @agg:sum` + `filter` (`:153-157`), `origin.computed @expr` (`:163-164`) | `src/generated/ProgramSummary.ts` (the `pgView` declaration + read schema), `src/generated/ProgramSummary.routes.ts` (read-only routes — no POST/PATCH/DELETE) | |
| 35 | +| 2 | **Entity views** (`view.*` control family) | `metaobjects/meta.catalog.yaml:25-93` — `Program`: `field.enum` (`:31-33`, → `<select>`), `view.textarea @rows` (`:40`), `field.currency` + `view.currency` (`:41-45`), `view.image` — all five attrs (`:53-58`) | `src/generated/Program.form.tsx` (select / `<textarea rows={6}>` / `<ImageUpload>` wrapped in `<Controller>`) | |
| 36 | +| 3 | **Value objects as jsonb columns** | `metaobjects/meta.catalog.yaml:80-92` — `Program.syllabus` (array-of-VO, `isArray: true` + `storage: jsonb`) and `Program.instructorProfile` (single VO); the VOs themselves in `metaobjects/meta.content.yaml:27-47` (`SyllabusSection`, `InstructorProfile` — pure `object.value`, ADR-0028: no identity, no source) | `src/generated/Program.ts` (`jsonb("syllabus").$type<SyllabusSection[]>()`, `jsonb("instructor_profile").$type<InstructorProfile>()`); `src/generated/Program.form.tsx` (a repeatable `useFieldArray` sub-form for `syllabus`, an embedded fieldset for `instructorProfile`) | |
| 37 | +| 4 | **Value objects as LLM/document payloads** | `metaobjects/meta.prompts.yaml` — `ProgramDescriptionPayload` (`:21-42`, an `object.value` projecting `Program`/`Author`/`Lesson` via `origin.passthrough`/`origin.aggregate`, no identity/source) + `template.output` (`:44-48`) | `src/generated/prompts.ts` (the payload interface), `src/generated/ProgramDescriptionOutput.output.ts` (tolerant parser), `src/generated/ProgramDescriptionOutput.render.ts` (typed `render<Name>()` wrapper); the mustache text lives at `templates/learn/program-description.mustache` | |
| 38 | + |
| 39 | +`metaobjects/meta.catalog.yaml` also declares `Author` and `Purchase` — the |
| 40 | +minimal supporting entities the four patterns above need (an FK to join |
| 41 | +across for pattern 1; a transactional record with its own currency/enum |
| 42 | +fields, structurally independent of `Program`'s `relationship.composition` |
| 43 | +lessons). `metaobjects/meta.content.yaml` declares `Lesson` (the |
| 44 | +`relationship.composition` target `ProgramSummary.lessonCount` aggregates |
| 45 | +over). |
| 46 | + |
| 47 | +## Regenerating |
| 48 | + |
| 49 | +From the repo root: |
| 50 | + |
| 51 | +```sh |
| 52 | +cd server/typescript |
| 53 | +bun run packages/cli/bin/meta.ts gen --cwd ../../examples/advanced-modeling |
| 54 | +bun run packages/cli/bin/meta.ts verify --cwd ../../examples/advanced-modeling --codegen |
| 55 | +bun run packages/cli/bin/meta.ts verify --cwd ../../examples/advanced-modeling --templates --prompts templates |
| 56 | +``` |
| 57 | + |
| 58 | +(`--prompts templates` is required for `--templates`: this example's mustache |
| 59 | +lives under `templates/`, the directory the `render-helper` codegen generator |
| 60 | +resolves at generation time — not `verify`'s own `prompts/` default. See |
| 61 | +"Gotchas" below.) |
| 62 | + |
| 63 | +`examples/` is **not** part of the Bun workspace (the repo-root |
| 64 | +`package.json` globs only `server/typescript/packages/*` and |
| 65 | +`client/web/packages/*`), so there is no local install step — the CLI's |
| 66 | +config loader resolves the `@metaobjectsdev/*` imports in |
| 67 | +`metaobjects.config.ts` from the CLI's own install regardless of the |
| 68 | +example's `node_modules` (which doesn't exist). |
| 69 | + |
| 70 | +## The drift gate |
| 71 | + |
| 72 | +`server/typescript/packages/cli/test/integration/advanced-modeling-drift.test.ts` |
| 73 | +runs three checks against this directory as committed: |
| 74 | + |
| 75 | +1. `meta verify --templates --prompts templates` — the declared |
| 76 | + `template.output`'s mustache is drift-free against its payload VO's field |
| 77 | + tree. |
| 78 | +2. `meta verify --codegen` — `src/generated/**` as committed is byte-identical |
| 79 | + to a fresh regen from the current metadata. |
| 80 | +3. Every committed `.ts`/`.tsx` file under `src/generated/` parses as valid |
| 81 | + TypeScript (`Bun.Transpiler`, resolution-free — `examples/` has no |
| 82 | + `node_modules` to resolve `drizzle-orm`/`zod`/`@metaobjectsdev/react`/etc. |
| 83 | + against, so a full typecheck isn't available here). This check exists |
| 84 | + because (2) alone is a pure string diff: a generator that emits |
| 85 | + *deterministically* invalid syntax still "matches a fresh regen" cleanly. |
| 86 | + That is not hypothetical — see "Gotchas" below. |
| 87 | + |
| 88 | +That test file lives in the `cli` package, whose full `bun test` suite is |
| 89 | +already part of `scripts/ci-local.sh`'s `gate_conf_ts` step (the `ts-fast` |
| 90 | +lane) — this is a **fast-lane addition to an existing package suite**, not a |
| 91 | +new CI job, per the plan's resolved decision. |
| 92 | + |
| 93 | +## Gotchas found building this spine |
| 94 | + |
| 95 | +Recon-first (per the plan's Step 1) still surfaced four real issues once the |
| 96 | +metadata actually ran through the full `meta gen` → `meta verify` pipeline — |
| 97 | +worth recording here since they're exactly the kind of thing a from-memory |
| 98 | +authoring pass would miss: |
| 99 | + |
| 100 | +- **`identity.reference` does not register `@onDelete`/`@onUpdate`.** |
| 101 | + `docs/features/relationships.md` documents these attrs as applying to |
| 102 | + "both" `relationship.*` and `identity.reference`; only `relationship.*` (all |
| 103 | + three subtypes — `association`/`aggregation`/`composition`, each with a |
| 104 | + sensible default: composition→cascade, aggregation→set-null, |
| 105 | + association→restrict) actually has them registered. The referential action |
| 106 | + belongs on the relationship side (`Program.lessons` / |
| 107 | + `Program.purchases`), never restated on the `identity.reference` FK side. |
| 108 | + The doc is stale on this one point; this example follows the registered |
| 109 | + vocabulary, not the doc. |
| 110 | +- **`routesFile()`/`formFile()` don't special-case a pure `object.value`.** |
| 111 | + `entityFile()` already renders a VO as shape-only (interface + Zod, no |
| 112 | + Drizzle table) — but `routesFile`/`formFile` assumed every entity has a |
| 113 | + backing table/entity-constants object, and would emit routes/forms |
| 114 | + referencing exports that don't exist for a VO. Worked around here via each |
| 115 | + generator's `filter` option (`metaobjects.config.ts`) rather than fixed in |
| 116 | + `codegen-ts`, since it's also the semantically correct call for this domain |
| 117 | + — none of `SyllabusSection`/`InstructorProfile`/`ProgramDescriptionPayload` |
| 118 | + is independently CRUD-addressable or form-submitted. |
| 119 | +- **`render-helper`'s payload type wasn't package-stripped.** A resolved |
| 120 | + `@payloadRef` can arrive fully-qualified (`acme::learn::X`); every other TS- |
| 121 | + identifier-emitting generator (`payload-codegen.ts`, `entity-file.ts`, …) |
| 122 | + already calls `stripPackage()` on a resolved ref before emitting it as an |
| 123 | + identifier — `render-helper.ts` didn't, so a `template.output` payload in a |
| 124 | + named package emitted `import type { acme::learn::X } from "./acme::learn::X.js"`, |
| 125 | + which is not legal TypeScript. Fixed in |
| 126 | + `codegen-ts/src/templates/render-helper.ts` (this PR) — narrow, mirrors the |
| 127 | + existing pattern, covered by the existing render-helper test suite. |
| 128 | +- **`meta verify --codegen` didn't carry the project's `templates/` dir into |
| 129 | + its throwaway regen root**, so any project using `render-helper()` (or |
| 130 | + other provider-backed template generators) would spuriously fail |
| 131 | + `--codegen` with an "unresolved" error unrelated to actual drift. Fixed in |
| 132 | + `cli/src/lib/codegen-drift.ts` (this PR) — mirrors the existing outDir-copy |
| 133 | + logic, extended to the templates dir. |
| 134 | +- **A relative `outDir` resolved against the ambient `process.cwd()`, not the |
| 135 | + resolved `--cwd`.** `meta gen`/`meta verify --codegen` silently wrote/read |
| 136 | + the wrong location whenever the CLI was invoked with `--cwd` pointing |
| 137 | + somewhere other than the actual process working directory (exactly this |
| 138 | + drift gate's own invocation pattern: a `bun test` run from `packages/cli/` |
| 139 | + targeting `examples/advanced-modeling/`). The common case — running `meta |
| 140 | + gen` from inside your own project, no `--cwd` — happened to work by |
| 141 | + accident (`process.cwd() === projectRoot`), which is why this had gone |
| 142 | + unnoticed. Fixed in `codegen-ts/src/runner.ts` (the write path) and |
| 143 | + `cli/src/lib/codegen-drift.ts` (the read path), both anchoring a relative |
| 144 | + `outDir` to the resolved project root. |
| 145 | + |
| 146 | +None of the above are metamodel vocabulary gaps (ADR-0023) — they're either a |
| 147 | +stale doc claim or codegen/CLI bugs, fixed narrowly and covered by the |
| 148 | +existing test suites (all four ports' conformance corpora and the `cli`/ |
| 149 | +`codegen-ts` package suites stayed green throughout). |
0 commit comments