diff --git a/.changeset/global-search-registry-propagate.md b/.changeset/global-search-registry-propagate.md new file mode 100644 index 0000000000..ab224e4e67 --- /dev/null +++ b/.changeset/global-search-registry-propagate.md @@ -0,0 +1,5 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +Global search (`searchAll`, the producer behind `GET /api/v1/search`) no longer invents an empty registry. The registry read was `registry?.getAllObjects?.() ?? []`, so a host whose registry does not implement `getAllObjects` — a structural omission that never throws — produced a successful "nothing matched" response with `objectsScanned: 0`. A registry that cannot enumerate its objects is never truthfully "no objects" (ADR-0110 D3): both halves of the swallow are removed and the omission now surfaces as the read's own failure, mapped by the REST door's standard error path. Unchanged neighbours: a registry that enumerates and truthfully answers "no objects" still yields the successful empty response, and a blank query still short-circuits before the registry is consulted. diff --git a/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts b/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts index f9aa33d6cb..7198ab2753 100644 --- a/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts +++ b/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts @@ -204,6 +204,105 @@ describe('[#8896] searchAll — an object that could not be READ is not an objec }); }); +describe('[#11754] searchAll — a registry that cannot ENUMERATE is not a registry with no objects', () => { + const acct = objectFixture('acct'); + + /** + * The seam this pins: `searchAll` used to read the registry as + * `(engine as any).registry?.getAllObjects?.() ?? []`. Neither guard could + * ever fire on an outage — `SchemaRegistry.getAllObjects()` walks in-memory + * Maps and has no throwing path — so what they absorbed was only the + * STRUCTURAL omission: a host whose registry does not implement + * `getAllObjects` at all. That omission never throws, so pre-fix the whole + * sweep was a silent no-op: `{ hits: [], totalObjects: 0, totalHits: 0, + * truncated: false }` under a successful response, indistinguishable from + * a deployment with genuinely nothing registered (ADR-0110 D3). + * + * No new code and no new envelope are minted here on purpose — the ruled + * disposition (#9284, the engine's own registry sweeps) is to drop both + * halves of the swallow and let the omission surface as the read's own + * failure, which for a missing method is the runtime's TypeError. + */ + + it('a registry WITHOUT getAllObjects rejects instead of inventing an empty sweep', async () => { + const find = vi.fn(async () => [{ id: 'a1', name: 'Acme' }]); + const engine = { + // Everything a registry needs EXCEPT enumeration — the structural + // omission, not an error class. + registry: { + getObject: (n: string) => (n === 'acct' ? acct : undefined), + getItem: () => undefined, + listItems: () => [], + }, + find, + findOne: vi.fn(async () => null), + }; + const protocol = new ObjectStackProtocolImplementation(engine as never); + + const caught = await rejection(() => protocol.searchAll({ q: 'Acme' })); + + // The omission surfaces as itself — the runtime's own TypeError naming + // the missing member — never a minted code, never an invented answer. + expect(caught).toBeInstanceOf(TypeError); + expect(caught.message).toContain('getAllObjects'); + // The sweep never ran: no data read was issued for an enumeration that + // did not happen. + expect(find).not.toHaveBeenCalled(); + // Pre-fix this resolved with `{ query: 'Acme', hits: [], + // totalObjects: 0, totalHits: 0, truncated: false }` — "nothing + // matched", reported for a registry that was never asked. + }); + + it('an engine with NO registry rejects too (the other dropped `?.`)', async () => { + const engine = { + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + }; + const protocol = new ObjectStackProtocolImplementation(engine as never); + + const caught = await rejection(() => protocol.searchAll({ q: 'Acme' })); + + expect(caught).toBeInstanceOf(TypeError); + }); + + it('control: a registry that truthfully answers "no objects" still resolves the empty response', async () => { + // The one benign emptiness: the registry ENUMERATED and the answer was + // empty. This is the neighbouring shape the fix must not move. + const engine = { + registry: { ...fixtureRegistry([]), getAllObjects: vi.fn(() => []) }, + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + }; + const protocol = new ObjectStackProtocolImplementation(engine as never); + + const result = await protocol.searchAll({ q: 'Acme' }); + + expect(result).toEqual({ query: 'Acme', hits: [], totalObjects: 0, totalHits: 0, truncated: false }); + // Proof the emptiness was SAID by the registry, not invented past it. + expect(engine.registry.getAllObjects).toHaveBeenCalledTimes(1); + }); + + it('control: a blank query still short-circuits BEFORE the registry is consulted', async () => { + // The early return for an empty `q` sits above the enumeration read. + // Pinned so the propagate change cannot drift it: a registry-less host + // asked nothing must keep getting the empty-query answer, not a throw. + const engine = { + registry: { + getObject: () => undefined, + getItem: () => undefined, + listItems: () => [], + }, + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + }; + const protocol = new ObjectStackProtocolImplementation(engine as never); + + const result = await protocol.searchAll({ q: ' ' }); + + expect(result).toEqual({ query: '', hits: [], totalObjects: 0, totalHits: 0, truncated: false }); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // findReferencesToMeta // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 37ad56a444..081fd2a994 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9863,7 +9863,19 @@ export class ObjectStackProtocolImplementation implements // pre-tokenised channel is exactly the divergence this card closed. const terms = q.split(/\s+/).filter(Boolean).slice(0, 8); - const allObjects = (this.engine as any).registry?.getAllObjects?.() ?? []; + // [#11754] No `?.` and no `?? []` on this read. The invented empty + // list made the whole sweep a silent no-op — zero hits, + // `objectsScanned: 0`, and a successful response — for a host whose + // registry cannot enumerate at all. `SchemaRegistry.getAllObjects()` + // walks in-memory Maps and has no throwing path, so what the guards + // absorbed was only the STRUCTURAL omission (a registry without the + // method), which never throws and is therefore invisible by + // construction. A registry that cannot enumerate its objects is + // never truthfully "no objects" (ADR-0110 D3) — the omission + // propagates, the same disposition as the engine's own registry + // sweeps (#9284). `registry` is a declared member of + // `MetadataHostEngine`, so the cast went with the guards. + const allObjects = this.engine.registry.getAllObjects(); const hits: Array<{ object: string; id: string; title: string; snippet?: string; record: any }> = []; let objectsScanned = 0;