Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/21910-cascade-federated-tenant-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/objectql': patch
---

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`.

Clause-②: no

- **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.
- **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.
- **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.
270 changes: 270 additions & 0 deletions packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#21910] The referential cascade does not treat a federated object's
* platform-INJECTED `organization_id` as a reference to `sys_organization`,
* and treats nothing else that way.
*
* The registry injects the tenant anchor (`organization_id`, a lookup to
* `sys_organization`) into every object it registers, ADR-0015 `external` ones
* included, and the platform provisions no storage for a federated object. On
* the showcase, deleting an organization ran the cascade scan's dependents
* probe against the remote `customers` table on that column. The SQL driver
* refused it (`INVALID_FILTER`, no such column), the probe's #8895 catch
* propagated the refusal, and the organization delete answered 500.
*
* What this file pins, all through `engine.delete` on a two-driver engine (the
* default one, and the remote a federated object is bound to by `datasource`):
*
* 1. the scan never reads a federated object on its injected anchor, so an
* organization delete lands while that read would be refused. A local
* object's injected anchor IS read on the same delete, which proves the
* scan ran;
* 2. an `organization_id` the AUTHOR declared on a federated object is still
* probed, and a probe failure still propagates (#8895);
* 3. any other lookup the author declares on a federated object is still
* probed, and a probe failure still propagates (#8895);
* 4. the cascade's atomicity plan agrees with the scan: an organization delete
* whose only cross-datasource "participant" was the injected anchor runs
* as one transaction, while an author-declared federated lookup still
* makes the plan cross-datasource.
*
* The seed rows are written straight into the stub's store, so no write path
* other than the delete under test runs. The door pin is
* `packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { resolveInjectedColumnProvenance } from '@objectstack/spec/data';
import { ObjectQL } from './engine.js';

/** The remote datasource every federated fixture below is bound to. */
const REMOTE = 'remote_ds';
const PACKAGE_ID = 'test-21910';

type Row = Record<string, unknown>;

/**
* A stub driver that records every read into a shared log and can be told to
* refuse reads of one object with an exact error object. Its `find` applies
* the caller's `limit` after the filter, by presence.
*/
function makeDriver(name: string, log: { reads: string[]; begun: number }) {
const tables: Record<string, Row[]> = {};
const failReads = new Map<string, unknown>();
const rowsOf = (o: string): Row[] => (tables[o] ??= []);
const matches = (row: Row, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
for (const [k, v] of Object.entries(where)) {
if (k.startsWith('$')) continue;
const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
if ((row[k] ?? null) !== (exp ?? null)) return false;
}
return true;
};
const driver: any = {
name, version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
async syncSchema() {},
registerExternalObject() {},
async find(o: string, ast: any) {
log.reads.push(o);
const failure = failReads.get(o);
if (failure !== undefined) throw failure;
const hit = (tables[o] ?? []).filter((r) => matches(r, ast?.where));
return typeof ast?.limit === 'number' ? hit.slice(0, ast.limit) : hit;
},
async findOne(o: string, ast: any) {
const [first] = await this.find(o, { ...ast, limit: 1 });
return first ?? null;
},
async count(o: string, ast: any) {
return (await this.find(o, { where: ast?.where })).length;
},
async create(o: string, data: Row) {
const row = { ...data, id: String(data.id) };
rowsOf(o).push(row);
return row;
},
async update(o: string, id: string, data: Row) {
const rows = rowsOf(o);
const at = rows.findIndex((r) => r.id === String(id));
if (at < 0) throw new Error(`not found ${o}/${id}`);
rows[at] = { ...rows[at], ...data, id: String(id) };
return rows[at];
},
async upsert(o: string, data: Row) { return this.create(o, data); },
async delete(o: string, id: string) {
const rows = rowsOf(o);
const at = rows.findIndex((r) => r.id === String(id));
if (at < 0) return false;
rows.splice(at, 1);
return true;
},
async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {},
async beginTransaction() { log.begun += 1; return { id: `trx_${log.begun}` }; },
async commit() {}, async rollback() {},
};
return {
driver,
failReads,
has: (o: string, id: string) => rowsOf(o).some((r) => r.id === id),
seed: (o: string, row: Row) => void rowsOf(o).push(row),
};
}

/** The deleted object. Its own injected anchor makes it self-referencing, harmlessly. */
const ORGANIZATION = {
name: 'sys_organization',
label: 'Organization',
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
};

/** A LOCAL object: the registry injects `organization_id`, and storage backs it. */
const LOCAL = {
name: 'acct',
label: 'Account',
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
};

/** Federated, as the showcase declares it: no `organization_id` of its own. */
const FEDERATED = {
name: 'ext_customer',
label: 'External Customer',
datasource: REMOTE,
external: { remoteName: 'customers' },
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
};

