diff --git a/.changeset/21597-lifecycle-registry-first-guard.md b/.changeset/21597-lifecycle-registry-first-guard.md new file mode 100644 index 00000000000..3a85dd937e5 --- /dev/null +++ b/.changeset/21597-lifecycle-registry-first-guard.md @@ -0,0 +1,11 @@ +--- +'@objectstack/objectql': patch +--- + +`LifecycleService` asks the registry whether `sys_organization` is registered before its governance tenant scan reads it, so a composition that registers no `sys_organization` sweeps single-tenant again instead of aborting every sweep + +Clause-②: no + +- The engine's in-process verbs refuse an object name the registry does not resolve (`OBJECT_NOT_FOUND`, 404) before any driver is asked. Take a composition with a settings service and lifecycle-declared objects but no `sys_organization` object. Its tenant scan got that refusal instead of a missing table, so every sweep aborted before applying any policy. The scan now asks `engine.registry.getObject('sys_organization')` first, the same shape `ObjectQL.probeInstallOrganizations` takes. An unregistered object answers "no tenant overrides", and the sweep runs one global pass on each declared window. +- A registered `sys_organization` is read as before. A missing table is still the one benign driver cause. Every other failure still aborts the sweep and is reported through `report.errors`, an `OBJECT_NOT_FOUND` from that read included. +- `LifecycleEngineLike['registry']` now declares the optional `getObject?(name)` member the scan reads. A registry without it cannot be asked, and the scan then reads exactly as before. No new export and no change to the sweep report's shape. diff --git a/packages/objectql/src/lifecycle/lifecycle-service.organization-registry.test.ts b/packages/objectql/src/lifecycle/lifecycle-service.organization-registry.test.ts new file mode 100644 index 00000000000..9a66456a8e3 --- /dev/null +++ b/packages/objectql/src/lifecycle/lifecycle-service.organization-registry.test.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21597] `loadGovernance`'s tenant scan asks the REGISTRY whether + * `sys_organization` is registered before it reads it. + * + * Since commit eb9ef791bd an in-process engine verb refuses an object name the + * registry does not resolve, with `OBJECT_NOT_FOUND` (404), before any driver + * is asked. A composition that registers no `sys_organization` therefore no + * longer reaches a driver with the tenant scan's read: it gets the engine's + * refusal. That is not a missing table, so the scan's catch rethrew it, and + * every sweep aborted before applying a single policy. + * + * Unregistered is the single-tenant answer. With no organization object there + * is no tenant to hold an override, and that is the same answer + * `ObjectQL.probeInstallOrganizations` gives to the same question. What does + * NOT change is the catch: a missing table on a REGISTERED `sys_organization` + * is still the one benign driver cause, and every other failure still aborts + * the sweep (#12853). In particular an `OBJECT_NOT_FOUND` is not read as + * absence wholesale: the registry already said the object is registered, so a + * refusal arriving from the read is about something else. + * + * Every case runs on a REAL `ObjectQL` engine over a stub driver, so the + * refusal the guard avoids is the engine's own and not a double's guess at it. + * `engine.find` is spied with call-through: the spy records what the sweep + * asked the engine, and the engine still answers. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { ObjectQL } from '../engine.js'; +import { LifecycleService } from './lifecycle-service.js'; +import { parseLifecycleDuration } from './duration.js'; + +const FIXED_NOW = 1_700_000_000_000; +const PACKAGE_ID = 'lifecycle-organization-registry'; + +/** The lifecycle-declared object every sweep below reaps. */ +const TELEMETRY_OBJ = { + name: 'sys_job_run', + fields: { status: { type: 'text' } }, + lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } }, +} as any; + +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; + +/** A system-context read of one row, spelled as a typed query. */ +const ORG_PROBE: EngineQueryOptions = { limit: 1, context: { isSystem: true } }; + +const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString(); + +/** The regulated tenant keeps its rows three times longer than the global window. */ +const TENANT_OVERRIDES = { org_reg: { retention_overrides: { sys_job_run: { maxAge: '90d' } } } }; + +/** Settings service whose only values are tenant-scoped ones. */ +function fakeSettings(tenantValues: Record>) { + return { + async get(_ns: string, key: string, ctx?: Record) { + const tenantId = ctx?.tenantId as string | undefined; + if (tenantId && tenantValues[tenantId] && key in tenantValues[tenantId]) { + return { value: tenantValues[tenantId][key], source: 'tenant' }; + } + return { value: undefined, source: 'default' }; + }, + }; +} + +/** A transient database outage, the shape `isMissingTableError` answers `false` for. */ +const outage = () => + Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); + +/** The benign unprovisioned table, in the SQLite-family spelling. */ +const missingTable = () => new Error('no such table: sys_organization'); + +const abortedError = (message: string) => + `governance snapshot could not be loaded (${message}) — sweep aborted before any policy ` + + 'was applied, so no rows were reaped for this object'; + +/** + * A real engine with `sys_job_run` registered, and `sys_organization` + * registered only when asked. The stub driver answers the organization read + * with `organizationRead` and every candidate page with no rows. + */ +async function lifecycleEngine(opts: { + registerOrganization: boolean; + organizationRead?: () => Array>; +}) { + const driverReads: string[] = []; + const driverDeletes: string[] = []; + const organizationRead = opts.organizationRead ?? (() => [{ id: 'org_reg' }]); + const driver = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string) { + driverReads.push(object); + return object === 'sys_organization' ? organizationRead() : []; + }, + async findOne() { return null; }, + async count() { return 0; }, + async create(_object: string, data: any) { return { id: 'r_1', ...data }; }, + async update(_object: string, id: string, data: any) { return { id, ...data }; }, + async delete(object: string) { driverDeletes.push(object); return true; }, + async bulkCreate(_object: string, rows: any[]) { return rows; }, + async bulkUpdate() { return []; }, + async bulkDelete(object: string) { driverDeletes.push(object); }, + async syncSchema() {}, + } as any; + + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(TELEMETRY_OBJ, PACKAGE_ID); + if (opts.registerOrganization) engine.registry.registerObject(ORG_OBJECT, PACKAGE_ID); + + const find = vi.spyOn(engine, 'find'); + const callsOn = (object: string) => + find.mock.calls.map((call, i) => ({ call, i })).filter(({ call }) => call[0] === object); + + return { + engine, + driverReads, + driverDeletes, + /** The `where` of every candidate read the reaper issued through the engine. */ + reapReads: () => callsOn('sys_job_run').map(({ call }) => call[1]?.where), + /** How many times the tenant scan asked the engine for `sys_organization`. */ + orgReads: () => callsOn('sys_organization').length, + /** What the engine rejected the tenant scan's read with. */ + orgReadRejection: async () => { + const [first] = callsOn('sys_organization'); + return (find.mock.results[first.i].value as Promise).then( + () => { throw new Error('the tenant scan read resolved; expected a rejection'); }, + (error: unknown) => error as Error & { code?: unknown; status?: unknown; object?: unknown }, + ); + }, + }; +} + +function sweepOnce(engine: ObjectQL, warn: (msg: string) => void = () => {}) { + return new LifecycleService({ + getEngine: () => engine, + logger: { info: () => {}, warn, debug: () => {} }, + now: () => FIXED_NOW, + initialDelayMs: 1, + sweepIntervalMs: 10, + getSettings: () => fakeSettings(TENANT_OVERRIDES), + referenceAudit: { enabled: false }, + }).sweep(); +} + +describe('LifecycleService.sweep — the tenant scan asks the registry first (#21597)', () => { + // ── POSITIVE CONTROL ────────────────────────────────────────────────────── + + it('control: a REGISTERED, provisioned sys_organization is read, and its tenant gets its own window', async () => { + const box = await lifecycleEngine({ registerOrganization: true }); + + const report = await sweepOnce(box.engine); + + // The guard does not skip the scan for a registered object: the read + // reached the driver, and the tenant pass ran before the global one. + expect(box.orgReads()).toBe(1); + expect(box.driverReads.filter((o) => o === 'sys_organization')).toHaveLength(1); + expect(box.reapReads()).toEqual([ + { created_at: { $lt: isoCutoff('90d') }, organization_id: 'org_reg' }, + { + created_at: { $lt: isoCutoff('30d') }, + $or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }], + }, + ]); + expect(report.errors).toEqual([]); + }); + + // ── THE DEFECT ──────────────────────────────────────────────────────────── + + it('premise: with no sys_organization registered, the engine refuses the read itself', async () => { + const box = await lifecycleEngine({ registerOrganization: false }); + + const refusal = await box.engine + .find('sys_organization', ORG_PROBE) + .then(() => undefined, (error: unknown) => error as Error & { code?: unknown; status?: unknown; object?: unknown }); + + expect(refusal?.code).toBe('OBJECT_NOT_FOUND'); + expect(refusal?.status).toBe(404); + expect(refusal?.object).toBe('sys_organization'); + // Refused before any driver was asked: this is not a missing table. + expect(box.driverReads).toEqual([]); + }); + + it('an UNREGISTERED sys_organization is the single-tenant answer: the sweep runs on the global window', async () => { + const warn = vi.fn(); + const box = await lifecycleEngine({ registerOrganization: false }); + + const report = await sweepOnce(box.engine, warn); + + // The sweep was not aborted. Asserted first so that a regression shows the + // abort it reported, not a call count. + expect(report.errors).toEqual([]); + // The registry answered, so the scan never read: no engine refusal to + // abort on, and nothing reached the driver for `sys_organization`. + expect(box.orgReads()).toBe(0); + expect(box.driverReads.filter((o) => o === 'sys_organization')).toEqual([]); + // The sweep RAN: one global pass, no tenant pass. + expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]); + expect(report.swept).toEqual([ + { object: 'sys_job_run', class: 'telemetry', policy: 'retention', cutoff: isoCutoff('30d'), deleted: 0 }, + ]); + expect(warn).not.toHaveBeenCalled(); + }); + + // ── THE BENIGN DRIVER CAUSE IS UNCHANGED ────────────────────────────────── + + it('a REGISTERED but unprovisioned sys_organization keeps the missing-table answer', async () => { + const box = await lifecycleEngine({ + registerOrganization: true, + organizationRead: () => { throw missingTable(); }, + }); + + const report = await sweepOnce(box.engine); + + // Proof the benign branch was exercised: the scan read, the driver threw. + expect(box.orgReads()).toBe(1); + expect(box.driverReads.filter((o) => o === 'sys_organization')).toHaveLength(1); + expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]); + expect(report.errors).toEqual([]); + expect(report.swept).toHaveLength(1); + }); + + // ── EVERYTHING ELSE STILL ABORTS ────────────────────────────────────────── + + it('a real driver fault on a registered sys_organization still aborts the sweep, with the fault itself', async () => { + const warn = vi.fn(); + const box = await lifecycleEngine({ + registerOrganization: true, + organizationRead: () => { throw outage(); }, + }); + + const report = await sweepOnce(box.engine, warn); + const fault = await box.orgReadRejection(); + + // The read the sweep aborted on rejected with the driver's own fault. + expect(fault.code).toBe('ECONNREFUSED'); + expect(fault.message).toContain('ECONNREFUSED'); + // …and that fault, not a swallowed outcome, is what the sweep reports. + expect(box.reapReads()).toEqual([]); + expect(box.driverDeletes).toEqual([]); + expect(report.swept).toEqual([]); + expect(report.errors).toEqual([{ object: 'sys_job_run', error: abortedError(fault.message) }]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain(`(${fault.message})`); + }); + + it('an OBJECT_NOT_FOUND attributed to ANOTHER object is not read as absence: the sweep aborts', async () => { + const box = await lifecycleEngine({ registerOrganization: true }); + // A hook on the organization read that itself reads an object this + // composition never registered — the engine refuses THAT read, and the + // refusal surfaces from the `sys_organization` scan. + box.engine.registerHook( + 'beforeFind', + async () => { + await box.engine.find('sys_org_unit', ORG_PROBE); + }, + { object: 'sys_organization' }, + ); + + const report = await sweepOnce(box.engine); + const refusal = await box.orgReadRejection(); + + expect(refusal.code).toBe('OBJECT_NOT_FOUND'); + expect(refusal.status).toBe(404); + expect(refusal.object).toBe('sys_org_unit'); + expect(box.reapReads()).toEqual([]); + expect(report.swept).toEqual([]); + expect(report.errors).toEqual([{ object: 'sys_job_run', error: abortedError(refusal.message) }]); + }); +}); diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index 2ce9089d270..a3ff3c2d4d3 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -85,7 +85,21 @@ export const DEFAULT_LIFECYCLE_INITIAL_DELAY_MS = 60_000; /** Minimal engine surface the service needs — duck-typed for tests. */ export interface LifecycleEngineLike { - registry: { getAllObjects(): LifecycleObjectLike[] }; + registry: { + getAllObjects(): LifecycleObjectLike[]; + /** + * [#21597] Registry presence by name: the question the engine's in-process + * verbs ask before they read (`ObjectQL`'s `SchemaRegistry.getObject`). A + * name it does not resolve is refused with `OBJECT_NOT_FOUND` before any + * driver is asked, so the governance tenant scan asks this first and reads + * `sys_organization` only when it is registered. + * + * Optional so that a double modelling only `getAllObjects` stays a legal + * engine. Such a registry cannot be asked, and the tenant scan then reads + * exactly as it did before this member existed. Every real engine has it. + */ + getObject?(name: string): unknown; + }; delete( object: string, options: { where: Record; multi: true; context: LifecycleSweepContext }, @@ -806,7 +820,10 @@ export class LifecycleService { * [#12853] The tenant scan is not. A `sys_organization` read that FAILED * throws out of here, because an empty `tenantOverrides` is the same value * as "this deployment has no tenant overrides" and the caller acts on the - * difference by DELETING rows. See the catch below. */ + * difference by DELETING rows. See the catch below. + * + * [#21597] The scan asks the registry before it reads. An unregistered + * `sys_organization` is never read, and answers "no tenant overrides". */ private async loadGovernance( engine: LifecycleEngineLike, declared: LifecycleObjectLike[], @@ -835,7 +852,22 @@ export class LifecycleService { // Tenant-level windows (ADR-0057 §3.2): only overrides genuinely stored // at TENANT scope count — inherited global values would otherwise turn // every tenant into a "tenant override" and break the global pass. - if (typeof engine.find === 'function' && declared.length > 0) { + // + // [#21597] Registry first, the shape `ObjectQL.probeInstallOrganizations` + // takes on the same question. Since commit eb9ef791bd the engine's + // in-process verbs refuse a name the registry does not resolve with + // `OBJECT_NOT_FOUND`, before any driver is asked. So in a composition that + // registers no `sys_organization` (a lean embedding) the read below cannot + // reach a table at all, and its refusal is not the missing-table cause the + // catch accepts: every sweep used to abort on it. That composition has no + // organization object, so it has no tenant to hold an override, and "no + // tenant overrides" is the truth: the single-tenant answer, given here + // without reading. The question is the registry's own `getObject`, the + // same one the refusal asks, never a list of names. A registry that cannot + // be asked (a double without `getObject`) reads as before. + const organizationUnregistered = + typeof engine.registry.getObject === 'function' && !engine.registry.getObject('sys_organization'); + if (typeof engine.find === 'function' && declared.length > 0 && !organizationUnregistered) { try { const orgs = await engine.find('sys_organization', { limit: TENANT_SCAN_LIMIT, @@ -887,6 +919,12 @@ export class LifecycleService { // incomplete evidence" is the correct failure direction: a log cannot // bring back a reaped row, and the rows this defers are still there for // the next sweep to reap once the read succeeds. + // + // [#21597] That includes an `OBJECT_NOT_FOUND`. The unregistered case + // never reaches this catch (the registry was asked above), so a + // refusal here names some other object (a hook's nested read, say) or + // contradicts the registry's own answer. Neither is evidence that no + // tenant exists, and neither is read as absence. if (!isMissingTableError(error, 'sys_organization')) throw error; } } diff --git a/packages/runtime/src/expected-read-refusal-noise.ts b/packages/runtime/src/expected-read-refusal-noise.ts index 51c4014d122..99f2f02f593 100644 --- a/packages/runtime/src/expected-read-refusal-noise.ts +++ b/packages/runtime/src/expected-read-refusal-noise.ts @@ -16,12 +16,15 @@ * * `resolveUserAuthzGrants` (`core/src/security/resolve-authz-context.ts`) * `tryFind`s six `sys_*` tables per grant resolution — the resolver is * fail-closed and must always resolve; - * * `ObjectQL.probeInstallOrganizations` (`objectql/src/engine.ts`) reads - * `sys_organization` and catches `isMissingTableError` **only**, which its - * own doc comment names as "the one benign cause"; - * * `SeedLoaderService.resolveSoleOrganizationId` - * (`metadata-protocol/src/seed-loader.ts`) and - * `LifecycleService`'s governance snapshot read the same table best-effort; + * * `ObjectQL.probeInstallOrganizations` (`objectql/src/engine.ts`) asks the + * registry first and never reads an UNREGISTERED `sys_organization`; for a + * registered one it catches `isMissingTableError` **only**, which its own + * doc comment names as the one benign driver cause; + * * `LifecycleService`'s governance snapshot takes the same registry-first + * shape on the same table, and `SeedLoaderService.resolveSoleOrganizationId` + * (`metadata-protocol/src/seed-loader.ts`) reads it best-effort, accepting a + * missing table or the engine's `OBJECT_NOT_FOUND` refusal attributed to + * `sys_organization` itself; * * `runBuildProbes` (`metadata-protocol/src/build-probes.ts`) reads the * object a published view is bound to, and turns a failure into a * `view_read_failed` publish issue rather than an exception;