From 5771db6bb6e08f0a874bf9fd6e63814b7aa3af9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 01:18:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(runtime,rest,plugin-auth,service-i18n,serv?= =?UTF-8?q?ice-storage):=20route-ledger=20=E6=9D=A1=E7=9B=AE=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=8A=A0=E5=8F=AF=E9=80=89=20responseSchema=20(#5791)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3877 的最小首步(维护者 2026-08-06 已批)。五个 route ledger 各加一个同名同措辞 的可选字段,现有条目零改动,字段缺省即「未声明」,零行为变更。 字段存的是 `@objectstack/spec/api` 的导出名而非 live schema 对象:三个 ledger 明确 要求保持 import-free(客户端守卫按相对源文件编译它们),且 `zod` 并非每个持有 ledger 的包的依赖(plugin-auth / service-i18n / service-storage / client 都没有)。解析放在 能 import spec 的守卫里。 只填 #5682 已给出双断言覆盖的 discovery 族两条,分处两个 ledger:runtime `GET /discovery`(走 { success, data } 信封,指 data)与 rest `GET /api/v1/discovery` (裸发,指整个 body)。`GET /api/v1` 别名共用同一个 handler 但没有自己的覆盖,故不填。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .changeset/route-ledger-response-schema.md | 53 ++++++ .../src/route-ledger-response-schema.test.ts | 165 ++++++++++++++++++ .../plugin-auth/src/auth-route-ledger.ts | 31 ++++ .../src/discovery-schema-conformance.test.ts | 56 ++++++ packages/rest/src/rest-route-ledger.ts | 42 ++++- .../src/discovery-schema-conformance.test.ts | 47 +++++ packages/runtime/src/route-ledger.ts | 40 ++++- .../service-i18n/src/i18n-route-ledger.ts | 31 ++++ .../src/storage-route-ledger.ts | 31 ++++ 9 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 .changeset/route-ledger-response-schema.md create mode 100644 packages/client/src/route-ledger-response-schema.test.ts diff --git a/.changeset/route-ledger-response-schema.md b/.changeset/route-ledger-response-schema.md new file mode 100644 index 0000000000..8f926206d7 --- /dev/null +++ b/.changeset/route-ledger-response-schema.md @@ -0,0 +1,53 @@ +--- +"@objectstack/runtime": patch +"@objectstack/rest": patch +"@objectstack/plugin-auth": patch +"@objectstack/service-i18n": patch +"@objectstack/service-storage": patch +--- + +feat(runtime,rest,plugin-auth,service-i18n,service-storage): route-ledger 条目类型加可选 `responseSchema` (#5791) + +#3877 的「最小首步」,维护者 2026-08-06 已批。**纯增量、零行为变更**:五个 route +ledger 的现有条目一行未改,字段缺省即「未声明」。 + +## 为什么是这一步 + +#3877 量到的洞不是「发出的和声明的不一致」,而是**大多数路由根本没有可对账的声明**: +237 条已挂载路由里 215 条是 `sdk` 面,而携带 schema 引用的是 **0 条**。于是同一单 +里裁定了两件事——Stage C(批量补 ~190 条响应 schema)**永不排期**(一条响应 schema +是「这个端点承诺什么」的产品决定,批量生产正是 #3676 / #3833 / #3847 / #3870 四个 +缺陷的成因),以及先把「这条路由声明了什么」变成**可查询数据**,让 Stage D 的棘轮 +将来有东西可棘。本次落地的就是后者。 + +## 字段语义 + +`responseSchema` 是 `@objectstack/spec/api` 导出名,指向该路由**响应载荷**的声明: +路由套 `{ success, data }` 信封时指 `data`,不套时指整个 body。信封本身不归它管, +由 `pnpm check:route-envelope` 结构化守住——一个字段无法同时诚实地描述两层。 + +五个 ledger 是五个各自独立声明、按约定同形的 interface,因此是五处同名同措辞的可选 +字段,**不是**新建共享类型包。三个 ledger 明确要求保持 import-free(客户端守卫按 +相对**源文件**编译它们),且 `zod` 并非每个持有 ledger 的包的依赖,故字段存的是 +**名字**而非 live schema 对象,解析放在能 import spec 的守卫里。 + +## 已填的两条(实证,不是批量) + +只填 #5682 已给出双断言覆盖(safeParse 判**值** + 键集判**键**)的 discovery 族两条, +且刻意分处两个 ledger,以证明一个字段形状确实服务五个独立声明的条目类型: + +- `packages/runtime` `GET /discovery` → `DiscoverySchema`(走信封,指 `data`) +- `packages/rest` `GET /api/v1/discovery` → `DiscoverySchema`(裸发,指整个 body) + +`GET /api/v1` 这条 bare-base 别名**故意不填**:它与上面那条共用同一个 +`discoveryHandler` 闭包,但 #5682 的测试只驱动 `/api/v1/discovery`,「同一个 handler +所以同一个形状」是对代码的论证而非对代码的测量。没有覆盖就不填。 + +## 新增守卫 + +- `packages/client/src/route-ledger-response-schema.test.ts` —— 五个 ledger 的并集里 + 每一个 `responseSchema` 都到**活的** `@objectstack/spec/api` 导出里解析,并且真的 + 调用一次 `safeParse`(spec 的 schema 是 `lazySchema()` 代理,只查属性存在会被代理 + 陷阱满足)。含否定对照(少一个字母的名字、空串、导出了但不是 schema)与反空转下界。 +- `discovery-schema-conformance.test.ts`(runtime / rest 各一)—— 钉住 ledger 报的 + schema 就是该套件实际解析用的**同一个对象**,并各自测量了载荷所在的层级。 diff --git a/packages/client/src/route-ledger-response-schema.test.ts b/packages/client/src/route-ledger-response-schema.test.ts new file mode 100644 index 0000000000..a417620c1f --- /dev/null +++ b/packages/client/src/route-ledger-response-schema.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `responseSchema` resolution guard (#5791, first step of #3877). + * + * WHAT THE FIELD IS. Every route ledger's entry type now carries an optional + * `responseSchema` — the NAME of the `@objectstack/spec/api` export declaring + * that route's response payload. #3877 measured the hole it opens onto: of 237 + * ledgered routes, 215 are `sdk` surface and **zero** carried any schema + * reference, so for ~90% of the mounted surface the problem was never + * "emitted ≠ declared" but that nothing was declared to check against. + * + * WHAT THIS FILE GUARDS, AND WHY IT IS THE ONE THING THE FIELD NEEDED FIRST. + * A name is data, and data lies. `responseSchema: 'GetDiscoverResponseSchema'` + * — one letter short — is accepted by `string`, reads correct in review, and + * silently describes nothing; a name pointing at a schema that later retires + * rots the same way with no edit at all. That is the "declared but nobody + * verifies" surface #3877 exists to remove, so the field could not land without + * the resolver that refuses it. Each name is looked up in the LIVE + * `@objectstack/spec/api` export namespace and required to be a real zod + * schema, exercised rather than duck-typed. + * + * WHY HERE, of all packages. The five ledgers are five independent declarations + * in five packages; `@objectstack/spec` cannot import them (wrong direction), + * and a copy of this resolver in each package would be five copies of one rule + * — the shape `scripts/check-route-envelope.mjs` records as the signal to lift + * something into one place. This package already unions all five as relative + * SOURCE files for the capstone URL guard (`client-url-conformance.test.ts`), + * and it already depends on `@objectstack/spec`, so it is the single scope + * where every ledger and the spec are both in hand. Nothing about the client is + * under test here; it is the meeting point, not the subject. + * + * WHAT THIS FILE DOES NOT GUARD. That a filled row HAS conformance coverage is + * #3877's Stage D ratchet and is explicitly "only meaningful after A/B" — it + * cannot be mechanised while ~190 routes declare nothing. Today the rule is + * carried by the field's JSDoc plus a per-row pin next to the coverage itself + * (`discovery-schema-conformance.test.ts` in `packages/runtime` and + * `packages/rest`, which assert that the ledger names the schema those suites + * actually parse with). A name that resolves is not yet a name that is checked. + */ + +import { describe, it, expect } from 'vitest'; +import * as specApi from '@objectstack/spec/api'; +import { ROUTE_LEDGER } from '../../runtime/src/route-ledger'; +import { REST_ROUTE_LEDGER } from '../../rest/src/rest-route-ledger'; +import { STORAGE_ROUTE_LEDGER } from '../../services/service-storage/src/storage-route-ledger'; +import { I18N_ROUTE_LEDGER } from '../../services/service-i18n/src/i18n-route-ledger'; +import { AUTH_ROUTE_LEDGER } from '../../plugins/plugin-auth/src/auth-route-ledger'; + +/** The five independent ledgers, labelled so a failure names the file to open. */ +const LEDGERS: ReadonlyArray<{ + readonly label: string; + readonly rows: ReadonlyArray<{ route: string; responseSchema?: string }>; +}> = [ + { label: 'packages/runtime/src/route-ledger.ts', rows: ROUTE_LEDGER }, + { label: 'packages/rest/src/rest-route-ledger.ts', rows: REST_ROUTE_LEDGER }, + { label: 'packages/plugins/plugin-auth/src/auth-route-ledger.ts', rows: AUTH_ROUTE_LEDGER }, + { label: 'packages/services/service-i18n/src/i18n-route-ledger.ts', rows: I18N_ROUTE_LEDGER }, + { label: 'packages/services/service-storage/src/storage-route-ledger.ts', rows: STORAGE_ROUTE_LEDGER }, +]; + +/** Every row across the five ledgers that declares a response schema. */ +function declaredRows(): Array<{ ledger: string; route: string; responseSchema: string }> { + return LEDGERS.flatMap(({ label, rows }) => + rows + .filter((r): r is typeof r & { responseSchema: string } => r.responseSchema !== undefined) + .map((r) => ({ ledger: label, route: r.route, responseSchema: r.responseSchema })), + ); +} + +const exportsOfSpecApi = specApi as unknown as Record; + +/** + * The resolver under test, extracted so the negative control below can drive + * the SAME function a real row goes through. A guard whose failure path is + * never executed is a guard nobody has seen fail. + * + * Note it EXERCISES the schema rather than duck-typing it: `@objectstack/spec` + * wraps its schemas in `lazySchema()` proxies (`packages/spec/src/shared/ + * lazy-schema.ts`), so property presence is answered by a Proxy trap and + * `typeof x.safeParse === 'function'` alone would be satisfied by a proxy over + * anything. Calling it forces the factory and proves a parser came back. + */ +function resolutionFailure(name: string): string | undefined { + if (name.trim() === '') return 'is empty'; + if (!Object.prototype.hasOwnProperty.call(exportsOfSpecApi, name)) { + return 'is not an export of `@objectstack/spec/api`'; + } + const candidate = exportsOfSpecApi[name] as { safeParse?: (v: unknown) => unknown }; + if (typeof candidate?.safeParse !== 'function') return 'is exported but is not a zod schema'; + const verdict = candidate.safeParse(undefined) as { success?: boolean } | undefined; + if (typeof verdict?.success !== 'boolean') return 'has a `safeParse` that returns no zod verdict'; + return undefined; +} + +describe('[#5791] every ledgered `responseSchema` names a real spec schema', () => { + it('resolves each declared name against the live `@objectstack/spec/api` exports', () => { + const broken = declaredRows() + .map(({ ledger, route, responseSchema }) => { + const why = resolutionFailure(responseSchema); + return why ? `${ledger} — \`${route}\`: responseSchema '${responseSchema}' ${why}` : undefined; + }) + .filter((m): m is string => m !== undefined); + + expect( + broken, + 'Ledger rows whose `responseSchema` does not resolve to a zod schema exported from ' + + '`@objectstack/spec/api`. Fix the name, or drop the field — an unresolvable ' + + 'declaration is worse than none (#3877).', + ).toEqual([]); + }); + + it('anti-vacuity: the sweep really has rows to resolve', () => { + // Without this, deleting every `responseSchema` in the repo would leave the + // assertion above green over an empty list — a sweep rotting to zero while + // reporting success is the failure mode `client-url-conformance.test.ts` + // calls out in its own anti-rot section, and this file is no less exposed. + const rows = declaredRows(); + expect( + rows.length, + 'No ledger row declares a `responseSchema`, so the resolver above proved nothing. ' + + 'The #5791 landing filled two discovery rows; if they were removed, remove this ' + + 'guard with them rather than leaving it green over nothing.', + ).toBeGreaterThanOrEqual(2); + }); + + it('the resolver rejects what it is meant to reject', () => { + // The failure path, driven. Each case is a way a hand-written name goes + // wrong in review, and none of them is caught by `string`. + expect(resolutionFailure('GetDiscoverResponseSchema')).toBe( + 'is not an export of `@objectstack/spec/api`', + ); // one letter short of a real export + expect(resolutionFailure('')).toBe('is empty'); + expect(resolutionFailure('WELL_KNOWN_CAPABILITY_KEYS')).toBe( + 'is exported but is not a zod schema', + ); // a real export of the right module, wrong kind of thing + // …and the positive control, so the rejections above are not a resolver + // that rejects everything. + expect(resolutionFailure('DiscoverySchema')).toBeUndefined(); + }); + + it('the two #5791 landing rows are the discovery pair, in two different ledgers', () => { + // The point of the landing evidence was that ONE field shape serves five + // independently-declared entry types; two rows in one ledger would not have + // shown that. Pinned so a later edit cannot quietly collapse it. + const byLedger = new Map(); + for (const { ledger, route } of declaredRows()) { + byLedger.set(ledger, [...(byLedger.get(ledger) ?? []), route]); + } + + // `?? []` rather than a bare `get`: an absent key makes `toContain` report + // "the given combination of arguments (undefined and string) is invalid" + // instead of naming the row that went missing — measured while reverse- + // verifying this file, and a guard whose failure text is a chai complaint + // about its own arguments has told the next reader nothing. + expect( + byLedger.get('packages/runtime/src/route-ledger.ts') ?? [], + 'the dispatcher discovery row must keep declaring its response schema (#5791)', + ).toContain('GET /discovery'); + expect( + byLedger.get('packages/rest/src/rest-route-ledger.ts') ?? [], + 'the REST discovery row must keep declaring its response schema (#5791)', + ).toContain('GET /api/v1/discovery'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index e30645eb75..67ef960294 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -79,6 +79,37 @@ export interface AuthRouteLedgerEntry { client?: string; /** Optional better-auth plugin this route needs (absent = always mounted). */ requires?: string; + /** + * Name of the `@objectstack/spec/api` export declaring this route's response + * PAYLOAD — the `data` of the shared `{ success, data }` envelope where the + * route emits one, the whole body where it does not. The envelope itself is + * not this field's business; `pnpm check:route-envelope` guards it + * structurally, and a single field cannot describe both halves honestly. + * + * ABSENT MEANS "UNDECLARED", and that is the state of most of the mounted + * surface: at the #3877 audit, 0 of 237 ledgered routes carried a schema + * reference. #3877 ruled that authoring the ~190 missing ones is NOT + * scheduled — a response schema is a product decision about what an endpoint + * promises, and mass-producing them is precisely how declarations nobody + * validated come to exist (the four defects #3676/#3833/#3847/#3870 fixed). + * So this field is filled incrementally, as a family lands conformance + * coverage or a route is touched for other reasons; a blank one changes no + * behaviour and is not a defect. + * + * ⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE. The field exists to + * make "what does this route declare" queryable so #3877's Stage D ratchet + * can demand coverage for it; a name written ahead of the test it points at + * would BE the "declared but unverified" surface the programme exists to + * remove. `packages/client/src/route-ledger-response-schema.test.ts` resolves + * every name written here against the live `@objectstack/spec/api` exports, + * so a typo or a retired schema fails loudly rather than rotting. + * + * A NAME rather than a live schema object, deliberately: this module stays + * import-free — the client-side guards compile it as a relative SOURCE file, + * and `zod` is not a dependency of every package that owns a ledger. The + * resolution belongs in the guard that can import the spec, not in the data. + */ + responseSchema?: string; /** One-line rationale. Required for every non-`sdk` disposition. */ note?: string; } diff --git a/packages/rest/src/discovery-schema-conformance.test.ts b/packages/rest/src/discovery-schema-conformance.test.ts index ef80ad102c..ab7d333395 100644 --- a/packages/rest/src/discovery-schema-conformance.test.ts +++ b/packages/rest/src/discovery-schema-conformance.test.ts @@ -22,8 +22,10 @@ import { GetDiscoveryResponseSchema, WELL_KNOWN_CAPABILITY_KEYS, } from '@objectstack/spec/api'; +import * as specApi from '@objectstack/spec/api'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server.js'; +import { REST_ROUTE_LEDGER } from './rest-route-ledger.js'; /** The keys the protocol declares for a discovery response (canonical + declared alias). */ function declaredResponseKeys(): Set { @@ -262,4 +264,58 @@ describe('[#4828] the REST /discovery live shape conforms to DiscoverySchema', ( // cannot take the composed capability with it. expect(body.capabilities.transactionalBatch).toBeDefined(); }); + + // ═══════════════════════════════════════════════════════════════════════════ + // [#5791] The ledger row points AT this suite + // ═══════════════════════════════════════════════════════════════════════════ + // + // `rest-route-ledger.ts`'s `GET /api/v1/discovery` row is one of the two + // first-ever filled `responseSchema` values. The field may only be written + // where the double assertion above already runs; that rule lives in a JSDoc, + // which cannot fail a build, so the loop is closed here in the file the row + // names. `packages/client/src/route-ledger-response-schema.test.ts` carries + // the other half (every name across all five ledgers resolves). + describe('[#5791] the ledger declares the schema this suite parses with', () => { + it('names `DiscoverySchema`, resolving to the very object asserted above', () => { + const row = REST_ROUTE_LEDGER.find(e => e.route === 'GET /api/v1/discovery'); + expect(row, 'the REST discovery row must exist in REST_ROUTE_LEDGER').toBeDefined(); + expect(row!.responseSchema).toBe('DiscoverySchema'); + + // Identity, not just resolvability: a name resolving to some OTHER schema + // would satisfy the client-side resolver and still describe the wrong + // contract. + expect((specApi as unknown as Record)[row!.responseSchema!]) + .toBe(DiscoverySchema); + }); + + it('describes the WHOLE BODY here — this mount answers bare, no envelope', async () => { + // The counterpart of the dispatcher's pin. `responseSchema` is defined + // against the response PAYLOAD, which lands at two different depths for + // the two discovery routes: `res.json(discovery)` here, so the named + // schema is a claim about the entire body; `{ success, data }` on the + // dispatcher, where the same name claims only `data`. + const body = await invoke(discoveryHandler()); + + expect(DiscoverySchema.safeParse(body).success).toBe(true); + expect(body).not.toHaveProperty('success'); + expect(body).not.toHaveProperty('data'); + }); + + it('leaves the bare-base alias UNFILLED — it shares the handler but not the coverage', () => { + // `GET /api/v1` is registered with this very `discoveryHandler` closure, + // so "same handler, therefore same shape" is tempting. It is also an + // argument about the code rather than a measurement of it, which is the + // substitution #3877 was opened about — and this suite resolves the + // handler at `/api/v1/discovery`, so the alias has no coverage of its own. + // Fill it in the PR that drives it; changing this expectation without + // adding that drive is the mistake. + const alias = REST_ROUTE_LEDGER.find(e => e.route === 'GET /api/v1'); + expect(alias, 'the bare-base discovery alias row must exist').toBeDefined(); + expect( + alias!.responseSchema, + 'GET /api/v1 must not declare a responseSchema until this suite (or another) ' + + 'drives that mount — no coverage, no fill (#5791).', + ).toBeUndefined(); + }); + }); }); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 05b459a879..eafa9189d7 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -70,16 +70,56 @@ export interface RestRouteLedgerEntry { disposition: RestRouteDisposition; /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ client?: string; + /** + * Name of the `@objectstack/spec/api` export declaring this route's response + * PAYLOAD — the `data` of the shared `{ success, data }` envelope where the + * route emits one, the whole body where it does not. The envelope itself is + * not this field's business; `pnpm check:route-envelope` guards it + * structurally, and a single field cannot describe both halves honestly. + * + * ABSENT MEANS "UNDECLARED", and that is the state of most of the mounted + * surface: at the #3877 audit, 0 of 237 ledgered routes carried a schema + * reference. #3877 ruled that authoring the ~190 missing ones is NOT + * scheduled — a response schema is a product decision about what an endpoint + * promises, and mass-producing them is precisely how declarations nobody + * validated come to exist (the four defects #3676/#3833/#3847/#3870 fixed). + * So this field is filled incrementally, as a family lands conformance + * coverage or a route is touched for other reasons; a blank one changes no + * behaviour and is not a defect. + * + * ⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE. The field exists to + * make "what does this route declare" queryable so #3877's Stage D ratchet + * can demand coverage for it; a name written ahead of the test it points at + * would BE the "declared but unverified" surface the programme exists to + * remove. `packages/client/src/route-ledger-response-schema.test.ts` resolves + * every name written here against the live `@objectstack/spec/api` exports, + * so a typo or a retired schema fails loudly rather than rotting. + * + * A NAME rather than a live schema object, deliberately: this module stays + * import-free — the client-side guards compile it as a relative SOURCE file, + * and `zod` is not a dependency of every package that owns a ledger. The + * resolution belongs in the guard that can import the spec, not in the data. + */ + responseSchema?: string; /** One-line rationale. Required for every non-`sdk` disposition. */ note?: string; } export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ // ── discovery ───────────────────────────────────────────────────────────── + // The alias carries NO `responseSchema` on purpose (#5791). It shares the + // very `discoveryHandler` closure the row below names, so the body is the + // same object — but `discovery-schema-conformance.test.ts` resolves the + // handler at `/api/v1/discovery` and drives only that mount, so this row has + // no conformance coverage of its own. The field's rule is "no coverage, no + // fill", and "same handler, therefore same shape" is an argument about the + // code rather than a measurement of it — exactly the substitution #3877 was + // opened about. Fill it in the PR that drives this mount, not before. { route: 'GET /api/v1', family: 'discovery', source: 'route-manager', disposition: 'server-only', note: 'bare-base discovery alias; the SDK connects via /api/v1/discovery' }, { route: 'GET /api/v1/discovery', family: 'discovery', source: 'route-manager', disposition: 'sdk', client: 'connect', - note: 'duplicate mount with the dispatcher /discovery branch — REST registers first and wins' }, + responseSchema: 'DiscoverySchema', + note: 'duplicate mount with the dispatcher /discovery branch — REST registers first and wins. Answers BARE (res.json(discovery), no envelope), so `DiscoverySchema` names the whole body here while the dispatcher row names its envelope `data`; discovery-schema-conformance.test.ts drives THIS handler through the #5682 double assertion' }, // ── openapi / docs ──────────────────────────────────────────────────────── { route: 'GET /api/v1/openapi.json', family: 'openapi', source: 'route-manager', disposition: 'server-only', diff --git a/packages/runtime/src/discovery-schema-conformance.test.ts b/packages/runtime/src/discovery-schema-conformance.test.ts index 429a87cc3c..747b653bef 100644 --- a/packages/runtime/src/discovery-schema-conformance.test.ts +++ b/packages/runtime/src/discovery-schema-conformance.test.ts @@ -21,7 +21,9 @@ import { GetDiscoveryResponseSchema, WELL_KNOWN_CAPABILITY_KEYS, } from '@objectstack/spec/api'; +import * as specApi from '@objectstack/spec/api'; import { HttpDispatcher } from './http-dispatcher.js'; +import { ROUTE_LEDGER } from './route-ledger.js'; /** The keys the protocol declares for a discovery response (canonical + declared alias). */ function declaredResponseKeys(): Set { @@ -318,6 +320,51 @@ describe('[#4828] getDiscoveryInfo() conforms to DiscoverySchema', () => { ); }); + // ═════════════════════════════════════════════════════════════════════════ + // [#5791] The ledger row points AT this suite + // ═════════════════════════════════════════════════════════════════════════ + // + // `route-ledger.ts`'s `GET /discovery` row is one of the two first-ever + // filled `responseSchema` values, and the field's rule is that a name may + // only be written where the double assertion above already runs. That rule + // lives in a JSDoc, which cannot fail a build — so the closing of the loop is + // asserted here, in the file the row names: the schema the ledger advertises + // is required to be the same object this suite parses with, not merely a name + // that resolves. `packages/client/src/route-ledger-response-schema.test.ts` + // carries the other half (every name across all five ledgers resolves). + describe('[#5791] the ledger declares the schema this suite parses with', () => { + it('names `DiscoverySchema`, resolving to the very object asserted above', async () => { + const row = ROUTE_LEDGER.find(e => e.route === 'GET /discovery'); + expect(row, 'the dispatcher discovery row must exist in ROUTE_LEDGER').toBeDefined(); + expect(row!.responseSchema).toBe('DiscoverySchema'); + + // Identity, not just resolvability: a name that resolved to some OTHER + // schema would pass the client-side resolver and still describe the wrong + // contract. + expect((specApi as unknown as Record)[row!.responseSchema!]) + .toBe(DiscoverySchema); + }); + + it('describes the ENVELOPE PAYLOAD — this producer wraps, the REST one does not', async () => { + // The whole reason `responseSchema` is defined against the payload rather + // than the wire body: these two discovery routes serve the same object at + // two different depths. Here `dispatch()` returns `{ success, data }`, so + // the ledger's `DiscoverySchema` is a claim about `body.data`; the REST + // row's identical value is a claim about the whole body (`res.json`, no + // envelope). Measured on both sides rather than asserted once. + const result = await dispatcher.dispatch('GET', '/discovery', undefined, {}, { request: {} } as any); + + expect(result.handled).toBe(true); + expect(result.response?.body?.success).toBe(true); + + // The envelope itself is NOT this field's business (check:route-envelope + // owns that); what matters here is that the thing the ledger names is the + // `data`, and that the whole body is NOT it. + expect(DiscoverySchema.safeParse(result.response?.body?.data).success).toBe(true); + expect(DiscoverySchema.safeParse(result.response?.body).success).toBe(false); + }); + }); + it('emits the canonical `name`, never the deprecated `apiName` alias', async () => { const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 7b1731eea3..f80bc678be 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -84,6 +84,37 @@ export interface RouteLedgerEntry { disposition: RouteDisposition; /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ client?: string; + /** + * Name of the `@objectstack/spec/api` export declaring this route's response + * PAYLOAD — the `data` of the shared `{ success, data }` envelope where the + * route emits one, the whole body where it does not. The envelope itself is + * not this field's business; `pnpm check:route-envelope` guards it + * structurally, and a single field cannot describe both halves honestly. + * + * ABSENT MEANS "UNDECLARED", and that is the state of most of the mounted + * surface: at the #3877 audit, 0 of 237 ledgered routes carried a schema + * reference. #3877 ruled that authoring the ~190 missing ones is NOT + * scheduled — a response schema is a product decision about what an endpoint + * promises, and mass-producing them is precisely how declarations nobody + * validated come to exist (the four defects #3676/#3833/#3847/#3870 fixed). + * So this field is filled incrementally, as a family lands conformance + * coverage or a route is touched for other reasons; a blank one changes no + * behaviour and is not a defect. + * + * ⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE. The field exists to + * make "what does this route declare" queryable so #3877's Stage D ratchet + * can demand coverage for it; a name written ahead of the test it points at + * would BE the "declared but unverified" surface the programme exists to + * remove. `packages/client/src/route-ledger-response-schema.test.ts` resolves + * every name written here against the live `@objectstack/spec/api` exports, + * so a typo or a retired schema fails loudly rather than rotting. + * + * A NAME rather than a live schema object, deliberately: this module stays + * import-free — the client-side guards compile it as a relative SOURCE file, + * and `zod` is not a dependency of every package that owns a ledger. The + * resolution belongs in the guard that can import the spec, not in the data. + */ + responseSchema?: string; /** One-line rationale. Required for every non-`sdk` disposition. */ note?: string; } @@ -146,7 +177,14 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /ready', domain: '/ready', disposition: 'server-only', note: 'readiness probe for orchestrators, not app traffic' }, // ── discovery ───────────────────────────────────────────────────────────── - { route: 'GET /discovery', domain: '/discovery', disposition: 'sdk', client: 'connect' }, + // First filled `responseSchema` in this ledger (#5791), and the reason it is + // fillable at all: #5682 gave this producer the double assertion (safeParse + // for VALUES, key-set for undeclared KEYS) in + // `discovery-schema-conformance.test.ts`, which is the coverage the field is + // forbidden to be written without. + { route: 'GET /discovery', domain: '/discovery', disposition: 'sdk', client: 'connect', + responseSchema: 'DiscoverySchema', + note: 'dispatch() returns this one through the { success, data } envelope, so `DiscoverySchema` names the `data` — which is exactly the value getDiscoveryInfo() produces and discovery-schema-conformance.test.ts parses (#5682)' }, // ── analytics ───────────────────────────────────────────────────────────── // Capability-conditional (#3891 follow-through): the plugin mounts these diff --git a/packages/services/service-i18n/src/i18n-route-ledger.ts b/packages/services/service-i18n/src/i18n-route-ledger.ts index 9f6bdd1921..c49555a886 100644 --- a/packages/services/service-i18n/src/i18n-route-ledger.ts +++ b/packages/services/service-i18n/src/i18n-route-ledger.ts @@ -57,6 +57,37 @@ export interface I18nRouteLedgerEntry { disposition: I18nRouteDisposition; /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ client?: string; + /** + * Name of the `@objectstack/spec/api` export declaring this route's response + * PAYLOAD — the `data` of the shared `{ success, data }` envelope where the + * route emits one, the whole body where it does not. The envelope itself is + * not this field's business; `pnpm check:route-envelope` guards it + * structurally, and a single field cannot describe both halves honestly. + * + * ABSENT MEANS "UNDECLARED", and that is the state of most of the mounted + * surface: at the #3877 audit, 0 of 237 ledgered routes carried a schema + * reference. #3877 ruled that authoring the ~190 missing ones is NOT + * scheduled — a response schema is a product decision about what an endpoint + * promises, and mass-producing them is precisely how declarations nobody + * validated come to exist (the four defects #3676/#3833/#3847/#3870 fixed). + * So this field is filled incrementally, as a family lands conformance + * coverage or a route is touched for other reasons; a blank one changes no + * behaviour and is not a defect. + * + * ⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE. The field exists to + * make "what does this route declare" queryable so #3877's Stage D ratchet + * can demand coverage for it; a name written ahead of the test it points at + * would BE the "declared but unverified" surface the programme exists to + * remove. `packages/client/src/route-ledger-response-schema.test.ts` resolves + * every name written here against the live `@objectstack/spec/api` exports, + * so a typo or a retired schema fails loudly rather than rotting. + * + * A NAME rather than a live schema object, deliberately: this module stays + * import-free — the client-side guards compile it as a relative SOURCE file, + * and `zod` is not a dependency of every package that owns a ledger. The + * resolution belongs in the guard that can import the spec, not in the data. + */ + responseSchema?: string; /** One-line rationale. Required for every non-`sdk` disposition. */ note?: string; } diff --git a/packages/services/service-storage/src/storage-route-ledger.ts b/packages/services/service-storage/src/storage-route-ledger.ts index b67420655b..7ac07f8037 100644 --- a/packages/services/service-storage/src/storage-route-ledger.ts +++ b/packages/services/service-storage/src/storage-route-ledger.ts @@ -59,6 +59,37 @@ export interface StorageRouteLedgerEntry { disposition: StorageRouteDisposition; /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ client?: string; + /** + * Name of the `@objectstack/spec/api` export declaring this route's response + * PAYLOAD — the `data` of the shared `{ success, data }` envelope where the + * route emits one, the whole body where it does not. The envelope itself is + * not this field's business; `pnpm check:route-envelope` guards it + * structurally, and a single field cannot describe both halves honestly. + * + * ABSENT MEANS "UNDECLARED", and that is the state of most of the mounted + * surface: at the #3877 audit, 0 of 237 ledgered routes carried a schema + * reference. #3877 ruled that authoring the ~190 missing ones is NOT + * scheduled — a response schema is a product decision about what an endpoint + * promises, and mass-producing them is precisely how declarations nobody + * validated come to exist (the four defects #3676/#3833/#3847/#3870 fixed). + * So this field is filled incrementally, as a family lands conformance + * coverage or a route is touched for other reasons; a blank one changes no + * behaviour and is not a defect. + * + * ⛔ DO NOT FILL A ROW THAT HAS NO CONFORMANCE COVERAGE. The field exists to + * make "what does this route declare" queryable so #3877's Stage D ratchet + * can demand coverage for it; a name written ahead of the test it points at + * would BE the "declared but unverified" surface the programme exists to + * remove. `packages/client/src/route-ledger-response-schema.test.ts` resolves + * every name written here against the live `@objectstack/spec/api` exports, + * so a typo or a retired schema fails loudly rather than rotting. + * + * A NAME rather than a live schema object, deliberately: this module stays + * import-free — the client-side guards compile it as a relative SOURCE file, + * and `zod` is not a dependency of every package that owns a ledger. The + * resolution belongs in the guard that can import the spec, not in the data. + */ + responseSchema?: string; /** One-line rationale. Required for every non-`sdk` disposition. */ note?: string; }