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
18 changes: 18 additions & 0 deletions .changeset/discovery-metadata-slot-computed.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
2 changes: 2 additions & 0 deletions content/docs/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
10 changes: 7 additions & 3 deletions content/docs/kernel/services-checklist.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion content/docs/releases/implementation-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0076-objectql-core-tiering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
27 changes: 26 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ServiceInfo> = {
// --- 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' },
};

Expand Down
21 changes: 21 additions & 0 deletions packages/objectql/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
35 changes: 35 additions & 0 deletions packages/objectql/src/protocol-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, any>();
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<string, any>();
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
Expand Down
61 changes: 61 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>([['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);
});
});

// ═══════════════════════════════════════════════════════════════
Expand Down
Loading
Loading