Skip to content

Commit 400c53f

Browse files
dmealingclaude
andcommitted
fix(codegen-ts): detect source.rdb structurally, not by instanceof MetaSource
`hasAnyRdbSource` / `hasWritableRdbSource` identified a source by `child instanceof MetaSource`. That is the same class-identity assumption that split ts-poet's Code objects in 0.21.6: two physical copies of @metaobjectsdev/metadata in one process give the loader's nodes a different MetaSource class object than these helpers close over, so `instanceof` is false for a node that is a source.rdb in every observable respect. Here the consequence is silent rather than loud. The entity reads as "not backed by any store", so the table, queries and routes tiers all emit nothing for it and nothing errors — the adopter gets a successful `meta gen` with an entity missing from the output. `meta gen` aliases @metaobjectsdev/metadata to the CLI's own copy (load-metaobjects-config.ts CLI_PKG_PATHS), which is why this has never fired in practice. But that alias map runs only for the CLI path: a consumer embedding runGen() programmatically never executes it, so the helpers should not depend on it. Both now gate on type/subType, and writability is read through the node's own surface rather than through the class. Behaviour is unchanged on a single-copy tree — the registry maps TYPE_SOURCE to MetaSource, so every source.rdb child already satisfied the old guard. A node that cannot answer isWritable() is still not counted as writable. Gated by test/source-detect.test.ts, which models a foreign-realm source as a MetaData subclass with the same type/subType and writability surface (instanceof false, everything else identical). Verified red without the fix: hasAnyRdbSource and hasWritableRdbSource both returned false. The suite also pins the two directions the relaxation could overshoot — a read-only kind stays non-writable, and a non-source child is not mistaken for a source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS
1 parent ac61bc1 commit 400c53f

2 files changed

Lines changed: 105 additions & 11 deletions

File tree

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

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,41 @@
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 { MetaSource } from "@metaobjectsdev/metadata";
109
import { TYPE_SOURCE, SOURCE_SUBTYPE_RDB } from "@metaobjectsdev/metadata";
11-
import type { MetaObject } from "@metaobjectsdev/metadata";
10+
import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
11+
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. */
30+
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();
43+
}
1244

