diff --git a/.changeset/discovery-metadata-slot-computed.md b/.changeset/discovery-metadata-slot-computed.md new file mode 100644 index 0000000000..9f10acf61c --- /dev/null +++ b/.changeset/discovery-metadata-slot-computed.md @@ -0,0 +1,18 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/runtime': patch +--- + +Both discovery builders now compute the `metadata` service entry from the implementation that fills the slot, instead of hardcoding opposite verdicts for it (#4089). + +`metadata` sat in a "kernel-provided (always available)" block above the loop that reads `__serviceInfo`, hardcoded separately in each builder — and the two disagreed about the same slot: + +- `@objectstack/runtime`'s dispatcher declared it permanently `status: 'degraded'` with `message: 'In-memory registry; DB persistence pending'`, so a stack with `MetadataPlugin` and a real `sys_metadata` table was still reported as having no persistence. +- `@objectstack/metadata-protocol` declared the same slot permanently `status: 'available'`, so the kernel's in-memory fallback (`createMemoryMetadata`, auto-registered when no metadata plugin is present) read exactly like a persisted registry — the `__serviceInfo` marker #4058 gave it went unread here. + +Both now read the registered service's `__serviceInfo` (via `readServiceSelfInfo`) and report what it declares: + +- kernel in-memory fallback, or plugin-dev's dev registry → `status: 'degraded'` plus that implementation's own `message`, which names what is missing and what to install. +- `MetadataPlugin` (or any implementation carrying no marker) → `status: 'available'` with no message. + +`handlerReady: true` is now stated unconditionally on both sides: it answers "is `/api/v1/meta` mounted?", and that route is served by the protocol whichever implementation occupies the slot — a degraded service in it does not unmount the route. Nothing about routing, gating, or dispatch changes; consumers that treat `status` as a capability claim (AI agents, the console) simply stop being told two different things by two hosts. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index c90cb84e6c..45f7e4b197 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -40,7 +40,11 @@ async function main() { // 2. Check available services console.log('Services:', discovery.services); - // → { metadata: { enabled: true, status: 'degraded' }, data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } } + // → { metadata: { enabled: true, status: 'degraded', message: 'In-memory metadata registry — …' }, + // data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } } + // `metadata` reports the implementation actually behind it: `degraded` plus a + // message naming what is missing while the kernel's in-memory fallback fills + // the slot, `available` once MetadataPlugin provides a persisted registry. // 3. Query data const tasks = await client.data.find('todo_task', { diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index cff50b25a7..a65a83d6a9 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -132,6 +132,8 @@ Returns the full discovery manifest. Disabled/uninstalled route keys (e.g. `auth`, `analytics`, `workflow`) are omitted from `routes` entirely rather than set to `null`; check `services` to tell "not installed" apart from "installed but not yet mounted here." The sample above shows a minimal install: `analytics` reports `unavailable` and advertises no route until `@objectstack/service-analytics` registers the engine — the dispatcher then also mounts `/api/v1/analytics/*` (the routes are capability-conditional; an uninstalled capability answers 404 for every method). +`metadata` is reported from whatever implementation fills its slot, so the sample's `available` is the `MetadataPlugin` case (a persisted `sys_metadata` registry). A stack running the kernel's in-memory fallback instead reports `status: "degraded"` with a `message` naming what is missing and what to install. `handlerReady` is `true` either way: `/api/v1/meta` is served by the protocol, so the route is mounted whichever registry sits behind it. + ### `GET /.well-known/objectstack` Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`, `features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does not. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`. diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index 0f9f3a36d9..ae08e61a6c 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -113,15 +113,19 @@ The metadata service is a **framework** — it works with an in-memory `SchemaRe ### Discovery: Per-Service Status -The discovery endpoint returns a `services` map so clients know what is available: +The discovery endpoint returns a `services` map so clients know what is available. +The `metadata` entry is **computed from the implementation that fills the slot**, not +fixed (#4089): a stack running the kernel's in-memory fallback reports `degraded` with +that fallback's own message, while a stack with `MetadataPlugin` reports `available` and +no message. Below is the former — the fallback case: ```json { "services": { "metadata": { - "enabled": true, "status": "degraded", + "enabled": true, "status": "degraded", "handlerReady": true, "route": "/api/v1/meta", "provider": "kernel", - "message": "In-memory registry; DB persistence pending" + "message": "In-memory metadata registry — real reads and writes, no persistence (lost on restart). Register MetadataPlugin for a persisted registry." }, "data": { "enabled": true, "status": "available", diff --git a/content/docs/releases/implementation-status.mdx b/content/docs/releases/implementation-status.mdx index 015beca5aa..71270ef03a 100644 --- a/content/docs/releases/implementation-status.mdx +++ b/content/docs/releases/implementation-status.mdx @@ -468,7 +468,7 @@ Implementation spans every layer of the platform. Core infrastructure, data mode | **Query Engine** | ⚠️ | Partial (depends on driver) | | **REST API** | ✅ | Yes | | **Client SDKs** | ✅ | Yes | -| **Metadata System** | ⚠️ | Partial (in-memory only, DB persistence pending) | +| **Metadata System** | ⚠️ | Partial (`sys_metadata` persistence via `MetadataPlugin`; without it the kernel's in-memory fallback fills the slot and discovery reports it `degraded`) | | **HTTP Caching** | ✅ | Yes | | **Testing Tools** | ✅ | Yes | | **UI Rendering** | ❌ | No | diff --git a/docs/adr/0076-objectql-core-tiering.md b/docs/adr/0076-objectql-core-tiering.md index 5dca62d51f..d0e505d4a4 100644 --- a/docs/adr/0076-objectql-core-tiering.md +++ b/docs/adr/0076-objectql-core-tiering.md @@ -134,7 +134,7 @@ Decision: each capability plugin registers its routes as a **normalized handler* **Inventory of current fakes / mis-reports:** - `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.)* - `ObjectQLPlugin` registers a ~66-line `analytics` **fallback** (the D10 note — deliberate, but it reports as fully available). -- `http-dispatcher.ts` `svcAvailable` — the hardcode above. +- `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.)* - *(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.)* **Decision — honest capabilities:** diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6e7cdb7c50..ef7095ef90 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1402,9 +1402,34 @@ export class ObjectStackProtocolImplementation implements // registry via SERVICE_CONFIG below — absent means `unavailable`, and // no route is advertised (the pre-#2462 hardcode here is exactly what // the fallback was invented to make true). + // + // [#4089] The `metadata` slot is reported from whatever fills it, not + // hardcoded `available`. The kernel auto-registers `createMemoryMetadata` + // when no MetadataPlugin is present, and plugin-dev registers its own + // in-memory registry; both self-describe as `degraded` (D12), and + // hardcoding `available` here overstated them as exactly equivalent to a + // sys_metadata-backed registry. Absent or unmarked ⇒ `available`, which + // is what the real MetadataPlugin (carrying no marker) reports. + // + // This is also where the two builders stopped disagreeing: the runtime + // dispatcher hardcoded the opposite verdict for the same slot + // (`degraded` + "DB persistence pending"), so one host called a persisted + // registry degraded while the other called an in-memory one available. + // Both now compute it, and `handlerReady: true` is stated on both sides: + // `/api/v1/meta` is served by the protocol, not by this service, so it is + // mounted whichever implementation occupies the slot. + const metadataSelf = readServiceSelfInfo(registeredServices.get('metadata')); + const services: Record = { // --- Kernel-provided (objectql is an example kernel implementation) --- - metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' }, + metadata: { + enabled: true, + status: metadataSelf?.status ?? ('available' as const), + handlerReady: true, + route: '/api/v1/meta', + provider: 'objectql', + ...(metadataSelf?.message ? { message: metadataSelf.message } : {}), + }, data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' }, }; diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 4bad4fe805..4bbcb64909 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -35,6 +35,27 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { expect(readServiceSelfInfo(metadataService)?.status).toBe('degraded'); }); + // #4089 — the self-description above only helps if the builder reads it. + // On this boot path (no MetadataPlugin) the whole chain runs for real: + // the kernel injects `createMemoryMetadata`, the protocol shim walks the + // live service registry, and discovery must say `degraded` — it used to + // hardcode `available` for the slot and report a registry that never + // reaches disk as fully real. + it('should report the kernel metadata fallback as degraded in discovery', async () => { + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + + const protocol = kernel.getService('protocol') as any; + const discovery = await protocol.getDiscovery(); + + expect(discovery.services.metadata.enabled).toBe(true); + expect(discovery.services.metadata.status).toBe('degraded'); + expect(discovery.services.metadata.message).toContain('MetadataPlugin'); + // Serving is unaffected — `/api/v1/meta` is the protocol's route. + expect(discovery.services.metadata.handlerReady).toBe(true); + expect(discovery.routes.metadata).toBe('/api/v1/meta'); + }); + it('should serve in-memory metadata definitions', async () => { // Arrange const plugin = new ObjectQLPlugin(); diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 8601aead43..ec72f8adad 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { createMemoryMetadata } from '@objectstack/core'; import { ObjectQL } from './engine.js'; describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => { @@ -146,6 +147,40 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.services.data.status).toBe('available'); }); + // #4089 — `metadata` was in the hardcoded kernel block, so it reported + // `available` even when the slot held the kernel's in-memory fallback: a + // registry that never reaches disk read exactly like a sys_metadata-backed + // one. The slot is now computed from the implementation's own D12 + // self-description, like every optional service below it. + it('should report the kernel in-memory metadata fallback as degraded, never available', async () => { + const mockServices = new Map(); + mockServices.set('metadata', createMemoryMetadata()); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const discovery = await protocol.getDiscovery(); + + expect(discovery.services.metadata.enabled).toBe(true); + expect(discovery.services.metadata.status).toBe('degraded'); + // The message names what is missing and what to install for the real thing. + expect(discovery.services.metadata.message).toContain('MetadataPlugin'); + // Still served: `/api/v1/meta` is the protocol's route, not this service's. + expect(discovery.services.metadata.handlerReady).toBe(true); + expect(discovery.services.metadata.route).toBe('/api/v1/meta'); + expect(discovery.routes.metadata).toBe('/api/v1/meta'); + }); + + it('should report an unmarked (real) metadata service as available', async () => { + const mockServices = new Map(); + mockServices.set('metadata', { register: async () => {}, list: async () => [] }); + + protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); + const discovery = await protocol.getDiscovery(); + + expect(discovery.services.metadata.status).toBe('available'); + expect(discovery.services.metadata.message).toBeUndefined(); + expect(discovery.services.metadata.handlerReady).toBe(true); + }); + // #3891 — the degraded ObjectQL fallback is retired. Analytics is an // ordinary optional service now: absent ⇒ unavailable, and the route must // NOT be advertised (an advertised route with no handler 404s — the exact diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 196062859d..7ba5575962 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2387,6 +2387,67 @@ describe('HttpDispatcher', () => { expect(info.services.workflow.status).toBe('available'); expect(info.services.workflow.handlerReady).toBe(true); }); + + // ── The `metadata` slot: computed, not hardcoded (#4089) ────────────── + // + // This entry used to be a fixed `degraded` + "In-memory registry; DB + // persistence pending" whatever filled the slot, so it was wrong for + // every stack with a persisted registry — and it was the exact reverse + // of metadata-protocol's hardcoded `available`, which was wrong for + // every stack running the kernel's in-memory fallback. + + it('reports the kernel in-memory metadata fallback as degraded, with the fallback\'s own message', async () => { + const { createMemoryMetadata } = await import('@objectstack/core'); + const fallback = createMemoryMetadata(); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => + name === 'metadata' ? fallback : null, + ); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services.metadata.enabled).toBe(true); + expect(info.services.metadata.status).toBe('degraded'); + expect(info.services.metadata.message).toContain('no persistence'); + // `handlerReady` is about the route, not the service: `/meta` is + // served by the protocol on every host, so a degraded service in + // this slot does not unmount it. + expect(info.services.metadata.handlerReady).toBe(true); + expect(info.routes.metadata).toBe('/api/v1/meta'); + }); + + it('reports an unmarked metadata service as available, with no stale "persistence pending" message', async () => { + (kernel as any).getService = vi.fn().mockImplementation((name: string) => + name === 'metadata' ? { register: vi.fn(), get: vi.fn(), list: vi.fn() } : null, + ); + + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + expect(info.services.metadata.status).toBe('available'); + expect(info.services.metadata.message).toBeUndefined(); + expect(info.services.metadata.handlerReady).toBe(true); + }); + + it('answers the metadata slot identically to the metadata-protocol builder', async () => { + const [{ createMemoryMetadata }, { ObjectStackProtocolImplementation }] = await Promise.all([ + import('@objectstack/core'), + import('@objectstack/metadata-protocol'), + ]); + // One service instance, both builders — the two used to give this + // very object opposite verdicts (`degraded` here, `available` there). + const fallback = createMemoryMetadata(); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => + name === 'metadata' ? fallback : null, + ); + + const fromDispatcher = (await dispatcher.getDiscoveryInfo('/api/v1')).services.metadata; + const fromProtocol = (await new ObjectStackProtocolImplementation( + mockObjectQL as any, + () => new Map([['metadata', fallback]]), + ).getDiscovery()).services.metadata; + + expect(fromDispatcher.status).toBe(fromProtocol.status); + expect(fromDispatcher.handlerReady).toBe(fromProtocol.handlerReady); + expect(fromDispatcher.message).toBe(fromProtocol.message); + expect(fromDispatcher.route).toBe(fromProtocol.route); + }); }); // ═══════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index f3d0267a91..4efc8c030a 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -841,6 +841,7 @@ export class HttpDispatcher { authSvc, searchSvc, realtimeSvc, filesSvc, analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc, uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc, mcpSvc, + metadataSvc, ] = await Promise.all([ this.resolveService(CoreServiceName.enum.auth), this.resolveService(CoreServiceName.enum.search), @@ -859,6 +860,9 @@ export class HttpDispatcher { // Not a CoreServiceName — plugin-mcp registers under the bare // 'mcp' key, the same string handleMcpRequest resolves. this.resolveService('mcp'), + // [#4089] Resolved for its self-description alone: the `metadata` + // slot is reported from what actually fills it, not hardcoded. + this.resolveService(CoreServiceName.enum.metadata), ]); const hasAuth = !!authSvc; @@ -959,6 +963,9 @@ export class HttpDispatcher { // Self-description of the registered realtime service, if any (D12). const realtimeSelf = realtimeSvc ? readServiceSelfInfo(realtimeSvc) : undefined; + // Self-description of whatever fills the `metadata` slot (D12, #4089). + const metadataSelf = metadataSvc ? readServiceSelfInfo(metadataSvc) : undefined; + // Derive locale info from actual i18n service when available let locale = { default: 'en', supported: ['en'], timezone: 'UTC' }; if (hasI18n && i18nSvc) { @@ -993,8 +1000,34 @@ export class HttpDispatcher { i18n: hasI18n, }, services: { - // Kernel-provided (always available via protocol implementation) - metadata: { enabled: true, status: 'degraded' as const, handlerReady: true, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' }, + // Kernel-provided (always served by the protocol implementation) + // — but the verdict is NOT hardcoded (#4089). Three different + // implementations fill this slot: MetadataPlugin's + // sys_metadata-backed manager, the kernel's in-memory fallback + // (`createMemoryMetadata`) and plugin-dev's dev registry. Only + // the implementation knows which it is, so read its D12 + // self-description and report that. + // + // The hardcode this replaces (`degraded` + "In-memory registry; + // DB persistence pending") was the mirror image of + // metadata-protocol's hardcoded `available`: the two builders + // gave the same slot opposite answers, and each was wrong for + // half the stacks — this one understated a persisted registry, + // that one overstated the in-memory fallback. + // + // `handlerReady` stays unconditionally true and is not taken + // from the self-description: it answers "is /meta mounted?", + // and `handleMetadata` serves that route from the protocol on + // every host — a degraded service in this slot does not unmount + // it. (Contrast `realtime` below, where no surface exists at all.) + metadata: { + enabled: true, + status: metadataSelf?.status ?? ('available' as const), + handlerReady: true, + route: routes.metadata, + provider: 'kernel', + ...(metadataSelf?.message ? { message: metadataSelf.message } : {}), + }, data: svcAvailable(routes.data, 'kernel'), // Plugin-provided — only available when a plugin registers the service auth: hasAuth ? svcAvailable(routes.auth, undefined, authSvc) : svcUnavailable('auth'),