Skip to content

Commit 5ce8fb6

Browse files
dmealingclaude
andcommitted
fix(metadata,codegen-ts,migrate-ts,runtime-ts): identify nodes by type, not instanceof
`x instanceof MetaSource` is only sound when the class object and the instance come from the same physical copy of @metaobjectsdev/metadata. A globally installed or linked meta CLI alongside a project-local dependency puts two copies in one process and the class check goes false for a node that is a source in every observable respect — the class-identity defect that split ts-poet's Code objects in 0.21.6, which 400c53f fixed for source-detect only. Across this boundary the failure is SILENT rather than loud. In codegen-ts an entity reads as "not backed by any store", so no table, queries or routes are emitted and meta gen still reports success. In migrate-ts it is worse: the entity drops out of the EXPECTED schema, so migrate sees a live table with no counterpart and proposes to DROP it, while verify reports drift against a model that is correct. The exposure is not "which package the code lives in" — it is who created the node the function is looking at. Loader-internal work (the validators, parser, subtype rules) and methods on a node are immune by construction. Anything taking a CALLER-SUPPLIED node is exposed, and that includes metadata's own exported helpers: resolveTableName / resolveTableSchema are called by migrate-ts on loader nodes, and their fallthrough is not a dropped table but the entity-name fallback — a DIFFERENT table name, which migrate emits as a rename against a live database. That one is the sharpest edge in the set. metadata now exports cross-realm guards (shared/node-guards.ts): isMetaRoot / isMetaObject / isMetaField / isMetaSource / isWritableSource / isReadOnlySource. They key on the metamodel `type` — the registry binds one class per type, so on a single-copy tree they and instanceof answer identically — and read behaviour through the node, failing closed when it cannot answer. All 13 exposed sites are converted; the rule is recorded in CLAUDE.md under "Coding discipline (TS)". Output is unchanged: regenerating the canonical Postgres schema with this change is byte-identical (sha256) to regenerating without it. Gated by metadata/test/node-guards.test.ts and migrate-ts/test/expected-schema-cross-realm.test.ts, which simulate the second copy by re-prototyping a REAL loaded node onto a clone of its own prototype — every method still resolves, instanceof is false. Each fix was verified by reverting it and watching the gate reproduce the original symptom. The migrate gate needed de-blinding first: entity "Order" with @table "orders" has a name-derived fallback of "orders", the identical string, so it passed with the defect present. It now uses a @table that differs from the fallback plus a non-default @Schema. Same failure shape as the case-aligned `like` corpus 0.21.6 had to de-blind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS
1 parent e8be443 commit 5ce8fb6

17 files changed

