Skip to content

Commit 0d05bee

Browse files
authored
Merge pull request #245 from metaobjectsdev/fix/gen-engine-version-stamp-and-adopter-docs
fix: resolve non-object references, make dbImport/dialect optional, stamp gen-state engine version
2 parents 647f512 + 43d90ca commit 0d05bee

13 files changed

Lines changed: 465 additions & 38 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,6 @@ server/java/.claude/
6363

6464
# Lavish review-surface scratch artifacts (local HTML review pages)
6565
.lavish/
66+
67+
# Serena MCP project cache (local tooling scratch)
68+
.serena/

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,49 @@ here. The format follows [Keep a Changelog](https://keepachangelog.com/), and
55
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
66
(pre-1.0; MINOR bumps may introduce breaking changes with notice).
77

8+
## [Unreleased]
9+
10+
**npm-only**`metadata` + `codegen-ts`; PyPI / NuGet / Maven Central unchanged.
11+
12+
### Fixed — a reference to a non-`object` top-level node now resolves (#194)
13+
14+
A `ReferenceDescriptor`'s `targetType` is a free string (the mechanism promises a
15+
downstream provider's references validate "present and future"), but the loader's symbol
16+
table indexed only `object.*` nodes — so a descriptor targeting a custom top-level type
17+
(`targetType: "adapter"`) type-checked, registered, and then *unconditionally* failed
18+
every reference with a false "does not resolve to an object". The symbol table is now
19+
keyed per node type, so a reference to any registered top-level node kind resolves under
20+
the same ADR-0042 package-local contract (FQN-exact, else the referrer's package, else
21+
root-level), and the unresolved-error message names the actual target kind. Object-target
22+
references (every core `@objectRef` / `@from` / `@references` / `@payloadRef`) are
23+
byte-identical — the whole conformance corpus is unchanged; the change is strictly
24+
enabling.
25+
26+
### Fixed — `dbImport` / `dialect` are optional for a value-object-only project (#194)
27+
28+
A model that declares only `object.value`s generates zero database / query / route code,
29+
yet `dbImport` and `dialect` were **required** codegen-config fields, so a
30+
value-object-only project had to supply dead-but-mandatory placeholders to satisfy
31+
`tsc`. They are now optional on the user config; `meta gen` fills inert defaults when they
32+
are absent AND the model emits no DB artifacts, and throws a clear error naming the
33+
offending entities when they are absent but the model *does* generate DB code (so a
34+
Postgres project that forgets `dialect` gets an error, never silently-emitted sqlite).
35+
The resolved config the generators consume still carries both as required — generated
36+
output for every DB-generating project is unchanged.
37+
38+
### Added — `meta gen` records the codegen engine version and flags a change since the last run (#232)
39+
40+
`.metaobjects/.gen-state/` recorded per-file content hashes but not the
41+
`@metaobjectsdev/codegen-ts` **engine version** that produced them, so a consumer who
42+
ran `npm update && meta gen` after an engine change saw a surprising diff (or a
43+
three-way-merge conflict) with no signal about *why* the output moved. `meta gen` now
44+
stamps the engine version alongside the hashes (a separate `.engine.json` — it never
45+
participates in the merge decision) and, when the recorded version differs from the
46+
installed one, prints one informational line before writing: `codegen engine
47+
<old> → <new> since last gen — generated output may differ; see CHANGELOG.` Purely
48+
informational, never blocks; a pre-`0.20.x` snapshot (or a fresh project) has no stamp
49+
and warns nothing. No change to generated output.
50+
851
## [7.20.12] — 2026-08-02
952

1053
**Maven-only PATCH** — Maven Central `7.20.12` (npm/PyPI/NuGet unchanged at `0.20.11`; the fix is Java-only, so only the Maven line moves — Maven now runs one patch ahead of the shared `20.11`, mirroring how npm runs a patch ahead with npm-only fixes). Fixes **[#233](https://github.com/metaobjectsdev/metaobjects/issues/233)**: a multi-module Maven reactor building `metaobjects-maven-plugin` in **parallel** (`mvn -T<N>`) deadlocked/hung; the serial default (`-T1`) always worked. Two compounding causes, both fixed:

docs/features/extending-with-providers.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,39 @@ configuration rich.
343343
All five ports compose providers in **dependency order** (Kahn's algorithm)
344344
and emit the same stable error codes when composition fails.
345345

346+
## Defining a custom top-level node type
347+
348+
`metadata.root`'s child rules are a **closed set** — by design (FR-033 fail-closed
349+
hardening): a document root admits `object` / `field` / `validator` / `template` nodes
350+
and nothing else. So registering a new top-level subtype via a provider is **two steps**,
351+
both in the same provider's `registerTypes`:
352+
353+
1. `registry.register(...)` the new `(type, subType)` — this declares the vocabulary
354+
*exists*, not where it may *live*.
355+
2. `registry.extend(TYPE_METADATA, SUBTYPE_ROOT, { childRules: [...] })` to **license**
356+
it as a permitted child of `metadata.root`.
357+
358+
Skip step 2 and the node fails to load with `ERR_CHILD_NOT_ALLOWED`. This is
359+
deliberate, not a papercut: registration declaring existence and the parent declaring
360+
admission are separate concerns (most registered subtypes — `view.image`,
361+
`template.toolcall` — are emphatically *not* root-level), and root admission is part of
362+
the byte-matched cross-port registry manifest. Auto-admitting every new type at the
363+
document root would be fail-open and would take admission out of the declarative record.
364+
365+
```ts
366+
registerTypes(registry) {
367+
registry.register({ typeId: new TypeId("adapter", "http"), /* … */ });
368+
registry.extend(TYPE_METADATA, SUBTYPE_ROOT, {
369+
childRules: [{ childType: "adapter", childSubType: "*", childName: "*" }],
370+
});
371+
}
372+
```
373+
374+
A **reference to your custom top-level type resolves package-aware for free** — a
375+
`ReferenceDescriptor` with `targetType: "adapter"` on some other node validates through
376+
the same resolver as `@objectRef` (FQN-exact, else the referrer's package, else
377+
root-level), so you do **not** hand-walk the tree in a `validate` hook.
378+
346379
## See also
347380

348381
- [`../recipes/extending-metaobjects-with-providers.md`](../recipes/extending-metaobjects-with-providers.md) — hands-on walkthrough

server/typescript/packages/cli/src/lib/output.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export interface GenFileEntry {
2525
export interface GenResultShape {
2626
files: GenFileEntry[];
2727
outDir: string;
28-
dialect: Dialect;
28+
/** Absent for a value-object-only project (no DB code generated → no dialect used). */
29+
dialect: Dialect | undefined;
2930
dryRun: boolean;
3031
warnings: string[];
3132
}
@@ -48,7 +49,8 @@ const GEN_WORDS: Record<GenFileStatus, string> = {
4849

4950
export function formatGenResult(result: GenResultShape, opts: FormatOptions): string {
5051
const symbols = opts.isTTY ? GEN_GLYPHS : GEN_WORDS;
51-
const header = `meta gen${result.dryRun ? " --dry-run" : ""}${result.dialect}, ${result.outDir}`;
52+
// A value-object-only project uses no dialect — show only the outDir in that case.
53+
const header = `meta gen${result.dryRun ? " --dry-run" : ""}${result.dialect ? `${result.dialect}, ` : ""}${result.outDir}`;
5254

5355
if (result.files.length === 0) {
5456
return `${header}\n\n No entities to generate.\n`;

server/typescript/packages/codegen-ts/src/metaobjects-config.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,23 @@ export interface ResolvedGenConfig {
6666
providedEnumModule?: string;
6767
}
6868

69-
export interface MetaobjectsGenConfig extends ResolvedGenConfig {
69+
/** Default dialect / entity-import when a value-object-only project omits them.
70+
* Inert — they are only ever read when DB code is generated, and a project that
71+
* would generate DB code is required to set them explicitly (see `runGen`'s guard). */
72+
export const DEFAULT_DIALECT: Dialect = "sqlite";
73+
export const DEFAULT_DB_IMPORT = "./db";
74+
75+
/**
76+
* The user-facing codegen config. `dbImport` / `dialect` are OPTIONAL here (unlike the
77+
* resolved `ResolvedGenConfig` the generators consume): a value-object-only project
78+
* (no `object.entity` / `object.projection`) generates zero database / query / route
79+
* code, so requiring them would be a dead-but-mandatory `tsc` obligation. `runGen`
80+
* fills inert defaults when they are absent AND the model emits no DB artifacts, and
81+
* throws a clear error when they are absent but the model DOES emit DB code (#194).
82+
*/
83+
export interface MetaobjectsGenConfig extends Omit<ResolvedGenConfig, "dbImport" | "dialect"> {
84+
dbImport?: string;
85+
dialect?: Dialect;
7086
/**
7187
* Generators to run. Each entry is either a typed generator factory result
7288
* (`entityFile()`) or a stable-name string (`"entity"`) resolved via the
@@ -129,7 +145,11 @@ export interface MetaobjectsGenConfig extends ResolvedGenConfig {
129145
* `targets` is Omitted from the base so it can narrow from the user-facing
130146
* TargetConfig to the fully-resolved ResolvedTarget (incompatible under
131147
* exactOptionalPropertyTypes otherwise). */
132-
export interface NormalizedMetaobjectsGenConfig extends Omit<MetaobjectsGenConfig, "targets" | "generators"> {
148+
export interface NormalizedMetaobjectsGenConfig
149+
extends Omit<MetaobjectsGenConfig, "targets" | "generators" | "dbImport" | "dialect"> {
150+
/** Resolved to a concrete value (the user's, else the inert default). */
151+
dbImport: string;
152+
dialect: Dialect;
133153
/** Fully resolved — every string spec has been mapped to its factory result. */
134154
generators: Generator[];
135155
columnNamingStrategy: ColumnNamingStrategy;
@@ -202,7 +222,7 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record<string, Res
202222
outDir: config.outDir,
203223
importBase: config.importBase,
204224
outputLayout: layout,
205-
dbImport: config.dbImport,
225+
dbImport: config.dbImport ?? DEFAULT_DB_IMPORT,
206226
// The default target is the server package — runtime bindings on.
207227
runtime: true,
208228
},
@@ -213,7 +233,7 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record<string, Res
213233
outDir: t.outDir,
214234
importBase: t.importBase,
215235
outputLayout: t.outputLayout ?? layout,
216-
dbImport: t.dbImport ?? config.dbImport,
236+
dbImport: t.dbImport ?? config.dbImport ?? DEFAULT_DB_IMPORT,
217237
runtime: t.runtime ?? true,
218238
};
219239
}
@@ -257,6 +277,8 @@ export function resolveGenerators(specs: readonly GeneratorSpec[]): Generator[]
257277
export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobjectsGenConfig {
258278
return {
259279
...config,
280+
dbImport: config.dbImport ?? DEFAULT_DB_IMPORT,
281+
dialect: config.dialect ?? DEFAULT_DIALECT,
260282
generators: resolveGenerators(config.generators),
261283
columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY,
262284
pluralizeCollections: config.pluralizeCollections ?? true,

server/typescript/packages/codegen-ts/src/overwrite-policy.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,38 @@ function saveHashes(genStateDir: string, hashes: HashesFile): void {
108108
writeFileSync(join(genStateDir, HASHES_FILE), JSON.stringify(hashes, null, 2) + "\n");
109109
}
110110

111+
const ENGINE_FILE = ".engine.json";
112+
113+
/**
114+
* The codegen engine version that last wrote this `.gen-state`, or undefined if it
115+
* was never stamped (a pre-#232 snapshot, or a fresh project). Informational only —
116+
* a separate reserved file from `.hashes.json`, it never participates in the
117+
* three-way merge decision. (#232)
118+
*/
119+
export function loadEngineVersion(genStateDir: string): string | undefined {
120+
const f = join(genStateDir, ENGINE_FILE);
121+
if (!existsSync(f)) return undefined;
122+
try {
123+
const parsed: unknown = JSON.parse(readFileSync(f, "utf-8"));
124+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
125+
const v = (parsed as { codegenVersion?: unknown }).codegenVersion;
126+
return typeof v === "string" ? v : undefined;
127+
}
128+
} catch {
129+
// Malformed → treat as unstamped; the stamp is informational, never load-bearing.
130+
}
131+
return undefined;
132+
}
133+
134+
/** Record the codegen engine version alongside the gen-state hashes (#232). */
135+
export function saveEngineVersion(genStateDir: string, version: string): void {
136+
mkdirSync(genStateDir, { recursive: true });
137+
writeFileSync(
138+
join(genStateDir, ENGINE_FILE),
139+
JSON.stringify({ codegenVersion: version }, null, 2) + "\n",
140+
);
141+
}
142+
111143
function snapshotPath(genStateDir: string, relPath: string): string {
112144
// `.hashes.json` is reserved at the top of .gen-state/; relPath must never
113145
// collide with it. Output paths derived from entity names ("Post.ts" etc.)

server/typescript/packages/codegen-ts/src/runner.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { join, relative, resolve, isAbsolute } from "node:path";
1+
import { join, relative, resolve, isAbsolute, dirname } from "node:path";
22
import { tmpdir } from "node:os";
3+
import { fileURLToPath } from "node:url";
4+
import { readFileSync } from "node:fs";
35
import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
46
import { MetaRoot, OBJECT_SUBTYPE_VALUE } from "@metaobjectsdev/metadata";
57
import { assignEmittedNames } from "./naming/collision-names.js";
@@ -13,6 +15,8 @@ import { buildRelationMap } from "./relation-resolver.js";
1315
import { makeRenderContext } from "./render-context.js";
1416
import {
1517
decideAndWrite,
18+
loadEngineVersion,
19+
saveEngineVersion,
1620
type WriteResult,
1721
type MergeStrategy,
1822
type BaselineMode,
@@ -54,6 +58,22 @@ export interface RunGenResult {
5458
conflicts: WriteResult[];
5559
}
5660

61+
/**
62+
* codegen-ts's own published version, for the #232 gen-state engine stamp. Read from
63+
* this package's package.json (one level above `dist/` or `src/`); undefined when it
64+
* can't be resolved (e.g. inside a compiled standalone binary with no on-disk
65+
* package.json) — the stamp is informational, so an unknown version simply skips it.
66+
*/
67+
function engineVersion(): string | undefined {
68+
try {
69+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
70+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: unknown };
71+
return typeof pkg.version === "string" ? pkg.version : undefined;
72+
} catch {
73+
return undefined;
74+
}
75+
}
76+
5777
export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
5878
const warnings: string[] = [];
5979
const strategy = opts.mergeStrategy ?? "overwrite";
@@ -76,6 +96,24 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
7696
? join(projectRoot, ".metaobjects", ".gen-state")
7797
: join(tmpdir(), `meta-gen-state-${process.pid}`));
7898

99+
// #232 — make an unexplained regen diff explained: if the codegen engine changed
100+
// since the last gen, note it (generated output may legitimately differ). Purely
101+
// informational — the version file is separate from `.hashes.json` and never
102+
// affects the merge. Only fires when a prior stamp exists AND differs.
103+
const hasPersistentGenState = opts.projectRoot !== undefined || opts.genStateDir !== undefined;
104+
const installedEngine = hasPersistentGenState ? engineVersion() : undefined;
105+
const recordedEngine = hasPersistentGenState ? loadEngineVersion(genStateDir) : undefined;
106+
if (
107+
installedEngine !== undefined &&
108+
recordedEngine !== undefined &&
109+
recordedEngine !== installedEngine
110+
) {
111+
warnings.push(
112+
`codegen engine ${recordedEngine}${installedEngine} since last gen — ` +
113+
`generated output may differ; see CHANGELOG.`,
114+
);
115+
}
116+
79117
// loadMemory now returns MetaRoot; guard here also covers callers that pass a
80118
// plain MetaData (e.g. test helpers that build trees programmatically).
81119
if (!(opts.metadata instanceof MetaRoot)) {
@@ -111,6 +149,30 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
111149
return { files: [], warnings, conflicts: [] };
112150
}
113151

152+
// #194 — dbImport / dialect are optional config, BUT a model that emits database
153+
// artifacts (any concrete object that is not an object.value — entities and
154+
// projections generate schema / query / route code) genuinely needs them. Guard
155+
// BEFORE normalizeConfig fills its inert defaults, so a DB project that forgot
156+
// either gets a clear error naming the objects, never silently-defaulted output
157+
// (e.g. a Postgres project quietly emitting sqlite). A value-object-only project
158+
// reaches normalizeConfig and gets the harmless placeholders.
159+
const dbEmittingObjects = safeEntities.filter(
160+
(e) => !e.isAbstract && e.subType !== OBJECT_SUBTYPE_VALUE,
161+
);
162+
if (dbEmittingObjects.length > 0) {
163+
const missing: string[] = [];
164+
if (opts.config.dialect === undefined) missing.push("dialect");
165+
if (opts.config.dbImport === undefined) missing.push("dbImport");
166+
if (missing.length > 0) {
167+
const names = dbEmittingObjects.map((e) => e.name).join(", ");
168+
throw new Error(
169+
`codegen config is missing ${missing.join(" and ")} — required because this model ` +
170+
`generates database code for: ${names}. Set ${missing.join(" and ")} in ` +
171+
`metaobjects.config.ts. (Only a value-object-only model may omit them.)`,
172+
);
173+
}
174+
}
175+
114176
// 2. Resolve targets + entity-module target.
115177
const config = normalizeConfig(opts.config);
116178
const targets = config.targets;
@@ -300,5 +362,9 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
300362
}
301363
}
302364

365+
// #232 — stamp the engine version that produced this snapshot, so the NEXT gen can
366+
// detect an engine change. Written after a successful run only.
367+
if (installedEngine !== undefined) saveEngineVersion(genStateDir, installedEngine);
368+
303369
return { files: writes, warnings, conflicts };
304370
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// #232 — the gen-state records the codegen engine version, and `meta gen` notes an
2+
// engine change since the last run (so an unexplained regen diff is explained). The
3+
// stamp is a separate file from `.hashes.json` and never affects the three-way merge.
4+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
5+
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs";
6+
import { tmpdir } from "node:os";
7+
import { join } from "node:path";
8+
import { loadEngineVersion, saveEngineVersion } from "../src/overwrite-policy.js";
9+
10+
let state: string;
11+
beforeEach(() => { state = mkdtempSync(join(tmpdir(), "codegen-engine-")); });
12+
afterEach(() => { rmSync(state, { recursive: true, force: true }); });
13+
14+
describe("#232 — gen-state engine-version stamp", () => {
15+
test("unstamped gen-state → loadEngineVersion is undefined (pre-#232 / fresh project)", () => {
16+
expect(loadEngineVersion(state)).toBeUndefined();
17+
});
18+
19+
test("save then load round-trips the version, in a separate .engine.json (not .hashes.json)", () => {
20+
saveEngineVersion(state, "0.20.9");
21+
expect(loadEngineVersion(state)).toBe("0.20.9");
22+
expect(existsSync(join(state, ".engine.json"))).toBe(true);
23+
// Must NOT pollute the hashes file (the merge input).
24+
const engine = JSON.parse(readFileSync(join(state, ".engine.json"), "utf-8"));
25+
expect(engine).toEqual({ codegenVersion: "0.20.9" });
26+
expect(existsSync(join(state, ".hashes.json"))).toBe(false);
27+
});
28+
29+
test("malformed .engine.json → undefined, never throws (informational only)", () => {
30+
// saveEngineVersion writes valid JSON; simulate corruption by overwriting.
31+
saveEngineVersion(state, "0.20.9");
32+
writeFileSync(join(state, ".engine.json"), "{ not json", "utf-8");
33+
expect(loadEngineVersion(state)).toBeUndefined();
34+
});
35+
});

0 commit comments

Comments
 (0)