1345
/**
1446
* True when the entity declares at least one writable source.rdb child.
@@ -22,10 +54,8 @@ export function hasWritableRdbSource(entity: MetaObject): boolean {
2254
// (the JVM port's "entity inheriting its source emitted NOTHING" bug); own-only
2355
// would suppress the Drizzle table for such an entity.
2456
for (const child of entity.children()) {
25-
if (child.type !== TYPE_SOURCE) continue;
26-
if (child.subType !== SOURCE_SUBTYPE_RDB) continue;
27-
if (!(child instanceof MetaSource)) continue;
28-
if (child.isWritable()) return true;
57+
if (!isRdbSource(child)) continue;
58+
if (reportsWritable(child)) return true;
2959
}
3060
return false;
3161
}
@@ -42,10 +72,7 @@ export function hasWritableRdbSource(entity: MetaObject): boolean {
4272
export function hasAnyRdbSource(entity: MetaObject): boolean {
4373
// ADR-0039: resolving — same rationale as hasWritableRdbSource.
4474
for (const child of entity.children()) {
45-
if (child.type !== TYPE_SOURCE) continue;
46-
if (child.subType !== SOURCE_SUBTYPE_RDB) continue;
47-
if (!(child instanceof MetaSource)) continue;
48-
return true;
75+
if (isRdbSource(child)) return true;
4976
}
5077
return false;
5178
}

‎server/typescript/packages/codegen-ts/test/source-detect.test.ts‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test";
22
import {
33
TypeId,
44
TYPE_SOURCE,
5+
TYPE_FIELD,
56
SOURCE_SUBTYPE_RDB,
67
SOURCE_ATTR_KIND,
78
SOURCE_ATTR_TABLE,
@@ -10,8 +11,9 @@ import {
1011
OBJECT_SUBTYPE_ENTITY,
1112
OBJECT_SUBTYPE_VALUE,
1213
} from "@metaobjectsdev/metadata";
14+
import { MetaData } from "@metaobjectsdev/metadata";
1315
import { meta, metaObject } from "./_meta-build.js";
14-
import { hasWritableRdbSource } from "../src/source-detect.js";
16+
import { hasWritableRdbSource, hasAnyRdbSource } from "../src/source-detect.js";
1517

1618
describe("hasWritableRdbSource", () => {
1719
test("returns false for an object.value with no source children", () => {
@@ -58,3 +60,68 @@ describe("hasWritableRdbSource", () => {
5860
expect(hasWritableRdbSource(entity)).toBe(true);
5961
});
6062
});
63+
64+
// ---------------------------------------------------------------------------
65+
// Cross-realm (split node_modules tree) — the ts-poet defect class of 0.21.6,
66+
// applied to @metaobjectsdev/metadata. Two physical copies of the package give
67+
// the loader's source nodes a DIFFERENT MetaSource class object than the one
68+
// these helpers close over, so `instanceof MetaSource` is false for a node that
69+
// is a source.rdb in every observable respect.
70+
//
71+
// The CLI closes this by aliasing @metaobjectsdev/metadata to its own copy
72+
// (load-metaobjects-config.ts CLI_PKG_PATHS) — but that alias map runs only for
73+
// `meta gen`; a consumer embedding runGen()/codegen-ts programmatically never
74+
// executes it. The failure is SILENT and severe: the entity reads as "not backed
75+
// by any store", so no Drizzle table, no queries and no routes are emitted for
76+
// it, and nothing errors.
77+
//
78+
// A foreign-realm node is modelled as a MetaData subclass carrying the same
79+
// type/subType and writability surface — which reproduces exactly the observable
80+
// condition (instanceof false, everything else identical) without planting a
81+
// second physical copy of the package on disk.
82+
class ForeignRealmSource extends MetaData {
83+
isReadOnly(): boolean {
84+
return this.attr(SOURCE_ATTR_KIND) === SOURCE_KIND_VIEW;
85+
}
86+
isWritable(): boolean {
87+
return !this.isReadOnly();
88+
}
89+
}
90+
91+
function foreignRdbSource(name: string, kind: string): ForeignRealmSource {
92+
const src = new ForeignRealmSource(new TypeId(TYPE_SOURCE, SOURCE_SUBTYPE_RDB), name);
93+
src.setAttr(SOURCE_ATTR_KIND, kind);
94+
return src;
95+
}
96+
97+
describe("source detection survives a split @metaobjectsdev/metadata tree", () => {
98+
test("hasAnyRdbSource sees a source.rdb built by a second physical copy", () => {
99+
const entity = metaObject(OBJECT_SUBTYPE_ENTITY, "Council");
100+
entity.addChild(foreignRdbSource("source", SOURCE_KIND_TABLE));
101+
expect(hasAnyRdbSource(entity)).toBe(true);
102+
});
103+
104+
test("hasWritableRdbSource sees a writable source.rdb from a second copy", () => {
105+
const entity = metaObject(OBJECT_SUBTYPE_ENTITY, "Council");
106+
entity.addChild(foreignRdbSource("source", SOURCE_KIND_TABLE));
107+
expect(hasWritableRdbSource(entity)).toBe(true);
108+
});
109+
110+
test("a read-only kind from a second copy is still not writable", () => {
111+
// The relaxed guard must not turn every foreign source into a writable one —
112+
// read-only kinds stay read-only, so the view/table split is still honoured.
113+
const entity = metaObject(OBJECT_SUBTYPE_ENTITY, "CouncilView");
114+
entity.addChild(foreignRdbSource("source", SOURCE_KIND_VIEW));
115+
expect(hasAnyRdbSource(entity)).toBe(true);
116+
expect(hasWritableRdbSource(entity)).toBe(false);
117+
});
118+
119+
test("a non-source child is not mistaken for one", () => {
120+
// Guards the structural check: type/subType still gate, so an unrelated
121+
// node that happens to expose isWritable() is not counted as a source.
122+
const entity = metaObject(OBJECT_SUBTYPE_ENTITY, "Council");
123+
entity.addChild(meta(new TypeId(TYPE_FIELD, "string"), "name"));
124+
expect(hasAnyRdbSource(entity)).toBe(false);
125+
expect(hasWritableRdbSource(entity)).toBe(false);
126+
});
127+
});

0 commit comments

Comments
 (0)