Lines changed: 530 additions & 76 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,7 @@ These are the load-bearing principles that have emerged through implementation.
511511
- **String literals OK only for**: error message text, instance/entity names that are user data, and test data values that aren't metamodel-level concepts.
512512
- **No backwards-compat hacks.**
513513
- **No `any` escape hatches.** Use `unknown` and narrow.
514+
- **Never `instanceof` a metadata node from another package.** Cross-package code (`codegen-ts`, `migrate-ts`, `runtime-ts`, `cli`) identifies nodes with the guards `@metaobjectsdev/metadata` exports — `isMetaRoot` / `isMetaObject` / `isMetaField` / `isMetaSource` / `isWritableSource` / `isReadOnlySource` — never `x instanceof MetaSource`. Two physical copies of the package in one process (a globally-installed or linked `meta` CLI plus a project-local dependency) give the class object and the instance different identities, so `instanceof` returns **false for a real node**. The failure is **silent**: in `codegen-ts` the entity reads as "not backed by any store" and simply emits no table/queries/routes; in `migrate-ts` it drops the table from the EXPECTED schema, so `meta migrate` proposes `DROP TABLE` against a live database. This is the same class-identity defect that split ts-poet's `Code` objects in 0.21.6. The CLI's alias map (`load-metaobjects-config.ts` `CLI_PKG_PATHS`) closes it for `meta gen`/`migrate` **only** — a consumer embedding `runGen()` or the migrate engine programmatically never runs it. Sites **inside** `metadata` are immune by construction (a package's own module graph resolves its own files) and keep using `instanceof`. Mechanism + blast radius: `metadata/src/shared/node-guards.ts`.
514515

515516
## Useful commands
516517

server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import {
1212
type AggregateFunction,
1313
type MetaData,
1414
MetaObject,
15-
MetaRoot,
16-
MetaSource,
15+
type MetaRoot,
16+
type MetaSource,
17+
isMetaRoot,
18+
isReadOnlySource,
19+
isWritableSource,
1720
SOURCE_KIND_VIEW,
1821
TYPE_FIELD,
1922
TYPE_IDENTITY,
@@ -81,7 +84,9 @@ export function buildProjectionViews(
8184
root: MetaData,
8285
opts: BuildProjectionViewsOptions,
8386
): ExpectedView[] {
84-
if (!(root instanceof MetaRoot)) {
87+
// isMetaRoot, not `instanceof`: under a split @metaobjectsdev/metadata tree the
88+
// class check rejects a perfectly good root, turning a working build into a throw.
89+
if (!isMetaRoot(root)) {
8590
throw new Error("buildProjectionViews: root must be a loaded MetaRoot.");
8691
}
8792
// D1 is SQLite at the SQL level.
@@ -164,9 +169,7 @@ type ReadOnlySourceClass =
164169
| { kind: "derive"; source: MetaSource };
165170

166171
function classifyReadOnlySource(host: MetaObject): ReadOnlySourceClass {
167-
const source = host.ownChildren().find(
168-
(c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
169-
);
172+
const source = host.ownChildren().find(isReadOnlySource);
170173
if (source === undefined) return { kind: "skip" };
171174
if (source.isUnmanaged) return { kind: "skip" }; // external — Flyway/hand-migration owns it
172175
if (source.sqlBody !== undefined) return { kind: "sql", source }; // author-supplied body
@@ -265,9 +268,7 @@ function collectSqlDependsOn(
265268
): string[] {
266269
const tables = new Set<string>();
267270
// The write-through host's own writable table (keyed the way the diff keys descriptors).
268-
const hasWritableSource = host.ownChildren().some(
269-
(c) => c instanceof MetaSource && c.isWritable(),
270-
);
271+
const hasWritableSource = host.ownChildren().some(isWritableSource);
271272
if (hasWritableSource) {
272273
const t = joinTables[host.resolutionKey()];
273274
if (t !== undefined) tables.add(t);

server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
TYPE_IDENTITY,
44
TYPE_ORIGIN,
55
TYPE_RELATIONSHIP,
6-
MetaSource,
6+
isMetaObject,
7+
isReadOnlySource,
78
ORIGIN_SUBTYPE_PASSTHROUGH,
89
ORIGIN_SUBTYPE_AGGREGATE,
910
ORIGIN_SUBTYPE_COMPUTED,
@@ -252,9 +253,7 @@ function viewName(projection: MetaObject, ctx: ExtractContext): string {
252253
// ADR-0039: own — projection source classification (mirrors C# projection
253254
// OwnSources / IsReadOnlyProjection): the view name comes from the projection's
254255
// OWN read-only source, not one inherited via extends.
255-
const viewSource = projection.ownChildren().find(
256-
(c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
257-
);
256+
const viewSource = projection.ownChildren().find(isReadOnlySource);
258257
const explicit = viewSource?.physicalName;
259258
// physicalName always returns a string; empty string means the source had
260259
// neither alias nor a name and the owning entity name was empty (impossible
@@ -317,7 +316,7 @@ function packageOf(obj: MetaData): string {
317316
*/
318317
function resolveEntityRef(root: MetaRoot, ref: string, referrerPkg: string): MetaObject | undefined {
319318
const node = resolveObjectRef(root, ref, referrerPkg).node;
320-
return node instanceof MetaObject ? node : undefined;
319+
return isMetaObject(node) ? node : undefined;
321320
}
322321

323322
function baseEntityFor(

server/typescript/packages/codegen-ts/src/projection/projection-detector.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
import { MetaSource } from "@metaobjectsdev/metadata";
1+
// isReadOnlySource / isWritableSource rather than `instanceof MetaSource`: a
2+
// second physical copy of @metaobjectsdev/metadata makes the class check false
3+
// for a real source, which would silently reclassify every projection and
4+
// write-through entity as a vanilla entity.
5+
import { isReadOnlySource, isWritableSource } from "@metaobjectsdev/metadata";
26
import type { MetaData } from "@metaobjectsdev/metadata";
37

48
function hasReadOnlyKindSource(entity: MetaData): boolean {
59
// ADR-0039: own — projection source-kind classification. Mirrors C#
610
// IsReadOnlyProjection()/projection OwnSources: an entity's projection-ness is
711
// determined by its OWN declared source @kind, not one inherited via extends.
8-
return entity.ownChildren().some(
9-
(c) => c instanceof MetaSource && c.isReadOnly(),
10-
);
12+
return entity.ownChildren().some(isReadOnlySource);
1113
}
1214

1315
function hasWritableKindSource(entity: MetaData): boolean {
1416
// ADR-0039: own — projection source-kind classification (see hasReadOnlyKindSource).
15-
return entity.ownChildren().some(
16-
(c) => c instanceof MetaSource && c.isWritable(),
17-
);
17+
return entity.ownChildren().some(isWritableSource);
1818
}
1919

2020
export function isProjection(entity: MetaData): boolean {

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
33
import { fileURLToPath } from "node:url";
44
import { existsSync, readFileSync } from "node:fs";
55
import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
6-
import { MetaRoot, OBJECT_SUBTYPE_VALUE, FIELD_SUBTYPE_TIMESTAMP, FIELD_ATTR_FILTERABLE } from "@metaobjectsdev/metadata";
6+
import { isMetaRoot, OBJECT_SUBTYPE_VALUE, FIELD_SUBTYPE_TIMESTAMP, FIELD_ATTR_FILTERABLE } from "@metaobjectsdev/metadata";
77
import { assignEmittedNames } from "./naming/collision-names.js";
88
import { isAbstract } from "./instance-artifacts.js";
99
import { hasAnyRdbSource } from "./source-detect.js";
@@ -128,7 +128,10 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
128128

129129
// loadMemory now returns MetaRoot; guard here also covers callers that pass a
130130
// plain MetaData (e.g. test helpers that build trees programmatically).
131-
if (!(opts.metadata instanceof MetaRoot)) {
131+
// isMetaRoot, not `instanceof`: a consumer embedding runGen() programmatically
132+
// never runs the CLI's @metaobjectsdev/metadata alias, so a split tree would
133+
// reject the very root the caller just loaded.
134+
if (!isMetaRoot(opts.metadata)) {
132135
throw new Error("runGen: opts.metadata must be a loaded MetaRoot.");
133136
}
134137
const root = opts.metadata;

server/typescript/packages/codegen-ts/src/source-detect.ts

Lines changed: 12 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,20 @@
66
// metadata-driven, not a typeId discriminator: any object subtype can opt out
77
// of Drizzle table emission simply by omitting source.rdb.
88

9-
import { TYPE_SOURCE, SOURCE_SUBTYPE_RDB } from "@metaobjectsdev/metadata";
9+
// Cross-realm safety: identify a source by metamodel type/subType and read its
10+
// writability through the node, never by `instanceof MetaSource`. Two physical
11+
// copies of @metaobjectsdev/metadata make the class check false for a real
12+
// source, and here the consequence is SILENT — the entity reads as "not backed
13+
// by any store", so no Drizzle table, no queries and no routes are emitted for
14+
// it and nothing errors. Mechanism and blast radius: metadata's
15+
// shared/node-guards.ts. Gated by test/source-detect.test.ts ("survives a split
16+
// @metaobjectsdev/metadata tree").
17+
import { SOURCE_SUBTYPE_RDB, isMetaSource, isWritableSource } from "@metaobjectsdev/metadata";
1018
import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
1119

12-
// Cross-realm safety: identify a source structurally (type/subType + the
13-
// writability surface), never by `instanceof MetaSource`.
14-
//
15-
// Two physical copies of @metaobjectsdev/metadata in one process give the
16-
// loader's nodes a different MetaSource class object than this module closes
17-
// over, so `instanceof` is false for a node that is a source.rdb in every
18-
// observable respect — the same class-identity defect that split ts-poet's Code
19-
// objects in 0.21.6. Here the consequence is silent: the entity reads as "not
20-
// backed by any store", so no Drizzle table, no queries and no routes are
21-
// emitted for it, and nothing errors.
22-
//
23-
// `meta gen` aliases @metaobjectsdev/metadata to the CLI's own copy
24-
// (load-metaobjects-config.ts CLI_PKG_PATHS), which closes the split for the CLI
25-
// path — but that alias map does not run when a consumer embeds runGen()
26-
// programmatically, so these helpers do not depend on it. Gated by
27-
// test/source-detect.test.ts ("survives a split @metaobjectsdev/metadata tree").
28-
29-
/** True when the child is a source.rdb node, by structure rather than class identity. */
20+
/** True when the child is a source.rdb node (subType-scoped — the rdb paradigm only). */
3021
function isRdbSource(child: MetaData): boolean {
31-
return child.type === TYPE_SOURCE && child.subType === SOURCE_SUBTYPE_RDB;
32-
}
33-
34-
/**
35-
* True when the node reports itself writable. Reads the writability surface
36-
* structurally so a source built by a second copy of the package still answers.
37-
* A node that cannot answer is not counted as writable (fail-closed — the same
38-
* outcome the `instanceof` guard produced for a non-source node).
39-
*/
40-
function reportsWritable(child: MetaData): boolean {
41-
const probe = child as unknown as { isWritable?: () => boolean };
42-
return typeof probe.isWritable === "function" && probe.isWritable();
22+
return isMetaSource(child) && child.subType === SOURCE_SUBTYPE_RDB;
4323
}
4424

4525
/**
@@ -55,7 +35,7 @@ export function hasWritableRdbSource(entity: MetaObject): boolean {
5535
// would suppress the Drizzle table for such an entity.
5636
for (const child of entity.children()) {
5737
if (!isRdbSource(child)) continue;
58-
if (reportsWritable(child)) return true;
38+
if (isWritableSource(child)) return true;
5939
}
6040
return false;
6141
}

server/typescript/packages/codegen-ts/src/templates/callable-file.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121

2222
import {
2323
type MetaObject,
24-
MetaSource,
24+
type MetaSource,
25+
isMetaSource,
2526
SOURCE_ATTR_PARAMETER_REF,
2627
SOURCE_KIND_STORED_PROC,
2728
SOURCE_KIND_TABLE_FUNCTION,
@@ -47,7 +48,9 @@ function callableSource(entity: MetaObject): MetaSource | undefined {
4748
// ADR-0039: resolving — an entity may inherit its callable source.rdb via extends.
4849
for (const child of entity.children()) {
4950
if (child.type !== TYPE_SOURCE) continue;
50-
if (!(child instanceof MetaSource)) continue;
51+
// isMetaSource, not `instanceof`: a split @metaobjectsdev/metadata tree would
52+
// make the class check false and silently emit no callable wrapper.
53+
if (!isMetaSource(child)) continue;
5154
if (CALLABLE_KINDS.has(child.effectiveKind)) return child;
5255
}
5356
return undefined;

server/typescript/packages/codegen-ts/src/templates/projection-decl.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import { code, imp, joinCode, type Code } from "ts-poet";
1111
import {
12-
MetaField, MetaObject, type MetaRoot,
12+
MetaField, MetaObject, type MetaRoot, isMetaObject,
1313
FIELD_ATTR_OBJECT_REF, stripPackage,
1414
} from "@metaobjectsdev/metadata";
1515
import { projectionViewName } from "../projection/extract-view-spec.js";
@@ -126,7 +126,7 @@ export function renderProjectionDecl(
126126
const superName = superModel?.name ?? projection.superRef;
127127
if (superName) {
128128
const baseObj =
129-
superModel instanceof MetaObject ? superModel : root.findObject(superName);
129+
isMetaObject(superModel) ? superModel : root.findObject(superName);
130130
if (baseObj) {
131131
// fields() returns effective fields, so inherited fields (from extends:/super:) are included.
132132
for (const f of baseObj.fields()) allFields.push(f);

server/typescript/packages/metadata/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ export type { AttrValue } from "./shared/meta-data.js";
4848
// Shared node classes
4949
export { MetaRoot } from "./shared/meta-root.js";
5050

51+
// Cross-realm node guards — identify a node by metamodel `type`, not `instanceof`.
52+
// Cross-package callers (codegen-ts / migrate-ts / runtime-ts) MUST use these:
53+
// `instanceof` silently fails when two physical copies of this package are
54+
// loaded. See shared/node-guards.ts for the mechanism.
55+
export {
56+
isMetaRoot,
57+
isMetaObject,
58+
isMetaField,
59+
isMetaSource,
60+
isWritableSource,
61+
isReadOnlySource,
62+
} from "./shared/node-guards.js";
63+
5164
// Core node classes
5265
export { MetaObject } from "./core/object/meta-object.js";
5366
export { MetaField } from "./core/field/meta-field.js";

server/typescript/packages/metadata/src/naming.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import {
66
SOURCE_ATTR_SCHEMA,
77
SOURCE_ROLE_PRIMARY,
88
} from "./persistence/source/source-constants.js";
9-
import { MetaSource } from "./persistence/source/meta-source.js";
9+
import type { MetaSource } from "./persistence/source/meta-source.js";
10+
// isMetaSource, not `instanceof`: unlike the loader-internal validators, these two
11+
// are EXPORTED helpers that run on a caller-supplied node — migrate-ts calls both
12+
// on nodes the CLI's loader built. Under a split @metaobjectsdev/metadata tree the
13+
// class check would be false for a real primary source, and resolveTableName would
14+
// silently fall through to the entity-name fallback: a DIFFERENT table name, which
15+
// migrate then emits as a rename against a live database.
16+
import { isMetaSource } from "./shared/node-guards.js";
1017

1118
/**
1219
* Strip the package prefix from a metadata-qualified name
@@ -72,7 +79,7 @@ export function resolveTableName(entity: MetaData): string {
7279
// entity-name fallback. For an entity declaring its own source, own shadows
7380
// inherited, so the result is unchanged.
7481
const source = entity.children().find(
75-
(c): c is MetaSource => c instanceof MetaSource && c.role === SOURCE_ROLE_PRIMARY,
82+
(c): c is MetaSource => isMetaSource(c) && c.role === SOURCE_ROLE_PRIMARY,
7683
);
7784
if (source !== undefined) return source.physicalName;
7885
return pluralize(toSnakeCase(entity.name));
@@ -98,8 +105,7 @@ export function resolveColumnName(
98105
export function resolveTableSchema(entity: MetaData): string | undefined {
99106
// ADR-0039: resolving — a concrete entity may inherit its source.rdb via extends.
100107
const source = entity.children().find(
101-
(c): c is MetaSource =>
102-
c instanceof MetaSource && c.role === SOURCE_ROLE_PRIMARY,
108+
(c): c is MetaSource => isMetaSource(c) && c.role === SOURCE_ROLE_PRIMARY,
103109
);
104110
if (!source) return undefined;
105111
// ADR-0039: resolving — an inherited source's @schema lives on the super node.

0 commit comments

Comments
 (0)