Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/dispatcher-search-slot-handler-ready.md
Original file line numberDiff line numberDiff line change
@@ -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.
51 changes: 51 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3024,6 +3024,57 @@ 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');
// 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<string, any>).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');
});
});

// ═══════════════════════════════════════════════════════════════
Expand Down
47 changes: 44 additions & 3 deletions packages/runtime/src/http-dispatcher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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),
};
};

Expand DownExpand Up@@ -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,
};
Expand Down
Loading