From d85fe02dc73fd64613bdf095c178bcee2b414742 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 13:18:53 +0000 Subject: [PATCH] fix(spec,runtime): a service-slot lookup returns the slot's contract, not `any` (#4127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4127's most valuable item was the one it did not do: add a gate for the class. Its four contract gaps were found by a human sweeping the dispatcher by hand. A sweep is not repeatable, and this one was not complete. The root was one line — `getService(name: string): any` in domain-handler-registry.ts. Against `any`, a domain calling a method its contract declares and a domain calling a method nobody declares typecheck identically. That is what let #4087 ship a `/storage` handler passing two arguments no implementation takes, and what hid #4127's four. `CoreServiceContracts` is the slot -> contract ledger. `CoreServiceName` named the slots and `contracts/*` described them; nothing connected the two. It does now, and `getService(name: K)` resolves through it, so a call outside the contract is a compile error at the call site. An entry is a claim, so entries are made only where the binding is evidenced: by the provider that registers the slot (service-storage -> file-storage; objectql -> data, whose own comment reads "ObjectQL implements IDataEngine"), or by dispatcher work that proved it (#4143/#4150 for automation, notification, i18n). `ui` is deliberately unmapped — the slot exists and domains/ui.ts serves it, but no IUiService was ever written. An unmapped slot resolves to `unknown`, not `any`, so it must be cast deliberately and the gap stays legible. Two findings within minutes of turning it on: - `/auth` called a method that does not exist. domains/auth.ts probed `authService.handler(request, response)`; `IAuthService` declares `handleRequest(request)` and `AuthManager` implements exactly that, with no `handler`. False on every deployment — #4143's dead `automation.trigger` again. #4127's sweep never mentions `/auth` in either its gap list or its "clean" list: the file the compiler flagged first is the one the human pass skipped. Not a live hole — the Hono adapter calls `handleRequest` itself and only falls through when no usable auth service answered — but reading the contract makes the branch reachable for the first time, so a host calling `handleAuth` directly WITH an auth service now gets it instead of mockAuthFallback's `mock_` session. - `POST /analytics/sql` invoked an optional method unguarded. `generateSql?` is optional on IAnalyticsService — unlike `query`/`getMeta` beside it — so a provider without it answered a 500 from TypeError instead of saying the capability is absent. Answers `handled: false` now, the same 404 the entry gate already gives for absent analytics capability. `isServiceServeable` becomes a type guard (`svc is NonNullable`). Every domain already calls it first on a resolved slot, so one predicate narrows away the `undefined` for the whole body — the null check and the capability check were always the same check. The test-side hole #4127 predicted, closed for this batch: THREE tests across two files mocked `{ handler }` for auth, including one whose subject was the resolution path, so it proved the lookup worked and nothing about the call. `ContractMock` guards mock keys against the contract; signatures stay `unknown` so vi.fn() does not force everything back to `as any`. The automation mock's `trigger` stays as a labelled negative control outside the checked literal — a test asserting the route never calls it is the point. The 12 domains not calling `getService` are untouched. `resolveService`, which also takes non-CoreServiceName names like `protocol` and `objectql`, is left for a later batch rather than widened here. Refs #4127 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw --- .../slot-lookups-return-their-contract.md | 90 +++++++++++++++++++ .../src/domain-handler-registry.test.ts | 13 ++- .../runtime/src/domain-handler-registry.ts | 23 ++++- packages/runtime/src/domains/analytics.ts | 12 +++ packages/runtime/src/domains/auth.ts | 25 +++++- packages/runtime/src/http-dispatcher.test.ts | 61 ++++++++++--- packages/runtime/src/service-serveable.ts | 9 +- packages/spec/api-surface.json | 2 + .../contracts/core-service-contracts.test.ts | 61 +++++++++++++ .../src/contracts/core-service-contracts.ts | 88 ++++++++++++++++++ packages/spec/src/contracts/index.ts | 5 ++ 11 files changed, 369 insertions(+), 20 deletions(-) create mode 100644 .changeset/slot-lookups-return-their-contract.md create mode 100644 packages/spec/src/contracts/core-service-contracts.test.ts create mode 100644 packages/spec/src/contracts/core-service-contracts.ts diff --git a/.changeset/slot-lookups-return-their-contract.md b/.changeset/slot-lookups-return-their-contract.md new file mode 100644 index 0000000000..b3de5bc3bb --- /dev/null +++ b/.changeset/slot-lookups-return-their-contract.md @@ -0,0 +1,90 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": patch +--- + +fix(spec,runtime): a service-slot lookup returns the slot's contract, not `any` — and it immediately found two more gaps (#4127) + +#4127's most valuable item was the one it did not do: "**给这个类别加个 gate**". +The four contract gaps it catalogued were found by a human sweeping the +dispatcher by hand. A sweep is not repeatable, and this one was not complete — +see `/auth` below. + +The root was one line: + +```ts +// domain-handler-registry.ts +getService(name: string): any; // ← every domain's service handle +``` + +Against `any`, a domain calling a method its contract declares and a domain +calling a method nobody declares typecheck identically. That is what let #4087 +ship a `/storage` handler passing two arguments no implementation takes, and +what hid #4127's four. + +**`CoreServiceContracts` — the slot → contract ledger.** `CoreServiceName` named +the slots and `contracts/*` described them; nothing connected the two. It does +now, and `getService(name: K)` resolves through it, so a call outside the +contract is a **compile error at the call site**. + +An entry is a claim, so entries are only made where the binding is evidenced — +by the provider that registers the slot (`service-storage` → `file-storage`, +`objectql` → `data`, whose own comment reads "ObjectQL implements IDataEngine"), +or by dispatcher work that proved it (#4143/#4150 for `automation`, +`notification`, `i18n`). **`ui` is deliberately unmapped**: the slot exists and +`domains/ui.ts` serves it, but no `IUiService` was ever written. An unmapped slot +resolves to `unknown`, not `any` — it must be cast deliberately, so the gap stays +legible instead of looking checked. + +**Two findings, within minutes of turning it on:** + +**`/auth` called a method that does not exist.** `domains/auth.ts` probed +`authService.handler(request, response)`. `IAuthService` declares +`handleRequest(request): Promise`; `AuthManager` implements exactly +that and has no `handler`. The probe was false on every deployment — #4143's dead +`automation.trigger` again. **#4127's manual sweep never mentions `/auth`**, +neither in its gap list nor in its "扫干净的" list: the file the compiler flagged +first is the one the human pass skipped entirely. + +Not a live hole: the Hono adapter calls `handleRequest` itself and only falls +through to the dispatcher when no usable auth service answered, so nothing was +served by the mock in that deployment. But reading the contract makes the branch +reachable for the first time — a host calling `handleAuth` directly WITH an auth +service registered used to get `mockAuthFallback`'s `mock_` session instead +of real authentication, and now gets the auth service. + +**`POST /analytics/sql` invoked an optional method unguarded.** `generateSql?` is +optional on `IAnalyticsService` — unlike `query`/`getMeta` beside it — and the +call had no probe, so a provider without it answers a 500 from `TypeError` +instead of saying the capability is absent. service-analytics implements it, +which is why nothing noticed; the contract permits a provider that does not, and +this slot is multi-provider by design. It answers `handled: false` now, the same +404 the file's entry gate already gives for absent analytics capability. + +**`isServiceServeable` is a type guard now** (`svc is NonNullable`). Every +domain already calls it first on a resolved slot, so one predicate narrows away +the `undefined` for the whole handler body — the null check and the capability +check were always the same check. + +**The test-side hole, closed for this batch.** #4127's last section predicted it: +the mocks are written to what the handler wants, so handler and test agree with +each other and with no implementation. **Three** tests across two files mocked +`{ handler }` for auth — including one whose entire subject was the *resolution +path*, so it proved the lookup worked and nothing about the call. `ContractMock` +(`Partial>`) now guards the mocks: keys are checked +against the contract, signatures deliberately left `unknown` so `vi.fn()` does not +force everything back to `as any`. The automation mock's `trigger` — genuinely +not on the contract — stays as an explicit, labelled negative control outside the +checked literal, because a test asserting the route *never* calls it is the point. + +Nothing is renamed and no runtime behavior changes except the two fixes above. +The 12 domains not calling `getService` are untouched; `resolveService` (which +also takes non-`CoreServiceName` names like `protocol` and `objectql`) is +deliberately left for a later batch rather than widened here. + +Verified: `@objectstack/runtime` **933 tests / 65 files**, `@objectstack/spec` +**7095 / 272** (6 new, pinning the map against the enum in both directions), +service-automation **457**, service-analytics **413**, service-messaging **137**, +service-i18n **62**, adapter-hono **73**; `tsc --noEmit` on spec, runtime, +downstream-contract and all four examples; `pnpm lint`; and all nine +`@objectstack/spec` `check:*` gates — clean. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 1a038ad0bd..344012b41a 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -626,11 +626,16 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { // --------------------------------------------------------------------------- describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => { - it('/auth delegates to the auth service handler when registered', async () => { - const handler = vi.fn().mockResolvedValue({ ok: true }); - const result = await makeDispatcher({ auth: { handler } }).dispatch('POST', '/auth/sign-in/email', { email: 'x@y.z' }, {}, {} as any); + // [#4127] Third copy of the fabricated `handler` mock (with + // http-dispatcher.test.ts's two). Three tests across two files all agreed + // the auth domain calls `handler`; no auth service has one, and + // `IAuthService` declares `handleRequest`. That is how a dead branch stays + // green — every test was written from the handler, not from the contract. + it('/auth delegates to the auth service via the contract handleRequest when registered', async () => { + const handleRequest = vi.fn().mockResolvedValue({ ok: true }); + const result = await makeDispatcher({ auth: { handleRequest } }).dispatch('POST', '/auth/sign-in/email', { email: 'x@y.z' }, {}, {} as any); expect(result.handled).toBe(true); - expect(handler).toHaveBeenCalledTimes(1); + expect(handleRequest).toHaveBeenCalledTimes(1); }); it('/auth mock fallback serves sign-up when no auth service is registered', async () => { diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index 0c9fe32ee0..f93d239b12 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -34,6 +34,8 @@ */ import type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js'; +import type { CoreServiceName } from '@objectstack/spec/system'; +import type { CoreServiceContract } from '@objectstack/spec/contracts'; /** * The normalized request slice a domain handler receives. `path` is the @@ -78,8 +80,25 @@ export interface DomainRoute { export interface DomainHandlerDeps { /** Environment-scoped service resolution (per-request kernel aware). */ resolveService(name: string, environmentId?: string): any; - /** Unscoped service lookup on the current kernel (may return a Promise). */ - getService(name: string): any; + /** + * Unscoped service lookup on the current kernel, typed by the slot. + * + * [#4127] Returned `any`, which is why nothing could tell a domain calling + * a method its contract declares from one calling a method nobody declared: + * both typecheck against `any`. #4087 rode that for months (a `/storage` + * handler passing two arguments no implementation takes), and the four gaps + * in #4127 were found by sweeping the domains by hand — not repeatable. + * + * {@link CoreServiceContract} resolves the slot to its contract, so the + * compiler asks the question on every call. A slot with no contract written + * yet resolves to `unknown`, so it must be cast deliberately and the gap + * stays visible. + * + * `undefined` when the slot is empty — the caller MUST narrow before use + * (`isServiceServeable` does it and also rejects a self-declared + * non-handler, ADR-0076 D12). + */ + getService(name: K): Promise | undefined>; /** * Environment-scoped ObjectQL lookup with a registry-shape check * (resolves the `objectql` service and returns it only when it exposes diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index 929077c35b..6e05dcb061 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -127,6 +127,18 @@ export async function handleAnalyticsRequest( if (subPath === 'sql' && m === 'POST') { // [#3878] Same body contract as /query — validated the same way. assertAnalyticsQueryBody(body); + // [#4127] `generateSql` is OPTIONAL on `IAnalyticsService` — unlike + // `query` / `getMeta` above, which are required — and this call had no + // guard, so a provider filling the slot without it answered a 500 from + // `TypeError: generateSql is not a function` instead of saying the + // capability is absent. service-analytics implements it, which is why + // nothing noticed; the contract permits a provider that does not, and + // the registry names this slot as multi-provider by design. + // + // `handled: false` is the file's own answer for absent analytics + // capability (the entry gate above), so an absent SUB-capability gets + // the same 404 rather than a new third shape. + if (typeof analyticsService.generateSql !== 'function') return { handled: false }; // [#2852] Scope the generated SQL to the caller too, so a preview // reflects the same per-object read filter the real query applies. const result = await analyticsService.generateSql(body, context?.executionContext); diff --git a/packages/runtime/src/domains/auth.ts b/packages/runtime/src/domains/auth.ts index 119d823d32..a95a5ea451 100644 --- a/packages/runtime/src/domains/auth.ts +++ b/packages/runtime/src/domains/auth.ts @@ -51,10 +51,29 @@ export function createAuthDomain(deps: DomainHandlerDeps): DomainRoute { * path: sub-path after /auth/ */ export async function handleAuthRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, context: HttpProtocolContext): Promise { - // 1. Try generic Auth Service + // 1. Try generic Auth Service. + // + // [#4127] This probed `authService.handler(request, response)` — a method + // no implementation has, taking two arguments the contract's does not. + // `IAuthService` declares `handleRequest(request): Promise` and + // `AuthManager` implements exactly that, so the probe was false on every + // deployment: #4087's shape, found by the compiler the moment `getService` + // started returning `IAuthService` instead of `any`. It survived the manual + // sweep in #4127, which never listed `/auth` in either its gap list or its + // "clean" list, and it was pinned GREEN by a test mocking `{ handler }` — + // the fabricated shape, not the declared one (the same test-side hole that + // kept #4087 green, catalogued in #4127's last section). + // + // Reading the contract also makes the branch reachable for the first time. + // The Hono adapter calls `handleRequest` itself and only falls through to + // this dispatcher when no usable auth service answered, so nothing was + // silently served by the mock below in that deployment — but a host that + // reaches `handleAuth` directly WITH an auth service registered used to get + // `mockAuthFallback`'s `mock_` session instead of real authentication. + // It now gets the auth service. const authService = await deps.getService(CoreServiceName.enum.auth); - if (authService && typeof authService.handler === 'function') { - const response = await authService.handler(context.request, context.response); + if (authService && typeof authService.handleRequest === 'function') { + const response = await authService.handleRequest(context.request as Request); return { handled: true, result: response }; } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index aa0e068f90..a3e292cac9 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -4,6 +4,21 @@ import { HttpDispatcher } from './http-dispatcher.js'; import { ObjectKernel } from '@objectstack/core'; import { ApiErrorSchema } from '@objectstack/spec/api'; import type { ConnectorDescriptor } from '@objectstack/spec/integration'; +import type { IAuthService, IAutomationService } from '@objectstack/spec/contracts'; + +/** + * [#4127] Mock-shape guard: every key must be a method the contract DECLARES. + * + * Signatures stay `unknown` on purpose — `vi.fn()` does not match a contract + * signature, and forcing it to would push these mocks straight back to `as any`, + * which is the state this is fixing. What it catches is the failure that keeps + * actually happening: a mock naming a method the contract does not have, so the + * handler and its test agree with each other and with no implementation. + * `upload(file, { request })` in #4087, `authService.handler` and + * `automation.trigger` here — each one sat green for months behind a mock + * written to the handler's wish rather than the declared surface. + */ +type ContractMock = Partial>; describe('HttpDispatcher', () => { let kernel: ObjectKernel; @@ -162,7 +177,8 @@ describe('HttpDispatcher', () => { let mockAutomationService: any; beforeEach(() => { - mockAutomationService = { + // [#4127] Everything the CONTRACT declares, checked against it. + const contractMethods = { listFlows: vi.fn().mockResolvedValue(['flow_a', 'flow_b']), getFlow: vi.fn().mockResolvedValue({ name: 'flow_a', label: 'Flow A' }), registerFlow: vi.fn(), @@ -171,7 +187,6 @@ describe('HttpDispatcher', () => { toggleFlow: vi.fn().mockResolvedValue(undefined), listRuns: vi.fn().mockResolvedValue([{ id: 'run_1', status: 'completed' }]), getRun: vi.fn().mockResolvedValue({ id: 'run_1', status: 'completed' }), - trigger: vi.fn().mockResolvedValue({ success: true }), resume: vi.fn().mockResolvedValue({ success: true, output: {}, durationMs: 7 }), // Sync per IAutomationService — `ScreenSpec | null`, not a promise. getSuspendedScreen: vi.fn().mockReturnValue({ nodeId: 'collect', fields: [] }), @@ -196,6 +211,17 @@ describe('HttpDispatcher', () => { { name: 'flow_a', enabled: true, bound: true }, { name: 'flow_b', enabled: false, bound: false }, ]), + } satisfies ContractMock; + + mockAutomationService = { + ...contractMethods, + // NEGATIVE CONTROL (#4143) — deliberately NOT on the contract. + // Nothing in the repo implements `trigger` on the automation + // slot; it exists here only so the legacy-route test below can + // assert it is never called. Kept outside the checked literal + // so it reads as the exception it is, instead of quietly + // re-opening the hole the check above closes. + trigger: vi.fn().mockResolvedValue({ success: true }), }; // Set up kernel services to include automation @@ -912,10 +938,19 @@ describe('HttpDispatcher', () => { }); describe('handleAuth with async service', () => { - it('should resolve auth service from Promise', async () => { + // [#4127] This mocked `{ handler }` — a method `IAuthService` does + // not declare and `AuthManager` does not have — and asserted it was + // called, which is why the dead branch stayed green. Exactly the + // test-side hole that kept #4087 alive: the mock was written to the + // handler's fabricated shape instead of the declared contract. It + // pins `handleRequest` now, and `satisfies` makes a future drift + // back to an undeclared name a compile error rather than a passing + // test asserting a call nothing makes. + it('should resolve auth service from Promise and call the contract method', async () => { const mockAuth = { - handler: vi.fn().mockResolvedValue({ user: { id: '1' } }), - }; + handleRequest: vi.fn().mockResolvedValue({ user: { id: '1' } }), + verify: vi.fn().mockResolvedValue({ success: true }), + } satisfies Pick; (kernel as any).getService = vi.fn().mockImplementation((name: string) => { if (name === 'auth') return Promise.resolve(mockAuth); return null; @@ -923,10 +958,10 @@ describe('HttpDispatcher', () => { const result = await dispatcher.handleAuth('', 'POST', {}, { request: {}, response: {} }); expect(result.handled).toBe(true); - expect(mockAuth.handler).toHaveBeenCalled(); + expect(mockAuth.handleRequest).toHaveBeenCalled(); }); - it('should fallback to mock auth when async auth service has no handler', async () => { + it('should fallback to mock auth when async auth service has no handleRequest', async () => { (kernel as any).getService = vi.fn().mockResolvedValue({}); const result = await dispatcher.handleAuth('/login', 'POST', { email: 'test@example.com' }, { request: {} }); @@ -1136,9 +1171,15 @@ describe('HttpDispatcher', () => { }); it('should prefer getServiceAsync over getService for auth', async () => { + // [#4127] Second copy of the same fabricated `handler` mock — this + // one asserted the resolution PATH (getServiceAsync over + // getService) while pinning a method no auth service has, so it + // proved the lookup worked and nothing about the call. The path + // assertion is the point of this test and is unchanged; the mock + // now names the contract method the handler actually invokes. const asyncAuth = { - handler: vi.fn().mockResolvedValue({ user: { id: '1' } }), - }; + handleRequest: vi.fn().mockResolvedValue({ user: { id: '1' } }), + } satisfies ContractMock; (kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncAuth); (kernel as any).getService = vi.fn().mockImplementation(() => { throw new Error("Service 'auth' is async - use await"); @@ -1146,7 +1187,7 @@ describe('HttpDispatcher', () => { const result = await dispatcher.handleAuth('', 'POST', {}, { request: {}, response: {} }); expect(result.handled).toBe(true); - expect(asyncAuth.handler).toHaveBeenCalled(); + expect(asyncAuth.handleRequest).toHaveBeenCalled(); expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('auth'); }); diff --git a/packages/runtime/src/service-serveable.ts b/packages/runtime/src/service-serveable.ts index 5d0613ae26..a5b0e3d892 100644 --- a/packages/runtime/src/service-serveable.ts +++ b/packages/runtime/src/service-serveable.ts @@ -35,8 +35,15 @@ import { readServiceSelfInfo } from '@objectstack/spec/api'; * An empty slot and a slot filled by a self-declared stub are the same amount * of capability, so callers answer both the same way — whatever their empty-slot * exit already is (`handled: false` → 404, or an explicit 501). + * + * [#4127] A type guard, not a `boolean`. Every domain already calls this as its + * first act on a resolved slot, so once `getService` returns + * `Contract | undefined` this one predicate narrows away the `undefined` for the + * whole handler body — the null check and the capability check are the same + * check, and were always meant to be. Written as `svc is NonNullable` so the + * caller keeps its own contract type instead of being widened to a shared one. */ -export function isServiceServeable(svc: unknown): boolean { +export function isServiceServeable(svc: T): svc is NonNullable { if (!svc) return false; return readServiceSelfInfo(svc)?.handlerReady !== false; } diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 06a14958a8..ff38979382 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3556,6 +3556,8 @@ "CheckNamespaceInput (interface)", "CheckNamespaceResult (interface)", "ClusterCallContext (interface)", + "CoreServiceContract (type)", + "CoreServiceContracts (interface)", "CounterIncrOptions (interface)", "CreateExportJobInput (interface)", "CreateExportJobResult (interface)", diff --git a/packages/spec/src/contracts/core-service-contracts.test.ts b/packages/spec/src/contracts/core-service-contracts.test.ts new file mode 100644 index 0000000000..da8ed468f4 --- /dev/null +++ b/packages/spec/src/contracts/core-service-contracts.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#4127] The map claims a binding per slot. These tests keep the claim honest +// in both directions: every key must be a real `CoreServiceName` member, and a +// slot deliberately left unmapped must resolve to `unknown` rather than quietly +// widening to `any` — the difference between "no contract written yet" and +// "checked", which is the distinction this whole line of work exists to make. + +import { describe, it, expect } from 'vitest'; +import { CoreServiceName } from '../system/core-services.zod'; +import type { CoreServiceContracts, CoreServiceContract } from './core-service-contracts'; +import type { IAutomationService } from './automation-service'; +import type { INotificationService } from './notification-service'; +import type { II18nService } from './i18n-service'; + +/** Compile-time assertion helper: fails to typecheck unless `T` is exactly `true`. */ +type Expect = T; +type Equals = (() => G extends A ? 1 : 2) extends (() => G extends B ? 1 : 2) ? true : false; + +describe('CoreServiceName → contract map (#4127)', () => { + it('maps every key to a declared CoreServiceName slot', () => { + // The map's keys are checked against the enum at compile time below; + // this asserts the same thing at runtime so a rename shows up as a + // failing test and not only as a type error in an unrelated package. + const mapped: Array = [ + 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', + 'automation', 'analytics', 'realtime', 'job', 'notification', 'ai', + 'i18n', 'workflow', + ]; + const slots = new Set(CoreServiceName.options); + for (const key of mapped) { + expect(slots.has(key), `'${key}' is not a CoreServiceName member`).toBe(true); + } + }); + + it('leaves exactly the slots with no written contract unmapped', () => { + const mapped = new Set([ + 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', + 'automation', 'analytics', 'realtime', 'job', 'notification', 'ai', + 'i18n', 'workflow', + ]); + const unmapped = CoreServiceName.options.filter((s) => !mapped.has(s)); + // `ui` has a slot and a serving domain but no `IUiService` — mapping it + // to a hand-rolled shape here would be a second, unwritten contract. + expect(unmapped).toEqual(['ui']); + }); + + it('resolves a mapped slot to its contract', () => { + type _Automation = Expect, IAutomationService>>; + type _Notification = Expect, INotificationService>>; + type _I18n = Expect, II18nService>>; + expect(true).toBe(true); + }); + + it('resolves an unmapped slot to unknown, not any', () => { + // `unknown` forces a deliberate cast at the call site. `any` would let + // an undeclared call through silently — the failure mode #4087 shipped. + type _Ui = Expect, unknown>>; + expect(true).toBe(true); + }); +}); diff --git a/packages/spec/src/contracts/core-service-contracts.ts b/packages/spec/src/contracts/core-service-contracts.ts new file mode 100644 index 0000000000..b51620ad2f --- /dev/null +++ b/packages/spec/src/contracts/core-service-contracts.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `CoreServiceName` → the contract a slot's occupant must satisfy. + * + * [#4127] The ledger this repo was missing. `CoreServiceName` names the slots + * and `contracts/*` describes them, but nothing connected the two — so a caller + * resolving a slot got back `any`, and "does this domain call a method the + * contract declares?" had no mechanical answer. It was answered by a human + * sweeping the dispatcher by hand, which is how #4087 (a `/storage` handler + * calling `upload(key, data, options?)` as `upload(file, { request })`) survived + * months, and how the four gaps in #4127 survived until someone looked. + * + * A sweep is not repeatable. This map makes the compiler do it: a lookup + * through {@link CoreServiceContract} returns the contract, so a call outside it + * is a compile error at the call site. + * + * **An entry is a claim, so it is only made where the binding is evidenced** — + * by the provider that registers the slot, or by dispatcher work that already + * proved the correspondence. A slot with no entry resolves to `unknown`, which + * forces the caller to cast visibly rather than silently receiving `any`: the + * remaining gap stays legible instead of looking finished. Add an entry when + * the binding is established, not to fill the table. + */ + +import type { IMetadataService } from './metadata-service'; +import type { IDataEngine } from './data-engine'; +import type { IAuthService } from './auth-service'; +import type { IStorageService } from './storage-service'; +import type { ISearchService } from './search-service'; +import type { ICacheService } from './cache-service'; +import type { IQueueService } from './queue-service'; +import type { IAutomationService } from './automation-service'; +import type { IAnalyticsService } from './analytics-service'; +import type { IRealtimeService } from './realtime-service'; +import type { IJobService } from './job-service'; +import type { INotificationService } from './notification-service'; +import type { IAIService } from './ai-service'; +import type { II18nService } from './i18n-service'; +import type { IWorkflowService } from './workflow-service'; + +/** + * The evidenced slot → contract bindings. + * + * Every key here is a `CoreServiceName` member; the type is checked against that + * enum in `core-service-contracts.test.ts`, so a slot renamed in + * `core-services.zod.ts` breaks this map rather than silently orphaning it. + * + * `ui` is deliberately absent: the slot exists and `domains/ui.ts` serves it, + * but no `IUiService` contract has been written. That absence is the honest + * state — mapping it to a hand-rolled shape here would be a second, unwritten + * contract, the exact failure #4127 catalogued. + */ +export interface CoreServiceContracts { + /** `packages/metadata` registers the object/field definition manager. */ + metadata: IMetadataService; + /** `packages/objectql` registers ObjectQL — "ObjectQL implements IDataEngine". */ + data: IDataEngine; + /** `plugin-auth` registers its auth manager. */ + auth: IAuthService; + /** `service-storage` registers the storage driver; the slot #4087 was about. */ + 'file-storage': IStorageService; + search: ISearchService; + /** `service-cache`'s own error text names `ICacheService` as the slot's contract. */ + cache: ICacheService; + queue: IQueueService; + /** `service-automation` registers the flow engine (#4143, #4150). */ + automation: IAutomationService; + /** `service-analytics` registers the semantic layer. */ + analytics: IAnalyticsService; + realtime: IRealtimeService; + job: IJobService; + /** `service-messaging` registers the notification slot (#4143). */ + notification: INotificationService; + ai: IAIService; + /** `service-i18n`, or the `app-plugin` in-memory fallback (#4143). */ + i18n: II18nService; + workflow: IWorkflowService; +} + +/** + * The contract for slot `K`, or `unknown` when no binding has been evidenced + * yet. `unknown` — not `any` — on purpose: an unmapped slot must be cast + * deliberately at the call site, so it reads as an open gap rather than as a + * checked lookup. + */ +export type CoreServiceContract = + K extends keyof CoreServiceContracts ? CoreServiceContracts[K] : unknown; diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 1ee60813a9..347c1a100d 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -31,6 +31,11 @@ export * from './ai-service.js'; export * from './llm-adapter.js'; export * from './i18n-service.js'; export * from './workflow-service.js'; + +// CoreServiceName → contract map (#4127). Lets a slot lookup return the slot's +// contract instead of `any`, so a call outside it is a compile error. +export * from './core-service-contracts.js'; + export * from './export-service.js'; export * from './email-service.js'; export * from './sms-service.js';