|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21597] `loadGovernance`'s tenant scan asks the REGISTRY whether |
| 5 | + * `sys_organization` is registered before it reads it. |
| 6 | + * |
| 7 | + * Since commit eb9ef791bd an in-process engine verb refuses an object name the |
| 8 | + * registry does not resolve, with `OBJECT_NOT_FOUND` (404), before any driver |
| 9 | + * is asked. A composition that registers no `sys_organization` therefore no |
| 10 | + * longer reaches a driver with the tenant scan's read: it gets the engine's |
| 11 | + * refusal. That is not a missing table, so the scan's catch rethrew it, and |
| 12 | + * every sweep aborted before applying a single policy. |
| 13 | + * |
| 14 | + * Unregistered is the single-tenant answer. With no organization object there |
| 15 | + * is no tenant to hold an override, and that is the same answer |
| 16 | + * `ObjectQL.probeInstallOrganizations` gives to the same question. What does |
| 17 | + * NOT change is the catch: a missing table on a REGISTERED `sys_organization` |
| 18 | + * is still the one benign driver cause, and every other failure still aborts |
| 19 | + * the sweep (#12853). In particular an `OBJECT_NOT_FOUND` is not read as |
| 20 | + * absence wholesale: the registry already said the object is registered, so a |
| 21 | + * refusal arriving from the read is about something else. |
| 22 | + * |
| 23 | + * Every case runs on a REAL `ObjectQL` engine over a stub driver, so the |
| 24 | + * refusal the guard avoids is the engine's own and not a double's guess at it. |
| 25 | + * `engine.find` is spied with call-through: the spy records what the sweep |
| 26 | + * asked the engine, and the engine still answers. |
| 27 | + */ |
| 28 | + |
| 29 | +import { describe, it, expect, vi } from 'vitest'; |
| 30 | +import type { EngineQueryOptions } from '@objectstack/spec/data'; |
| 31 | +import { ObjectQL } from '../engine.js'; |
| 32 | +import { LifecycleService } from './lifecycle-service.js'; |
| 33 | +import { parseLifecycleDuration } from './duration.js'; |
| 34 | + |
| 35 | +const FIXED_NOW = 1_700_000_000_000; |
| 36 | +const PACKAGE_ID = 'lifecycle-organization-registry'; |
| 37 | + |
| 38 | +/** The lifecycle-declared object every sweep below reaps. */ |
| 39 | +const TELEMETRY_OBJ = { |
| 40 | + name: 'sys_job_run', |
| 41 | + fields: { status: { type: 'text' } }, |
| 42 | + lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } }, |
| 43 | +} as any; |
| 44 | + |
| 45 | +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; |
| 46 | + |
| 47 | +/** A system-context read of one row, spelled as a typed query. */ |
| 48 | +const ORG_PROBE: EngineQueryOptions = { limit: 1, context: { isSystem: true } }; |
| 49 | + |
| 50 | +const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString(); |
| 51 | + |
| 52 | +/** The regulated tenant keeps its rows three times longer than the global window. */ |
| 53 | +const TENANT_OVERRIDES = { org_reg: { retention_overrides: { sys_job_run: { maxAge: '90d' } } } }; |
| 54 | + |
| 55 | +/** Settings service whose only values are tenant-scoped ones. */ |
| 56 | +function fakeSettings(tenantValues: Record<string, Record<string, unknown>>) { |
| 57 | + return { |
| 58 | + async get(_ns: string, key: string, ctx?: Record<string, unknown>) { |
| 59 | + const tenantId = ctx?.tenantId as string | undefined; |
| 60 | + if (tenantId && tenantValues[tenantId] && key in tenantValues[tenantId]) { |
| 61 | + return { value: tenantValues[tenantId][key], source: 'tenant' }; |
| 62 | + } |
| 63 | + return { value: undefined, source: 'default' }; |
| 64 | + }, |
| 65 | + }; |
| 66 | +} |
| 67 | + |
| 68 | +/** A transient database outage, the shape `isMissingTableError` answers `false` for. */ |
| 69 | +const outage = () => |
| 70 | + Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); |
| 71 | + |
| 72 | +/** The benign unprovisioned table, in the SQLite-family spelling. */ |
| 73 | +const missingTable = () => new Error('no such table: sys_organization'); |
| 74 | + |
| 75 | +const abortedError = (message: string) => |
| 76 | + `governance snapshot could not be loaded (${message}) — sweep aborted before any policy ` + |
| 77 | + 'was applied, so no rows were reaped for this object'; |
| 78 | + |
| 79 | +/** |
| 80 | + * A real engine with `sys_job_run` registered, and `sys_organization` |
| 81 | + * registered only when asked. The stub driver answers the organization read |
| 82 | + * with `organizationRead` and every candidate page with no rows. |
| 83 | + */ |
| 84 | +async function lifecycleEngine(opts: { |
| 85 | + registerOrganization: boolean; |
| 86 | + organizationRead?: () => Array<Record<string, unknown>>; |
| 87 | +}) { |
| 88 | + const driverReads: string[] = []; |
| 89 | + const driverDeletes: string[] = []; |
| 90 | + const organizationRead = opts.organizationRead ?? (() => [{ id: 'org_reg' }]); |
| 91 | + const driver = { |
| 92 | + name: 'memory', |
| 93 | + version: '0.0.0', |
| 94 | + supports: {}, |
| 95 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, |
| 96 | + async execute() { return null; }, |
| 97 | + async find(object: string) { |
| 98 | + driverReads.push(object); |
| 99 | + return object === 'sys_organization' ? organizationRead() : []; |
| 100 | + }, |
| 101 | + async findOne() { return null; }, |
| 102 | + async count() { return 0; }, |
| 103 | + async create(_object: string, data: any) { return { id: 'r_1', ...data }; }, |
| 104 | + async update(_object: string, id: string, data: any) { return { id, ...data }; }, |
| 105 | + async delete(object: string) { driverDeletes.push(object); return true; }, |
| 106 | + async bulkCreate(_object: string, rows: any[]) { return rows; }, |
| 107 | + async bulkUpdate() { return []; }, |
| 108 | + async bulkDelete(object: string) { driverDeletes.push(object); }, |
| 109 | + async syncSchema() {}, |
| 110 | + } as any; |
| 111 | + |
| 112 | + const engine = new ObjectQL(); |
| 113 | + engine.registerDriver(driver, true); |
| 114 | + await engine.init(); |
| 115 | + engine.registry.registerObject(TELEMETRY_OBJ, PACKAGE_ID); |
| 116 | + if (opts.registerOrganization) engine.registry.registerObject(ORG_OBJECT, PACKAGE_ID); |
| 117 | + |
| 118 | + const find = vi.spyOn(engine, 'find'); |
| 119 | + const callsOn = (object: string) => |
| 120 | + find.mock.calls.map((call, i) => ({ call, i })).filter(({ call }) => call[0] === object); |
| 121 | + |
| 122 | + return { |
| 123 | + engine, |
| 124 | + driverReads, |
| 125 | + driverDeletes, |
| 126 | + /** The `where` of every candidate read the reaper issued through the engine. */ |
| 127 | + reapReads: () => callsOn('sys_job_run').map(({ call }) => call[1]?.where), |
| 128 | + /** How many times the tenant scan asked the engine for `sys_organization`. */ |
| 129 | + orgReads: () => callsOn('sys_organization').length, |
| 130 | + /** What the engine rejected the tenant scan's read with. */ |
| 131 | + orgReadRejection: async () => { |
| 132 | + const [first] = callsOn('sys_organization'); |
| 133 | + return (find.mock.results[first.i].value as Promise<unknown>).then( |
| 134 | + () => { throw new Error('the tenant scan read resolved; expected a rejection'); }, |
| 135 | + (error: unknown) => error as Error & { code?: unknown; status?: unknown; object?: unknown }, |
| 136 | + ); |
| 137 | + }, |
| 138 | + }; |
| 139 | +} |
| 140 | + |
| 141 | +function sweepOnce(engine: ObjectQL, warn: (msg: string) => void = () => {}) { |
| 142 | + return new LifecycleService({ |
| 143 | + getEngine: () => engine, |
| 144 | + logger: { info: () => {}, warn, debug: () => {} }, |
| 145 | + now: () => FIXED_NOW, |
| 146 | + initialDelayMs: 1, |
| 147 | + sweepIntervalMs: 10, |
| 148 | + getSettings: () => fakeSettings(TENANT_OVERRIDES), |
| 149 | + referenceAudit: { enabled: false }, |
| 150 | + }).sweep(); |
| 151 | +} |
| 152 | + |
| 153 | +describe('LifecycleService.sweep — the tenant scan asks the registry first (#21597)', () => { |
| 154 | + // ── POSITIVE CONTROL ────────────────────────────────────────────────────── |
| 155 | + |
| 156 | + it('control: a REGISTERED, provisioned sys_organization is read, and its tenant gets its own window', async () => { |
| 157 | + const box = await lifecycleEngine({ registerOrganization: true }); |
| 158 | + |
| 159 | + const report = await sweepOnce(box.engine); |
| 160 | + |
| 161 | + // The guard does not skip the scan for a registered object: the read |
| 162 | + // reached the driver, and the tenant pass ran before the global one. |
| 163 | + expect(box.orgReads()).toBe(1); |
| 164 | + expect(box.driverReads.filter((o) => o === 'sys_organization')).toHaveLength(1); |
| 165 | + expect(box.reapReads()).toEqual([ |
| 166 | + { created_at: { $lt: isoCutoff('90d') }, organization_id: 'org_reg' }, |
| 167 | + { |
| 168 | + created_at: { $lt: isoCutoff('30d') }, |
| 169 | + $or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }], |
| 170 | + }, |
| 171 | + ]); |
| 172 | + expect(report.errors).toEqual([]); |
| 173 | + }); |
| 174 | + |
| 175 | + // ── THE DEFECT ──────────────────────────────────────────────────────────── |
| 176 | + |
| 177 | + it('premise: with no sys_organization registered, the engine refuses the read itself', async () => { |
| 178 | + const box = await lifecycleEngine({ registerOrganization: false }); |
| 179 | + |
| 180 | + const refusal = await box.engine |
| 181 | + .find('sys_organization', ORG_PROBE) |
| 182 | + .then(() => undefined, (error: unknown) => error as Error & { code?: unknown; status?: unknown; object?: unknown }); |
| 183 | + |
| 184 | + expect(refusal?.code).toBe('OBJECT_NOT_FOUND'); |
| 185 | + expect(refusal?.status).toBe(404); |
| 186 | + expect(refusal?.object).toBe('sys_organization'); |
| 187 | + // Refused before any driver was asked: this is not a missing table. |
| 188 | + expect(box.driverReads).toEqual([]); |
| 189 | + }); |
| 190 | + |
| 191 | + it('an UNREGISTERED sys_organization is the single-tenant answer: the sweep runs on the global window', async () => { |
| 192 | + const warn = vi.fn(); |
| 193 | + const box = await lifecycleEngine({ registerOrganization: false }); |
| 194 | + |
| 195 | + const report = await sweepOnce(box.engine, warn); |
| 196 | + |
| 197 | + // The sweep was not aborted. Asserted first so that a regression shows the |
| 198 | + // abort it reported, not a call count. |
| 199 | + expect(report.errors).toEqual([]); |
| 200 | + // The registry answered, so the scan never read: no engine refusal to |
| 201 | + // abort on, and nothing reached the driver for `sys_organization`. |
| 202 | + expect(box.orgReads()).toBe(0); |
| 203 | + expect(box.driverReads.filter((o) => o === 'sys_organization')).toEqual([]); |
| 204 | + // The sweep RAN: one global pass, no tenant pass. |
| 205 | + expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]); |
| 206 | + expect(report.swept).toEqual([ |
| 207 | + { object: 'sys_job_run', class: 'telemetry', policy: 'retention', cutoff: isoCutoff('30d'), deleted: 0 }, |
| 208 | + ]); |
| 209 | + expect(warn).not.toHaveBeenCalled(); |
| 210 | + }); |
| 211 | + |
| 212 | + // ── THE BENIGN DRIVER CAUSE IS UNCHANGED ────────────────────────────────── |
| 213 | + |
| 214 | + it('a REGISTERED but unprovisioned sys_organization keeps the missing-table answer', async () => { |
| 215 | + const box = await lifecycleEngine({ |
| 216 | + registerOrganization: true, |
| 217 | + organizationRead: () => { throw missingTable(); }, |
| 218 | + }); |
| 219 | + |
| 220 | + const report = await sweepOnce(box.engine); |
| 221 | + |
| 222 | + // Proof the benign branch was exercised: the scan read, the driver threw. |
| 223 | + expect(box.orgReads()).toBe(1); |
| 224 | + expect(box.driverReads.filter((o) => o === 'sys_organization')).toHaveLength(1); |
| 225 | + expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]); |
| 226 | + expect(report.errors).toEqual([]); |
| 227 | + expect(report.swept).toHaveLength(1); |
| 228 | + }); |
| 229 | + |
| 230 | + // ── EVERYTHING ELSE STILL ABORTS ────────────────────────────────────────── |
| 231 | + |
| 232 | + it('a real driver fault on a registered sys_organization still aborts the sweep, with the fault itself', async () => { |
| 233 | + const warn = vi.fn(); |
| 234 | + const box = await lifecycleEngine({ |
| 235 | + registerOrganization: true, |
| 236 | + organizationRead: () => { throw outage(); }, |
| 237 | + }); |
| 238 | + |
| 239 | + const report = await sweepOnce(box.engine, warn); |
| 240 | + const fault = await box.orgReadRejection(); |
| 241 | + |
| 242 | + // The read the sweep aborted on rejected with the driver's own fault. |
| 243 | + expect(fault.code).toBe('ECONNREFUSED'); |
| 244 | + expect(fault.message).toContain('ECONNREFUSED'); |
| 245 | + // …and that fault, not a swallowed outcome, is what the sweep reports. |
| 246 | + expect(box.reapReads()).toEqual([]); |
| 247 | + expect(box.driverDeletes).toEqual([]); |
| 248 | + expect(report.swept).toEqual([]); |
| 249 | + expect(report.errors).toEqual([{ object: 'sys_job_run', error: abortedError(fault.message) }]); |
| 250 | + expect(warn).toHaveBeenCalledTimes(1); |
| 251 | + expect(warn.mock.calls[0][0]).toContain(`(${fault.message})`); |
| 252 | + }); |
| 253 | + |
| 254 | + it('an OBJECT_NOT_FOUND attributed to ANOTHER object is not read as absence: the sweep aborts', async () => { |
| 255 | + const box = await lifecycleEngine({ registerOrganization: true }); |
| 256 | + // A hook on the organization read that itself reads an object this |
| 257 | + // composition never registered — the engine refuses THAT read, and the |
| 258 | + // refusal surfaces from the `sys_organization` scan. |
| 259 | + box.engine.registerHook( |
| 260 | + 'beforeFind', |
| 261 | + async () => { |
| 262 | + await box.engine.find('sys_org_unit', ORG_PROBE); |
| 263 | + }, |
| 264 | + { object: 'sys_organization' }, |
| 265 | + ); |
| 266 | + |
| 267 | + const report = await sweepOnce(box.engine); |
| 268 | + const refusal = await box.orgReadRejection(); |
| 269 | + |
| 270 | + expect(refusal.code).toBe('OBJECT_NOT_FOUND'); |
| 271 | + expect(refusal.status).toBe(404); |
| 272 | + expect(refusal.object).toBe('sys_org_unit'); |
| 273 | + expect(box.reapReads()).toEqual([]); |
| 274 | + expect(report.swept).toEqual([]); |
| 275 | + expect(report.errors).toEqual([{ object: 'sys_job_run', error: abortedError(refusal.message) }]); |
| 276 | + }); |
| 277 | +}); |
0 commit comments