Skip to content

Commit 13a22d0

Browse files
fix(objectql): the cascade skips every injected column a federated object does not provision (#21937)
Fixes #21918 Clause-②: no ## What was wrong Deleting a record runs the engine's referential cascade (`ObjectQL.cascadeDeleteRelations`), which probes every registered `lookup` / `master_detail` field that references the deleted object. The registry injects its own columns into every object, federated (ADR-0015 `external`) ones included: the tenant anchor `organization_id`, the ADR-0117 D1 anchor `owning_business_unit_id`, the owner `owner_id`, and the audit lookups `created_by` / `updated_by`. The platform provisions no storage for a federated object, so none of them exists on the remote table. PR #21917 (for #21910) taught the scan to skip `organization_id` alone, through `isFederatedInjectedTenantAnchor`. The scan still probed the remote table on the other anchors. The SQL driver refused the unknown column (`INVALID_FILTER`), the probe's catch propagated it as #8895 rules, and the delete failed: - an admin's `DELETE /api/v1/data/sys_business_unit/:id` answered **400** (`INVALID_FILTER` on `showcase_ext_customer.owning_business_unit_id`); - removing a user answered **500** (the same refusal on `showcase_ext_customer.created_by`, raised inside better-auth). ## What changed - `packages/objectql/src/federated-object.ts`: `isFederatedInjectedTenantAnchor` is replaced by one general predicate, `isFederatedUnprovisionedInjectedColumn(schema, fieldName)`. It is `isFederatedObject(schema)` and `resolveInjectedColumnProvenance(schema, fieldName) === 'injected-unprovisioned'`, the registry's own #7865 provenance. It names no column. The `isFederatedObject` conjunct changes no verdict (the provenance only answers `injected-unprovisioned` on an `external` object). It comes first so a local object answers without deriving its injection plan, since the cascade asks this for every relation on every delete. The file is not re-exported from the package entry. - `packages/objectql/src/engine.ts`: `cascadeDeleteRelations` and `planCascadeAtomicity` ask the general predicate at the same place #21910 put the tenant-only one, so the two still agree. ⛔ The probe's catch is not widened. A lookup the author declares on a federated object, including an author's own `organization_id` or `owner_id`, answers `author` and stays in the scan with #8895's propagate disposition. - `packages/objectql/src/lifecycle/lifecycle-service.ts`: the reap and archive passes now get their per-tenant windows from one shared helper, `tenantWindowsFor`. It returns no windows for an object whose `organization_id` is a federated unprovisioned injected column. Measured: the spec accepts a `lifecycle` block beside `external` (retention, ttl and archive all parse), so the triage ruling brings these passes in scope. Such an object's rows carry no organization, so it has no tenant partition. It runs its one global pass, which is the window a provisioned object's no-organization rows get from the same `$or` arm. Before this change, every partitioned pass was refused as an unknown column, and the object's sweep failed before its global pass ran. - `.changeset/21918-federated-injected-anchors.md`: `@objectstack/objectql` patch, `Clause-②: no`. The producer side is ruled (#7865 direction B keeps the injection and supplies the marker this reads), so the fix stays in the engine's readers. No `packages/spec` edit. ## The enumeration pin (the closing act) `packages/objectql/src/federated-injected-column-readers.test.ts` scans every non-test source of `@objectstack/objectql` with the TypeScript parser for every use of a named seam: - the federated decisions and the provenance they read; - the relation-carrier arbiters (`referenceCarrierOf`, `referenceTargetOf`); - the tenant-column resolver and its constant; - every spelling of an injected column's name. The names are not listed. They come from `injectedSystemColumnDefs` (`@objectstack/spec/data`), the table the registry spreads. Each use is keyed `FILE#FUNCTION :: SEAM`, and every key must have a row in a closed-disposition table, while every row must still be found. A row that says the site asks a federated predicate is checked against the source: the site calls it, or calls the named same-file helper that does. Why a scan and not a registry the readers call into: the readers that failed in this family did not know the question existed, so they would never have registered. A scan finds them by the seam they cannot avoid. The 63 seam uses today, by disposition: | Disposition | Sites | |---|---| | skips (asks the general predicate) | `cascadeDeleteRelations`, `planCascadeAtomicity`, lifecycle `tenantWindowsFor` (and `reap` / `archiveObject` through it) | | exempt (asks `isFederatedObject`) | `buildDriverOptions`, the related-record read (`resolvePredicateRelated`), `resolveSystemInsertOrganization` | | excludes (reads the provenance) | the dangling-reference audit (`auditableReferenceFields`, `organizationFieldOf`) | | row-value | `eventOrganizationId` reads the written row; a federated row has no tenant column, so the key is omitted | | target-by-id | `assertReferencesResolve`, `expandRelatedRecords`, `resolveRelatedTitleTarget` | | caller-predicate | relation-filter lowering, five validation-rule sites | | author-declared | `buildSummaryIndex` (a roll-up's FK inference; see Acceptance notes) | | policy-subject | lifecycle `created_at` (the age a retention / archive selects by) | | writer | the audit hook's `created_by` / `updated_by` stamping | | not-a-read / definition | name vocabularies, sync routing, injection constants, refusal text, the predicate and resolver themselves | ## Pins - `packages/objectql/src/federated-object.test.ts` (5): the general predicate accepts every injected anchor of a registered federated object. It agrees with `unprovisionedInjectedColumns` on every field of three objects. It refuses an author-declared `organization_id`, `owner_id` and lookup (`author`), and every injected column of a local object (`injected-provisioned`). It also refuses `id` and inputs that are not objects. - `packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts` (#21910's 5 pins kept, 7 added, through `engine.delete` on a two-driver engine; the existing stub driver now also logs which columns each read filters on): - a business-unit delete never reads the federated object, and a local object's `owning_business_unit_id` IS probed (control); - a user delete never reads it, and the local `owner_id` / `created_by` / `updated_by` ARE probed; - author-declared `unit_ref` and `owner_id` on a federated object are still probed, and only those columns are. Their failure propagates with its envelope (`INVALID_FILTER`, 400, the same error object); - both plans run one transaction when injected anchors were the only cross-datasource references, and an author lookup keeps both plans `split` with one warning. - `packages/objectql/src/lifecycle/lifecycle-service.test.ts` (4 added): a federated object's reap and archive run one global pass and never filter on `organization_id` (the double refuses any read naming it). The same declaration on a local object, and an `organization_id` the author declared on a federated object, keep their per-tenant partition. - The enumeration pin (5). - Door pins, `packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts`. They boot the showcase with `orgContext`, provision the fixture with `onEnable` in the test's own `mkdtemp` directory, and assert the premises on the same boot: the remote rows are served, each anchor is `injected-unprovisioned`, and a SYSTEM read filtered on each one is refused `INVALID_FILTER`. Then: - `DELETE /api/v1/data/sys_business_unit/bu_21918` answers 200, the row is gone, and the federated rows are untouched; - `POST /api/v1/auth/admin/remove-user` answers 200, the user row is gone, and the federated rows are untouched. - #21910's door pin (`organization-delete-federated-fixture.dogfood.test.ts`) stays green. ## The user-delete door, and a harness gap The only HTTP door that deletes a user is better-auth's `POST /api/v1/auth/admin/remove-user`, which `plugin-auth` mounts when the better-auth admin plugin is on. - `DELETE /data/sys_user/:id` answers 405 by design (ADR-0092), and `/auth/delete-user` is unconfigured (404). - `objectstack serve` turns the admin plugin on by default (`OS_AUTH_ADMIN`, `packages/cli/src/commands/serve.ts`). The verify harness constructs `AuthPlugin` with no plugin options, so the route answers 404 there. That is the 404 #21910's dev measured. - The harness exposes no auth option. The door pin turns the plugin on through `OS_SCIM_ENABLED`, the one switch the harness reads that does (ADR-0134), the same knob `admin-credential-lifecycle.dogfood.test.ts` uses. - The vendor route authorizes on the legacy `sys_user.role === 'admin'` scalar, which ADR-0068 D2 retired. So a platform admin is refused there with 403 `YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS`, a ruled state. - The pin writes that scalar onto the admin row, as `plugin-auth`'s `remove-user-atomicity.test.ts` does, because its subject is the cascade and not the route's authorization. ## Measured readings (showcase with the federated fixture, own temp dir) | Door | Before (`f243a29290`) | After | |---|---|---| | admin `DELETE /data/sys_business_unit/:id` | 400 `INVALID_FILTER` on `owning_business_unit_id`; log `[sql-driver] INVALID_FILTER ... showcase_ext_customer ('owning_business_unit_id')` | 200 | | `POST /auth/admin/remove-user` (admin plugin on, legacy scalar) | 500, empty body; log `INVALID_FILTER ... ('created_by')`, better-auth `SERVER_ERROR`; user row survives | 200, row gone | | same route, platform admin without the scalar | 403 `YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS` (ruled, unchanged) | unchanged | **Atomicity plan** (`planCascadeAtomicity` on the booted showcase): organization, business unit and user all read `split` before and `atomic` after. The only non-default-driver participants were the two federated objects, reached through injected anchors. **Scan and plan agree**, measured after: the set of objects the scan probed (its `[reference-cleanup]` record, filed once per probed child) equals the plan's first-level participant set: organization 53 = 53, business unit 27 = 27, user 66 = 66, with no federated object in any. ## Reverse verification (committed HEAD `1a131e4b4b`, every mutation through `scripts/ablation-replace.mjs`) - **Leg A, the base predicate restored** (`fieldName === 'organization_id' &&` put back in front, anchor 1 to 0, blob `a432c5754eb4` to `cccef63025f7`). After rebuilding `@objectstack/objectql`, `ablation-dist-preflight` found the marker in 4 built files. - Unit: 9 failed / 8 passed (3 general-predicate pins, 6 business-unit and user cascade and plan pins). - Door: the business-unit delete read `expected 400 to be 200` (`INVALID_FILTER`), and the user removal `expected 500 to be 200`. - Restore: blob `a432c5754eb4` equals HEAD and `git diff HEAD` is empty. After a rebuild, the marker is absent from all 14 built files, and `git status --porcelain` is empty. - **Leg B1, a new reader with no disposition** (`referenceTargetOf` planted in `eventOrganizationId`): the enumeration pin goes red, naming the unlisted key `engine.ts#eventOrganizationId :: referenceTargetOf()`. Restored to the HEAD blob. - **Leg B2, the plan stops asking** (its skip line deleted): 5 failed (#21910's and #21918's three plan pins, plus the enumeration pin's set and asks checks). Restored to the HEAD blob. - **Leg C, the lifecycle partition stops asking**: 4 failed (both federated lifecycle pins, and the enumeration pin twice). Restored to the HEAD blob `1b78524d3f29`. - Legs B and C read source only: the unit pins import relatively, and the enumeration pin parses `src/`. ## Gates (at `7c2888a239`, after merging `origin/main` `faf8dce482`) - `node scripts/pm/dispatch-gates.mjs --commands` over the branch derived the same 71 commands as the dispatch. All 71 exit 0, and `--ran` reads `71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN`. `check:dual-build-cjs-loads` first answered exit 3 `PREREQUISITE NOT MET` (8 packages had no `dist/`). After building them it passes (106 entry points across 66 packages). - Artifact-roster block: 53 commands; 50 exit 0. `check-closing-target-claim.mjs`, `check-partof-closing-keyword.mjs` and `check-single-claim-paths.mjs` need a PR in their environment (exit 2, NOT WIRED before this PR existed); their CI workflows run them. - Symbol-anchor sweeps: `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`: all exit 0. - `check:objectql-double-limit` passes: no new double. The existing stub driver was extended, and its `find` still applies the caller's `limit` after the filter. - `pnpm --filter @objectstack/objectql test` (two shards): 378 files, 7495 tests passed. `pnpm --filter @objectstack/objectql typecheck`: `tsc` clean, and `check:test-typecheck` reads OK with no new debt (234 ledgered errors, none in the four touched test files, which `tsc --listFiles` includes). `pnpm --filter @objectstack/dogfood typecheck`: exit 0, and it includes the door pin. - ESLint, narrowed to the 8 touched code files: - population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` outside `NEVER_LINTED`; - count: `--format json` reports 8 files, 0 errors and 0 warnings; - invariance: the config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff moves no untouched file's verdict. The full `pnpm lint` is CI's. ## Acceptance notes - **`buildSummaryIndex`** (disposition `author-declared`, not changed here). A roll-up declared with no `relationshipField` infers its foreign key from the child's first relation to the parent. Injected anchors point only at `sys_organization`, `sys_business_unit` and `sys_user`, so it can meet one only for a roll-up declared on one of those, over a federated child. Inference, unmeasured. - **The audit hook is a writer** (`plugin.ts#registerAuditHooks` stamps `created_by` / `updated_by` on insert and update, federated objects included). A write to a writable federated datasource would carry columns the remote may lack. Inference, unmeasured: the showcase's federated datasource refuses writes. - **Lifecycle `created_at` stays the policy's subject.** A federated object that declares `retention` (or `archive` without `ttl`) reaps by `created_at`, which is the registry's injection there. A remote without it refuses the filter, and the sweep reports the object in `errors` every sweep. The spec accepts the declaration and lint does not warn. - **A tenant-scoped retention override naming a federated object** selects no rows, as it would on a provisioned object whose rows all carry no organization. The object's sweep now runs its global window instead of failing. - **The verify harness has no auth plugin options.** Its `AuthPlugin` differs from `serve.ts`'s default (`admin` on), so vendor admin routes answer 404 under the harness unless a test sets `OS_SCIM_ENABLED`. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 131b937 commit 13a22d0

9 files changed

Lines changed: 1345 additions & 65 deletions
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 a business unit or a user no longer fails on a deployment that has a federated (ADR-0015 `external`) object bound. The engine's referential cascade no longer treats any column the platform injects into a federated object as a reference.
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** The registry injects its own columns into every object, federated ones included: the tenant anchor `organization_id`, the business-unit anchor `owning_business_unit_id`, the owner `owner_id`, and the audit lookups `created_by` and `updated_by`. The platform provisions no storage for a federated object, so none of them exists on the remote table. An earlier fix taught the cascade to skip `organization_id` alone. The cascade's dependents probe still filtered the remote table on the other anchors, the SQL driver refused the unknown column (`INVALID_FILTER`), and the failure propagated. On the showcase with its federated fixture provisioned, deleting a business unit answered 400 and removing a user answered 500.
10+
- **What changed.** The cascade scan and its atomicity plan skip every column the registry injected into a federated object and the object does not provision. They read which columns those are from the registry's own injected-column provenance, not from a list of names, so a column the registry injects later is covered too. The lifecycle reap and archive passes no longer split a federated object's rows per tenant on its injected `organization_id`: such rows carry no organization, so a tenant-scoped retention override for that object has no rows to select, and the object is swept in one global pass.
11+
- **What did not change.** A lookup the author declares on a federated object, including the author's own `organization_id` or `owner_id`, 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.

‎packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts‎

Lines changed: 191 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,17 @@
2929
* as one transaction, while an author-declared federated lookup still
3030
* makes the plan cross-datasource.
3131
*
32+
* [#21918] The same four, for every OTHER anchor the registry injects into a
33+
* federated object and the object does not provision, read from the
34+
* registry's provenance rather than from a column name: `owning_business_unit_id`
35+
* on a business-unit delete, and `owner_id` / `created_by` / `updated_by` on a
36+
* user delete. Their blocks are at the foot of this file, and the predicate's
37+
* own pin is `federated-object.test.ts`.
38+
*
3239
* 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`.
40+
* other than the delete under test runs. The door pins are
41+
* `packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts`
42+
* and `packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts`.
3543
*/
3644

3745
import { describe, it, expect } from 'vitest';
@@ -44,12 +52,26 @@ const PACKAGE_ID = 'test-21910';
4452

4553
type Row = Record<string, unknown>;
4654

55+
/** Every field name a `where` filters on, at any depth, as `object.field`. */
56+
function filteredColumns(object: string, where: unknown, out: string[] = []): string[] {
57+
if (Array.isArray(where)) {
58+
for (const w of where) filteredColumns(object, w, out);
59+
} else if (where && typeof where === 'object') {
60+
for (const [k, v] of Object.entries(where)) {
61+
if (k.startsWith('$')) filteredColumns(object, v, out);
62+
else out.push(`${object}.${k}`);
63+
}
64+
}
65+
return out;
66+
}
67+
4768
/**
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.
69+
* A stub driver that records every read into a shared log, with the columns
70+
* each read filters on, and can be told to refuse reads of one object with an
71+
* exact error object. Its `find` applies the caller's `limit` after the
72+
* filter, by presence.
5173
*/
52-
function makeDriver(name: string, log: { reads: string[]; begun: number }) {
74+
function makeDriver(name: string, log: { reads: string[]; probes: string[]; begun: number }) {
5375
const tables: Record<string, Row[]> = {};
5476
const failReads = new Map<string, unknown>();
5577
const rowsOf = (o: string): Row[] => (tables[o] ??= []);
@@ -69,6 +91,7 @@ function makeDriver(name: string, log: { reads: string[]; begun: number }) {
6991
registerExternalObject() {},
7092
async find(o: string, ast: any) {
7193
log.reads.push(o);
94+
log.probes.push(...filteredColumns(o, ast?.where));
7295
const failure = failReads.get(o);
7396
if (failure !== undefined) throw failure;
7497
const hit = (tables[o] ?? []).filter((r) => matches(r, ast?.where));
@@ -176,7 +199,7 @@ function unknownColumnRefusal(object: string, column: string) {
176199
}
177200

178201
async function makeEngine(objects: any[]) {
179-
const log = { reads: [] as string[], begun: 0 };
202+
const log = { reads: [] as string[], probes: [] as string[], begun: 0 };
180203
const warnings: string[] = [];
181204
const logger = {
182205
debug() {}, info() {}, error() {},
@@ -268,3 +291,164 @@ describe('[#21910] the cascade atomicity plan agrees with the scan about who tak
268291
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toHaveLength(1);
269292
});
270293
});
294+
295+
// ---------------------------------------------------------------------------
296+
// [#21918] Every other injected anchor of a federated object, read from the
297+
// registry's provenance: `owning_business_unit_id` (ADR-0117 D1) on a business
298+
// unit delete, and the owner and audit lookups `owner_id` / `created_by` /
299+
// `updated_by` on a user delete. #21910's predicate named `organization_id`
300+
// alone, so on the showcase with its federated fixture a business-unit delete
301+
// answered 400 (`INVALID_FILTER` on `showcase_ext_customer.owning_business_unit_id`)
302+
// and a user delete answered 500 (on `created_by`). The door pins are
303+
// `packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts`.
304+
// ---------------------------------------------------------------------------
305+
306+
/** The business-unit object a federated object's injected `owning_business_unit_id` names. */
307+
const BUSINESS_UNIT = {
308+
name: 'sys_business_unit',
309+
label: 'Business Unit',
310+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
311+
};
312+
313+
/** The user object a federated object's injected `owner_id` / `created_by` / `updated_by` name. */
314+
const USER = {
315+
name: 'sys_user',
316+
label: 'User',
317+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
318+
};
319+
320+
/**
321+
* Federated, with two lookups the AUTHOR declared on the anchors' targets: an
322+
* `owner_id` of its own (it maps a real remote column) and `unit_ref`.
323+
*/
324+
const FEDERATED_AUTHOR_ANCHORS = {
325+
name: 'ext_assignment',
326+
label: 'External Assignment',
327+
datasource: REMOTE,
328+
external: { remoteName: 'assignments' },
329+
fields: {
330+
title: { name: 'title', label: 'Title', type: 'text' as const },
331+
owner_id: { name: 'owner_id', label: 'Remote Owner', type: 'lookup' as const, reference: 'sys_user' },
332+
unit_ref: { name: 'unit_ref', label: 'Unit', type: 'lookup' as const, reference: 'sys_business_unit' },
333+
},
334+
};
335+
336+
const UNIT_ID = 'bu_21918';
337+
const USER_ID = 'usr_21918';
338+
339+
async function makeAnchorEngine(objects: any[]) {
340+
const made = await makeEngine([ORGANIZATION, BUSINESS_UNIT, USER, ...objects]);
341+
made.local.seed('sys_business_unit', { id: UNIT_ID, name: 'Doomed Unit' });
342+
made.local.seed('sys_user', { id: USER_ID, name: 'Doomed User' });
343+
return made;
344+
}
345+
346+
/** The columns a delete's scan probed on one object, deduplicated and sorted. */
347+
const probedOn = (log: { probes: string[] }, object: string): string[] =>
348+
[...new Set(log.probes.filter((p) => p.startsWith(`${object}.`)))].sort();
349+
350+
describe('[#21918] the cascade scan skips every injected anchor a federated object does not provision', () => {
351+
it('a business-unit delete never probes a federated object on its injected owning_business_unit_id, and lands', async () => {
352+
const { engine, local, remote, log } = await makeAnchorEngine([LOCAL, FEDERATED]);
353+
// PREMISE: the registered anchor is the registry's own, and unprovisioned.
354+
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_customer'), 'owning_business_unit_id'))
355+
.toBe('injected-unprovisioned');
356+
remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'owning_business_unit_id'));
357+
358+
log.reads.length = 0;
359+
log.probes.length = 0;
360+
await engine.delete('sys_business_unit', { where: { id: UNIT_ID } } as any);
361+
362+
expect(local.has('sys_business_unit', UNIT_ID)).toBe(false);
363+
expect(log.reads).not.toContain('ext_customer');
364+
// CONTROL: the scan ran, and probed the LOCAL object's injected anchor of the same name.
365+
expect(probedOn(log, 'acct')).toContain('acct.owning_business_unit_id');
366+
});
367+
368+
it('a user delete never probes a federated object on its injected owner_id, created_by or updated_by, and lands', async () => {
369+
const { engine, local, remote, log } = await makeAnchorEngine([LOCAL, FEDERATED]);
370+
const schema = engine.getSchema('ext_customer');
371+
for (const column of ['owner_id', 'created_by', 'updated_by']) {
372+
expect(resolveInjectedColumnProvenance(schema, column), column).toBe('injected-unprovisioned');
373+
}
374+
remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'created_by'));
375+
376+
log.reads.length = 0;
377+
log.probes.length = 0;
378+
await engine.delete('sys_user', { where: { id: USER_ID } } as any);
379+
380+
expect(local.has('sys_user', USER_ID)).toBe(false);
381+
expect(log.reads).not.toContain('ext_customer');
382+
// CONTROL: the LOCAL object's injected owner and audit lookups ARE probed.
383+
expect(probedOn(log, 'acct')).toEqual(
384+
expect.arrayContaining(['acct.created_by', 'acct.owner_id', 'acct.updated_by']),
385+
);
386+
});
387+
388+
it('still probes lookups the AUTHOR declared on a federated object, and a business-unit probe failure propagates (#8895)', async () => {
389+
const { engine, local, remote, log } = await makeAnchorEngine([FEDERATED_AUTHOR_ANCHORS]);
390+
const injected = unknownColumnRefusal('ext_assignment', 'unit_ref');
391+
remote.failReads.set('ext_assignment', injected);
392+
393+
log.probes.length = 0;
394+
const err: any = await engine.delete('sys_business_unit', { where: { id: UNIT_ID } } as any).catch((e) => e);
395+
396+
expect(err).toBe(injected);
397+
expect(err.code).toBe('INVALID_FILTER');
398+
expect(err.status).toBe(400);
399+
// The probe that ran is the author's column, never the injected anchor beside it.
400+
expect(probedOn(log, 'ext_assignment')).toEqual(['ext_assignment.unit_ref']);
401+
expect(local.has('sys_business_unit', UNIT_ID)).toBe(true);
402+
});
403+
404+
it('still probes an owner_id the AUTHOR declared on a federated object, and a user probe failure propagates (#8895)', async () => {
405+
const { engine, local, remote, log } = await makeAnchorEngine([FEDERATED_AUTHOR_ANCHORS]);
406+
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_assignment'), 'owner_id')).toBe('author');
407+
const injected = unknownColumnRefusal('ext_assignment', 'owner_id');
408+
remote.failReads.set('ext_assignment', injected);
409+
410+
log.probes.length = 0;
411+
const err: any = await engine.delete('sys_user', { where: { id: USER_ID } } as any).catch((e) => e);
412+
413+
expect(err).toBe(injected);
414+
expect(err.code).toBe('INVALID_FILTER');
415+
expect(err.status).toBe(400);
416+
expect(probedOn(log, 'ext_assignment')).toEqual(['ext_assignment.owner_id']);
417+
expect(local.has('sys_user', USER_ID)).toBe(true);
418+
});
419+
});
420+
421+
describe('[#21918] the cascade atomicity plan agrees with the scan for every injected anchor', () => {
422+
it('runs a business-unit delete as one transaction when injected anchors were its only cross-datasource references', async () => {
423+
const { engine, local, log, warnings } = await makeAnchorEngine([LOCAL, FEDERATED]);
424+
425+
await engine.delete('sys_business_unit', { where: { id: UNIT_ID } } as any);
426+
427+
expect(local.has('sys_business_unit', UNIT_ID)).toBe(false);
428+
expect(log.begun).toBe(1);
429+
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]);
430+
});
431+
432+
it('runs a user delete as one transaction when injected anchors were its only cross-datasource references', async () => {
433+
const { engine, local, log, warnings } = await makeAnchorEngine([LOCAL, FEDERATED]);
434+
435+
await engine.delete('sys_user', { where: { id: USER_ID } } as any);
436+
437+
expect(local.has('sys_user', USER_ID)).toBe(false);
438+
expect(log.begun).toBe(1);
439+
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]);
440+
});
441+
442+
it('CONTROL: lookups the author declared on a federated object still make both plans cross-datasource', async () => {
443+
for (const [object, id] of [['sys_business_unit', UNIT_ID], ['sys_user', USER_ID]] as const) {
444+
const { engine, local, log, warnings } = await makeAnchorEngine([LOCAL, FEDERATED_AUTHOR_ANCHORS]);
445+
446+
await engine.delete(object, { where: { id } } as any);
447+
448+
expect(local.has(object, id), object).toBe(false);
449+
expect(log.reads, object).toContain('ext_assignment');
450+
expect(log.begun, object).toBe(0);
451+
expect(warnings.filter((w) => w.includes(NOT_ATOMIC)), object).toHaveLength(1);
452+
}
453+
});
454+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +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-
// [#21910] And its tenant-anchor refinement, which both cascade walks ask.
308-
import { isFederatedObject, isFederatedInjectedTenantAnchor } from './federated-object.js';
307+
// [#21910, #21918] And its injected-column refinement, which both cascade walks ask.
308+
import { isFederatedObject, isFederatedUnprovisionedInjectedColumn } from './federated-object.js';
309309
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
310310
import {
311311
resolveEngineDeleteDispatch,
@@ -15857,12 +15857,13 @@ export class ObjectQL implements IObjectQLEngine {
1585715857
let resolvedRef: string | undefined;
1585815858
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
1585915859
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
15860+
// [#21910, #21918] The scan skips every column the registry injected
15861+
// into a federated object and the object does not provision, so this
15862+
// walk does too: the participant test stays the scan's own, as the
15863+
// comment above requires. A federated object this walk still reaches
15864+
// through a relation its author declared keeps the verdict
1586415865
// `'split'`, because the scan probes that relation.
15865-
if (isFederatedInjectedTenantAnchor(child, fieldName)) continue;
15866+
if (isFederatedUnprovisionedInjectedColumn(child, fieldName)) continue;
1586615867
out.push(childName);
1586715868
break;
1586815869
}
@@ -16331,25 +16332,30 @@ export class ObjectQL implements IObjectQLEngine {
1633116332
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
1633216333
if (ref !== object && resolvedRef !== object) continue;
1633316334

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.
16335+
// [#21910, #21918] A lookup the registry INJECTED into a federated
16336+
// object, and the object does not provision, is not a reference to
16337+
// anything, so it is not a relation to probe. That is the tenant
16338+
// anchor `organization_id`, the ADR-0117 D1 anchor
16339+
// `owning_business_unit_id`, and the owner and audit lookups
16340+
// `owner_id` / `created_by` / `updated_by`. On a federated object each
16341+
// exists in the registered schema and nowhere else: the probe below
16342+
// was refused by the driver (`INVALID_FILTER`, no such column), its
16343+
// catch propagated the refusal as #8895 rules for a missing column,
16344+
// and deleting the organization, business unit or user it names was
16345+
// refused on a deployment with a federated object bound.
1634116346
// `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.
16347+
// reading of the tenant column.
16348+
// {@link isFederatedUnprovisionedInjectedColumn} reads which columns
16349+
// those are from the registry's own provenance, never from a list of
16350+
// names, and {@link ObjectQL.planCascadeAtomicity} asks it too.
1634516351
//
1634616352
// ⛔ The catch below is deliberately NOT widened to pass a missing
1634716353
// 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;
16354+
// propagate for every object, not just these injected columns: a
16355+
// lookup the author declared on a federated object, including an
16356+
// author's own `organization_id` or `owner_id`, stays in the scan, and
16357+
// its probe failure still propagates.
16358+
if (isFederatedUnprovisionedInjectedColumn(child, fieldName)) continue;
1635316359

1635416360
// A master-detail parent owns its children: cascade by default (the
1635516361
// child FK is typically required, so set_null would be invalid). Only

0 commit comments

Comments
 (0)