diff --git a/.changeset/discovery-remedy-names-a-real-package.md b/.changeset/discovery-remedy-names-a-real-package.md new file mode 100644 index 0000000000..2c656512e9 --- /dev/null +++ b/.changeset/discovery-remedy-names-a-real-package.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': patch +'@objectstack/metadata-protocol': patch +--- + +Discovery's "install this to enable" now names a package that exists (#4093 follow-up). + +Discovery tells a consumer two things about an absent capability: that it is absent, and what to do about it. The first has been carefully honest since #2462/#4000. The second was invented from the slot name. + +The dispatcher templated `Install a ${slot} plugin to enable` across twelve slots, and `metadata-protocol` carried a hand-written table in which **ten of fifteen entries named a package that does not exist** — `plugin-redis`, `plugin-bullmq`, `job-scheduler`, `plugin-notifications`, `plugin-storage`, `plugin-automation`, `ui-plugin`, plus `plugin-ai`, `plugin-search` and `plugin-workflow` for slots nothing implements at all. That value is also surfaced as discovery's `provider`. + +A remedy naming a package that cannot be installed is a dead end handed to someone at the exact moment they are trying to fix their stack — and an agent reading discovery cannot tell it apart from a package it should install. It is the same `declared ≠ enforced` failure this lineage has been closing, one level over: not "does the capability exist" but "is the fix real". + +`CORE_SERVICE_PROVIDER` and `serviceUnavailableMessage()` in `@objectstack/spec/system` are now the one place that sentence is written, and both discovery builders read them, so the two hosts cannot tell a consumer to install different things (the drift #4089 and #4130 closed for the `metadata` and `data` entries). Entries were verified against what actually calls `registerService` for each slot rather than against name similarity — which is how `notification` turned out to be filled by `@objectstack/service-messaging`, the one slot whose package shares no word with its name. + +Four slots — `ai`, `search`, `workflow`, `graphql` — have no implementation anywhere, so they now say so instead of naming a plausible package. `ui` keeps the fuller sentence it got in #4146 (`/ui` is served by the `protocol` service; nothing registers the `ui` slot), and that sentence now reaches both builders instead of one. + +`scripts/check-service-providers.mjs` (wired into the lint workflow as `check:service-providers`) fails CI when a named package is not a real workspace package, or when a `CoreServiceName` slot has no entry — so a rename or a deletion cannot leave a stale instruction behind. + +FROM → TO: `services..message` and `services..provider` change text for most unavailable slots. Anything matching on the old `Install a plugin to enable` wording should match on `status: 'unavailable'` instead — the status field is the contract; the message is prose for humans and agents. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a2e8888b6e..585f109d53 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -99,6 +99,16 @@ jobs: - name: Single authz resolver guard run: pnpm check:authz-resolver + # #4093 follow-up. Discovery tells a consumer an absent capability is + # absent AND what to install. The first half has been carefully honest + # since #2462/#4000; the second was invented from the slot name, so ten + # of fifteen entries named packages that do not exist — a dead end handed + # to whoever is trying to fix their stack. Asserts every package named in + # CORE_SERVICE_PROVIDER is a real workspace package, and that no core + # slot is missing an entry. + - name: Service-provider remedy guard + run: pnpm check:service-providers + # #3843 response-envelope guard. The route ledgers audit which routes EXIST; # nothing audited what comes back, so six route modules emitted bodies # outside BaseResponseSchema while carrying green `sdk` rows. Counts the diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index b45f69d440..b00e238652 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -134,7 +134,7 @@ no message. Below is the former — the fallback case: }, "auth": { "enabled": false, "status": "unavailable", - "message": "Install a auth plugin to enable" + "message": "Install @objectstack/plugin-auth to enable" } } } diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index a0321cb9bb..7627861d59 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -53,9 +53,9 @@ GET /api/v1/discovery HTTP/1.1 "services": { "data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" }, "metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" }, - "auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "plugin-auth" }, - "workflow": { "enabled": false, "status": "unavailable", "message": "Install plugin-workflow to enable" }, - "ai": { "enabled": false, "status": "unavailable", "message": "Install plugin-ai to enable" } + "auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" }, + "workflow": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'workflow' slot — register a service under it to enable" }, + "ai": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'ai' slot — register a service under it to enable" } }, "locale": { "default": "en-US", diff --git a/package.json b/package.json index e48faaeaba..4c26e4897b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "check:role-word": "node scripts/check-role-word.mjs", "check:org-identifier": "node scripts/check-org-identifier.mjs", "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs", + "check:service-providers": "node scripts/check-service-providers.mjs", "check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs", "check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs", "check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs", diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 229a6c3ac4..59e4230866 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -21,7 +21,7 @@ import { readServiceSelfInfo } from '@objectstack/spec/api'; import { parseFilterAST, isFilterAST, type DroppedFieldsEvent, type QueryAST } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui'; -import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; +import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage } from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed } from '@objectstack/spec/kernel'; import { extractProtection, @@ -863,28 +863,42 @@ function suggestQueryParam(param: string, knownFields: readonly string[]): strin * not advertise a route for it (ADR-0076 D12, #2462: an advertised route * with no mounted handler 404s and misleads consumers). */ -const SERVICE_CONFIG: Record = { +/** + * [#4093 follow-up] `plugin` is no longer written here. It named the package a + * consumer should install, and ten of the fifteen names did not exist — + * `plugin-redis` / `plugin-bullmq` / `job-scheduler` / `plugin-notifications` / + * `plugin-storage` / `plugin-automation` / `ui-plugin`, plus `plugin-ai`, + * `plugin-search` and `plugin-workflow` for slots nothing implements at all. + * The value is surfaced as discovery's `provider` and as its remedy line, so a + * wrong name is a dead end handed to whoever is trying to fix their stack. + * + * It now comes from `CORE_SERVICE_PROVIDER` in `@objectstack/spec/system`, the + * one table both discovery builders read, verified against what actually + * registers each slot and guarded by `scripts/check-service-providers.mjs`. + * Only the ROUTE stays local — that is this builder's own knowledge. + */ +const SERVICE_CONFIG: Record = { // Plugin-provided like every other optional service since the degraded // ObjectQL fallback was retired (#3891): advertised iff the real engine // is registered — never hardcoded 'available' (the pre-#2462 lie the // fallback existed to paper over). - analytics: { route: '/api/v1/analytics', plugin: 'service-analytics' }, - auth: { route: '/api/v1/auth', plugin: 'plugin-auth' }, - automation: { route: '/api/v1/automation', plugin: 'plugin-automation' }, - cache: { route: '/api/v1/cache', plugin: 'plugin-redis' }, - queue: { route: '/api/v1/queue', plugin: 'plugin-bullmq' }, - job: { route: '/api/v1/jobs', plugin: 'job-scheduler' }, - ui: { route: '/api/v1/ui', plugin: 'ui-plugin' }, - workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow' }, + analytics: { route: '/api/v1/analytics' }, + auth: { route: '/api/v1/auth' }, + automation: { route: '/api/v1/automation' }, + cache: { route: '/api/v1/cache' }, + queue: { route: '/api/v1/queue' }, + job: { route: '/api/v1/jobs' }, + ui: { route: '/api/v1/ui' }, + workflow: { route: '/api/v1/workflow' }, // service-realtime is an in-process pub/sub bus; nothing mounts // /api/v1/realtime, so no route is advertised (D12, #2462). - realtime: { plugin: 'service-realtime' }, - notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' }, - ai: { route: '/api/v1/ai', plugin: 'plugin-ai' }, - i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' }, - graphql: { route: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST) - 'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage' }, - search: { route: '/api/v1/search', plugin: 'plugin-search' }, + realtime: {}, + notification: { route: '/api/v1/notifications' }, + ai: { route: '/api/v1/ai' }, + i18n: { route: '/api/v1/i18n' }, + graphql: { route: '/graphql' }, // GraphQL uses /graphql by convention (not versioned REST) + 'file-storage': { route: '/api/v1/storage' }, + search: { route: '/api/v1/search' }, }; /** @@ -1694,7 +1708,7 @@ export class ObjectStackProtocolImplementation implements enabled: true, status: self?.status ?? (noHttpSurface ? ('degraded' as const) : ('available' as const)), route: advertisedRoute(serviceName, config.route), - provider: config.plugin, + provider: CORE_SERVICE_PROVIDER[serviceName] ?? undefined, ...(noHttpSurface || self?.handlerReady !== undefined ? { handlerReady: noHttpSurface ? false : self?.handlerReady } : {}), @@ -1709,7 +1723,7 @@ export class ObjectStackProtocolImplementation implements services[serviceName] = { enabled: false, status: 'unavailable' as const, - message: `Install ${config.plugin} to enable`, + message: serviceUnavailableMessage(serviceName), }; } } diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 695eb81db0..d41f52d9da 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -41,7 +41,9 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.services.auth.enabled).toBe(true); expect(discovery.services.auth.status).toBe('available'); expect(discovery.services.auth.route).toBe('/api/v1/auth'); - expect(discovery.services.auth.provider).toBe('plugin-auth'); + // [#4093 follow-up] Scoped now: `provider` comes from CORE_SERVICE_PROVIDER, + // which names the package you can actually install rather than a bare label. + expect(discovery.services.auth.provider).toBe('@objectstack/plugin-auth'); expect(discovery.routes.auth).toBe('/api/v1/auth'); }); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 09736afcaa..8fa6cf2c4b 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2290,6 +2290,61 @@ describe('HttpDispatcher', () => { expect(info.features.i18n).toBe(false); }); + // [#4093 follow-up] Discovery's remedy line must name a package that can + // actually be installed. It used to be templated from the slot name + // (`Install a ${slot} plugin to enable`), which named nothing real for + // `ai` / `search` / `workflow` and got it wrong wherever the package is + // not called after its slot. Both builders now read one table. + describe('unavailable slots name a real remedy (#4093 follow-up)', () => { + beforeEach(() => { + (kernel as any).getService = vi.fn().mockResolvedValue(null); + (kernel as any).services = new Map(); + }); + + it('names the actual package, including where the name differs from the slot', async () => { + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + // service-messaging fills `notification` — the case a + // name-derived guess can never get right. + expect(info.services.notification.message).toBe('Install @objectstack/service-messaging to enable'); + expect(info.services.auth.message).toBe('Install @objectstack/plugin-auth to enable'); + expect(info.services['file-storage'].message).toBe('Install @objectstack/service-storage to enable'); + }); + + it('says nothing ships rather than naming a package that does not exist', async () => { + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + for (const slot of ['ai', 'search', 'workflow'] as const) { + expect(info.services[slot].message, `services.${slot}.message`).not.toMatch(/Install/); + expect(info.services[slot].message, `services.${slot}.message`).toContain(slot); + } + }); + + it('never emits the old slot-name-derived template', async () => { + const info = await dispatcher.getDiscoveryInfo('/api/v1'); + for (const [slot, entry] of Object.entries(info.services as Record)) { + if (typeof entry?.message !== 'string') continue; + expect(entry.message, `services.${slot}.message`).not.toMatch(/Install a .+ plugin to enable/); + } + }); + + it('gives the same remedy as the metadata-protocol builder', async () => { + const { ObjectStackProtocolImplementation } = await import('@objectstack/metadata-protocol'); + const fromProtocol = (await new ObjectStackProtocolImplementation( + mockObjectQL as any, + () => new Map(), + ).getDiscovery()).services; + const fromDispatcher = (await dispatcher.getDiscoveryInfo('/api/v1')).services as Record; + + // Every slot both builders report must carry the same remedy — + // two hosts telling a consumer to install different things is + // the drift #4089/#4130 closed for `metadata` and `data`. + for (const slot of Object.keys(fromProtocol)) { + const mine = fromDispatcher[slot]; + if (!mine || mine.enabled !== false) continue; + expect(mine.message, `services.${slot}.message parity`).toBe((fromProtocol as any)[slot].message); + } + }); + }); + it('should detect i18n via getServiceAsync (async factory) in discovery', async () => { const mockI18nService = { getLocales: vi.fn().mockReturnValue(['en', 'fr']), diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 636b96798a..dd88e4dc94 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -5,7 +5,7 @@ import { } from '@objectstack/core'; import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; -import { CoreServiceName } from '@objectstack/spec/system'; +import { CoreServiceName, serviceUnavailableMessage } from '@objectstack/spec/system'; import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api'; import { apiErrorResponse } from './error-envelope.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -995,9 +995,16 @@ export class HttpDispatcher { } return { enabled: true, status: 'available' as const, handlerReady: true, route, provider }; }; + // [#4093 follow-up] The remedy comes from the shared provider table + // (`CORE_SERVICE_PROVIDER`), not from the slot name. `Install a ${name} + // plugin to enable` named a package that does not exist for `ai`, + // `search` and `workflow` — nothing implements those slots — and got + // the name wrong wherever the package is not called after its slot + // (`notification` is filled by service-messaging). A fix a consumer + // cannot carry out is worse than no fix named. const svcUnavailable = (name: string) => ({ enabled: false, status: 'unavailable' as const, handlerReady: false, - message: `Install a ${name} plugin to enable`, + message: serviceUnavailableMessage(name), }); // Self-description of the registered realtime service, if any (D12). @@ -1124,7 +1131,7 @@ export class HttpDispatcher { ? svcAvailable(routes.ui, 'metadata-protocol', protocolSvc) : { enabled: false, status: 'unavailable' as const, handlerReady: false, - message: 'Served by the protocol service — register MetadataPlugin (@objectstack/metadata-protocol) to enable', + message: serviceUnavailableMessage('ui'), }, workflow: hasWorkflow ? svcAvailable(routes.workflow, undefined, workflowSvc) : svcUnavailable('workflow'), // Honest entry (ADR-0076 D12, #2462): the registered realtime diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 46a786dc84..9d7e91f448 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -690,6 +690,7 @@ "BucketConfig (type)", "BucketConfigSchema (const)", "CLOUD_PROVIDED_OBJECT_NAMES (const)", + "CORE_SERVICE_PROVIDER (const)", "CRDTMergeResult (type)", "CRDTMergeResultSchema (const)", "CRDTState (type)", @@ -1363,6 +1364,7 @@ "resolveViewDescription (function)", "resolveViewLabel (function)", "s3StorageExample (const)", + "serviceUnavailableMessage (function)", "toLocaleDescriptors (function)", "translateAction (function)", "translateApp (function)", diff --git a/packages/spec/src/system/core-service-provider.test.ts b/packages/spec/src/system/core-service-provider.test.ts new file mode 100644 index 0000000000..813ccc83c2 --- /dev/null +++ b/packages/spec/src/system/core-service-provider.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { CoreServiceName, CORE_SERVICE_PROVIDER, serviceUnavailableMessage } from './core-services.zod'; + +/** + * [#4093 follow-up] Discovery tells a consumer two things about an absent + * capability: that it is absent, and what to do about it. The first has been + * carefully honest since #2462/#4000. The second was invented from the slot + * name — `Install a ${slot} plugin to enable` in the dispatcher, and a + * hand-written table in metadata-protocol where ten of fifteen names were not + * real packages. These pin the remedy to reality. + * + * `scripts/check-service-providers.mjs` is the other half: it fails CI when a + * name here stops being a workspace package. This file pins the SHAPE of the + * answer; the script pins its TRUTH against the filesystem. + */ +describe('CORE_SERVICE_PROVIDER', () => { + it('never names a package derived from the slot name alone', () => { + // The entry that proves the rule: nothing about "notification" suggests + // "messaging", so any name-derived guess is wrong here by construction. + expect(CORE_SERVICE_PROVIDER['notification']).toBe('@objectstack/service-messaging'); + }); + + it('uses null — not a plausible name — for slots nothing implements', () => { + for (const slot of ['ai', 'search', 'workflow', 'graphql']) { + expect(CORE_SERVICE_PROVIDER[slot], `${slot} must have no provider`).toBeNull(); + } + }); + + it('scopes every named package, so the remedy is copy-pasteable', () => { + for (const [slot, pkg] of Object.entries(CORE_SERVICE_PROVIDER)) { + if (pkg === null) continue; + expect(pkg, `${slot} provider`).toMatch(/^@objectstack\//); + } + }); + + it('covers every core slot that can be filled by an installable package', () => { + // `data` and `metadata` come from the engine itself, never from an + // optional package, so they carry no remedy. + const engineOwned = new Set(['data', 'metadata']); + for (const slot of CoreServiceName.options) { + if (engineOwned.has(slot)) continue; + expect( + Object.prototype.hasOwnProperty.call(CORE_SERVICE_PROVIDER, slot), + `${slot} must have an entry (even if null)`, + ).toBe(true); + } + }); +}); + +describe('serviceUnavailableMessage', () => { + it('names the package to install when one exists', () => { + expect(serviceUnavailableMessage('auth')).toBe('Install @objectstack/plugin-auth to enable'); + expect(serviceUnavailableMessage('file-storage')).toBe('Install @objectstack/service-storage to enable'); + }); + + it('says nothing ships rather than naming a package that cannot be installed', () => { + const msg = serviceUnavailableMessage('search'); + expect(msg).not.toMatch(/Install/); + expect(msg).toContain("'search'"); + // The old template produced exactly this — a package that never existed. + expect(msg).not.toContain('search plugin'); + }); + + it('treats an unknown slot as "nothing ships", never inventing a name', () => { + const msg = serviceUnavailableMessage('not-a-real-slot'); + expect(msg).not.toMatch(/Install/); + }); +}); diff --git a/packages/spec/src/system/core-services.zod.ts b/packages/spec/src/system/core-services.zod.ts index 6ddf8e99d9..a2644477d2 100644 --- a/packages/spec/src/system/core-services.zod.ts +++ b/packages/spec/src/system/core-services.zod.ts @@ -43,6 +43,92 @@ export const CoreServiceName = z.enum([ export type CoreServiceName = z.infer; +/** + * Which published package actually fills each service slot — or `null` when + * **nothing ships for it yet**. + * + * [#4093 follow-up] Discovery tells a consumer two things about an absent + * capability: that it is absent, and what to do about it. The first has been + * carefully honest since #2462/#4000; the second was invented from the slot + * name. The dispatcher templated `Install a ${slot} plugin to enable` for 12 + * slots, and metadata-protocol carried a hand-written table in which **ten of + * fifteen** names did not exist (`plugin-redis`, `plugin-bullmq`, + * `job-scheduler`, `plugin-notifications`, `plugin-storage`, `ui-plugin`, + * `plugin-automation`, and `plugin-ai` / `plugin-search` / `plugin-workflow` + * for slots nothing implements). That value is also surfaced as `provider`. + * + * A remedy naming a package that cannot be installed is a dead end handed to + * someone at the exact moment they are trying to fix their stack — the same + * `declared ≠ enforced` failure this lineage has been closing, one level over: + * not "does the capability exist" but "is the fix real". An agent reading + * discovery cannot tell the difference between a package it should install and + * one that was never written. + * + * Entries are verified against what actually calls `registerService` for the + * slot, not against name similarity, and `scripts/check-service-providers.mjs` + * fails CI if any name here is not a real workspace package — so a rename or a + * deletion cannot leave a stale instruction behind. + * + * `null` is a first-class answer, not a gap: for those slots the honest remedy + * is "nothing to install", and saying so beats naming a plausible package. + */ +export const CORE_SERVICE_PROVIDER: Readonly> = { + // Verified: each of these registers the slot itself. + 'analytics': '@objectstack/service-analytics', + 'auth': '@objectstack/plugin-auth', + 'automation': '@objectstack/service-automation', + 'cache': '@objectstack/service-cache', + 'queue': '@objectstack/service-queue', + 'job': '@objectstack/service-job', + 'realtime': '@objectstack/service-realtime', + 'file-storage': '@objectstack/service-storage', + 'i18n': '@objectstack/service-i18n', + // The `notification` slot is filled by the messaging service — the one entry + // whose package name shares no word with its slot, and the reason a + // name-derived guess cannot be right in general. + 'notification': '@objectstack/service-messaging', + // `/ui` is served by the `protocol` service, which MetadataPlugin registers; + // the `ui` slot itself has no implementation anywhere (#4093 / #4146). + 'ui': '@objectstack/metadata-protocol', + // Nothing ships for these. `search` and `workflow` have no consumer either + // (ADR-0115 Evidence 5); `graphql` and `ai` have surfaces but no provider. + 'ai': null, + 'search': null, + 'workflow': null, + 'graphql': null, +} as const; + +/** + * Slots whose remedy needs more than "install X", because **the slot is not + * what serves the capability**. `Install ` would be true but misleading: + * it reads as "this package fills this slot", and someone who then checks + * discovery still sees the slot empty. + * + * `/ui` is the case (#4146): it is served by the `protocol` service, and + * nothing anywhere registers `ui` — so the sentence has to say what actually + * serves it, not just what to install. + */ +const REMEDY_DETAIL: Readonly> = { + 'ui': 'Served by the protocol service — register MetadataPlugin (@objectstack/metadata-protocol) to enable', +}; + +/** + * The remedy line discovery reports for an unavailable slot — the one place + * that sentence is written, so the dispatcher and the metadata-protocol + * builder cannot tell a consumer to install different things (the drift #4089 + * and #4130 closed for the `metadata` and `data` entries). + * + * See {@link CORE_SERVICE_PROVIDER}. + */ +export function serviceUnavailableMessage(slot: string): string { + const detail = REMEDY_DETAIL[slot]; + if (detail) return detail; + const pkg = CORE_SERVICE_PROVIDER[slot]; + return pkg + ? `Install ${pkg} to enable` + : `No implementation ships for the '${slot}' slot — register a service under it to enable`; +} + /** * Service Criticality Level * Defines the startup behavior when a service is missing. diff --git a/scripts/check-service-providers.mjs b/scripts/check-service-providers.mjs new file mode 100644 index 0000000000..e7612794bf --- /dev/null +++ b/scripts/check-service-providers.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// Guards `CORE_SERVICE_PROVIDER` (packages/spec/src/system/core-services.zod.ts): +// the table that tells a consumer WHICH PACKAGE to install when discovery +// reports a service slot unavailable. +// +// The bug it exists to prevent (#4093 follow-up): that remedy used to be +// invented from the slot name — the dispatcher templated `Install a ${slot} +// plugin to enable`, and metadata-protocol hand-wrote a table in which ten of +// fifteen names were not real packages (`plugin-redis`, `plugin-bullmq`, +// `job-scheduler`, `plugin-notifications`, `plugin-storage`, `ui-plugin`, +// `plugin-automation`, plus `plugin-ai` / `plugin-search` / `plugin-workflow` +// for slots nothing implements). Discovery also surfaces the value as +// `provider`. A name that cannot be installed is a dead end handed to someone +// at the moment they are trying to fix their stack, and an agent reading +// discovery cannot tell it apart from a package it should install. +// +// Two ways that table rots, both caught here: +// 1. A name that is not a workspace package (typo, guess, or a rename +// landing in the package but not here). +// 2. A slot in `CoreServiceName` with no entry at all — which would fall +// back to `undefined` and print a remedy naming nothing. +// +// `null` is a legitimate entry: it means nothing ships for that slot yet, and +// the message says so instead of naming a plausible package. + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = process.cwd(); +const TABLE_FILE = 'packages/spec/src/system/core-services.zod.ts'; + +/** Every package name declared anywhere in the workspace. */ +function workspacePackageNames() { + const names = new Set(); + const roots = ['packages', join('packages', 'plugins'), join('packages', 'services')]; + for (const dir of roots) { + const abs = join(ROOT, dir); + if (!existsSync(abs)) continue; + for (const entry of readdirSync(abs)) { + const pkg = join(abs, entry, 'package.json'); + if (!existsSync(pkg)) continue; + try { + const { name } = JSON.parse(readFileSync(pkg, 'utf8')); + if (name) names.add(name); + } catch { /* unparseable package.json is another check's problem */ } + } + } + return names; +} + +/** Slot → provider entries, read from the table's source. */ +function parseTable(src) { + const start = src.indexOf('export const CORE_SERVICE_PROVIDER'); + if (start === -1) throw new Error(`CORE_SERVICE_PROVIDER not found in ${TABLE_FILE}`); + const end = src.indexOf('} as const;', start); + if (end === -1) throw new Error('CORE_SERVICE_PROVIDER is not closed with `} as const;`'); + const body = src.slice(start, end); + const entries = new Map(); + // `'slot': '@scope/pkg',` or `'slot': null,` — comments are skipped by the + // anchored quote/null alternation rather than by stripping them. + for (const m of body.matchAll(/'([a-z0-9-]+)':\s*(?:'([^']+)'|(null))\s*,/g)) { + entries.set(m[1], m[3] ? null : m[2]); + } + return entries; +} + +/** The slot names the kernel declares. */ +function coreServiceNames(src) { + const start = src.indexOf('export const CoreServiceName = z.enum(['); + const end = src.indexOf(']);', start); + return [...src.slice(start, end).matchAll(/'([a-z0-9-]+)'/g)].map((m) => m[1]); +} + +const src = readFileSync(join(ROOT, TABLE_FILE), 'utf8'); +const table = parseTable(src); +const slots = coreServiceNames(src); +const packages = workspacePackageNames(); + +const problems = []; + +for (const [slot, pkg] of table) { + if (pkg === null) continue; + if (!packages.has(pkg)) { + problems.push( + ` ${slot} → ${pkg}\n` + + ' Not a workspace package. Point it at the package that actually registers\n' + + ` the '${slot}' slot, or use \`null\` if nothing ships for it yet.`, + ); + } +} + +// `data` and `metadata` are filled by the engine itself (ObjectQL / the +// protocol implementation), never by an installable optional package, so they +// carry no remedy and need no entry. +const NO_REMEDY_SLOTS = new Set(['data', 'metadata']); +for (const slot of slots) { + if (NO_REMEDY_SLOTS.has(slot) || table.has(slot)) continue; + problems.push( + ` ${slot} → (missing)\n` + + ' Declared in CoreServiceName but absent from CORE_SERVICE_PROVIDER, so\n' + + ' discovery would report it unavailable with a remedy naming nothing.', + ); +} + +if (problems.length > 0) { + console.error('✗ Service-provider remedies (#4093 follow-up)\n'); + console.error(problems.join('\n\n')); + console.error( + '\nDiscovery reports these as `provider` and as "Install X to enable". A name\n' + + `that cannot be installed is worse than none. See ${TABLE_FILE}.`, + ); + process.exit(1); +} + +const named = [...table.values()].filter(Boolean).length; +console.log( + `✓ check:service-providers — ${table.size} slot(s): ${named} name a real workspace ` + + `package, ${table.size - named} correctly report that nothing ships yet.`, +);