Skip to content

Commit 89ced04

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21911-principal-less-producers-engine
2 parents 3d50c58 + faf8dce commit 89ced04

15 files changed

Lines changed: 1256 additions & 59 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/service-datasource': patch
4+
---
5+
6+
An import over a code-defined datasource is held to the namespace of the package that declares it (ADR-0028), and the draft door answers the prefixed name (#21889).
7+
8+
Clause-②: no
9+
10+
- **Before.** `POST /api/v1/datasources/:name/external/tables/:remote/import` with an explicit `name` that carried no namespace prefix answered `201` and saved an unprefixed federated object, and `POST …/external/tables/:remote/draft` answered the bare remote table name with a `TODO(namespace)` note. Measured on the showcase's `showcase_external` under `objectstack dev` and `objectstack start`.
11+
- **`@objectstack/runtime`.** `AppPlugin` registers each code-defined datasource through `applyProtection` with the id and version of the package body that declares it, so the item carries `_packageId`, `_packageVersion` and `_provenance: 'package'`. On an ADR-0130 `packages[]` artifact each datasource takes its own body's id, never the artifact's top-level manifest id. A top-level datasource that no body declares keeps its registration under the artifact's own id, and a warning names it.
12+
- **`@objectstack/service-datasource`.** The federation service reads the datasource's package record from the engine registry (`registry.getPackage` on the `objectql` service), the store the runtime publish gate reads for the same check. It used to ask the `metadata` service, which holds no package records in any composition, so no datasource resolved a namespace. The package id still comes only from the stamped `_packageId`.
13+
- **What a caller sees now.** On a datasource whose package declares `manifest.namespace`, an unprefixed import `name` answers `400 EXTERNAL_IMPORT_ERROR` with ADR-0028's message, which names the prefixed name to use. An import with no `name` override saves the prefixed name the draft derives (for example `showcase_customers` instead of `customers`). `GET /api/v1/meta/datasource` lists the three provenance keys on a code-defined datasource; all three are declared on `DatasourceSchema`. The datasource admin list (`GET /api/v1/datasources`) is unchanged, and the admin door still refuses to edit or remove a code-defined datasource.
14+
- **Unchanged.** A datasource that carries no `_packageId` (the host `default` is one) and a package that declares no namespace resolve no namespace, so their imports and drafts answer as before.
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 it keeps counting exactly the relations the scan probes.
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: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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 Row = 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. Its `find` applies
50+
* the caller's `limit` after the filter, by presence.
51+
*/
52+
function makeDriver(name: string, log: { reads: string[]; begun: number }) {
53+
const tables: Record<string, Row[]> = {};
54+
const failReads = new Map<string, unknown>();
55+
const rowsOf = (o: string): Row[] => (tables[o] ??= []);
56+
const matches = (row: Row, where: any): boolean => {
57+
if (!where || typeof where !== 'object') return true;
58+
for (const [k, v] of Object.entries(where)) {
59+
if (k.startsWith('$')) continue;
60+
const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
61+
if ((row[k] ?? null) !== (exp ?? null)) return false;
62+
}
63+
return true;
64+
};
65+
const driver: any = {
66+
name, version: '0.0.0', supports: {},
67+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
68+
async syncSchema() {},
69+
registerExternalObject() {},
70+
async find(o: string, ast: any) {
71+
log.reads.push(o);
72+
const failure = failReads.get(o);
73+
if (failure !== undefined) throw failure;
74+
const hit = (tables[o] ?? []).filter((r) => matches(r, ast?.where));
75+
return typeof ast?.limit === 'number' ? hit.slice(0, ast.limit) : hit;
76+
},
77+
async findOne(o: string, ast: any) {
78+
const [first] = await this.find(o, { ...ast, limit: 1 });
79+
return first ?? null;
80+
},
81+
async count(o: string, ast: any) {
82+
return (await this.find(o, { where: ast?.where })).length;
83+
},
84+
async create(o: string, data: Row) {
85+
const row = { ...data, id: String(data.id) };
86+
rowsOf(o).push(row);
87+
return row;
88+
},
89+
async update(o: string, id: string, data: Row) {
90+
const rows = rowsOf(o);
91+
const at = rows.findIndex((r) => r.id === String(id));
92+
if (at < 0) throw new Error(`not found ${o}/${id}`);
93+
rows[at] = { ...rows[at], ...data, id: String(id) };
94+
return rows[at];
95+
},
96+
async upsert(o: string, data: Row) { return this.create(o, data); },
97+
async delete(o: string, id: string) {
98+
const rows = rowsOf(o);
99+
const at = rows.findIndex((r) => r.id === String(id));
100+
if (at < 0) return false;
101+
rows.splice(at, 1);
102+
return true;
103+
},
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 {
109+
driver,
110+
failReads,
111+
has: (o: string, id: string) => rowsOf(o).some((r) => r.id === id),
112+
seed: (o: string, row: Row) => void rowsOf(o).push(row),
113+
};
114+
}
115+
116+
/** The deleted object. Its own injected anchor makes it self-referencing, harmlessly. */
117+
const ORGANIZATION = {
118+
name: 'sys_organization',
119+
label: 'Organization',
120+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
121+
};
122+
123+
/** A LOCAL object: the registry injects `organization_id`, and storage backs it. */
124+
const LOCAL = {
125+
name: 'acct',
126+
label: 'Account',
127+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
128+
};
129+
130+
/** Federated, as the showcase declares it: no `organization_id` of its own. */
131+
const FEDERATED = {
132+
name: 'ext_customer',
133+
label: 'External Customer',
134+
datasource: REMOTE,
135+
external: { remoteName: 'customers' },
136+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
137+
};
138+
139+
/** Federated, with an `organization_id` the AUTHOR declared: it maps a real remote column. */
140+
const FEDERATED_DECLARED_ANCHOR = {
141+
name: 'ext_tenant_customer',
142+
label: 'External Tenant Customer',
143+
datasource: REMOTE,
144+
external: { remoteName: 'tenant_customers' },
145+
fields: {
146+
name: { name: 'name', label: 'Name', type: 'text' as const },
147+
organization_id: {
148+
name: 'organization_id',
149+
label: 'Remote Organization',
150+
type: 'lookup' as const,
151+
reference: 'sys_organization',
152+
},
153+
},
154+
};
155+
156+
/** Federated, with another lookup the author declared against the organization. */
157+
const FEDERATED_AUTHOR_LOOKUP = {
158+
name: 'ext_order',
159+
label: 'External Order',
160+
datasource: REMOTE,
161+
external: { remoteName: 'orders' },
162+
fields: {
163+
amount: { name: 'amount', label: 'Amount', type: 'number' as const },
164+
org_ref: { name: 'org_ref', label: 'Organization', type: 'lookup' as const, reference: 'sys_organization' },
165+
},
166+
};
167+
168+
const ORG_ID = 'org_21910';
169+
170+
/** The refusal the SQL driver answers for a filter on a column the remote does not have. */
171+
function unknownColumnRefusal(object: string, column: string) {
172+
return Object.assign(
173+
new Error(`A filter on object '${object}' names a column the database could not resolve (${column}).`),
174+
{ code: 'INVALID_FILTER', status: 400 },
175+
);
176+
}
177+
178+
async function makeEngine(objects: any[]) {
179+
const log = { reads: [] as string[], begun: 0 };
180+
const warnings: string[] = [];
181+
const logger = {
182+
debug() {}, info() {}, error() {},
183+
warn: (message: unknown) => void warnings.push(String(message)),
184+
};
185+
const engine = new ObjectQL({ logger } as any);
186+
const local = makeDriver('memory', log);
187+
const remote = makeDriver(REMOTE, log);
188+
engine.registerDriver(local.driver, true);
189+
engine.registerDriver(remote.driver);
190+
await engine.init();
191+
for (const o of objects) engine.registry.registerObject(o, PACKAGE_ID);
192+
local.seed('sys_organization', { id: ORG_ID, name: 'Doomed Org' });
193+
return { engine, local, remote, log, warnings };
194+
}
195+
196+
const NOT_ATOMIC = 'cannot run as one unit of work';
197+
198+
describe('[#21910] the cascade scan skips a federated object\'s injected tenant anchor, and nothing else', () => {
199+
it('never probes a federated object on its injected organization_id, so the organization delete lands', async () => {
200+
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]);
201+
// PREMISE: the registered schema carries the platform's injected anchor.
202+
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_customer'), 'organization_id'))
203+
.toBe('injected-unprovisioned');
204+
// Any read of the remote table on that column is refused, as the showcase measured.
205+
remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'organization_id'));
206+
207+
log.reads.length = 0;
208+
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);
209+
210+
expect(local.has('sys_organization', ORG_ID)).toBe(false);
211+
expect(log.reads).not.toContain('ext_customer');
212+
// CONTROL: the scan ran for this delete, and probed the local object's injected anchor.
213+
expect(log.reads).toContain('acct');
214+
});
215+
216+
it('still probes an organization_id the AUTHOR declared on a federated object, and its failure propagates (#8895)', async () => {
217+
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_DECLARED_ANCHOR]);
218+
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_tenant_customer'), 'organization_id'))
219+
.toBe('author');
220+
const injected = unknownColumnRefusal('ext_tenant_customer', 'organization_id');
221+
remote.failReads.set('ext_tenant_customer', injected);
222+
223+
log.reads.length = 0;
224+
const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e);
225+
226+
expect(err).toBe(injected);
227+
expect(err.code).toBe('INVALID_FILTER');
228+
expect(err.status).toBe(400);
229+
expect(log.reads).toContain('ext_tenant_customer');
230+
expect(local.has('sys_organization', ORG_ID)).toBe(true);
231+
});
232+
233+
it('still probes any other lookup the author declared on a federated object, and its failure propagates (#8895)', async () => {
234+
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_AUTHOR_LOOKUP]);
235+
const injected = unknownColumnRefusal('ext_order', 'org_ref');
236+
remote.failReads.set('ext_order', injected);
237+
238+
log.reads.length = 0;
239+
const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e);
240+
241+
expect(err).toBe(injected);
242+
expect(err.code).toBe('INVALID_FILTER');
243+
expect(err.status).toBe(400);
244+
expect(log.reads).toContain('ext_order');
245+
expect(local.has('sys_organization', ORG_ID)).toBe(true);
246+
});
247+
});
248+
249+
describe('[#21910] the cascade atomicity plan agrees with the scan about who takes part', () => {
250+
it('runs the organization delete as one transaction when the injected anchor was its only cross-datasource reference', async () => {
251+
const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]);
252+
253+
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);
254+
255+
expect(local.has('sys_organization', ORG_ID)).toBe(false);
256+
expect(log.begun).toBe(1);
257+
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]);
258+
});
259+
260+
it('CONTROL: an author-declared lookup on a federated object still makes the plan cross-datasource', async () => {
261+
const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED_AUTHOR_LOOKUP]);
262+
263+
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);
264+
265+
expect(local.has('sys_organization', ORG_ID)).toBe(false);
266+
expect(log.reads).toContain('ext_order');
267+
expect(log.begun).toBe(0);
268+
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toHaveLength(1);
269+
});
270+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 29 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,12 @@ 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: the participant test stays the
15862+
// scan's own, as the comment above requires. A federated object this
15863+
// walk still reaches through any other relation keeps the verdict
15864+
// `'split'`, because the scan probes that relation.
15865+
if (isFederatedInjectedTenantAnchor(child, fieldName)) continue;
1585915866
out.push(childName);
1586015867
break;
1586115868
}
@@ -16324,6 +16331,26 @@ export class ObjectQL implements IObjectQLEngine {
1632416331
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
1632516332
if (ref !== object && resolvedRef !== object) continue;
1632616333

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

0 commit comments

Comments
 (0)