|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21910] The referential cascade does not treat a federated object's |
| 5 | + * platform-INJECTED `organization_id` as a reference to `sys_organization`, |
| 6 | + * and treats nothing else that way. |
| 7 | + * |
| 8 | + * The registry injects the tenant anchor (`organization_id`, a lookup to |
| 9 | + * `sys_organization`) into every object it registers, ADR-0015 `external` ones |
| 10 | + * included, and the platform provisions no storage for a federated object. On |
| 11 | + * the showcase, deleting an organization ran the cascade scan's dependents |
| 12 | + * probe against the remote `customers` table on that column. The SQL driver |
| 13 | + * refused it (`INVALID_FILTER`, no such column), the probe's #8895 catch |
| 14 | + * propagated the refusal, and the organization delete answered 500. |
| 15 | + * |
| 16 | + * What this file pins, all through `engine.delete` on a two-driver engine (the |
| 17 | + * default one, and the remote a federated object is bound to by `datasource`): |
| 18 | + * |
| 19 | + * 1. the scan never reads a federated object on its injected anchor, so an |
| 20 | + * organization delete lands while that read would be refused. A local |
| 21 | + * object's injected anchor IS read on the same delete, which proves the |
| 22 | + * scan ran; |
| 23 | + * 2. an `organization_id` the AUTHOR declared on a federated object is still |
| 24 | + * probed, and a probe failure still propagates (#8895); |
| 25 | + * 3. any other lookup the author declares on a federated object is still |
| 26 | + * probed, and a probe failure still propagates (#8895); |
| 27 | + * 4. the cascade's atomicity plan agrees with the scan: an organization delete |
| 28 | + * whose only cross-datasource "participant" was the injected anchor runs |
| 29 | + * as one transaction, while an author-declared federated lookup still |
| 30 | + * makes the plan cross-datasource. |
| 31 | + * |
| 32 | + * The seed rows are written straight into the stub's store, so no write path |
| 33 | + * other than the delete under test runs. The door pin is |
| 34 | + * `packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts`. |
| 35 | + */ |
| 36 | + |
| 37 | +import { describe, it, expect } from 'vitest'; |
| 38 | +import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; |
| 39 | +import { ObjectQL } from './engine.js'; |
| 40 | + |
| 41 | +/** The remote datasource every federated fixture below is bound to. */ |
| 42 | +const REMOTE = 'remote_ds'; |
| 43 | +const PACKAGE_ID = 'test-21910'; |
| 44 | + |
| 45 | +type Rows = Map<string, Map<string, Record<string, unknown>>>; |
| 46 | + |
| 47 | +/** |
| 48 | + * A stub driver that records every read into a shared log and can be told to |
| 49 | + * refuse reads of one object with an exact error object. |
| 50 | + */ |
| 51 | +function makeDriver(name: string, log: { reads: string[]; begun: number }) { |
| 52 | + const stores: Rows = new Map(); |
| 53 | + const failReads = new Map<string, unknown>(); |
| 54 | + const storeFor = (o: string) => { |
| 55 | + let s = stores.get(o); |
| 56 | + if (!s) { s = new Map(); stores.set(o, s); } |
| 57 | + return s; |
| 58 | + }; |
| 59 | + const matches = (row: Record<string, unknown>, where: any): boolean => { |
| 60 | + if (!where || typeof where !== 'object') return true; |
| 61 | + for (const [k, v] of Object.entries(where)) { |
| 62 | + if (k.startsWith('$')) continue; |
| 63 | + const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; |
| 64 | + if ((row[k] ?? null) !== (exp ?? null)) return false; |
| 65 | + } |
| 66 | + return true; |
| 67 | + }; |
| 68 | + const gate = (o: string) => { |
| 69 | + log.reads.push(o); |
| 70 | + if (failReads.has(o)) throw failReads.get(o); |
| 71 | + }; |
| 72 | + const driver: any = { |
| 73 | + name, version: '0.0.0', supports: {}, |
| 74 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, |
| 75 | + async syncSchema() {}, |
| 76 | + registerExternalObject() {}, |
| 77 | + async find(o: string, ast: any) { |
| 78 | + gate(o); |
| 79 | + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); |
| 80 | + }, |
| 81 | + async findOne(o: string, ast: any) { |
| 82 | + gate(o); |
| 83 | + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; |
| 84 | + return null; |
| 85 | + }, |
| 86 | + async count(o: string, ast: any) { |
| 87 | + gate(o); |
| 88 | + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length; |
| 89 | + }, |
| 90 | + async create(o: string, data: Record<string, unknown>) { |
| 91 | + const row = { ...data, id: String(data.id) }; |
| 92 | + storeFor(o).set(row.id, row); |
| 93 | + return row; |
| 94 | + }, |
| 95 | + async update(o: string, id: string, data: Record<string, unknown>) { |
| 96 | + const cur = storeFor(o).get(String(id)); |
| 97 | + if (!cur) throw new Error(`not found ${o}/${id}`); |
| 98 | + const up = { ...cur, ...data, id: String(id) }; |
| 99 | + storeFor(o).set(String(id), up); |
| 100 | + return up; |
| 101 | + }, |
| 102 | + async upsert(o: string, data: Record<string, unknown>) { return this.create(o, data); }, |
| 103 | + async delete(o: string, id: string) { return storeFor(o).delete(String(id)); }, |
| 104 | + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 105 | + async beginTransaction() { log.begun += 1; return { id: `trx_${log.begun}` }; }, |
| 106 | + async commit() {}, async rollback() {}, |
| 107 | + }; |
| 108 | + return { driver, stores, failReads, seed: (o: string, row: Record<string, unknown>) => storeFor(o).set(String(row.id), row) }; |
| 109 | +} |
| 110 | + |
| 111 | +/** The deleted object. Its own injected anchor makes it self-referencing, harmlessly. */ |
| 112 | +const ORGANIZATION = { |
| 113 | + name: 'sys_organization', |
| 114 | + label: 'Organization', |
| 115 | + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, |
| 116 | +}; |
| 117 | + |
| 118 | +/** A LOCAL object: the registry injects `organization_id`, and storage backs it. */ |
| 119 | +const LOCAL = { |
| 120 | + name: 'acct', |
| 121 | + label: 'Account', |
| 122 | + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, |
| 123 | +}; |
| 124 | + |
| 125 | +/** Federated, as the showcase declares it: no `organization_id` of its own. */ |
| 126 | +const FEDERATED = { |
| 127 | + name: 'ext_customer', |
| 128 | + label: 'External Customer', |
| 129 | + datasource: REMOTE, |
| 130 | + external: { remoteName: 'customers' }, |
| 131 | + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, |
| 132 | +}; |
| 133 | + |
| 134 | +/** Federated, with an `organization_id` the AUTHOR declared: it maps a real remote column. */ |
| 135 | +const FEDERATED_DECLARED_ANCHOR = { |
| 136 | + name: 'ext_tenant_customer', |
| 137 | + label: 'External Tenant Customer', |
| 138 | + datasource: REMOTE, |
| 139 | + external: { remoteName: 'tenant_customers' }, |
| 140 | + fields: { |
| 141 | + name: { name: 'name', label: 'Name', type: 'text' as const }, |
| 142 | + organization_id: { |
| 143 | + name: 'organization_id', |
| 144 | + label: 'Remote Organization', |
| 145 | + type: 'lookup' as const, |
| 146 | + reference: 'sys_organization', |
| 147 | + }, |
| 148 | + }, |
| 149 | +}; |
| 150 | + |
| 151 | +/** Federated, with another lookup the author declared against the organization. */ |
| 152 | +const FEDERATED_AUTHOR_LOOKUP = { |
| 153 | + name: 'ext_order', |
| 154 | + label: 'External Order', |
| 155 | + datasource: REMOTE, |
| 156 | + external: { remoteName: 'orders' }, |
| 157 | + fields: { |
| 158 | + amount: { name: 'amount', label: 'Amount', type: 'number' as const }, |
| 159 | + org_ref: { name: 'org_ref', label: 'Organization', type: 'lookup' as const, reference: 'sys_organization' }, |
| 160 | + }, |
| 161 | +}; |
| 162 | + |
| 163 | +const ORG_ID = 'org_21910'; |
| 164 | + |
| 165 | +/** The refusal the SQL driver answers for a filter on a column the remote does not have. */ |
| 166 | +function unknownColumnRefusal(object: string, column: string) { |
| 167 | + return Object.assign( |
| 168 | + new Error(`A filter on object '${object}' names a column the database could not resolve (${column}).`), |
| 169 | + { code: 'INVALID_FILTER', status: 400 }, |
| 170 | + ); |
| 171 | +} |
| 172 | + |
| 173 | +async function makeEngine(objects: any[]) { |
| 174 | + const log = { reads: [] as string[], begun: 0 }; |
| 175 | + const warnings: string[] = []; |
| 176 | + const logger = { |
| 177 | + debug() {}, info() {}, error() {}, |
| 178 | + warn: (message: unknown) => void warnings.push(String(message)), |
| 179 | + }; |
| 180 | + const engine = new ObjectQL({ logger } as any); |
| 181 | + const local = makeDriver('memory', log); |
| 182 | + const remote = makeDriver(REMOTE, log); |
| 183 | + engine.registerDriver(local.driver, true); |
| 184 | + engine.registerDriver(remote.driver); |
| 185 | + await engine.init(); |
| 186 | + for (const o of objects) engine.registry.registerObject(o, PACKAGE_ID); |
| 187 | + local.seed('sys_organization', { id: ORG_ID, name: 'Doomed Org' }); |
| 188 | + return { engine, local, remote, log, warnings }; |
| 189 | +} |
| 190 | + |
| 191 | +const NOT_ATOMIC = 'cannot run as one unit of work'; |
| 192 | + |
| 193 | +describe('[#21910] the cascade scan skips a federated object\'s injected tenant anchor, and nothing else', () => { |
| 194 | + it('never probes a federated object on its injected organization_id, so the organization delete lands', async () => { |
| 195 | + const { engine, local, remote, log } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]); |
| 196 | + // PREMISE: the registered schema carries the platform's injected anchor. |
| 197 | + expect(resolveInjectedColumnProvenance(engine.getSchema('ext_customer'), 'organization_id')) |
| 198 | + .toBe('injected-unprovisioned'); |
| 199 | + // Any read of the remote table on that column is refused, as the showcase measured. |
| 200 | + remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'organization_id')); |
| 201 | + |
| 202 | + log.reads.length = 0; |
| 203 | + await engine.delete('sys_organization', { where: { id: ORG_ID } } as any); |
| 204 | + |
| 205 | + expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(false); |
| 206 | + expect(log.reads).not.toContain('ext_customer'); |
| 207 | + // CONTROL: the scan ran for this delete, and probed the local object's injected anchor. |
| 208 | + expect(log.reads).toContain('acct'); |
| 209 | + }); |
| 210 | + |
| 211 | + it('still probes an organization_id the AUTHOR declared on a federated object, and its failure propagates (#8895)', async () => { |
| 212 | + const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_DECLARED_ANCHOR]); |
| 213 | + expect(resolveInjectedColumnProvenance(engine.getSchema('ext_tenant_customer'), 'organization_id')) |
| 214 | + .toBe('author'); |
| 215 | + const injected = unknownColumnRefusal('ext_tenant_customer', 'organization_id'); |
| 216 | + remote.failReads.set('ext_tenant_customer', injected); |
| 217 | + |
| 218 | + log.reads.length = 0; |
| 219 | + const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e); |
| 220 | + |
| 221 | + expect(err).toBe(injected); |
| 222 | + expect(err.code).toBe('INVALID_FILTER'); |
| 223 | + expect(err.status).toBe(400); |
| 224 | + expect(log.reads).toContain('ext_tenant_customer'); |
| 225 | + expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(true); |
| 226 | + }); |
| 227 | + |
| 228 | + it('still probes any other lookup the author declared on a federated object, and its failure propagates (#8895)', async () => { |
| 229 | + const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_AUTHOR_LOOKUP]); |
| 230 | + const injected = unknownColumnRefusal('ext_order', 'org_ref'); |
| 231 | + remote.failReads.set('ext_order', injected); |
| 232 | + |
| 233 | + log.reads.length = 0; |
| 234 | + const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e); |
| 235 | + |
| 236 | + expect(err).toBe(injected); |
| 237 | + expect(err.code).toBe('INVALID_FILTER'); |
| 238 | + expect(err.status).toBe(400); |
| 239 | + expect(log.reads).toContain('ext_order'); |
| 240 | + expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(true); |
| 241 | + }); |
| 242 | +}); |
| 243 | + |
| 244 | +describe('[#21910] the cascade atomicity plan agrees with the scan about who takes part', () => { |
| 245 | + it('runs the organization delete as one transaction when the injected anchor was its only cross-datasource reference', async () => { |
| 246 | + const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]); |
| 247 | + |
| 248 | + await engine.delete('sys_organization', { where: { id: ORG_ID } } as any); |
| 249 | + |
| 250 | + expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(false); |
| 251 | + expect(log.begun).toBe(1); |
| 252 | + expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]); |
| 253 | + }); |
| 254 | + |
| 255 | + it('CONTROL: an author-declared lookup on a federated object still makes the plan cross-datasource', async () => { |
| 256 | + const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED_AUTHOR_LOOKUP]); |
| 257 | + |
| 258 | + await engine.delete('sys_organization', { where: { id: ORG_ID } } as any); |
| 259 | + |
| 260 | + expect(local.stores.get('sys_organization')?.has(ORG_ID)).toBe(false); |
| 261 | + expect(log.reads).toContain('ext_order'); |
| 262 | + expect(log.begun).toBe(0); |
| 263 | + expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toHaveLength(1); |
| 264 | + }); |
| 265 | +}); |
0 commit comments