/** Federated, with an `organization_id` the AUTHOR declared: it maps a real remote column. */
const FEDERATED_DECLARED_ANCHOR = {
name: 'ext_tenant_customer',
label: 'External Tenant Customer',
datasource: REMOTE,
external: { remoteName: 'tenant_customers' },
fields: {
name: { name: 'name', label: 'Name', type: 'text' as const },
organization_id: {
name: 'organization_id',
label: 'Remote Organization',
type: 'lookup' as const,
reference: 'sys_organization',
},
},
};

/** Federated, with another lookup the author declared against the organization. */
const FEDERATED_AUTHOR_LOOKUP = {
name: 'ext_order',
label: 'External Order',
datasource: REMOTE,
external: { remoteName: 'orders' },
fields: {
amount: { name: 'amount', label: 'Amount', type: 'number' as const },
org_ref: { name: 'org_ref', label: 'Organization', type: 'lookup' as const, reference: 'sys_organization' },
},
};

const ORG_ID = 'org_21910';

/** The refusal the SQL driver answers for a filter on a column the remote does not have. */
function unknownColumnRefusal(object: string, column: string) {
return Object.assign(
new Error(`A filter on object '${object}' names a column the database could not resolve (${column}).`),
{ code: 'INVALID_FILTER', status: 400 },
);
}

async function makeEngine(objects: any[]) {
const log = { reads: [] as string[], begun: 0 };
const warnings: string[] = [];
const logger = {
debug() {}, info() {}, error() {},
warn: (message: unknown) => void warnings.push(String(message)),
};
const engine = new ObjectQL({ logger } as any);
const local = makeDriver('memory', log);
const remote = makeDriver(REMOTE, log);
engine.registerDriver(local.driver, true);
engine.registerDriver(remote.driver);
await engine.init();
for (const o of objects) engine.registry.registerObject(o, PACKAGE_ID);
local.seed('sys_organization', { id: ORG_ID, name: 'Doomed Org' });
return { engine, local, remote, log, warnings };
}

const NOT_ATOMIC = 'cannot run as one unit of work';

describe('[#21910] the cascade scan skips a federated object\'s injected tenant anchor, and nothing else', () => {
it('never probes a federated object on its injected organization_id, so the organization delete lands', async () => {
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]);
// PREMISE: the registered schema carries the platform's injected anchor.
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_customer'), 'organization_id'))
.toBe('injected-unprovisioned');
// Any read of the remote table on that column is refused, as the showcase measured.
remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'organization_id'));

log.reads.length = 0;
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);

expect(local.has('sys_organization', ORG_ID)).toBe(false);
expect(log.reads).not.toContain('ext_customer');
// CONTROL: the scan ran for this delete, and probed the local object's injected anchor.
expect(log.reads).toContain('acct');
});

it('still probes an organization_id the AUTHOR declared on a federated object, and its failure propagates (#8895)', async () => {
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_DECLARED_ANCHOR]);
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_tenant_customer'), 'organization_id'))
.toBe('author');
const injected = unknownColumnRefusal('ext_tenant_customer', 'organization_id');
remote.failReads.set('ext_tenant_customer', injected);

log.reads.length = 0;
const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e);

expect(err).toBe(injected);
expect(err.code).toBe('INVALID_FILTER');
expect(err.status).toBe(400);
expect(log.reads).toContain('ext_tenant_customer');
expect(local.has('sys_organization', ORG_ID)).toBe(true);
});

it('still probes any other lookup the author declared on a federated object, and its failure propagates (#8895)', async () => {
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_AUTHOR_LOOKUP]);
const injected = unknownColumnRefusal('ext_order', 'org_ref');
remote.failReads.set('ext_order', injected);

log.reads.length = 0;
const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e);

expect(err).toBe(injected);
expect(err.code).toBe('INVALID_FILTER');
expect(err.status).toBe(400);
expect(log.reads).toContain('ext_order');
expect(local.has('sys_organization', ORG_ID)).toBe(true);
});
});

describe('[#21910] the cascade atomicity plan agrees with the scan about who takes part', () => {
it('runs the organization delete as one transaction when the injected anchor was its only cross-datasource reference', async () => {
const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]);

await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);

expect(local.has('sys_organization', ORG_ID)).toBe(false);
expect(log.begun).toBe(1);
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]);
});

it('CONTROL: an author-declared lookup on a federated object still makes the plan cross-datasource', async () => {
const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED_AUTHOR_LOOKUP]);

await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);

