Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/route-ledger-response-schema.md
Original file line numberDiff line numberDiff line change
@@ -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 就是该套件实际解析用的**同一个对象**,并各自测量了载荷所在的层级。
165 changes: 165 additions & 0 deletions packages/client/src/route-ledger-response-schema.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/**
* 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<string, string[]>();
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');
});
});
31 changes: 31 additions & 0 deletions packages/plugins/plugin-auth/src/auth-route-ledger.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}
Expand Down
56 changes: 56 additions & 0 deletions packages/rest/src/discovery-schema-conformance.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string> {
Expand DownExpand Up@@ -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<string, unknown>)[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();
});
});
});
Loading
Loading