Skip to content

Commit 0373d52

Browse files
authored
fix(runtime,metadata-protocol): the data discovery slot is computed too, closing the hardcoded kernel block (#4130) (#4141)
#4089 made both discovery builders derive the `metadata` entry from the implementation in the slot. `data` was the one entry left that judged itself: `status: 'available'`, `handlerReady: true`, unconditionally, in both builders. That was true — by a convention in a different package. ObjectQL is the slot's only producer (`ctx.registerService('data', this.ql)`), and plugin-dev fills a core slot only when it is empty while always loading `ObjectQLPlugin` as a child, so plugin-dev's `data` stub — `find()` returns `[]`, `insert()` mints an id and stores nothing — never reaches the slot. Neither builder verified any of that. It is the same shape the `metadata` hardcodes had while they were still "roughly right", on the platform's most load-bearing capability, and it breaks the moment a second `data` producer appears or a dev config drops the ObjectQL child. Both builders now read the slot's `__serviceInfo`: - a real engine carries no marker ⇒ `available` + `handlerReady: true`, which is byte-identical to the hardcode it replaces (pinned on a real kernel boot, not just on mocks); - a self-declared stub ⇒ its own `status` / `message`, and `handlerReady: false`, which `readServiceSelfInfo` already defaults for `stub`. `handlerReady` is derived here rather than pinned `true` as it is for `metadata`, because each entry says what its own route does. `/meta` answers from the protocol — down to a last-resort default type list — whatever fills the metadata slot, so a degraded implementation there does not unmount it. `/data` has no such floor: `callData` needs the `protocol` service or an objectql-shaped one and throws 503 without them, and the only way a stub occupies the `data` slot is a stack where ObjectQL never registered, i.e. exactly the stack where `/data` cannot serve. Deliberately NOT done: re-deriving serveability from `protocol`/`objectql` inside the builder. Discovery resolves services UNSCOPED (it has no request context), so on a multi-kernel host that could report the required data capability as `handlerReady: false` while per-request scoped resolution serves it fine — a worse lie than the one being fixed. Nothing routes off this field either: the `data` domain resolves its engine directly, and `isServiceServeable` (#4058 step 2) gates the optional domains only. Tests: both directions per builder, the cross-builder agreement extended to `data` (same instance ⇒ same status / handlerReady / message / route), and the real-boot assertion that a live ObjectQL keeps reporting `available`. Closes #4130
1 parent 9a4932a commit 0373d52

7 files changed

Lines changed: 178 additions & 4 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
Both discovery builders now derive the `data` service entry from the implementation in the slot, closing the hardcoded "kernel-provided" block (#4130).
7+
8+
#4089 computed `metadata`; `data` was the last entry that judged itself, reporting `status: 'available'` and `handlerReady: true` unconditionally. That was true — but by a convention in a different package, not by anything either builder checked: ObjectQL is the slot's only producer, and plugin-dev always loads `ObjectQLPlugin` as a child, so plugin-dev's `data` stub (`find()` returns `[]`, `insert()` mints an id and stores nothing) never reaches the slot. A second producer, or a trimmed dev config, and the hardcode starts lying about the platform's most load-bearing capability.
9+
10+
Both builders now read the registered service's `__serviceInfo`:
11+
12+
- a real engine carries no marker ⇒ `available` + `handlerReady: true`, byte-identical to the hardcode it replaces (verified on a real kernel boot);
13+
- a self-declared stub ⇒ its own `status` and `message`, with `handlerReady: false` (the default for `stub`), so a consumer that gates on `handlerReady` stops treating an empty query engine as a real one.
14+
15+
`handlerReady` is derived here rather than pinned `true` as it is for `metadata`, because the two routes differ: `/meta` answers from the protocol whatever fills the metadata slot, while `/data` needs the `protocol` or an objectql-shaped service and 503s without them — and the only stack where a stub occupies the `data` slot is one where ObjectQL never registered. No routing, gating or dispatch behavior changes: the `data` domain resolves its engine directly and never consulted this slot.

docs/adr/0076-objectql-core-tiering.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ Decision: each capability plugin registers its routes as a **normalized handler*
134134
**Inventory of current fakes / mis-reports:**
135135
- `plugin-dev` registers ~8 dev stubs — `storage` / `search` / `automation` / `graphql` / `analytics` / `realtime` / `notification` / `ai` (they already carry a `_dev: true` marker that nothing respects). *(Update #4000: the `analytics` one is **gone** — after the fallback's retirement (#3891/#3989) it was the last thing refilling that slot, and refilling it re-created the retired shape in dev: the dispatcher gated on service presence, so the stub was called like an engine and answered 200 with fabricated rows. The remaining stubs are unchanged, and the class-wide question they raise is tracked separately — see conclusion 3 below.)* *(Update #4058 step 1: the blanket `_dev: true` is **gone** too. Every dev implementation now carries the standard `__serviceInfo`, classified in one reviewable table (`DEV_STUB_SELF_INFO`) by what it actually is — `degraded` for the ones that really work with reduced capability (`file-storage` / `search` / `metadata` / `workflow` / `realtime`, plus the wrapped kernel fallbacks), `stub` for the ones whose answer is fabricated (`ai` / `automation` / `notification` / `data` / `auth` / `security.*`). The single marker made those two indistinguishable and declared the working ones fake, which is why "adopt the dispatcher gate everywhere?" could not be answered. The gates themselves are untouched — that is #4058 step 2.)*
136136
- `ObjectQLPlugin` registers a ~66-line `analytics` **fallback** (the D10 note — deliberate, but it reports as fully available).
137-
- `http-dispatcher.ts` `svcAvailable` — the hardcode above. *(Update #4089: `svcAvailable` respects the marker, but two entries never went through it at all — a "kernel-provided (always available)" block above the loop, hardcoded per builder and **contradicting itself across them**: the dispatcher declared `metadata` permanently `degraded` with "In-memory registry; DB persistence pending", while metadata-protocol declared the same slot permanently `available`. So one host called a `sys_metadata`-backed registry degraded and the other called the in-memory fallback fully real — the marker #4058 had just added to `createMemoryMetadata` went unread on both sides, because neither side read anything. Both now compute the slot from the registered implementation's `__serviceInfo` and agree; `handlerReady: true` stays unconditional on both, since `/api/v1/meta` is served by the protocol whichever implementation occupies the slot. `data` keeps its hardcode: ObjectQL is the only producer that ever fills it, and `ObjectQLPlugin` — which plugin-dev always loads as a child — registers the real engine, so plugin-dev's `data` stub is unreachable in a stack that has a discovery builder at all.)*
137+
- `http-dispatcher.ts` `svcAvailable` — the hardcode above. *(Update #4089: `svcAvailable` respects the marker, but two entries never went through it at all — a "kernel-provided (always available)" block above the loop, hardcoded per builder and **contradicting itself across them**: the dispatcher declared `metadata` permanently `degraded` with "In-memory registry; DB persistence pending", while metadata-protocol declared the same slot permanently `available`. So one host called a `sys_metadata`-backed registry degraded and the other called the in-memory fallback fully real — the marker #4058 had just added to `createMemoryMetadata` went unread on both sides, because neither side read anything. Both now compute the slot from the registered implementation's `__serviceInfo` and agree; `handlerReady: true` stays unconditional on both, since `/api/v1/meta` is served by the protocol whichever implementation occupies the slot. `data` kept its hardcode in that pass: ObjectQL is the only producer that ever fills it, and `ObjectQLPlugin` — which plugin-dev always loads as a child — registers the real engine, so plugin-dev's `data` stub is unreachable in a stack that has a discovery builder at all.)* *(Update #4130: `data` is computed now too, which closes the block. Its hardcoded `available` was true, but by a **load-order convention in another package** rather than by anything either builder verified — the same shape the `metadata` hardcodes had while they were still "roughly right". Both builders derive the entry from the slot's `__serviceInfo`: an unmarked engine reproduces the old `available` / `handlerReady: true` byte for byte, while plugin-dev's `data` stub — `status: 'stub'`, so `handlerReady` defaults to `false` — is reported as the non-handler it declares itself to be. Unlike `metadata`, `handlerReady` here is NOT pinned `true`: `/meta` answers from the protocol (down to a last-resort default type list) whatever fills its slot, whereas `callData` needs the `protocol` service or an objectql-shaped one and throws 503 without them — and the only way a stub reaches the `data` slot is a stack where ObjectQL never registered, i.e. exactly the stack where `/data` cannot serve. Deliberately NOT done: re-deriving serveability from `protocol`/`objectql` inside the builder. Discovery resolves services **unscoped**, so that could report the required data capability as `handlerReady: false` on a multi-kernel host while per-request scoped resolution serves it fine — a worse lie than the one being fixed. No domain gates on this slot either: `isServiceServeable` (#4058 step 2) covers the optional domains, and `/data` resolves its engine directly.)*
138138
- *(Added by #4058: the **kernel's own** fallbacks — `createMemoryCache` / `Queue` / `Job` / `I18n` / `Metadata`, auto-registered by ObjectKernel — were missing from this inventory and were the worst case in it: they carried `_fallback: true`, a marker **no** reader recognized (not even `readServiceSelfInfo`), so both discovery builders reported them as fully `available`. They now self-describe as `degraded` with `handlerReady: false` where no HTTP surface exists (`cache` / `queue` / `job`). The lone duck-typed consumer of `_fallback`/`_dev` — an i18n diagnostic in `app-plugin.ts` — reads `readServiceSelfInfo` instead.)*
139139

140140
**Decision — honest capabilities:**

packages/metadata-protocol/src/protocol.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,16 @@ export class ObjectStackProtocolImplementation implements
14811481
// mounted whichever implementation occupies the slot.
14821482
const metadataSelf = readServiceSelfInfo(registeredServices.get('metadata'));
14831483

1484+
// [#4130] `data` was the last self-judging entry in this block. Same
1485+
// reasoning, one degree weaker: its hardcoded `available` is currently
1486+
// true, but only because ObjectQL is the slot's sole producer and
1487+
// plugin-dev (whose `data` stub declares `stub`) always loads
1488+
// ObjectQLPlugin as a child. That is a load-order convention in another
1489+
// package, not something this builder verifies — so verify it here.
1490+
// Unmarked implementation ⇒ `available` + `handlerReady: true`, i.e.
1491+
// exactly what the hardcode said, now derived rather than asserted.
1492+
const dataSelf = readServiceSelfInfo(registeredServices.get('data'));
1493+
14841494
const services: Record<string, ServiceInfo> = {
14851495
// --- Kernel-provided (objectql is an example kernel implementation) ---
14861496
metadata: {
@@ -1491,7 +1501,14 @@ export class ObjectStackProtocolImplementation implements
14911501
provider: 'objectql',
14921502
...(metadataSelf?.message ? { message: metadataSelf.message } : {}),
14931503
},
1494-
data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
1504+
data: {
1505+
enabled: true,
1506+
status: dataSelf?.status ?? ('available' as const),
1507+
handlerReady: dataSelf?.handlerReady ?? true,
1508+
route: '/api/v1/data',
1509+
provider: 'objectql',
1510+
...(dataSelf?.message ? { message: dataSelf.message } : {}),
1511+
},
14951512
};
14961513

14971514
// [#4000, #4058] The dispatcher answers a self-declared non-handler in

packages/objectql/src/plugin.integration.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
5454
// Serving is unaffected — `/api/v1/meta` is the protocol's route.
5555
expect(discovery.services.metadata.handlerReady).toBe(true);
5656
expect(discovery.routes.metadata).toBe('/api/v1/meta');
57+
58+
// …and on the same boot, `data` — derived from its slot too now (#4130)
59+
// — is unchanged: ObjectQLPlugin registers the real engine there and it
60+
// carries no marker, so the derivation reproduces the old hardcode.
61+
expect(discovery.services.data.status).toBe('available');
62+
expect(discovery.services.data.handlerReady).toBe(true);
63+
expect(discovery.services.data.message).toBeUndefined();
5764
});
5865

5966
it('should serve in-memory metadata definitions', async () => {

packages/objectql/src/protocol-discovery.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,42 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
181181
expect(discovery.services.metadata.handlerReady).toBe(true);
182182
});
183183

184+
// #4130 — `data` was the last hardcoded entry in the kernel block. Its
185+
// `available` happened to be true, but only because ObjectQL is the slot's
186+
// sole producer and plugin-dev always loads it as a child, so its `data` stub
187+
// never lands there. A convention in another package is not something this
188+
// builder verifies — now it does.
189+
it('should report a self-declared data stub as a stub, never available', async () => {
190+
const mockServices = new Map<string, any>();
191+
mockServices.set('data', {
192+
__serviceInfo: { status: 'stub', message: 'Dev stub — find() always returns []. Register ObjectQLPlugin for a real engine.' },
193+
find: async () => [],
194+
});
195+
196+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
197+
const discovery = await protocol.getDiscovery();
198+
199+
expect(discovery.services.data.enabled).toBe(true);
200+
expect(discovery.services.data.status).toBe('stub');
201+
expect(discovery.services.data.handlerReady).toBe(false);
202+
expect(discovery.services.data.message).toContain('ObjectQLPlugin');
203+
});
204+
205+
it('should keep reporting a real (unmarked) data engine as available', async () => {
206+
const mockServices = new Map<string, any>();
207+
mockServices.set('data', { find: async () => [], insert: async () => ({ id: '1' }) });
208+
209+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
210+
const discovery = await protocol.getDiscovery();
211+
212+
// Byte-for-byte what the hardcode said, now derived from the slot.
213+
expect(discovery.services.data.status).toBe('available');
214+
expect(discovery.services.data.handlerReady).toBe(true);
215+
expect(discovery.services.data.route).toBe('/api/v1/data');
216+
expect(discovery.services.data.provider).toBe('objectql');
217+
expect(discovery.services.data.message).toBeUndefined();
218+
});
219+
184220
// #3891 — the degraded ObjectQL fallback is retired. Analytics is an
185221
// ordinary optional service now: absent ⇒ unavailable, and the route must
186222
// NOT be advertised (an advertised route with no handler 404s — the exact

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2415,6 +2415,71 @@ describe('HttpDispatcher', () => {
24152415
expect(fromDispatcher.message).toBe(fromProtocol.message);
24162416
expect(fromDispatcher.route).toBe(fromProtocol.route);
24172417
});
2418+
2419+
// ── The `data` slot: the same hardcode, one degree weaker (#4130) ─────
2420+
//
2421+
// `available` / `handlerReady: true` was true here only by a convention
2422+
// in another package (ObjectQL is the slot's only producer, and
2423+
// plugin-dev always loads it as a child so its `data` stub never lands).
2424+
// These pin the computation that replaces the convention.
2425+
2426+
it('keeps reporting a real (unmarked) data engine as available — the hardcode it replaces', async () => {
2427+
(kernel as any).getService = vi.fn().mockImplementation((name: string) =>
2428+
name === 'data' ? mockObjectQL : null,
2429+
);
2430+
2431+
const info = await dispatcher.getDiscoveryInfo('/api/v1');
2432+
expect(info.services.data.enabled).toBe(true);
2433+
expect(info.services.data.status).toBe('available');
2434+
expect(info.services.data.handlerReady).toBe(true);
2435+
expect(info.services.data.route).toBe('/api/v1/data');
2436+
expect(info.services.data.provider).toBe('kernel');
2437+
expect(info.services.data.message).toBeUndefined();
2438+
});
2439+
2440+
it('reports plugin-dev\'s data stub as a stub with no ready handler, not as a query engine', async () => {
2441+
// The real marker plugin-dev attaches (DEV_STUB_SELF_INFO.data):
2442+
// `stub` with no explicit handlerReady, which readServiceSelfInfo
2443+
// defaults to false.
2444+
const devDataStub = {
2445+
__serviceInfo: {
2446+
status: 'stub',
2447+
message: 'Dev stub — find() always returns [], insert() mints an id and stores nothing. Register ObjectQLPlugin for a real engine.',
2448+
},
2449+
find: vi.fn().mockResolvedValue([]),
2450+
insert: vi.fn().mockResolvedValue({ id: 'x' }),
2451+
};
2452+
(kernel as any).getService = vi.fn().mockImplementation((name: string) =>
2453+
name === 'data' ? devDataStub : null,
2454+
);
2455+
2456+
const info = await dispatcher.getDiscoveryInfo('/api/v1');
2457+
expect(info.services.data.status).toBe('stub');
2458+
expect(info.services.data.handlerReady).toBe(false);
2459+
expect(info.services.data.message).toContain('ObjectQLPlugin');
2460+
});
2461+
2462+
it('answers the data slot identically to the metadata-protocol builder', async () => {
2463+
const { ObjectStackProtocolImplementation } = await import('@objectstack/metadata-protocol');
2464+
const degradedData = {
2465+
__serviceInfo: { status: 'degraded', message: 'read-only engine' },
2466+
find: vi.fn().mockResolvedValue([]),
2467+
};
2468+
(kernel as any).getService = vi.fn().mockImplementation((name: string) =>
2469+
name === 'data' ? degradedData : null,
2470+
);
2471+
2472+
const fromDispatcher = (await dispatcher.getDiscoveryInfo('/api/v1')).services.data;
2473+
const fromProtocol = (await new ObjectStackProtocolImplementation(
2474+
mockObjectQL as any,
2475+
() => new Map<string, any>([['data', degradedData]]),
2476+
).getDiscovery()).services.data;
2477+
2478+
expect(fromDispatcher.status).toBe(fromProtocol.status);
2479+
expect(fromDispatcher.handlerReady).toBe(fromProtocol.handlerReady);
2480+
expect(fromDispatcher.message).toBe(fromProtocol.message);
2481+
expect(fromDispatcher.route).toBe(fromProtocol.route);
2482+
});
24182483
});
24192484

24202485
// ═══════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)