Skip to content

Commit 8a15848

Browse files
committed
fix(objectql): the cascade skips a federated object's injected tenant anchor
The registry injects `organization_id` (a lookup to `sys_organization`) into every object it registers, federated ones included, and the platform provisions no storage for a federated object. The cascade scan probed the remote table on that column, the driver refused the unknown column, and every organization delete answered 500 on the showcase once its federated fixture existed. Both cascade walks now ask one predicate, `isFederatedInjectedTenantAnchor`: the column is `organization_id`, the object is federated by `isFederatedObject`, and the field is the platform's own definition by the injected-column provenance marker. An author-declared `organization_id` and any other author lookup on a federated object stay in the scan, and the probe's catch is unchanged. The atomicity plan asks the same predicate so it keeps the scan's participant set. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6dc7a2 commit 8a15848

5 files changed

Lines changed: 481 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
Deleting an organization no longer fails with a 500 on a deployment that has a federated (ADR-0015 `external`) object bound. The engine's referential cascade no longer treats the `organization_id` the platform injects into a federated object as a reference to `sys_organization`.
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** The registry injects `organization_id` into every object, federated ones included, and the platform provisions no storage for a federated object. The cascade's dependents probe filtered the remote table on that column, the SQL driver refused the unknown column (`INVALID_FILTER`), and the probe's failure propagated, so the delete failed. The showcase, with its federated fixture provisioned, answered every organization delete with 500.
10+
- **What changed.** The cascade scan skips a federated object's injected tenant anchor. It asks the same `isFederatedObject` predicate as the driver-option builder and the related-record read, plus the injected-column provenance marker, so an `organization_id` the author declared on a federated object is still probed. The cascade's atomicity plan asks the same question, so an organization delete on such a deployment now runs as one transaction instead of being reported as a cross-datasource cascade.
11+
- **What did not change.** Any lookup an author declares on a federated object is still probed, and a probe that cannot run still fails the delete. Only a missing child table is passed over as having no dependents.
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21910] The referential cascade does not treat a federated object's
5+
* platform-INJECTED `organization_id` as a reference to `sys_organization`,
6+
* and treats nothing else that way.
7+
*
8+
* The registry injects the tenant anchor (`organization_id`, a lookup to
9+
* `sys_organization`) into every object it registers, ADR-0015 `external` ones
10+
* included, and the platform provisions no storage for a federated object. On
11+
* the showcase, deleting an organization ran the cascade scan's dependents
12+
* probe against the remote `customers` table on that column. The SQL driver
13+
* refused it (`INVALID_FILTER`, no such column), the probe's #8895 catch
14+
* propagated the refusal, and the organization delete answered 500.
15+
*
16+
* What this file pins, all through `engine.delete` on a two-driver engine (the
17+
* default one, and the remote a federated object is bound to by `datasource`):
18+
*
19+
* 1. the scan never reads a federated object on its injected anchor, so an
20+
* organization delete lands while that read would be refused. A local
21+
* object's injected anchor IS read on the same delete, which proves the
22+
* scan ran;
23+
* 2. an `organization_id` the AUTHOR declared on a federated object is still
24+
* probed, and a probe failure still propagates (#8895);
25+
* 3. any other lookup the author declares on a federated object is still
26+
* probed, and a probe failure still propagates (#8895);
27+
* 4. the cascade's atomicity plan agrees with the scan: an organization delete
28+
* whose only cross-datasource "participant" was the injected anchor runs
29+
* as one transaction, while an author-declared federated lookup still
30+
* makes the plan cross-datasource.
31+
*
32+
* The seed rows are written straight into the stub's store, so no write path
33+
* other than the delete under test runs. The door pin is
34+
* `packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts`.
35+
*/
36+
37+
import { describe, it, expect } from 'vitest';
38+
import { resolveInjectedColumnProvenance } from '@objectstack/spec/data';
39+
import { ObjectQL } from './engine.js';
40+
41+
/** The remote datasource every federated fixture below is bound to. */
42+
const REMOTE = 'remote_ds';
43+
const PACKAGE_ID = 'test-21910';
44+
45+
type Rows = Map<string, Map<string, Record<string, unknown>>>;
46+
47+
/**
48+
* A stub driver that records every read into a shared log and can be told to
49+
* refuse reads of one object with an exact error object.
50+
*/
51+
function makeDriver(name: string, log: { reads: string[]; begun: number }) {
52+
const stores: Rows = new Map();
53+
const failReads = new Map<string, unknown>();
54+
const storeFor = (o: string) => {
55+
let s = stores.get(o);
56+
if (!s) { s = new Map(); stores.set(o, s); }
57+
return s;
58+
};
59+
const matches = (row: Record<string, unknown>, where: any): boolean => {
60+
if (!where || typeof where !== 'object') return true;
61+
for (const [k, v] of Object.entries(where)) {
62+
if (k.startsWith('$')) continue;
63+
const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
64+
if ((row[k] ?? null) !== (exp ?? null)) return false;
65+
}
66+
return true;
67+
};
68+
const gate = (o: string) => {
69+
log.reads.push(o);
70+
if (failReads.has(o)) throw failReads.get(o);
71+
};
72+
const driver: any = {
73+
name, version: '0.0.0', supports: {},
74+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
75+
async syncSchema() {},
76+
registerExternalObject() {},
77+
async find(o: string, ast: any) {
78+
gate(o);
79+
return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where));
80+
},
81+
async findOne(o: string, ast: any) {
82+
gate(o);
83+
for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r;
84+
return null;
85+
},
86+
async count(o: string, ast: any) {
87+
gate(o);
88+
return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length;
89+
},
90+
async create(o: string, data: Record<string, unknown>) {
91+
const row = { ...data, id: String(data.id) };
92+
storeFor(o).set(row.id, row);
93+
return row;
94+
},
95+
async update(o: string, id: string, data: Record<string, unknown>) {
96+
const cur = storeFor(o).get(String(id));
97+
if (!cur) throw new Error(`not found ${o}/${id}`);
98+
const up = { ...cur, ...data, id: String(id) };
99+
storeFor(o).set(String(id), up);
100+
return up;
101+
},
102+
async upsert(o: string, data: Record<string, unknown>) { return this.create(o, data); },
103+
async delete(o: string, id: string) { return storeFor(o).delete(String(id)); },
104+
async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {},
105+
async beginTransaction() { log.begun += 1; return { id: `trx_${log.begun}` }; },
106+
async commit() {}, async rollback() {},
107+
};
108+
return { driver, stores, failReads, seed: (o: string, row: Record<string, unknown>) => storeFor(o).set(String(row.id), row) };
109+
}
110+
111+
/** The deleted object. Its own injected anchor makes it self-referencing, harmlessly. */
112+
const ORGANIZATION = {
113+
name: 'sys_organization',
114+
label: 'Organization',
115+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
116+
};
117+
118+
/** A LOCAL object: the registry injects `organization_id`, and storage backs it. */
119+
const LOCAL = {
120+
name: 'acct',
121+
label: 'Account',
122+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
123+
};
124+
125+
/** Federated, as the showcase declares it: no `organization_id` of its own. */
126+
const FEDERATED = {
127+
name: 'ext_customer',
128+
label: 'External Customer',
129+
datasource: REMOTE,
130+
external: { remoteName: 'customers' },
131+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
132+
};
133+
134+
/** Federated, with an `organization_id` the AUTHOR declared: it maps a real remote column. */
135+
const FEDERATED_DECLARED_ANCHOR = {
136+
name: 'ext_tenant_customer',
137+
label: 'External Tenant Customer',
138+
datasource: REMOTE,
139+
external: { remoteName: 'tenant_customers' },
140+
fields: {
141+
name: { name: 'name', label: 'Name', type: 'text' as const },
142+
organization_id: {
143+
name: 'organization_id',
144+
label: 'Remote Organization',
145+
type: 'lookup' as const,
146+
reference: 'sys_organization',
147+
},
148+
},
149+
};
150+
151+
/** Federated, with another lookup the author declared against the organization. */
152+
const FEDERATED_AUTHOR_LOOKUP = {
153+
name: 'ext_order',
154+
label: 'External Order',
155+
datasource: REMOTE,
156+
external: { remoteName: 'orders' },
157+
fields: {
158+
amount: { name: 'amount', label: 'Amount', type: 'number' as const },
159+
org_ref: { name: 'org_ref', label: 'Organization', type: 'lookup' as const, reference: 'sys_organization' },
160+
},
161+
};
162+
163+
const ORG_ID = 'org_21910';
164+
165+
/** The refusal the SQL driver answers for a filter on a column the remote does not have. */
166+
function unknownColumnRefusal(object: string, column: string) {
167+
return Object.assign(
168+
new Error(`A filter on object '${object}' names a column the database could not resolve (${column}).`),
169+
{ code: 'INVALID_FILTER', status: 400 },
170+
);
171+
}
172+
173+
async function makeEngine(objects: any[]) {
174+
const log = { reads: [] as string[], begun: 0 };
175+
const warnings: string[] = [];
176+
const logger = {
177+
debug() {}, info() {}, error() {},
178+
warn: (message: unknown) => void warnings.push(String(message)),
179+
};
180+
const engine = new ObjectQL({ logger } as any);
181+
const local = makeDriver('memory', log);
182+
const remote = makeDriver(REMOTE, log);
183+
engine.registerDriver(local.driver, true);
184+
engine.registerDriver(remote.driver);
185+
await engine.init();
186+
for (const o of objects) engine.registry.registerObject(o, PACKAGE_ID);
187+
local.seed('sys_organization', { id: ORG_ID, name: 'Doomed Org' });
188+
return { engine, local, remote, log, warnings };
189+
}
190+
191+
const NOT_ATOMIC = 'cannot run as one unit of work';
192+
193+
describe('[#21910] the cascade scan skips a federated object\'s injected tenant anchor, and nothing else', () => {
194+
it('never probes a federated object on its injected organization_id, so the organization delete lands', async () => {
195+
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]);
196+
// PREMISE: the registered schema carries the platform's injected anchor.
197+
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_customer'), 'organization_id'))
198+
.toBe('injected-unprovisioned');
199+
// Any read of the remote table on that column is refused, as the showcase measured.
200+
remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'organization_id'));
201+
202+
log.reads.length = 0;
203+
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);
204+
205+
expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(false);
206+
expect(log.reads).not.toContain('ext_customer');
207+
// CONTROL: the scan ran for this delete, and probed the local object's injected anchor.
208+
expect(log.reads).toContain('acct');
209+
});
210+
211+
it('still probes an organization_id the AUTHOR declared on a federated object, and its failure propagates (#8895)', async () => {
212+
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_DECLARED_ANCHOR]);
213+
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_tenant_customer'), 'organization_id'))
214+
.toBe('author');
215+
const injected = unknownColumnRefusal('ext_tenant_customer', 'organization_id');
216+
remote.failReads.set('ext_tenant_customer', injected);
217+
218+
log.reads.length = 0;
219+
const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e);
220+
221+
expect(err).toBe(injected);
222+
expect(err.code).toBe('INVALID_FILTER');
223+
expect(err.status).toBe(400);
224+
expect(log.reads).toContain('ext_tenant_customer');
225+
expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(true);
226+
});
227+
228+
it('still probes any other lookup the author declared on a federated object, and its failure propagates (#8895)', async () => {
229+
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_AUTHOR_LOOKUP]);
230+
const injected = unknownColumnRefusal('ext_order', 'org_ref');
231+
remote.failReads.set('ext_order', injected);
232+
233+
log.reads.length = 0;
234+
const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e);
235+
236+
expect(err).toBe(injected);
237+
expect(err.code).toBe('INVALID_FILTER');
238+
expect(err.status).toBe(400);
239+
expect(log.reads).toContain('ext_order');
240+
expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(true);
241+
});
242+
});
243+
244+
describe('[#21910] the cascade atomicity plan agrees with the scan about who takes part', () => {
245+
it('runs the organization delete as one transaction when the injected anchor was its only cross-datasource reference', async () => {
246+
const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]);
247+
248+
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);
249+
250+
expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(false);
251+
expect(log.begun).toBe(1);
252+
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]);
253+
});
254+
255+
it('CONTROL: an author-declared lookup on a federated object still makes the plan cross-datasource', async () => {
256+
const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED_AUTHOR_LOOKUP]);
257+
258+
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);
259+
260+
expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(false);
261+
expect(log.reads).toContain('ext_order');
262+
expect(log.begun).toBe(0);
263+
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toHaveLength(1);
264+
});
265+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,8 @@ import {
304304
withDeclaredColumnsOnly,
305305
} from './declared-read-columns.js';
306306
// [#21777] "Is this schema the remote's?" One predicate, shared with the boot sync.
307-
import { isFederatedObject } from './federated-object.js';
307+
// [#21910] And its tenant-anchor refinement, which both cascade walks ask.
308+
import { isFederatedObject, isFederatedInjectedTenantAnchor } from './federated-object.js';
308309
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
309310
import {
310311
resolveEngineDeleteDispatch,
@@ -15832,7 +15833,7 @@ export class ObjectQL implements IObjectQLEngine {
1583215833
const childName = (child as any)?.name as string | undefined;
1583315834
const fields = (child as any)?.fields as Record<string, any> | undefined;
1583415835
if (!childName || !fields) continue;
15835-
for (const fdef of Object.values(fields)) {
15836+
for (const [fieldName, fdef] of Object.entries(fields)) {
1583615837
if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue;
1583715838
// [#18550] The carrier is read through the ONE arbiter, so a
1583815839
// `reference` no reader can read REFUSES here instead of reading as
@@ -15856,6 +15857,13 @@ export class ObjectQL implements IObjectQLEngine {
1585615857
let resolvedRef: string | undefined;
1585715858
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
1585815859
if (ref !== name && resolvedRef !== name) continue;
15860+
// [#21910] The scan skips a federated object's injected tenant
15861+
// anchor, so this walk does too, and the participant set stays the
15862+
// scan's. Counting it read every organization delete on a deployment
15863+
// with a federated object bound as cross-datasource (`'split'`): the
15864+
// cascade ran unwrapped, and the warning named a datasource the
15865+
// cascade never touches.
15866+
if (isFederatedInjectedTenantAnchor(child, fieldName)) continue;
1585915867
out.push(childName);
1586015868
break;
1586115869
}
@@ -16324,6 +16332,26 @@ export class ObjectQL implements IObjectQLEngine {
1632416332
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
1632516333
if (ref !== object && resolvedRef !== object) continue;
1632616334

16335+
// [#21910] A federated object's platform-INJECTED tenant anchor is not
16336+
// a reference to `sys_organization`, so it is not a relation to probe.
16337+
// On a federated object that column exists in the registered schema
16338+
// and nowhere else: the probe below was refused by the driver
16339+
// (`INVALID_FILTER`, no such column), its catch propagated the refusal
16340+
// as #8895 rules for a missing column, and every organization delete
16341+
// answered 500 on a deployment with a federated object bound.
16342+
// `buildDriverOptions` and the related-record read already refuse this
16343+
// reading of the same column. {@link isFederatedInjectedTenantAnchor}
16344+
// says why it is exactly that column, and
16345+
// {@link ObjectQL.planCascadeAtomicity} asks it too.
16346+
//
16347+
// ⛔ The catch below is deliberately NOT widened to pass a missing
16348+
// column as benign. That would invert #8895's discriminate or
16349+
// propagate for every object, not just this injected column: an
16350+
// `organization_id` the author declared on a federated object, and any
16351+
// other lookup the author declares on one, stay in the scan, and their
16352+
// probe failures still propagate.
16353+
if (isFederatedInjectedTenantAnchor(child, fieldName)) continue;
16354+
1632716355
// A master-detail parent owns its children: cascade by default (the
1632816356
// child FK is typically required, so set_null would be invalid). Only
1632916357
// an explicit `restrict` deviates. A plain lookup honors its

‎packages/objectql/src/federated-object.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { resolveInjectedColumnProvenance } from '@objectstack/spec/data';
4+
import { DEFAULT_TENANT_FIELD } from './tenancy/system-write-organization.js';
5+
36
/**
47
* Is `schema` a federated object (ADR-0015 `external`), one whose schema is
58
* owned by the REMOTE database?
@@ -31,3 +34,42 @@
3134
export function isFederatedObject(schema: unknown): boolean {
3235
return (schema as { external?: unknown } | null | undefined)?.external != null;
3336
}
37+
38+
/**
39+
* [#21910] Is `fieldName` a federated object's platform-INJECTED tenant
40+
* anchor: the `organization_id` lookup to `sys_organization` that the
41+
* registry adds and the remote table does not have?
42+
*
43+
* `applySystemFields` injects `organization_id` into every object it
44+
* registers, ADR-0015 `external` ones included (the #7865 ruling, direction
45+
* B), and the platform provisions no storage for a federated object. So on
46+
* one, that column exists in the registered schema and nowhere else, and it
47+
* is never a reference to an organization: no remote row can hold one. The
48+
* engine's referential cascade asks this in both of its walks, so the two
49+
* cannot disagree about which objects take part in an organization delete:
50+
*
51+
* - `ObjectQL.cascadeDeleteRelations` does not probe the remote table on it
52+
* (the probe was refused as an unknown column, and every organization
53+
* delete answered 500);
54+
* - `ObjectQL.planCascadeAtomicity` does not count the federated object as a
55+
* participant on another datasource.
56+
*
57+
* Three conjuncts, and each one is the narrowing:
58+
*
59+
* - the column is the tenant anchor, `organization_id`. The other anchors
60+
* the registry injects (`owner_id`, `created_by`, ...) are not this
61+
* question;
62+
* - the object is federated, by {@link isFederatedObject}, the same predicate
63+
* `buildDriverOptions` and the related-record read ask;
64+
* - the field is the platform's own definition, by the #7865 provenance
65+
* marker. An `organization_id` the author declared answers `'author'` and
66+
* stays a relation: it may map a real remote column, and its probe keeps
67+
* #8895's discriminate or propagate.
68+
*/
69+
export function isFederatedInjectedTenantAnchor(schema: unknown, fieldName: string): boolean {
70+
return (
71+
fieldName === DEFAULT_TENANT_FIELD &&
72+
isFederatedObject(schema) &&
73+
resolveInjectedColumnProvenance(schema, fieldName) === 'injected-unprovisioned'
74+
);
75+
}

0 commit comments

Comments
 (0)