From a42af3b989be9ba1c4344622a3d47306c99c5915 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:47:49 +0000 Subject: [PATCH 1/2] fix(runtime): search slot reports handlerReady:false, not true (#7939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit svcAvailable() defaulted an unmarked search occupant to handlerReady: true, contradicting the services map's own contract ("handlerReady: true means the dispatcher has a real, bound handler for this route") — the dispatcher has no /search route or handler at all. Fixed with the same svcInProcess() remedy #4318 used for cache/queue/job, with search's own message (svcInProcess's shared 'Kernel-internal service' wording is false for an external search engine). --- packages/runtime/src/http-dispatcher.test.ts | 47 ++++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 47 ++++++++++++++++++-- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 67dde89e72..0281b42677 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -3024,6 +3024,53 @@ describe('HttpDispatcher', () => { } } }); + + // ── `search`: the one slot of the #4318 shape it did not reach (#7939) ── + // + // `svcAvailable(undefined, undefined, searchSvc)` defaulted an unmarked + // occupant to `handlerReady: true` — a lie by this very map's own + // contract (stated above `svcAvailable`'s definition): the dispatcher + // has NO `/search` route (no `route-ledger.ts` entry, no branch here, + // `route` is always `undefined`), so "the handler is ready" cannot be + // true. Latent until now because `CORE_SERVICE_PROVIDER['search']` is + // `null` — nothing registers this slot anywhere — so the fixture below + // fills it explicitly; without that the assertion would be vacuous. + it('reports an unmarked search occupant with no route and handlerReady false, not true (#7939)', async () => { + const searchSvc = { /* real, unmarked ISearchService-shaped occupant */ + index: vi.fn(), remove: vi.fn(), search: vi.fn(), + }; + (kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === 'search' ? searchSvc : null)); + (kernel as any).services = new Map([['search', searchSvc]]); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + const reported = info.services.search; + expect(reported.enabled, 'services.search.enabled').toBe(true); + expect(reported.status, 'services.search.status').toBe('available'); + expect(reported.handlerReady, 'services.search.handlerReady').toBe(false); + expect(reported.route, 'services.search.route').toBeUndefined(); + // The remedy string is search's OWN wording, not the shared + // cache/queue/job "Kernel-internal service" sentence — a search + // engine is an external occupant with no dispatcher surface, not a + // kernel-managed one, so reusing that sentence would misdescribe it. + expect(reported.message, 'services.search.message').not.toContain('Kernel-internal'); + expect(reported.message, 'services.search.message').toMatch(/no HTTP route/i); + expect(reported.message, 'services.search.message').toContain('capabilities.search'); + }); + + it('reports a self-describing search occupant (e.g. a dev stub) with its own status, still handlerReady false (#7939)', async () => { + const stubSearch = { + __serviceInfo: { status: 'stub', message: 'Development stub — no real search backend' }, + index: vi.fn(), remove: vi.fn(), search: vi.fn(), + }; + (kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === 'search' ? stubSearch : null)); + (kernel as any).services = new Map([['search', stubSearch]]); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services.search.enabled).toBe(true); + expect(info.services.search.status).toBe('stub'); + expect(info.services.search.handlerReady).toBe(false); + expect(info.services.search.message).toContain('no real search backend'); + }); }); // ═══════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 6508c8ee01..8de8f10a8b 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -175,6 +175,25 @@ export interface HttpDispatcherOptions { scopeManager?: EnvironmentScopeManager; } +/** + * `services.search`'s in-process remedy string (#7939), kept out of the + * shared `inProcessServiceMessage('search')` path on purpose: that helper's + * wording ("Kernel-internal service — consumed in-process via the service + * registry") is written for `cache`/`queue`/`job` — kernel-managed contracts + * with no HTTP surface *by construction*. A registered search service is the + * opposite kind of occupant: an external engine (Elasticsearch/Meilisearch, + * per `core-services.zod.ts`'s `REMEDY_DETAIL['search']`) that simply has no + * *dispatcher* route to advertise. Reusing the shared sentence would call an + * external engine "kernel-internal", which is false; this says the true thing + * instead, and cross-references `capabilities.search` (a different question + * about the same slot — see #7602 / PR #7937) rather than leaving readers to + * wonder why an occupied slot never got a route. + */ +const SEARCH_IN_PROCESS_MESSAGE = + "Search engine registered, but the dispatcher has no HTTP route for the 'search' slot — " + + 'no dedicated search endpoint is mounted on its behalf. Cross-object search, where a host ' + + 'serves it, is reported separately by capabilities.search.'; + /** * The HTTP dispatch engine — translates an inbound (method, path, body, ctx) * request into a kernel response. Used directly by the framework's HTTP adapters @@ -1250,13 +1269,20 @@ export class HttpDispatcher { // reduced capability (contrast `realtime` below, whose advertised // capability IS the missing surface). Message written once in // `@objectstack/spec/system` so both discovery builders agree. - const svcInProcess = (name: string, svc: unknown) => { + // `fallbackMessage` lets a slot opt out of the shared "Kernel-internal + // service" wording (#7939): that sentence is true for cache/queue/job + // — kernel-managed, in-process by construction — but false for a slot + // whose occupant is an external engine with no dispatcher surface + // (`search`: Elasticsearch/Meilisearch, per `core-services.zod.ts`'s + // REMEDY_DETAIL). The shared string still applies wherever it reads + // true; only the slot that would misdescribe itself with it overrides. + const svcInProcess = (name: string, svc: unknown, fallbackMessage?: string) => { const self = svc ? readServiceSelfInfo(svc) : undefined; return { enabled: true, status: self?.status ?? ('available' as const), handlerReady: false, - message: self?.message ?? inProcessServiceMessage(name), + message: self?.message ?? fallbackMessage ?? inProcessServiceMessage(name), }; }; @@ -1567,7 +1593,22 @@ export class HttpDispatcher { // occupant's behalf — which is the same fact `capabilities // .search` states above, said about the slot instead of about // the host's HTTP surface. - search: searchRegistered ? svcAvailable(undefined, undefined, searchSvc) : svcUnavailable('search'), + // + // [#7939] But `svcAvailable` was still the wrong builder for a + // filled slot: it defaults an unmarked occupant to + // `handlerReady: true`, which this map's own contract (above) + // reserves for "the dispatcher has a real, bound handler for + // this route" — and the dispatcher has none for search (no + // `route-ledger.ts` entry, no branch here, `route` is always + // `undefined`). That is exactly the contradiction #4318 closed + // for `cache`/`queue`/`job` with `svcInProcess`; `search` was + // the one slot of that shape #4318 didn't reach, latent only + // because no host has ever registered one (`CORE_SERVICE_PROVIDER + // ['search'] === null`). Fixed the same way, with its own + // remedy string — see `svcInProcess`'s fallbackMessage note. + search: searchRegistered + ? svcInProcess('search', searchSvc, SEARCH_IN_PROCESS_MESSAGE) + : svcUnavailable('search'), }, locale, }; From a0b28e16923b97b8efcd33c4bad8f5d4534fdfdb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 09:02:26 +0000 Subject: [PATCH 2/2] test(runtime): cast search's route-less discovery record, add changeset (#7939) TS2339 on info.services.search.route: svcInProcess()'s return type carries no route key at all, matching the same cast the #4318 cache/queue/job tests already use. Re-measured @objectstack/runtime's TEST_DEBT at 227 (recorded), unchanged. --- .../dispatcher-search-slot-handler-ready.md | 29 +++++++++++++++++++ packages/runtime/src/http-dispatcher.test.ts | 6 +++- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 .changeset/dispatcher-search-slot-handler-ready.md diff --git a/.changeset/dispatcher-search-slot-handler-ready.md b/.changeset/dispatcher-search-slot-handler-ready.md new file mode 100644 index 0000000000..e87ab2582a --- /dev/null +++ b/.changeset/dispatcher-search-slot-handler-ready.md @@ -0,0 +1,29 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): discovery no longer claims a bound handler for the `search` slot (#7939) + +`getDiscoveryInfo()`'s `services.search` entry reported `handlerReady: true` for +any registered search-service occupant that carries no `__serviceInfo` +self-description — even though the dispatcher has no `/search` route or handler +at all (no `route-ledger.ts` entry, no branch in `http-dispatcher.ts`). That +contradicted the map's own stated contract: `handlerReady: true` means "the +dispatcher has a real, bound handler for this route." + +This is the same contradiction #4318 closed for `cache`/`queue`/`job`, applied +to the one slot of that shape it did not reach. `search` now uses the same +`svcInProcess()` remedy, reporting `handlerReady: false` for a filled slot — +with its own message rather than the shared "Kernel-internal service" wording, +since a registered search service is an external engine (Elasticsearch/ +Meilisearch), not a kernel-managed in-process contract. + +`capabilities.search` (the host's `/search` HTTP surface, fixed separately in +#7602 / PR #7937) is untouched — this only corrects `services.search`, which +describes what is *registered*, not what is served. + +Latent until now: nothing registers an `ISearchService` in either repository +(`CORE_SERVICE_PROVIDER['search']` is `null`), so the wrong branch was never +reachable in practice. Covered by two new fixture-filled tests that register a +search occupant before asserting `handlerReady`, since an empty-slot test would +pass regardless of what the line says. diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 0281b42677..38e275cc44 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -3043,7 +3043,11 @@ describe('HttpDispatcher', () => { (kernel as any).services = new Map([['search', searchSvc]]); const info = await dispatcher.getDiscoveryInfo('/api/v1'); - const reported = info.services.search; + // Cast like the #4318 cache/queue/job tests above: `svcInProcess`'s + // return type carries no `route` key at all (route-less by + // construction), so a direct `info.services.search.route` read + // does not typecheck against the narrowed union. + const reported = (info.services as Record).search; expect(reported.enabled, 'services.search.enabled').toBe(true); expect(reported.status, 'services.search.status').toBe('available'); expect(reported.handlerReady, 'services.search.handlerReady').toBe(false);