From 8a158488a77c412ff04226a4d997ad4aa99282ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:41:03 +0000 Subject: [PATCH 1/3] 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 --- .../21910-cascade-federated-tenant-anchor.md | 11 + ...ne-cascade-federated-tenant-anchor.test.ts | 265 ++++++++++++++++++ packages/objectql/src/engine.ts | 32 ++- packages/objectql/src/federated-object.ts | 42 +++ ...n-delete-federated-fixture.dogfood.test.ts | 133 +++++++++ 5 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 .changeset/21910-cascade-federated-tenant-anchor.md create mode 100644 packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts create mode 100644 packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts diff --git a/.changeset/21910-cascade-federated-tenant-anchor.md b/.changeset/21910-cascade-federated-tenant-anchor.md new file mode 100644 index 00000000000..285f2999ce7 --- /dev/null +++ b/.changeset/21910-cascade-federated-tenant-anchor.md @@ -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 an organization delete on such a deployment now runs as one transaction instead of being reported as a cross-datasource cascade. +- **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. diff --git a/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts b/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts new file mode 100644 index 00000000000..1c929299dd3 --- /dev/null +++ b/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts @@ -0,0 +1,265 @@ +// 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 Rows = Map>>; + +/** + * 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. + */ +function makeDriver(name: string, log: { reads: string[]; begun: number }) { + const stores: Rows = new Map(); + const failReads = new Map(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const matches = (row: Record, 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 gate = (o: string) => { + log.reads.push(o); + if (failReads.has(o)) throw failReads.get(o); + }; + 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) { + gate(o); + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + }, + async findOne(o: string, ast: any) { + gate(o); + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async count(o: string, ast: any) { + gate(o); + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length; + }, + async create(o: string, data: Record) { + const row = { ...data, id: String(data.id) }; + storeFor(o).set(row.id, row); + return row; + }, + async update(o: string, id: string, data: Record) { + const cur = storeFor(o).get(String(id)); + if (!cur) throw new Error(`not found ${o}/${id}`); + const up = { ...cur, ...data, id: String(id) }; + storeFor(o).set(String(id), up); + return up; + }, + async upsert(o: string, data: Record) { return this.create(o, data); }, + async delete(o: string, id: string) { return storeFor(o).delete(String(id)); }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { log.begun += 1; return { id: `trx_${log.begun}` }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores, failReads, seed: (o: string, row: Record) => storeFor(o).set(String(row.id), 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.stores.get('sys_organization')?.has(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.stores.get('sys_organization')?.has(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.stores.get('sys_organization')?.has(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.stores.get('sys_organization')?.has(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.stores.get('sys_organization')?.has(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); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 48b8210911c..0f0fec41bb0 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -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, @@ -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 | 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 @@ -15856,6 +15857,13 @@ 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, and the participant set stays the + // scan's. Counting it read every organization delete on a deployment + // with a federated object bound as cross-datasource (`'split'`): the + // cascade ran unwrapped, and the warning named a datasource the + // cascade never touches. + if (isFederatedInjectedTenantAnchor(child, fieldName)) continue; out.push(childName); break; } @@ -16324,6 +16332,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 diff --git a/packages/objectql/src/federated-object.ts b/packages/objectql/src/federated-object.ts index f058111b663..97fe0757e42 100644 --- a/packages/objectql/src/federated-object.ts +++ b/packages/objectql/src/federated-object.ts @@ -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? @@ -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 the federated object as a + * participant on another datasource. + * + * 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' + ); +} diff --git a/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts b/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts new file mode 100644 index 00000000000..a9442f45913 --- /dev/null +++ b/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21910] An owner deletes an organization on a deployment with a federated + * object provisioned, through better-auth's own endpoint, and gets 200. + * + * ## What was broken + * + * Deleting a record runs the engine's referential cascade scan + * (`ObjectQL.cascadeDeleteRelations`): every registered `lookup` / + * `master_detail` field that references the deleted object is probed for + * dependents. The registry injects the platform's tenant anchor, + * `organization_id` (a lookup to `sys_organization`), into a federated + * (ADR-0015 `external`) object too, where it is registered and never + * provisioned on the remote table. The scan read that injection as a real + * reference and probed the showcase's remote `customers` table on + * `organization_id`. The SQL driver refused the filter (`INVALID_FILTER`, no + * such column), the probe's catch propagated it as #8895 rules for a missing + * column, and every organization delete answered 500 once the showcase's + * federated fixture existed. With no fixture the same delete answered 200, + * because an unprovisioned child TABLE is the probe's one benign failure. + * + * The scan now skips a federated object's platform-injected tenant anchor, + * the reading `buildDriverOptions` and the related-record read already apply. + * Nothing else is skipped: an author-declared lookup on a federated object is + * still probed, and its failure still propagates. That half is pinned at the + * seam, in `packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts`. + * + * ## Premises, asserted on the same boot so a green delete cannot be vacuous + * + * - the fixture IS provisioned: the federated object answers its seeded rows, + * so the delete cannot be passing through the missing-table branch; + * - the remote really lacks the column: a system read filtered on + * `organization_id`, the probe the scan used to run, is refused; + * - the field that probe named is the platform's injected anchor + * (`resolveInjectedColumnProvenance` answers `injected-unprovisioned`). + * + * Booted with `orgContext`, the harness's switch for the real single-org + * default-organization bootstrap: it creates the organization and seats the + * admin as its owner, which is what lets better-auth delete it. + * + * The working directory is a temporary one. The showcase's external datasource + * and its fixture both name a cwd-relative SQLite file, so this file's remote + * database is its own, never one another file left in the package directory. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { resolveInjectedColumnProvenance } from '@objectstack/metadata-core'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** The federated object the showcase ships, bound to the remote table `customers`. */ +const FEDERATED = 'showcase_ext_customer'; + +const SYSTEM_CTX = { isSystem: true }; + +async function findRows(ql: any, object: string, where: Record, limit = 50): Promise { + const rows = await ql.find(object, { where, limit, context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +describe('[#21910] an organization delete with the showcase federated fixture provisioned', () => { + let stack: VerifyStack; + let ql: any; + let token: string; + let orgId: string; + let prevCwd: string; + let dir: string; + + beforeAll(async () => { + prevCwd = process.cwd(); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21910-')); + process.chdir(dir); + // Provision the remote tables, exactly as `os dev` does at boot (the + // harness imports only the stack's default export, so `onEnable` never + // runs on its own). + await onEnable({ logger: { info() {}, warn() {} } } as never); + stack = await bootStack(showcaseStack, { + orgContext: true, + databaseFile: join(dir, 'showcase.db'), + }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org, 'PREMISE: the bootstrap created the default organization').toBeTruthy(); + orgId = String(org.id); + const [admin] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + const seats = await findRows(ql, 'sys_member', { user_id: String(admin?.id), organization_id: orgId }, 5); + expect(seats.map((m) => m.role), 'PREMISE: the admin owns the organization').toEqual(['owner']); + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('PREMISE: the fixture is provisioned, and its remote table refuses a filter on the injected organization_id', async () => { + const listed = await stack.apiAs(token, 'GET', `/data/${FEDERATED}`); + expect(listed.status, await listed.clone().text()).toBe(200); + const body: any = await listed.json(); + const rows: unknown[] = body?.records ?? body?.data ?? []; + expect(rows.length, 'the remote customers table answers its seeded rows').toBeGreaterThan(0); + + const schema = ql.getSchema(FEDERATED); + expect(schema?.external, `${FEDERATED} is federated`).toBeTruthy(); + expect(resolveInjectedColumnProvenance(schema, 'organization_id')).toBe('injected-unprovisioned'); + + // The read the cascade scan used to issue: SYSTEM identity, filtered on + // the injected column. It is refused, so a scan that still probed this + // object could not answer the delete below with 200. + const refused: any = await findRows(ql, FEDERATED, { organization_id: orgId }, 1).then( + () => null, + (e: unknown) => e, + ); + expect(refused, 'the remote has no organization_id column').not.toBeNull(); + expect(refused.code).toBe('INVALID_FILTER'); + }); + + it('the owner deletes the organization: 200, the row is gone, and the federated rows are untouched', async () => { + const before = await findRows(ql, FEDERATED, {}, 500); + + const deleted = await stack.apiAs(token, 'POST', '/auth/organization/delete', { organizationId: orgId }); + expect(deleted.status, await deleted.clone().text()).toBe(200); + + expect(await findRows(ql, 'sys_organization', { id: orgId }, 1)).toHaveLength(0); + expect((await findRows(ql, FEDERATED, {}, 500)).length).toBe(before.length); + }); +}); From 5c5d9b315ec5e229f7ee95350f32d08fcc1c69a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:44:29 +0000 Subject: [PATCH 2/3] docs(objectql): say what the atomicity plan's twin test changes, as measured On the showcase the plan still reaches the federated object through its injected owning_business_unit_id at depth 1, so its verdict for an organization delete stays cross-datasource. The comments and the changeset now claim only that the plan keeps the scan's participant test. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .changeset/21910-cascade-federated-tenant-anchor.md | 2 +- packages/objectql/src/engine.ts | 9 ++++----- packages/objectql/src/federated-object.ts | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.changeset/21910-cascade-federated-tenant-anchor.md b/.changeset/21910-cascade-federated-tenant-anchor.md index 285f2999ce7..c1b2270dfb1 100644 --- a/.changeset/21910-cascade-federated-tenant-anchor.md +++ b/.changeset/21910-cascade-federated-tenant-anchor.md @@ -7,5 +7,5 @@ Deleting an organization no longer fails with a 500 on a deployment that has a f 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 an organization delete on such a deployment now runs as one transaction instead of being reported as a cross-datasource cascade. +- **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. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0f0fec41bb0..2073a1d4b84 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -15858,11 +15858,10 @@ export class ObjectQL implements IObjectQLEngine { 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, and the participant set stays the - // scan's. Counting it read every organization delete on a deployment - // with a federated object bound as cross-datasource (`'split'`): the - // cascade ran unwrapped, and the warning named a datasource the - // cascade never touches. + // 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; diff --git a/packages/objectql/src/federated-object.ts b/packages/objectql/src/federated-object.ts index 97fe0757e42..8f5d20860c6 100644 --- a/packages/objectql/src/federated-object.ts +++ b/packages/objectql/src/federated-object.ts @@ -51,8 +51,8 @@ export function isFederatedObject(schema: unknown): boolean { * - `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 the federated object as a - * participant on another datasource. + * - `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: * From 99eb3665777cdd9c84cd9e6ea485e833ef32a27d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 23:04:21 +0000 Subject: [PATCH 3/3] test(objectql): the cascade anchor pin's stub driver honours the caller's limit check:objectql-double-limit could not drive the stub's find (its failure lookup threw on the gate's row stub) and refused it as a new unjudged double. The find now reads rows from a table map, applies the bound after the filter by presence, and the gate grades it as applying the bound. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...ne-cascade-federated-tenant-anchor.test.ts | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts b/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts index 1c929299dd3..1429019ea91 100644 --- a/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts +++ b/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts @@ -42,21 +42,18 @@ import { ObjectQL } from './engine.js'; const REMOTE = 'remote_ds'; const PACKAGE_ID = 'test-21910'; -type Rows = Map>>; +type Row = Record; /** * 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. + * 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 stores: Rows = new Map(); + const tables: Record = {}; const failReads = new Map(); - const storeFor = (o: string) => { - let s = stores.get(o); - if (!s) { s = new Map(); stores.set(o, s); } - return s; - }; - const matches = (row: Record, where: any): boolean => { + 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; @@ -65,47 +62,55 @@ function makeDriver(name: string, log: { reads: string[]; begun: number }) { } return true; }; - const gate = (o: string) => { - log.reads.push(o); - if (failReads.has(o)) throw failReads.get(o); - }; 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) { - gate(o); - return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + 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) { - gate(o); - for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; - return null; + const [first] = await this.find(o, { ...ast, limit: 1 }); + return first ?? null; }, async count(o: string, ast: any) { - gate(o); - return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length; + return (await this.find(o, { where: ast?.where })).length; }, - async create(o: string, data: Record) { + async create(o: string, data: Row) { const row = { ...data, id: String(data.id) }; - storeFor(o).set(row.id, row); + rowsOf(o).push(row); return row; }, - async update(o: string, id: string, data: Record) { - const cur = storeFor(o).get(String(id)); - if (!cur) throw new Error(`not found ${o}/${id}`); - const up = { ...cur, ...data, id: String(id) }; - storeFor(o).set(String(id), up); - return up; + 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 upsert(o: string, data: Record) { return this.create(o, data); }, - async delete(o: string, id: string) { return storeFor(o).delete(String(id)); }, async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, async beginTransaction() { log.begun += 1; return { id: `trx_${log.begun}` }; }, async commit() {}, async rollback() {}, }; - return { driver, stores, failReads, seed: (o: string, row: Record) => storeFor(o).set(String(row.id), row) }; + 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. */ @@ -202,7 +207,7 @@ describe('[#21910] the cascade scan skips a federated object\'s injected tenant log.reads.length = 0; await engine.delete('sys_organization', { where: { id: ORG_ID } } as any); - expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(false); + 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'); @@ -222,7 +227,7 @@ describe('[#21910] the cascade scan skips a federated object\'s injected tenant expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); expect(log.reads).toContain('ext_tenant_customer'); - expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(true); + 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 () => { @@ -237,7 +242,7 @@ describe('[#21910] the cascade scan skips a federated object\'s injected tenant expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); expect(log.reads).toContain('ext_order'); - expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(true); + expect(local.has('sys_organization', ORG_ID)).toBe(true); }); }); @@ -247,7 +252,7 @@ describe('[#21910] the cascade atomicity plan agrees with the scan about who tak await engine.delete('sys_organization', { where: { id: ORG_ID } } as any); - expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(false); + expect(local.has('sys_organization', ORG_ID)).toBe(false); expect(log.begun).toBe(1); expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]); }); @@ -257,7 +262,7 @@ describe('[#21910] the cascade atomicity plan agrees with the scan about who tak await engine.delete('sys_organization', { where: { id: ORG_ID } } as any); - expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(false); + 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);