expect(local.has('sys_organization', ORG_ID)).toBe(false);
expect(log.reads).toContain('ext_order');
expect(log.begun).toBe(0);
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toHaveLength(1);
});
});
31 changes: 29 additions & 2 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,8 @@ import {
withDeclaredColumnsOnly,
} from './declared-read-columns.js';
// [#21777] "Is this schema the remote's?" One predicate, shared with the boot sync.
import { isFederatedObject } from './federated-object.js';
// [#21910] And its tenant-anchor refinement, which both cascade walks ask.
import { isFederatedObject, isFederatedInjectedTenantAnchor } from './federated-object.js';
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
import {
resolveEngineDeleteDispatch,
Expand Down Expand Up @@ -15832,7 +15833,7 @@ export class ObjectQL implements IObjectQLEngine {
const childName = (child as any)?.name as string | undefined;
const fields = (child as any)?.fields as Record<string, any> | undefined;
if (!childName || !fields) continue;
for (const fdef of Object.values(fields)) {
for (const [fieldName, fdef] of Object.entries(fields)) {
if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue;
// [#18550] The carrier is read through the ONE arbiter, so a
// `reference` no reader can read REFUSES here instead of reading as
Expand All @@ -15856,6 +15857,12 @@ export class ObjectQL implements IObjectQLEngine {
let resolvedRef: string | undefined;
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
if (ref !== name && resolvedRef !== name) continue;
// [#21910] The scan skips a federated object's injected tenant
// anchor, so this walk does too: the participant test stays the
// scan's own, as the comment above requires. A federated object this
// walk still reaches through any other relation keeps the verdict
// `'split'`, because the scan probes that relation.
if (isFederatedInjectedTenantAnchor(child, fieldName)) continue;
out.push(childName);
break;
}
Expand Down Expand Up @@ -16324,6 +16331,26 @@ export class ObjectQL implements IObjectQLEngine {
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
if (ref !== object && resolvedRef !== object) continue;

// [#21910] A federated object's platform-INJECTED tenant anchor is not
// a reference to `sys_organization`, so it is not a relation to probe.
// On a federated object that column exists in the registered schema
// and nowhere else: the probe below was refused by the driver
// (`INVALID_FILTER`, no such column), its catch propagated the refusal
// as #8895 rules for a missing column, and every organization delete
// answered 500 on a deployment with a federated object bound.
// `buildDriverOptions` and the related-record read already refuse this
// reading of the same column. {@link isFederatedInjectedTenantAnchor}
// says why it is exactly that column, and
// {@link ObjectQL.planCascadeAtomicity} asks it too.
//
// ⛔ The catch below is deliberately NOT widened to pass a missing
// column as benign. That would invert #8895's discriminate or
// propagate for every object, not just this injected column: an
// `organization_id` the author declared on a federated object, and any
// other lookup the author declares on one, stay in the scan, and their
// probe failures still propagate.
if (isFederatedInjectedTenantAnchor(child, fieldName)) continue;

// A master-detail parent owns its children: cascade by default (the
// child FK is typically required, so set_null would be invalid). Only
// an explicit `restrict` deviates. A plain lookup honors its
Expand Down
42 changes: 42 additions & 0 deletions packages/objectql/src/federated-object.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { resolveInjectedColumnProvenance } from '@objectstack/spec/data';
import { DEFAULT_TENANT_FIELD } from './tenancy/system-write-organization.js';

/**
* Is `schema` a federated object (ADR-0015 `external`), one whose schema is
* owned by the REMOTE database?
Expand Down Expand Up @@ -31,3 +34,42 @@
export function isFederatedObject(schema: unknown): boolean {
return (schema as { external?: unknown } | null | undefined)?.external != null;
}

/**
* [#21910] Is `fieldName` a federated object's platform-INJECTED tenant
* anchor: the `organization_id` lookup to `sys_organization` that the
* registry adds and the remote table does not have?
*
* `applySystemFields` injects `organization_id` into every object it
* registers, ADR-0015 `external` ones included (the #7865 ruling, direction
* B), and the platform provisions no storage for a federated object. So on
* one, that column exists in the registered schema and nowhere else, and it
* is never a reference to an organization: no remote row can hold one. The
* engine's referential cascade asks this in both of its walks, so the two
* cannot disagree about which objects take part in an organization delete:
*
* - `ObjectQL.cascadeDeleteRelations` does not probe the remote table on it
* (the probe was refused as an unknown column, and every organization
* delete answered 500);
* - `ObjectQL.planCascadeAtomicity` does not count that column as making the
* federated object a participant, so its participant test stays the scan's.
*
* Three conjuncts, and each one is the narrowing:
*
* - the column is the tenant anchor, `organization_id`. The other anchors
* the registry injects (`owner_id`, `created_by`, ...) are not this
* question;
* - the object is federated, by {@link isFederatedObject}, the same predicate
* `buildDriverOptions` and the related-record read ask;
* - the field is the platform's own definition, by the #7865 provenance
* marker. An `organization_id` the author declared answers `'author'` and
* stays a relation: it may map a real remote column, and its probe keeps
* #8895's discriminate or propagate.
*/
export function isFederatedInjectedTenantAnchor(schema: unknown, fieldName: string): boolean {
return (
fieldName === DEFAULT_TENANT_FIELD &&
isFederatedObject(schema) &&
resolveInjectedColumnProvenance(schema, fieldName) === 'injected-unprovisioned'
);
}
Loading
Loading