Skip to content

Commit 7fbc39e

Browse files
committed
test(objectql): pin the general predicate, the cascade and plan on every injected anchor, and the lifecycle partition
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
1 parent 725a71b commit 7fbc39e

3 files changed

Lines changed: 471 additions & 7 deletions

File tree

‎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+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21918] `isFederatedUnprovisionedInjectedColumn` answers "the registry
5+
* injected this column into a federated object, and the object does not
6+
* provision it" for EVERY such column, and for nothing else.
7+
*
8+
* #21910 introduced the predicate for `organization_id` alone. The business
9+
* unit and user deletes then failed on the next anchors the same registry pass
10+
* injects (`owning_business_unit_id`, `owner_id`, `created_by`, `updated_by`),
11+
* so the predicate now reads the registry's own provenance
12+
* (`resolveInjectedColumnProvenance`, the #7865 marker) and names no column.
13+
*
14+
* Every schema below is the REGISTERED one, read back from a `SchemaRegistry`
15+
* after `registerObject`, because the provenance verdict is about the
16+
* definitions the injection actually stored, not about what the author wrote.
17+
*
18+
* The engine readers that ask the predicate are pinned at their seams:
19+
* `engine-cascade-federated-tenant-anchor.test.ts` (the cascade scan and its
20+
* plan) and `lifecycle/lifecycle-service.test.ts` (the tenant partition). The
21+
* enumeration of every reader is `federated-injected-column-readers.test.ts`.
22+
*/
23+
24+
import { describe, it, expect } from 'vitest';
25+
import { resolveInjectedColumnProvenance, unprovisionedInjectedColumns } from '@objectstack/spec/data';
26+
import { SchemaRegistry } from './registry.js';
27+
import { isFederatedObject, isFederatedUnprovisionedInjectedColumn } from './federated-object.js';
28+
29+
const REMOTE = 'remote_ds';
30+
31+
/** Federated, as the showcase declares its external objects: no column of the platform's own. */
32+
const FEDERATED = {
33+
name: 'ext_customer',
34+
label: 'External Customer',
35+
datasource: REMOTE,
36+
external: { remoteName: 'customers' },
37+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
38+
};
39+
40+
/** The same declaration with no `external` binding: the platform provisions its storage. */
41+
const LOCAL = {
42+
name: 'acct',
43+
label: 'Account',
44+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
45+
};
46+
47+
/**
48+
* Federated, with an `organization_id` and an `owner_id` the AUTHOR declared
49+
* (each maps a real remote column), and one more lookup of the author's own.
50+
*/
51+
const FEDERATED_AUTHOR_COLUMNS = {
52+
name: 'ext_tenant_customer',
53+
label: 'External Tenant Customer',
54+
datasource: REMOTE,
55+
external: { remoteName: 'tenant_customers' },
56+
fields: {
57+
name: { name: 'name', label: 'Name', type: 'text' as const },
58+
organization_id: {
59+
name: 'organization_id',
60+
label: 'Remote Organization',
61+
type: 'lookup' as const,
62+
reference: 'sys_organization',
63+
},
64+
owner_id: { name: 'owner_id', label: 'Remote Owner', type: 'lookup' as const, reference: 'sys_user' },
65+
unit_ref: { name: 'unit_ref', label: 'Unit', type: 'lookup' as const, reference: 'sys_business_unit' },
66+
},
67+
};
68+
69+
function registered(...objects: any[]): SchemaRegistry {
70+
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
71+
for (const o of objects) registry.registerObject(o, 'test-21918');
72+
return registry;
73+
}
74+
75+
/** The injected lookups on a federated object, read off the registered schema's relation fields. */
76+
function relationFieldsOf(schema: any): string[] {
77+
return Object.entries<any>(schema?.fields ?? {})
78+
.filter(([, f]) => f?.type === 'lookup' || f?.type === 'master_detail')
79+
.map(([name]) => name);
80+
}
81+
82+
describe('[#21918] isFederatedUnprovisionedInjectedColumn: every injected column a federated object does not provision', () => {
83+
it('accepts every injected anchor of a federated object, the tenant anchor and every other one', () => {
84+
const schema = registered(FEDERATED).getObject('ext_customer');
85+
expect(isFederatedObject(schema)).toBe(true);
86+
87+
// PREMISE: the registry injected these lookups, and its provenance calls
88+
// each one unprovisioned. The names are the measurement, not the decision.
89+
const relations = relationFieldsOf(schema);
90+
expect(relations).toEqual(
91+
expect.arrayContaining(['organization_id', 'owning_business_unit_id', 'owner_id', 'created_by', 'updated_by']),
92+
);
93+
94+
for (const column of relations) {
95+
expect(resolveInjectedColumnProvenance(schema, column), column).toBe('injected-unprovisioned');
96+
expect(isFederatedUnprovisionedInjectedColumn(schema, column), column).toBe(true);
97+
}
98+
// The anchors past the tenant one, named, because they are what #21910's
99+
// tenant-only predicate missed and the business-unit and user deletes hit.
100+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'owning_business_unit_id')).toBe(true);
101+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'owner_id')).toBe(true);
102+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'created_by')).toBe(true);
103+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'updated_by')).toBe(true);
104+
// And the non-lookup audit columns, which the same pass injects.
105+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'created_at')).toBe(true);
106+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'updated_at')).toBe(true);
107+
});
108+
109+
it('agrees with the registry provenance on every field of every registered object, and asks it for nothing else', () => {
110+
const registry = registered(FEDERATED, LOCAL, FEDERATED_AUTHOR_COLUMNS);
111+
for (const name of ['ext_customer', 'acct', 'ext_tenant_customer']) {
112+
const schema = registry.getObject(name);
113+
const unprovisioned = new Set(unprovisionedInjectedColumns(schema));
114+
for (const column of [...Object.keys((schema as any)?.fields ?? {}), 'id', 'no_such_column']) {
115+
expect(isFederatedUnprovisionedInjectedColumn(schema, column), `${name}.${column}`).toBe(unprovisioned.has(column));
116+
}
117+
}
118+
});
119+
120+
it('refuses a column the author declared on a federated object, including its own organization_id and owner_id', () => {
121+
const schema = registered(FEDERATED_AUTHOR_COLUMNS).getObject('ext_tenant_customer');
122+
for (const column of ['organization_id', 'owner_id', 'unit_ref', 'name']) {
123+
expect(resolveInjectedColumnProvenance(schema, column), column).toBe('author');
124+
expect(isFederatedUnprovisionedInjectedColumn(schema, column), column).toBe(false);
125+
}
126+
// The anchors the author did NOT declare are still the registry's, and still unprovisioned.
127+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'owning_business_unit_id')).toBe(true);
128+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'created_by')).toBe(true);
129+
});
130+
131+
it('refuses every injected column of a LOCAL object: the platform provisions its storage', () => {
132+
const schema = registered(LOCAL).getObject('acct');
133+
expect(isFederatedObject(schema)).toBe(false);
134+
for (const column of relationFieldsOf(schema)) {
135+
expect(resolveInjectedColumnProvenance(schema, column), column).toBe('injected-provisioned');
136+
expect(isFederatedUnprovisionedInjectedColumn(schema, column), column).toBe(false);
137+
}
138+
});
139+
140+
it('refuses a column that is neither injected nor declared, and any input that is not an object', () => {
141+
const schema = registered(FEDERATED).getObject('ext_customer');
142+
expect(resolveInjectedColumnProvenance(schema, 'id')).toBe('absent');
143+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'id')).toBe(false);
144+
expect(isFederatedUnprovisionedInjectedColumn(schema, 'no_such_column')).toBe(false);
145+
for (const input of [undefined, null, 'ext_customer', 42, []]) {
146+
expect(isFederatedUnprovisionedInjectedColumn(input, 'organization_id')).toBe(false);
147+
}
148+
});
149+
});

0 commit comments

Comments
 (0)