Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,49 @@ 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** — `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)

`.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
<old> → <new> 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
Expand Down
33 changes: 33 additions & 0 deletions docs/features/extending-with-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,39 @@ configuration rich.
All five ports compose providers in **dependency order** (Kahn's algorithm)
and emit the same stable error codes when composition fails.

## 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

- [`../recipes/extending-metaobjects-with-providers.md`](../recipes/extending-metaobjects-with-providers.md) — hands-on walkthrough
Expand Down
6 changes: 4 additions & 2 deletions server/typescript/packages/cli/src/lib/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand All @@ -48,7 +49,8 @@ const GEN_WORDS: Record<GenFileStatus, string> = {

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`;
Expand Down
30 changes: 26 additions & 4 deletions server/typescript/packages/codegen-ts/src/metaobjects-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedGenConfig, "dbImport" | "dialect"> {
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
Expand Down Expand Up @@ -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<MetaobjectsGenConfig, "targets" | "generators"> {
export interface NormalizedMetaobjectsGenConfig
extends Omit<MetaobjectsGenConfig, "targets" | "generators" | "dbImport" | "dialect"> {
/** 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;
Expand Down Expand Up @@ -202,7 +222,7 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record<string, Res
outDir: config.outDir,
importBase: config.importBase,
outputLayout: layout,
dbImport: config.dbImport,
dbImport: config.dbImport ?? DEFAULT_DB_IMPORT,
// The default target is the server package — runtime bindings on.
runtime: true,
},
Expand All @@ -213,7 +233,7 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record<string, Res
outDir: t.outDir,
importBase: t.importBase,
outputLayout: t.outputLayout ?? layout,
dbImport: t.dbImport ?? config.dbImport,
dbImport: t.dbImport ?? config.dbImport ?? DEFAULT_DB_IMPORT,
runtime: t.runtime ?? true,
};
}
Expand Down Expand Up @@ -257,6 +277,8 @@ export function resolveGenerators(specs: readonly GeneratorSpec[]): Generator[]
export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobjectsGenConfig {
return {
...config,
dbImport: config.dbImport ?? DEFAULT_DB_IMPORT,
dialect: config.dialect ?? DEFAULT_DIALECT,
generators: resolveGenerators(config.generators),
columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY,
pluralizeCollections: config.pluralizeCollections ?? true,
Expand Down
32 changes: 32 additions & 0 deletions server/typescript/packages/codegen-ts/src/overwrite-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
70 changes: 68 additions & 2 deletions server/typescript/packages/codegen-ts/src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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 { MetaRoot, OBJECT_SUBTYPE_VALUE } from "@metaobjectsdev/metadata";
import type { Generator, GenContext, EmittedFile } from "./generator.js";
import type { MetaobjectsGenConfig } from "./metaobjects-config.js";
import { normalizeConfig, DEFAULT_TARGET_NAME } from "./metaobjects-config.js";
Expand All @@ -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,
Expand Down Expand Up @@ -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<RunGenResult> {
const warnings: string[] = [];
const strategy = opts.mergeStrategy ?? "overwrite";
Expand All @@ -74,6 +94,24 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
? 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 hasPersistentGenState = opts.projectRoot !== undefined || opts.genStateDir !== undefined;
const installedEngine = hasPersistentGenState ? engineVersion() : undefined;
const recordedEngine = hasPersistentGenState ? loadEngineVersion(genStateDir) : undefined;
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)) {
Expand Down Expand Up @@ -109,6 +147,30 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
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;
Expand Down Expand Up @@ -268,5 +330,9 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
}
}

// #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 };
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading