From bd8f2e7167d7e0d4c34d0e4b1f03110014523d41 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 28 Jul 2026 22:09:12 -0400 Subject: [PATCH 1/3] feat(#232): stamp codegen engine version in gen-state + warn on change; docs(#194): adopter constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #232 — meta gen records the @metaobjectsdev/codegen-ts engine version alongside the gen-state hashes (a separate .engine.json, never part of the three-way merge) and prints one informational line when it differs from the last run, so a post-`npm update` regen diff is explained rather than surprising. No change to generated output. #194 — document three adopter constraints in extending-with-providers.md: a new top-level node type needs registry.extend on metadata.root's child rules; the built-in ref resolver only indexes object.* targets; and dbImport/dialect are required config even for value-object-only projects (inert when no DB code is generated). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TJRi8FtEW24z9HKGUL1xh8 --- .gitignore | 3 ++ CHANGELOG.md | 17 ++++++++ docs/features/extending-with-providers.md | 28 ++++++++++++ .../codegen-ts/src/overwrite-policy.ts | 32 ++++++++++++++ .../packages/codegen-ts/src/runner.ts | 43 ++++++++++++++++++- .../test/engine-version-stamp.test.ts | 35 +++++++++++++++ 6 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 server/typescript/packages/codegen-ts/test/engine-version-stamp.test.ts diff --git a/.gitignore b/.gitignore index 20a009f54..c4111f6b1 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ server/java/.claude/ # Lavish review-surface scratch artifacts (local HTML review pages) .lavish/ + +# Serena MCP project cache (local tooling scratch) +.serena/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b977a8710..5cca29d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ here. The format follows [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (pre-1.0; MINOR bumps may introduce breaking changes with notice). +## [Unreleased] + +**npm-only** — `codegen-ts`; PyPI / NuGet / Maven Central unchanged. + +### Added — `meta gen` records the codegen engine version and flags a change since the last run (#232) + +`.metaobjects/.gen-state/` recorded per-file content hashes but not the +`@metaobjectsdev/codegen-ts` **engine version** that produced them, so a consumer who +ran `npm update && meta gen` after an engine change saw a surprising diff (or a +three-way-merge conflict) with no signal about *why* the output moved. `meta gen` now +stamps the engine version alongside the hashes (a separate `.engine.json` — it never +participates in the merge decision) and, when the recorded version differs from the +installed one, prints one informational line before writing: `codegen engine + since last gen — generated output may differ; see CHANGELOG.` Purely +informational, never blocks; a pre-`0.20.x` snapshot (or a fresh project) has no stamp +and warns nothing. No change to generated output. + ## [0.20.9] — 2026-07-28 **npm-only** — `migrate-ts` + `codegen-ts` (schema migrations and projection-view codegen diff --git a/docs/features/extending-with-providers.md b/docs/features/extending-with-providers.md index a9bc13f80..b9e61d1f4 100644 --- a/docs/features/extending-with-providers.md +++ b/docs/features/extending-with-providers.md @@ -328,6 +328,34 @@ configuration rich. All five ports compose providers in **dependency order** (Kahn's algorithm) and emit the same stable error codes when composition fails. +## Adopter constraints (worth knowing up front) + +Three constraints that surface when building a real consumer — each is resolvable, +but none is obvious from the happy-path docs: + +1. **Adding a new top-level node type requires `registry.extend` on `metadata.root`.** + `metadata.root`'s child rules are closed: a custom top-level subtype (e.g. an + `adapter.*` / `probe.*` you register via a provider) fails to load with + `ERR_CHILD_NOT_ALLOWED` unless a provider **also** `extend`s `metadata.root`'s + child rules to license it as a permitted child. Registering the subtype is not + enough on its own — its container must be told the subtype is allowed there. + +2. **Declarative reference resolution only indexes `object.*` nodes.** The built-in + ref resolver (the one behind `@objectRef` / `@from` / `@references` / `@payloadRef`) + binds references to `object.*` targets only. A reference **to a non-`object.*` + node** (e.g. a custom `adapter.*` referencing another custom top-level node) is not + resolved by the built-in resolver — resolve it yourself in the provider's `validate` + hook by walking the tree. Model references to entities/values/projections and you + get resolution for free; references to other node kinds are yours to resolve. + +3. **`dbImport` / `dialect` are required config even for a value-object-only project.** + A model that declares only `object.value`s (no write-through entities) generates + zero database / query / route code — yet `dbImport` and `dialect` are **required** + fields of the codegen config, so omitting them is a `tsc` type error. Supply inert + placeholders (e.g. `dialect: "sqlite"`, `dbImport: "./db"`); they are never read + when no DB code is generated. This is required regardless and harmless for + value-object-only models. + ## See also - [`../recipes/extending-metaobjects-with-providers.md`](../recipes/extending-metaobjects-with-providers.md) — hands-on walkthrough diff --git a/server/typescript/packages/codegen-ts/src/overwrite-policy.ts b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts index 5aa582964..eab2ce278 100644 --- a/server/typescript/packages/codegen-ts/src/overwrite-policy.ts +++ b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts @@ -108,6 +108,38 @@ function saveHashes(genStateDir: string, hashes: HashesFile): void { writeFileSync(join(genStateDir, HASHES_FILE), JSON.stringify(hashes, null, 2) + "\n"); } +const ENGINE_FILE = ".engine.json"; + +/** + * The codegen engine version that last wrote this `.gen-state`, or undefined if it + * was never stamped (a pre-#232 snapshot, or a fresh project). Informational only — + * a separate reserved file from `.hashes.json`, it never participates in the + * three-way merge decision. (#232) + */ +export function loadEngineVersion(genStateDir: string): string | undefined { + const f = join(genStateDir, ENGINE_FILE); + if (!existsSync(f)) return undefined; + try { + const parsed: unknown = JSON.parse(readFileSync(f, "utf-8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const v = (parsed as { codegenVersion?: unknown }).codegenVersion; + return typeof v === "string" ? v : undefined; + } + } catch { + // Malformed → treat as unstamped; the stamp is informational, never load-bearing. + } + return undefined; +} + +/** Record the codegen engine version alongside the gen-state hashes (#232). */ +export function saveEngineVersion(genStateDir: string, version: string): void { + mkdirSync(genStateDir, { recursive: true }); + writeFileSync( + join(genStateDir, ENGINE_FILE), + JSON.stringify({ codegenVersion: version }, null, 2) + "\n", + ); +} + function snapshotPath(genStateDir: string, relPath: string): string { // `.hashes.json` is reserved at the top of .gen-state/; relPath must never // collide with it. Output paths derived from entity names ("Post.ts" etc.) diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index c5f2c3ab4..e6a689818 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -1,5 +1,7 @@ -import { join, relative, resolve, isAbsolute } from "node:path"; +import { join, relative, resolve, isAbsolute, dirname } from "node:path"; import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { readFileSync } from "node:fs"; import type { MetaData, MetaObject } from "@metaobjectsdev/metadata"; import { MetaRoot } from "@metaobjectsdev/metadata"; import type { Generator, GenContext, EmittedFile } from "./generator.js"; @@ -11,6 +13,8 @@ import { buildRelationMap } from "./relation-resolver.js"; import { makeRenderContext } from "./render-context.js"; import { decideAndWrite, + loadEngineVersion, + saveEngineVersion, type WriteResult, type MergeStrategy, type BaselineMode, @@ -52,6 +56,22 @@ export interface RunGenResult { conflicts: WriteResult[]; } +/** + * codegen-ts's own published version, for the #232 gen-state engine stamp. Read from + * this package's package.json (one level above `dist/` or `src/`); undefined when it + * can't be resolved (e.g. inside a compiled standalone binary with no on-disk + * package.json) — the stamp is informational, so an unknown version simply skips it. + */ +function engineVersion(): string | undefined { + try { + const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: unknown }; + return typeof pkg.version === "string" ? pkg.version : undefined; + } catch { + return undefined; + } +} + export async function runGen(opts: RunGenOpts): Promise { const warnings: string[] = []; const strategy = opts.mergeStrategy ?? "overwrite"; @@ -74,6 +94,23 @@ export async function runGen(opts: RunGenOpts): Promise { ? join(projectRoot, ".metaobjects", ".gen-state") : join(tmpdir(), `meta-gen-state-${process.pid}`)); + // #232 — make an unexplained regen diff explained: if the codegen engine changed + // since the last gen, note it (generated output may legitimately differ). Purely + // informational — the version file is separate from `.hashes.json` and never + // affects the merge. Only fires when a prior stamp exists AND differs. + const installedEngine = engineVersion(); + const recordedEngine = loadEngineVersion(genStateDir); + if ( + installedEngine !== undefined && + recordedEngine !== undefined && + recordedEngine !== installedEngine + ) { + warnings.push( + `codegen engine ${recordedEngine} → ${installedEngine} since last gen — ` + + `generated output may differ; see CHANGELOG.`, + ); + } + // loadMemory now returns MetaRoot; guard here also covers callers that pass a // plain MetaData (e.g. test helpers that build trees programmatically). if (!(opts.metadata instanceof MetaRoot)) { @@ -268,5 +305,9 @@ export async function runGen(opts: RunGenOpts): Promise { } } + // #232 — stamp the engine version that produced this snapshot, so the NEXT gen can + // detect an engine change. Written after a successful run only. + if (installedEngine !== undefined) saveEngineVersion(genStateDir, installedEngine); + return { files: writes, warnings, conflicts }; } diff --git a/server/typescript/packages/codegen-ts/test/engine-version-stamp.test.ts b/server/typescript/packages/codegen-ts/test/engine-version-stamp.test.ts new file mode 100644 index 000000000..1c451a461 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/engine-version-stamp.test.ts @@ -0,0 +1,35 @@ +// #232 — the gen-state records the codegen engine version, and `meta gen` notes an +// engine change since the last run (so an unexplained regen diff is explained). The +// stamp is a separate file from `.hashes.json` and never affects the three-way merge. +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadEngineVersion, saveEngineVersion } from "../src/overwrite-policy.js"; + +let state: string; +beforeEach(() => { state = mkdtempSync(join(tmpdir(), "codegen-engine-")); }); +afterEach(() => { rmSync(state, { recursive: true, force: true }); }); + +describe("#232 — gen-state engine-version stamp", () => { + test("unstamped gen-state → loadEngineVersion is undefined (pre-#232 / fresh project)", () => { + expect(loadEngineVersion(state)).toBeUndefined(); + }); + + test("save then load round-trips the version, in a separate .engine.json (not .hashes.json)", () => { + saveEngineVersion(state, "0.20.9"); + expect(loadEngineVersion(state)).toBe("0.20.9"); + expect(existsSync(join(state, ".engine.json"))).toBe(true); + // Must NOT pollute the hashes file (the merge input). + const engine = JSON.parse(readFileSync(join(state, ".engine.json"), "utf-8")); + expect(engine).toEqual({ codegenVersion: "0.20.9" }); + expect(existsSync(join(state, ".hashes.json"))).toBe(false); + }); + + test("malformed .engine.json → undefined, never throws (informational only)", () => { + // saveEngineVersion writes valid JSON; simulate corruption by overwriting. + saveEngineVersion(state, "0.20.9"); + writeFileSync(join(state, ".engine.json"), "{ not json", "utf-8"); + expect(loadEngineVersion(state)).toBeUndefined(); + }); +}); From d51d2436c0fbec063910547a3707be8ab7aec3a7 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 28 Jul 2026 22:29:03 -0400 Subject: [PATCH 2/3] no-mistakes(review): Guard engine-version stamp to persistent gen-state only --- server/typescript/packages/codegen-ts/src/runner.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index e6a689818..9d93ee460 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -98,8 +98,9 @@ export async function runGen(opts: RunGenOpts): Promise { // since the last gen, note it (generated output may legitimately differ). Purely // informational — the version file is separate from `.hashes.json` and never // affects the merge. Only fires when a prior stamp exists AND differs. - const installedEngine = engineVersion(); - const recordedEngine = loadEngineVersion(genStateDir); + const hasPersistentGenState = opts.projectRoot !== undefined || opts.genStateDir !== undefined; + const installedEngine = hasPersistentGenState ? engineVersion() : undefined; + const recordedEngine = hasPersistentGenState ? loadEngineVersion(genStateDir) : undefined; if ( installedEngine !== undefined && recordedEngine !== undefined && From 36e670479852faee05d300b5f882b12ff1e6250b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 29 Jul 2026 08:35:29 -0400 Subject: [PATCH 3/3] fix(#194): resolve non-object references + make dbImport/dialect optional (replaces docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Fable adjudication of the three #194 adopter constraints ruled two of them real bugs the prior commit had merely DOCUMENTED rather than fixed. This fixes both in code and keeps only the genuinely correct-by-design one as docs: - Item 2 (FIX): a ReferenceDescriptor's targetType is a free string, but the loader symbol table indexed only object.* nodes, so a descriptor targeting a custom top-level type resolved cleanly then unconditionally failed with a false 'does not resolve to an object'. The symbol table is now keyed per node type; a reference to any registered top-level kind resolves under the ADR-0042 contract, and a top-level non-object node establishes package context for its subtree. Object-target refs are byte-identical (2252 metadata tests green). - Item 3 (FIX): dbImport/dialect were required config even for a value-object-only project that emits zero DB code. They are now optional on the user config; the runner fills inert defaults when absent AND no DB object is present, and throws a clear error naming the entities when a DB-emitting model omits them (never a silent sqlite default for a forgotten Postgres dialect). Resolved config the generators consume stays required — DB-project output unchanged. - Item 1 (DOCUMENT): requiring registry.extend on metadata.root to license a custom top-level type is chartered fail-closed design (FR-033) — kept as docs, rewritten to a 'defining a custom top-level node type' how-to that also notes item 2's fix. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TJRi8FtEW24z9HKGUL1xh8 --- CHANGELOG.md | 28 ++++++- docs/features/extending-with-providers.md | 59 +++++++------- .../typescript/packages/cli/src/lib/output.ts | 6 +- .../codegen-ts/src/metaobjects-config.ts | 30 ++++++- .../packages/codegen-ts/src/runner.ts | 26 ++++++- .../test/metaobjects-config.test.ts | 13 +++- .../test/vo-only-config-optional.test.ts | 74 ++++++++++++++++++ .../src/loader/validation-registry.ts | 78 ++++++++++++------- .../packages/metadata/src/validation-types.ts | 12 ++- .../metadata/test/validation-registry.test.ts | 76 ++++++++++++++++++ 10 files changed, 336 insertions(+), 66 deletions(-) create mode 100644 server/typescript/packages/codegen-ts/test/vo-only-config-optional.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cca29d7b..cdf5132de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,33 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] -**npm-only** — `codegen-ts`; PyPI / NuGet / Maven Central unchanged. +**npm-only** — `metadata` + `codegen-ts`; PyPI / NuGet / Maven Central unchanged. + +### Fixed — a reference to a non-`object` top-level node now resolves (#194) + +A `ReferenceDescriptor`'s `targetType` is a free string (the mechanism promises a +downstream provider's references validate "present and future"), but the loader's symbol +table indexed only `object.*` nodes — so a descriptor targeting a custom top-level type +(`targetType: "adapter"`) type-checked, registered, and then *unconditionally* failed +every reference with a false "does not resolve to an object". The symbol table is now +keyed per node type, so a reference to any registered top-level node kind resolves under +the same ADR-0042 package-local contract (FQN-exact, else the referrer's package, else +root-level), and the unresolved-error message names the actual target kind. Object-target +references (every core `@objectRef` / `@from` / `@references` / `@payloadRef`) are +byte-identical — the whole conformance corpus is unchanged; the change is strictly +enabling. + +### Fixed — `dbImport` / `dialect` are optional for a value-object-only project (#194) + +A model that declares only `object.value`s generates zero database / query / route code, +yet `dbImport` and `dialect` were **required** codegen-config fields, so a +value-object-only project had to supply dead-but-mandatory placeholders to satisfy +`tsc`. They are now optional on the user config; `meta gen` fills inert defaults when they +are absent AND the model emits no DB artifacts, and throws a clear error naming the +offending entities when they are absent but the model *does* generate DB code (so a +Postgres project that forgets `dialect` gets an error, never silently-emitted sqlite). +The resolved config the generators consume still carries both as required — generated +output for every DB-generating project is unchanged. ### Added — `meta gen` records the codegen engine version and flags a change since the last run (#232) diff --git a/docs/features/extending-with-providers.md b/docs/features/extending-with-providers.md index b9e61d1f4..734e26f42 100644 --- a/docs/features/extending-with-providers.md +++ b/docs/features/extending-with-providers.md @@ -328,33 +328,38 @@ configuration rich. All five ports compose providers in **dependency order** (Kahn's algorithm) and emit the same stable error codes when composition fails. -## Adopter constraints (worth knowing up front) - -Three constraints that surface when building a real consumer — each is resolvable, -but none is obvious from the happy-path docs: - -1. **Adding a new top-level node type requires `registry.extend` on `metadata.root`.** - `metadata.root`'s child rules are closed: a custom top-level subtype (e.g. an - `adapter.*` / `probe.*` you register via a provider) fails to load with - `ERR_CHILD_NOT_ALLOWED` unless a provider **also** `extend`s `metadata.root`'s - child rules to license it as a permitted child. Registering the subtype is not - enough on its own — its container must be told the subtype is allowed there. - -2. **Declarative reference resolution only indexes `object.*` nodes.** The built-in - ref resolver (the one behind `@objectRef` / `@from` / `@references` / `@payloadRef`) - binds references to `object.*` targets only. A reference **to a non-`object.*` - node** (e.g. a custom `adapter.*` referencing another custom top-level node) is not - resolved by the built-in resolver — resolve it yourself in the provider's `validate` - hook by walking the tree. Model references to entities/values/projections and you - get resolution for free; references to other node kinds are yours to resolve. - -3. **`dbImport` / `dialect` are required config even for a value-object-only project.** - A model that declares only `object.value`s (no write-through entities) generates - zero database / query / route code — yet `dbImport` and `dialect` are **required** - fields of the codegen config, so omitting them is a `tsc` type error. Supply inert - placeholders (e.g. `dialect: "sqlite"`, `dbImport: "./db"`); they are never read - when no DB code is generated. This is required regardless and harmless for - value-object-only models. +## Defining a custom top-level node type + +`metadata.root`'s child rules are a **closed set** — by design (FR-033 fail-closed +hardening): a document root admits `object` / `field` / `validator` / `template` nodes +and nothing else. So registering a new top-level subtype via a provider is **two steps**, +both in the same provider's `registerTypes`: + +1. `registry.register(...)` the new `(type, subType)` — this declares the vocabulary + *exists*, not where it may *live*. +2. `registry.extend(TYPE_METADATA, SUBTYPE_ROOT, { childRules: [...] })` to **license** + it as a permitted child of `metadata.root`. + +Skip step 2 and the node fails to load with `ERR_CHILD_NOT_ALLOWED`. This is +deliberate, not a papercut: registration declaring existence and the parent declaring +admission are separate concerns (most registered subtypes — `view.image`, +`template.toolcall` — are emphatically *not* root-level), and root admission is part of +the byte-matched cross-port registry manifest. Auto-admitting every new type at the +document root would be fail-open and would take admission out of the declarative record. + +```ts +registerTypes(registry) { + registry.register({ typeId: new TypeId("adapter", "http"), /* … */ }); + registry.extend(TYPE_METADATA, SUBTYPE_ROOT, { + childRules: [{ childType: "adapter", childSubType: "*", childName: "*" }], + }); +} +``` + +A **reference to your custom top-level type resolves package-aware for free** — a +`ReferenceDescriptor` with `targetType: "adapter"` on some other node validates through +the same resolver as `@objectRef` (FQN-exact, else the referrer's package, else +root-level), so you do **not** hand-walk the tree in a `validate` hook. ## See also diff --git a/server/typescript/packages/cli/src/lib/output.ts b/server/typescript/packages/cli/src/lib/output.ts index 84d849132..c1be9ccd5 100644 --- a/server/typescript/packages/cli/src/lib/output.ts +++ b/server/typescript/packages/cli/src/lib/output.ts @@ -25,7 +25,8 @@ export interface GenFileEntry { export interface GenResultShape { files: GenFileEntry[]; outDir: string; - dialect: Dialect; + /** Absent for a value-object-only project (no DB code generated → no dialect used). */ + dialect: Dialect | undefined; dryRun: boolean; warnings: string[]; } @@ -48,7 +49,8 @@ const GEN_WORDS: Record = { export function formatGenResult(result: GenResultShape, opts: FormatOptions): string { const symbols = opts.isTTY ? GEN_GLYPHS : GEN_WORDS; - const header = `meta gen${result.dryRun ? " --dry-run" : ""} — ${result.dialect}, ${result.outDir}`; + // A value-object-only project uses no dialect — show only the outDir in that case. + const header = `meta gen${result.dryRun ? " --dry-run" : ""} — ${result.dialect ? `${result.dialect}, ` : ""}${result.outDir}`; if (result.files.length === 0) { return `${header}\n\n No entities to generate.\n`; diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index ccdb5c522..71c050af9 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -66,7 +66,23 @@ export interface ResolvedGenConfig { providedEnumModule?: string; } -export interface MetaobjectsGenConfig extends ResolvedGenConfig { +/** Default dialect / entity-import when a value-object-only project omits them. + * Inert — they are only ever read when DB code is generated, and a project that + * would generate DB code is required to set them explicitly (see `runGen`'s guard). */ +export const DEFAULT_DIALECT: Dialect = "sqlite"; +export const DEFAULT_DB_IMPORT = "./db"; + +/** + * The user-facing codegen config. `dbImport` / `dialect` are OPTIONAL here (unlike the + * resolved `ResolvedGenConfig` the generators consume): a value-object-only project + * (no `object.entity` / `object.projection`) generates zero database / query / route + * code, so requiring them would be a dead-but-mandatory `tsc` obligation. `runGen` + * fills inert defaults when they are absent AND the model emits no DB artifacts, and + * throws a clear error when they are absent but the model DOES emit DB code (#194). + */ +export interface MetaobjectsGenConfig extends Omit { + dbImport?: string; + dialect?: Dialect; /** * Generators to run. Each entry is either a typed generator factory result * (`entityFile()`) or a stable-name string (`"entity"`) resolved via the @@ -129,7 +145,11 @@ export interface MetaobjectsGenConfig extends ResolvedGenConfig { * `targets` is Omitted from the base so it can narrow from the user-facing * TargetConfig to the fully-resolved ResolvedTarget (incompatible under * exactOptionalPropertyTypes otherwise). */ -export interface NormalizedMetaobjectsGenConfig extends Omit { +export interface NormalizedMetaobjectsGenConfig + extends Omit { + /** Resolved to a concrete value (the user's, else the inert default). */ + dbImport: string; + dialect: Dialect; /** Fully resolved — every string spec has been mapped to its factory result. */ generators: Generator[]; columnNamingStrategy: ColumnNamingStrategy; @@ -202,7 +222,7 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record { return { files: [], warnings, conflicts: [] }; } + // #194 — dbImport / dialect are optional config, BUT a model that emits database + // artifacts (any concrete object that is not an object.value — entities and + // projections generate schema / query / route code) genuinely needs them. Guard + // BEFORE normalizeConfig fills its inert defaults, so a DB project that forgot + // either gets a clear error naming the objects, never silently-defaulted output + // (e.g. a Postgres project quietly emitting sqlite). A value-object-only project + // reaches normalizeConfig and gets the harmless placeholders. + const dbEmittingObjects = safeEntities.filter( + (e) => !e.isAbstract && e.subType !== OBJECT_SUBTYPE_VALUE, + ); + if (dbEmittingObjects.length > 0) { + const missing: string[] = []; + if (opts.config.dialect === undefined) missing.push("dialect"); + if (opts.config.dbImport === undefined) missing.push("dbImport"); + if (missing.length > 0) { + const names = dbEmittingObjects.map((e) => e.name).join(", "); + throw new Error( + `codegen config is missing ${missing.join(" and ")} — required because this model ` + + `generates database code for: ${names}. Set ${missing.join(" and ")} in ` + + `metaobjects.config.ts. (Only a value-object-only model may omit them.)`, + ); + } + } + // 2. Resolve targets + entity-module target. const config = normalizeConfig(opts.config); const targets = config.targets; diff --git a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts index 8b2d38c96..8eaa60f44 100644 --- a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts +++ b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, expectTypeOf } from "bun:test"; -import { defineConfig, normalizeConfig, resolveTargets, DEFAULT_TARGET_NAME, type MetaobjectsGenConfig, type ResolvedGenConfig } from "../src/metaobjects-config.js"; +import { defineConfig, normalizeConfig, resolveTargets, DEFAULT_TARGET_NAME, type MetaobjectsGenConfig, type ResolvedGenConfig, type Dialect } from "../src/metaobjects-config.js"; import type { Generator } from "../src/generator.js"; describe("resolveTargets", () => { @@ -67,9 +67,14 @@ describe("defineConfig", () => { expectTypeOf().toEqualTypeOf<(Generator | string)[]>(); }); - test("type-level: MetaobjectsGenConfig embeds ResolvedGenConfig (all required fields present, types exact)", () => { - expectTypeOf>() - .toEqualTypeOf(); + test("type-level: MetaobjectsGenConfig embeds ResolvedGenConfig's non-DB fields exactly (#194 — dbImport/dialect are OPTIONAL here, filled by the runner)", () => { + // Everything except dbImport/dialect matches ResolvedGenConfig field-for-field. + expectTypeOf>() + .toEqualTypeOf>(); + // dbImport/dialect are OPTIONAL on the user config (a value-object-only project omits + // them); ResolvedGenConfig (what generators consume) keeps them required. + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); }); diff --git a/server/typescript/packages/codegen-ts/test/vo-only-config-optional.test.ts b/server/typescript/packages/codegen-ts/test/vo-only-config-optional.test.ts new file mode 100644 index 000000000..2f34b7fe3 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/vo-only-config-optional.test.ts @@ -0,0 +1,74 @@ +// #194 item 3 — a value-object-only project may omit dbImport/dialect (it generates +// zero DB code, so requiring them was a dead-but-mandatory tsc obligation). A model +// that DOES emit DB code must still set them, and omitting them errors clearly rather +// than silently defaulting (which would e.g. emit sqlite for a Postgres project). +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { runGen } from "../src/runner.js"; +import { entityFile } from "../src/generators/entity-file.js"; +import type { MetaobjectsGenConfig } from "../src/metaobjects-config.js"; + +let tmp: string; +beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "codegen-vo-only-")); }); +afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + +async function load(children: unknown[]) { + const json = JSON.stringify({ "metadata.root": { package: "app", children } }); + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(json)]); + expect(errors).toEqual([]); + return root; +} + +const VALUE_ONLY = [ + { "object.value": { name: "Slots", children: [ + { "field.string": { name: "goal", "@required": true } }, + { "field.string": { name: "note" } }, + ] } }, +]; + +const ENTITY = [ + { "object.entity": { name: "User", children: [ + { "field.long": { name: "id" } }, + { "field.string": { name: "email", "@required": true } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] } }, +]; + +// dbImport/dialect deliberately OMITTED — legal for a value-object-only model. +// outDir is filled per-test with the beforeEach tmp dir (no writes into the package). +const configNoDb = (): MetaobjectsGenConfig => + ({ outDir: tmp, extStyle: "js", generators: [entityFile()] } as MetaobjectsGenConfig); + +describe("#194 item 3 — dbImport/dialect optional for value-object-only projects", () => { + test("a value-object-only model generates with NO dbImport/dialect set (no throw)", async () => { + const root = await load(VALUE_ONLY); + const result = await runGen({ config: configNoDb(), metadata: root, projectRoot: tmp }); + // It runs to completion; a value object emits no query/route DB code, so the + // absent dbImport/dialect are never read. + expect(result.warnings.some((w) => w.includes("missing"))).toBe(false); + expect(Array.isArray(result.files)).toBe(true); + }); + + test("a model with a DB entity but no dbImport/dialect throws a clear error naming the entity", async () => { + const root = await load(ENTITY); + let err: Error | undefined; + try { + await runGen({ config: configNoDb(), metadata: root, projectRoot: tmp }); + } catch (e) { + err = e as Error; + } + expect(err).toBeDefined(); + expect(err!.message).toContain("dialect and dbImport"); + expect(err!.message).toContain("User"); // names the DB-emitting object + }); + + test("a DB entity WITH dbImport/dialect set generates normally (no regression)", async () => { + const root = await load(ENTITY); + const cfg = { ...configNoDb(), dbImport: "./db", dialect: "postgres" as const } as MetaobjectsGenConfig; + const result = await runGen({ config: cfg, metadata: root, projectRoot: tmp }); + expect(result.files.length).toBeGreaterThan(0); + }); +}); diff --git a/server/typescript/packages/metadata/src/loader/validation-registry.ts b/server/typescript/packages/metadata/src/loader/validation-registry.ts index 6c6de4f70..5cc609ee5 100644 --- a/server/typescript/packages/metadata/src/loader/validation-registry.ts +++ b/server/typescript/packages/metadata/src/loader/validation-registry.ts @@ -14,29 +14,43 @@ import { PACKAGE_SEPARATOR } from "../shared/structural.js"; import type { TypeRegistry } from "../registry.js"; import type { LoaderCode, SymbolTable, ValidationContext } from "../validation-types.js"; -/** A symbol table of every top-level object, built once per load (the binder analogue). */ +/** A symbol table of every top-level node, built once per load (the binder analogue). + * Indexed PER TYPE so a reference descriptor can target a non-`object` node kind (its + * `targetType` is a free string) and so a template and an object sharing `pkg::Name` + * never collide. */ class SymbolTableImpl implements SymbolTable { - private readonly byKey = new Map(); + private readonly byType = new Map>(); static build(root: MetaData): SymbolTableImpl { const t = new SymbolTableImpl(); // ADR-0039: root has no super; children()==ownChildren() but resolving is the default. for (const child of root.children()) { - if (child.type !== TYPE_OBJECT) continue; + let bucket = t.byType.get(child.type); + if (bucket === undefined) { + bucket = new Map(); + t.byType.set(child.type, bucket); + } // ADR-0042: key by the canonical resolution key ONLY (the FQN, or the bare // name for a root-level/empty-package object) — NO bare-name fallback, so a - // bare ref never binds a same-named object in another package. - t.byKey.set(child.resolutionKey(), child); + // bare ref never binds a same-named node in another package. + bucket.set(child.resolutionKey(), child); } return t; } - /** ADR-0042 package-local resolution: FQN → exact resolution-key match; bare → - * the referrer's own package (`::`), else a root-level object. */ - resolveObject(ref: string, referrerPkg: string): MetaData | undefined { - if (ref.includes(PACKAGE_SEPARATOR)) return this.byKey.get(ref); + /** ADR-0042 package-local resolution within the `type` bucket: FQN → exact + * resolution-key match; bare → the referrer's own package (`::`), + * else a root-level node of that type. */ + resolve(type: string, ref: string, referrerPkg: string): MetaData | undefined { + const bucket = this.byType.get(type); + if (bucket === undefined) return undefined; + if (ref.includes(PACKAGE_SEPARATOR)) return bucket.get(ref); const localKey = referrerPkg !== "" ? `${referrerPkg}${PACKAGE_SEPARATOR}${ref}` : ref; - return this.byKey.get(localKey) ?? this.byKey.get(ref); + return bucket.get(localKey) ?? bucket.get(ref); + } + + resolveObject(ref: string, referrerPkg: string): MetaData | undefined { + return this.resolve(TYPE_OBJECT, ref, referrerPkg); } } @@ -55,17 +69,23 @@ class ValidationContextImpl implements ValidationContext { */ export function runRegisteredValidation(root: MetaData, registry: TypeRegistry): ParseError[] { const ctx = new ValidationContextImpl(SymbolTableImpl.build(root)); - walk(root, ""); + walk(root, "", false); return ctx.errors; - function walk(node: MetaData, referrerPkg: string): void { - // ADR-0042: a top-level object establishes the package context for its - // subtree; nested ref-bearing nodes (relationship/field.object/ - // identity.reference) resolve BARE refs against it. Nested nodes carry no - // `package`, so they inherit the enclosing object's (via fileDefaultPackage, - // else the threaded context). + function walk(node: MetaData, referrerPkg: string, isTopLevel: boolean): void { + // ADR-0042: a TOP-LEVEL node establishes the package context for its subtree; + // nested ref-bearing nodes (relationship/field.object/identity.reference) resolve + // BARE refs against it. Nested nodes carry no `package`, so they inherit the + // enclosing object's (via fileDefaultPackage, else the threaded context). A + // top-level node of ANY type sets the context — including a provider's custom + // top-level type — so a non-object top-level ref-bearing node resolves its bare + // refs in its own package, not at root level (#194 item 2). Objects still set + // context whether top-level or nested; behavior for every pre-existing case is + // unchanged (a nested non-object still inherits the threaded context). const pkg = - node.type === TYPE_OBJECT ? (node.package ?? node.fileDefaultPackage ?? referrerPkg) : referrerPkg; + isTopLevel || node.type === TYPE_OBJECT + ? (node.package ?? node.fileDefaultPackage ?? referrerPkg) + : referrerPkg; const def = registry.find(node.type, node.subType); if (def) { for (const desc of def.references ?? []) { @@ -74,21 +94,24 @@ export function runRegisteredValidation(root: MetaData, registry: TypeRegistry): const raw = node.attr(desc.attr); if (typeof raw !== "string" || raw === "") continue; // absence is the required-attr pass's job const entityRef = desc.dottedFieldPath ? (raw.split(".")[0] ?? raw) : raw; - const target = ctx.symbols.resolveObject(entityRef, pkg); + const target = ctx.symbols.resolve(desc.targetType, entityRef, pkg); // Qualify the node name with its owning entity (e.g. "Order.items") so the error is // locatable from the message alone, not just the source envelope. const qname = node.parent?.name ? `${node.parent.name}.${node.name}` : node.name; if (!target) { + // `did-you-mean` is object-scoped (its candidate scan is over objects), so only + // append it for object targets; a non-object target names its own kind. + const wantKind = desc.targetType === TYPE_OBJECT ? "an object" : `a ${desc.targetType}`; + const hint = desc.targetType === TYPE_OBJECT ? didYouMeanHint(root, entityRef) : ""; ctx.error( desc.errorCode, node, - `${node.type}.${node.subType} "${qname}" @${desc.attr} "${raw}" does not resolve to an object.${didYouMeanHint(root, entityRef)}`, + `${node.type}.${node.subType} "${qname}" @${desc.attr} "${raw}" does not resolve to ${wantKind}.${hint}`, ); - } else if ( - target.type !== desc.targetType || - (desc.targetSubType !== undefined && target.subType !== desc.targetSubType) - ) { - const want = desc.targetSubType ? `${desc.targetType}.${desc.targetSubType}` : desc.targetType; + } else if (desc.targetSubType !== undefined && target.subType !== desc.targetSubType) { + // `resolve` already guarantees target.type === desc.targetType; only the + // subType can still mismatch. + const want = `${desc.targetType}.${desc.targetSubType}`; ctx.error( desc.errorCode, node, @@ -101,6 +124,9 @@ export function runRegisteredValidation(root: MetaData, registry: TypeRegistry): } // ADR-0039: own — structural walk visiting every physical node once at its // declaration site (an inherited child is validated on its declaring parent). - for (const child of node.ownChildren()) walk(child, pkg); + // The root's own children ARE the top-level nodes (each sets its own package + // context); everything deeper is nested. + const childrenAreTopLevel = node === root; + for (const child of node.ownChildren()) walk(child, pkg, childrenAreTopLevel); } } diff --git a/server/typescript/packages/metadata/src/validation-types.ts b/server/typescript/packages/metadata/src/validation-types.ts index 2c386e216..8b9a4d028 100644 --- a/server/typescript/packages/metadata/src/validation-types.ts +++ b/server/typescript/packages/metadata/src/validation-types.ts @@ -30,10 +30,20 @@ export interface ReferenceDescriptor { readonly errorCode: LoaderCode; } -/** Resolve a ref string to its object node under the ADR-0042 package-local +/** Resolve a ref string to a top-level node under the ADR-0042 package-local * contract. `referrerPkg` is the effective package of the node that declares * the ref (a bare ref resolves in that package, else root-level). */ export interface SymbolTable { + /** + * Resolve a ref to a node of the given target TYPE. A `ReferenceDescriptor` may + * target a non-`object` node kind (its `targetType` is a free string), so the + * resolver is keyed by type — a downstream provider's `adapter.*`→`adapter.*` + * reference resolves for free, matching the descriptor mechanism's "present and + * future" promise. (Previously only `object.*` targets were indexed, so a + * non-object `targetType` was silently unsatisfiable.) + */ + resolve(type: string, ref: string, referrerPkg: string): MetaData | undefined; + /** The common case — an `object`-target reference. Delegates to `resolve("object", …)`. */ resolveObject(ref: string, referrerPkg: string): MetaData | undefined; } diff --git a/server/typescript/packages/metadata/test/validation-registry.test.ts b/server/typescript/packages/metadata/test/validation-registry.test.ts index 11c7d0988..c7e137b82 100644 --- a/server/typescript/packages/metadata/test/validation-registry.test.ts +++ b/server/typescript/packages/metadata/test/validation-registry.test.ts @@ -12,6 +12,8 @@ import { coreProviders } from "../src/core-types.js"; import type { MetaDataTypeProvider } from "../src/provider.js"; import { TypeId, type TypeRegistry, type TypeDefinition } from "../src/registry.js"; import { MetaData } from "../src/shared/meta-data.js"; +import { CHILD_RULE_WILDCARD } from "../src/shared/structural.js"; +import { TYPE_METADATA, SUBTYPE_ROOT } from "../src/shared/base-types.js"; // A concrete node class for the downstream type (MetaData itself is abstract). class WidgetNode extends MetaData {} @@ -97,3 +99,77 @@ metadata.root: expect(codes).not.toContain("ERR_WIDGET_RANGE"); }); }); + +// A reference descriptor's `targetType` is a free string, and the mechanism promises a +// downstream provider's references validate "present and future". This proves a reference +// to a NON-`object` node kind resolves — previously the symbol table indexed only objects, +// so a non-object `targetType` was silently unsatisfiable (every ref falsely "unresolved"). +class AdapterNode extends MetaData {} +class ProbeNode extends MetaData {} + +const adapterProvider: MetaDataTypeProvider = { + id: "fake-adapters", + description: "A downstream app's custom top-level adapter/probe vocabulary.", + registerTypes(registry: TypeRegistry): void { + registry.register({ + typeId: new TypeId("adapter", "http"), + description: "A downstream-defined HTTP adapter (a non-object top-level node).", + factory: (typeId, name) => new AdapterNode(typeId, name), + childRules: [], + attributes: [], + }); + registry.register({ + typeId: new TypeId("probe", "ping"), + description: "A probe that references an adapter by name.", + factory: (typeId, name) => new ProbeNode(typeId, name), + childRules: [], + attributes: [], + // The reference targets a NON-object type — the case the fix enables. + references: [{ attr: "adapterRef", targetType: "adapter", errorCode: "ERR_PROBE_ADAPTER_UNRESOLVED" }], + }); + // License both new top-level types under metadata.root (its child rules are closed). + registry.extend(TYPE_METADATA, SUBTYPE_ROOT, { + childRules: [ + { childType: "adapter", childSubType: CHILD_RULE_WILDCARD, childName: CHILD_RULE_WILDCARD }, + { childType: "probe", childSubType: CHILD_RULE_WILDCARD, childName: CHILD_RULE_WILDCARD }, + ], + }); + }, +}; + +describe("validation derived from the type registry — non-object reference targets (#194 item 2)", () => { + it("a reference to a non-object top-level node RESOLVES (no false 'unresolved')", async () => { + const registry = composeRegistry([...coreProviders, adapterProvider]); + const model = ` +metadata.root: + package: app + children: + - adapter.http: { name: MainApi } + - probe.ping: { name: HealthCheck, adapterRef: MainApi } +`; + const loader = new MetaDataLoader({ registry, strict: false }); + const { errors } = await loader.load([ + new InMemoryStringSource(model, { id: "app.yaml", format: "yaml" }), + ]); + const codes = errors.map((e) => (e as { code?: string }).code); + expect(codes).not.toContain("ERR_PROBE_ADAPTER_UNRESOLVED"); + }); + + it("a dangling non-object reference errors, naming the target kind", async () => { + const registry = composeRegistry([...coreProviders, adapterProvider]); + const model = ` +metadata.root: + package: app + children: + - adapter.http: { name: MainApi } + - probe.ping: { name: HealthCheck, adapterRef: NoSuchAdapter } +`; + const loader = new MetaDataLoader({ registry, strict: false }); + const { errors } = await loader.load([ + new InMemoryStringSource(model, { id: "app.yaml", format: "yaml" }), + ]); + expect(errors.map((e) => (e as { code?: string }).code)).toContain("ERR_PROBE_ADAPTER_UNRESOLVED"); + // The message names the target's own kind, not a hardcoded "object". + expect(errors.some((e) => /does not resolve to a adapter/.test(e.message))).toBe(true); + }); +});