From f7390dfbfd4003c6733642ab4a10b9d694d3bbdf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 14:12:18 +0000 Subject: [PATCH 1/5] fix(rest): direct-mount registrars consume RestServer.getApiBasePath() (#6306) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- ...ry-advertised-direct-mounts.parity.test.ts | 28 +++++++++------- packages/rest/src/rest-api-plugin.ts | 16 +++++++-- packages/rest/src/rest-server.ts | 33 ++++++++++++++----- 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts index 1cbae8b910..2dd6dacf63 100644 --- a/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts +++ b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts @@ -15,16 +15,20 @@ // (`RestServer.getDirectMountRouteBases`), and the mounts are the arrays the // registrars iterated to mount (#5822). This test holds the two ends of that // chain together against the live surface, so ANY future change that moves -// only one side — including #6306 moving the direct-mount base onto -// `apiPath` — goes red here: move the mount without the advertisement (or +// only one side goes red here: move the mount without the advertisement (or // vice versa) and the advertised URL stops resolving in the handler table. +// #6306 was the first such move and it landed with no edit in this file — +// which is the property working, not the pin missing it. // -// The non-default-base case is the load-bearing one. Today the direct-mount -// registrars mount at the PLUGIN's `versionedBase`, not at the RestServer's -// `getApiBasePath()` — the two disagree exactly when `apiPath` is set (#6306's -// subject). Advertising from the recorded mounts keeps `/discovery` honest on -// both sides of that move, and this file measures it at a base that is not -// `/api/v1` to prove nothing re-derives the convention. +// The non-default-base case is the load-bearing one: it drives the +// composition at a base that is not `/api/v1` to prove nothing re-derives the +// convention. Note the level this file measures at — it calls +// `mountAndRecordDirectRoutes` directly, so `versionedBase` is its own +// parameter and the projection is pinned independently of WHO chooses that +// base. Since #6306 the production chooser is `RestServer.getApiBasePath()` +// (so `apiPath` deployments mount and advertise under `{apiPath}`); that +// wiring — plugin config in, mounted+advertised+documented URLs out — is +// pinned end to end in `direct-mount-base-follows-apipath.test.ts`. import { describe, it, expect, vi } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; @@ -165,10 +169,10 @@ describe('[#6633] /discovery advertises the direct-mount surfaces where they are }); it('follows a NON-default mount base — the advertisement derives from the mounts, never from the /api/v1 convention', async () => { - // Today this split is real: the direct-mount registrars mount at the - // plugin's `versionedBase` while /discovery itself serves from the - // RestServer's own base. When #6306 moves the mount base, this case is - // what keeps advertisement and mount inseparable. + // The mount base is an input here, deliberately decoupled from the + // RestServer's own base: that is what proves the advertisement is read + // off the recorded mounts rather than re-derived from config. It is also + // what kept advertisement and mount inseparable across #6306's move. const { table } = boot({ versionedBase: '/backend/api/v9' }); const discovery = await readDiscovery(table); diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 8160eedfff..54905236dc 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -379,9 +379,6 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { throw err; } - const basePath = config.api?.api?.basePath || '/api'; - const version = config.api?.api?.version || 'v1'; - const versionedBase = `${basePath}/${version}`; const enableProjectScoping = config.api?.api?.enableProjectScoping ?? false; const projectResolution = config.api?.api?.projectResolution ?? 'auto'; @@ -393,6 +390,19 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // those are; the route-ledger conformance guard drives the same // function, so a registrar added there cannot slip past it. if (restServer) { + // [#6306] ONE base for the whole surface. This is the same + // value `registerRoutes()` mounted everything else under — + // asked of the server that owns it, never recomputed here. + // The old line was `${basePath}/${version}`, which is + // `getApiBasePath()` minus its `apiPath` branch: a deployment + // setting `api.apiPath` moved 83 routes and left these 9 + // behind at `/api/v1`. Reading the base instead of rebuilding + // it is the whole fix — and it is why the advertisement needs + // no edit: `/discovery`'s `routes.packages` / + // `routes.datasources` are projections of the routes these + // registrars report back (#6633), so the advertisement moves + // with the mount by construction. + const versionedBase = restServer.getApiBasePath(); mountAndRecordDirectRoutes({ server, recorder: restServer, diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index ca5f75d124..b77c62ce70 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3232,9 +3232,24 @@ export class RestServer { } /** - * Get the full API base path + * The full API base path — THE base for this deployment's REST surface. + * + * [#6306] Public because it is the single source of truth, not merely a + * convenience: `rest-api-plugin.ts` threads this very value into the + * direct-mount registrars (`packages.*`, `datasources/:name/external/*`) + * so those nine routes mount under the same prefix as everything the + * RouteManager registers. It used to recompute `${basePath}/${version}` + * for itself, which silently dropped `apiPath` — the two expressions + * agree only while `apiPath` is unset, so a deployment that set it got + * two API prefixes at once: 83 routes under `{apiPath}` and 9 left behind + * at `/api/v1`, invisible to `{apiPath}/openapi.json` (whose section is + * filtered to this base) and to `/discovery`. + * + * The fix is the SHARING, not the expression: do not copy the `??` chain + * to a second site — copying it is precisely how the divergence happened. + * Call this. */ - private getApiBasePath(): string { + getApiBasePath(): string { const { api } = this.config; return api.apiPath ?? `${api.basePath}/${api.version}`; } @@ -3443,12 +3458,12 @@ export class RestServer { // direct mounts (#5822): the advertised base is read off // the very route arrays the registrars iterated to mount, // so advertisement and mounting derive from one fact and - // cannot drift. Today those registrars mount at the - // plugin's `versionedBase` (`/api/v1`) — NOT at this - // server's `getApiBasePath()` — and the advertisement - // says so; when #6306 moves the mount base, the recorded - // paths move and this advertisement follows by - // construction, with no edit here. + // cannot drift. Since #6306 those registrars mount at + // this server's own `getApiBasePath()` — the single + // base — so an `apiPath` deployment advertises + // `{apiPath}/packages` and `{apiPath}/datasources`. + // That move landed with NO edit in this block, which is + // exactly the property #6633 was built to provide. // // A boot that mounted nothing (no `package` service ⇒ // the registrar was never called) advertises nothing: @@ -9294,7 +9309,7 @@ export class RestServer { * * This is the load-bearing half of the mounted ⇒ advertised parity * (ADR-0076 D12): the registrars mount at whatever base the plugin threads - * in (`versionedBase` today; #6306 will move it), the recorder keeps the + * in (since #6306 that is `getApiBasePath()`), the recorder keeps the * very arrays they iterated to mount (#5822), and this method projects the * advertised `routes.packages` / `routes.datasources` out of those arrays. * One expression, two consumers — a future change that moves the mount From 4549b785f93fb6f30d387974995335c7e73e2aa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 17:26:11 +0000 Subject: [PATCH 2/5] =?UTF-8?q?test(rest):=20pin=20the=20single=20API=20ba?= =?UTF-8?q?se=20end=20to=20end=20=E2=80=94=20the=20nine=20follow=20apiPath?= =?UTF-8?q?=20(#6306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .../direct-mount-base-follows-apipath.test.ts | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 packages/rest/src/direct-mount-base-follows-apipath.test.ts diff --git a/packages/rest/src/direct-mount-base-follows-apipath.test.ts b/packages/rest/src/direct-mount-base-follows-apipath.test.ts new file mode 100644 index 0000000000..acd9e1c12a --- /dev/null +++ b/packages/rest/src/direct-mount-base-follows-apipath.test.ts @@ -0,0 +1,294 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#6306] ONE API base for the whole REST surface — pinned end to end through +// the plugin that composes it in production. +// +// The defect this replaces: `RestServer.getApiBasePath()` answers +// `api.apiPath ?? `${basePath}/${version}``, but `rest-api-plugin.ts` built +// its own `${basePath}/${version}` for the two direct-mount registrars and +// never read `apiPath`. The two expressions agree only while `apiPath` is +// unset, so a deployment that set it served TWO API prefixes at once — +// measured on `origin/main` @ 11066f681 with `apiPath: '/backend/api/v9'`: +// 92 routes mounted, 83 under `{apiPath}`, and exactly 9 left behind at +// `/api/v1` (`packages.*` ×4, `datasources/:name/external/*` ×5). Those 9 +// were also absent from `{apiPath}/openapi.json` (71 paths vs 79), because +// that document is filtered to this server's base — the filter is what made +// the split visible (#5822 / PR #6303). +// +// What is pinned here, and at which level. This file drives +// `createRestApiPlugin(config).start(ctx)` — the real composition — over a +// recording host server whose handler table IS the mounted surface, then asks +// the three consumers that must agree: where the routes mount, what +// `{base}/openapi.json` documents, and what `{base}/discovery` advertises. +// That plugin-level wiring is deliberately NOT what +// `discovery-advertised-direct-mounts.parity.test.ts` measures: it calls +// `mountAndRecordDirectRoutes` directly with its own `versionedBase`, so it +// pins mounted ⇒ advertised for whatever base it is handed and stays green +// whichever base the plugin picks. The choice of base is this file's subject. +// +// The single-source assertion is the point, not the URLs: each case compares +// the mounted base against `getApiBasePath()` read off an independently +// constructed `RestServer` with the same config. A future edit that +// re-derives the base at the registrars' call site — however correctly — +// fails these, which is the intent: the bug was a second expression, so the +// pin is on there being one. + +// Relative imports carry their `.js` extension (see the note in +// `direct-mount-introspection.test.ts`): under `moduleResolution: nodenext` an +// extension-less one does not resolve and every symbol it names becomes `any`. +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; +import { createRestApiPlugin } from './rest-api-plugin.js'; +import { REST_ROUTE_LEDGER } from './rest-route-ledger.js'; +import { toTemplatePath } from './openapi-builtin-paths.js'; + +type Handler = (req: any, res: any) => any; + +/** The ledger's own list of the nine, as `VERB {base-relative}` suffixes. */ +const DIRECT_MOUNT_SUFFIXES = REST_ROUTE_LEDGER + .filter((e) => e.source === 'direct-mount') + .map((e) => { + const [method, path] = e.route.split(' '); + return { method, suffix: path.replace(/^\/api\/v1/, '') }; + }); + +/** + * A host server whose registrations land in a real handler table — the + * RouteManager rows `RestServer` mounts and the direct-mount registrars' rows + * alike, so one table answers "what is mounted" for the whole boot. + */ +function createRecordingServer() { + const table = new Map(); + const on = (method: string) => vi.fn((path: string, handler: Handler) => { + table.set(`${method} ${path}`, handler); + }); + const server = { + table, + get: on('GET'), post: on('POST'), put: on('PUT'), delete: on('DELETE'), patch: on('PATCH'), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; + return server; +} + +/** Match a concrete URL against the table's `:param` patterns. */ +function resolveRoute(table: Map, method: string, url: string) { + const urlSegs = url.split('/'); + for (const [key, handler] of table) { + const [m, pattern] = key.split(' '); + if (m !== method) continue; + const patSegs = pattern.split('/'); + if (patSegs.length !== urlSegs.length) continue; + const params: Record = {}; + let ok = true; + for (let i = 0; i < patSegs.length; i++) { + if (patSegs[i].startsWith(':')) params[patSegs[i].slice(1)] = urlSegs[i]; + else if (patSegs[i] !== urlSegs[i]) { ok = false; break; } + } + if (ok) return { handler, params }; + } + return undefined; +} + +async function drive(entry: { handler: Handler; params: Record }, req: Record = {}) { + let body: any; + let statusCode = 200; + const res: any = { + status: (c: number) => { statusCode = c; return res; }, + json: (b: any) => { body = b; }, + header: () => res, + send: () => {}, + }; + await entry.handler({ params: entry.params, query: {}, body: {}, headers: { host: 'example.test' }, ...req }, res); + return { statusCode, body }; +} + +function makeProtocol() { + const engine = { registry: { getObject: (_n: string) => undefined, getRegisteredTypes: () => [] } }; + const services = new Map([['package', { list: async () => [] }]]); + return new ObjectStackProtocolImplementation(engine as any, () => services); +} + +function createCtx(services: Record) { + return { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name in services) return services[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(services))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getKernel: vi.fn(), + }; +} + +/** + * Boot the REST plugin exactly as production does, and report both the + * mounted surface and the base an independently constructed `RestServer` + * computes from the same config — the two things every case compares. + */ +async function bootPlugin(apiConfig?: Record) { + const server = createRecordingServer(); + const ctx = createCtx({ + 'http.server': server, + protocol: makeProtocol(), + package: { list: vi.fn(), get: vi.fn(), publish: vi.fn(), delete: vi.fn() }, + 'external-datasource': { listRemoteTables: async () => [{ name: 'customers' }] }, + }); + await createRestApiPlugin(apiConfig as any).start!(ctx as any); + + // The base the server that owns the surface computes — the single source. + const expectedBase = new RestServer( + createRecordingServer() as any, + makeProtocol() as any, + (apiConfig?.api ?? {}) as any, + ).getApiBasePath(); + + return { server, table: server.table, expectedBase }; +} + +function mountedKeys(table: Map): string[] { + return [...table.keys()]; +} + +async function serveOpenApi(table: Map, base: string) { + const entry = resolveRoute(table, 'GET', `${base}/openapi.json`); + expect(entry, `GET ${base}/openapi.json must be mounted for this pin to mean anything`).toBeDefined(); + const { body } = await drive(entry!, { path: `${base}/openapi.json` }); + return body; +} + +async function readDiscovery(table: Map, base: string) { + const entry = resolveRoute(table, 'GET', `${base}/discovery`); + expect(entry, `GET ${base}/discovery must be mounted`).toBeDefined(); + const { body } = await drive(entry!); + return body; +} + +function documented(doc: any, wirePath: string, method: string): boolean { + return Boolean(doc?.paths?.[toTemplatePath(wirePath)]?.[method.toLowerCase()]); +} + +// --------------------------------------------------------------------------- +// the move — a deployment that sets `apiPath` +// --------------------------------------------------------------------------- + +describe('#6306 — with `apiPath` set, the direct-mount routes follow it', () => { + const API_PATH = '/backend/api/v9'; + const config = { api: { api: { apiPath: API_PATH } } }; + + it('mounts all nine under {apiPath}, and leaves nothing behind at the convention prefix', async () => { + const { table, expectedBase } = await bootPlugin(config); + + // The base is the server's, not a second expression that happens to agree. + expect(expectedBase).toBe(API_PATH); + + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect( + mountedKeys(table), + `${method} ${expectedBase}${suffix} must mount under the one API base`, + ).toContain(`${method} ${expectedBase}${suffix}`); + } + + // The whole surface moved, not merely the nine: no route is left at the + // `/api/v1` convention. This is the split itself — on `origin/main` this + // set had exactly 9 members. + const stragglers = mountedKeys(table).filter((k) => k.split(' ')[1].startsWith('/api/v1')); + expect(stragglers, 'no route may stay at /api/v1 when apiPath moves the surface').toEqual([]); + }); + + it('documents all nine in {apiPath}/openapi.json — the filter that made the split visible now includes them', async () => { + const { table, expectedBase } = await bootPlugin(config); + const doc = await serveOpenApi(table, expectedBase); + + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect( + documented(doc, `${expectedBase}${suffix}`, method), + `${method} ${expectedBase}${suffix} is mounted but not documented`, + ).toBe(true); + } + // …and the stale prefix is documented nowhere, so the document describes + // one surface rather than two. + expect(Object.keys(doc.paths).filter((p) => p.startsWith('/api/v1'))).toEqual([]); + }); + + it('advertises the moved bases in {apiPath}/discovery, and the advertised URLs answer', async () => { + const { table, expectedBase } = await bootPlugin(config); + const discovery = await readDiscovery(table, expectedBase); + + // No edit was needed in the advertising code for this: `routes.packages` / + // `routes.datasources` are projections of the recorded mounts (#6633), so + // moving the mount moved the advertisement. + expect(discovery.routes.packages).toBe(`${expectedBase}/packages`); + expect(discovery.routes.datasources).toBe(`${expectedBase}/datasources`); + + const pkg = resolveRoute(table, 'GET', discovery.routes.packages); + expect(pkg, 'the advertised packages URL must be mounted').toBeDefined(); + expect((await drive(pkg!)).statusCode).toBe(200); + + const ext = resolveRoute(table, 'GET', `${discovery.routes.datasources}/pg_main/external/tables`); + expect(ext, 'the advertised datasources base must be the base of the mounted family').toBeDefined(); + expect((await drive(ext!)).statusCode).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// the second divergent expression the single source also collapses +// --------------------------------------------------------------------------- + +describe('#6306 — the base is READ, not rebuilt: `??` and `||` no longer disagree', () => { + it('an empty `basePath` puts the nine where the rest of the surface already was', async () => { + // A second, independent way the two expressions differed: the plugin + // defaulted with `||` (empty string ⇒ `/api`) while `RestServer` + // normalizes with `??` (empty string kept). So `basePath: ''` mounted the + // RouteManager surface at `/v1` and the nine at `/api/v1` — the same + // split, reached without `apiPath` at all. Reading the base cannot + // disagree with itself. + const { table, expectedBase } = await bootPlugin({ api: { api: { basePath: '', version: 'v1' } } }); + expect(expectedBase).toBe('/v1'); + + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect(mountedKeys(table)).toContain(`${method} /v1${suffix}`); + } + expect(mountedKeys(table).filter((k) => k.split(' ')[1].startsWith('/api/v1'))).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// the baseline — unchanged where the two expressions always agreed +// --------------------------------------------------------------------------- + +describe('#6306 — default and conventional configs are unchanged', () => { + // NOTE, honestly: these two cases are green both before and after the fix — + // `apiPath ?? `${basePath}/${version}`` and `${basePath}/${version}` are the + // same string here, which is exactly why the defect hid for so long. They + // are not reverse-verification evidence; they are the regression floor, + // pinning that single-sourcing moved nothing for deployments that never set + // `apiPath` (measured: the default mount list is identical, 92 routes, + // before and after). + it('default config keeps all nine at /api/v1, documented and advertised there', async () => { + const { table, expectedBase } = await bootPlugin(undefined); + expect(expectedBase).toBe('/api/v1'); + + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect(mountedKeys(table)).toContain(`${method} /api/v1${suffix}`); + } + const doc = await serveOpenApi(table, '/api/v1'); + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect(documented(doc, `/api/v1${suffix}`, method)).toBe(true); + } + const discovery = await readDiscovery(table, '/api/v1'); + expect(discovery.routes.packages).toBe('/api/v1/packages'); + expect(discovery.routes.datasources).toBe('/api/v1/datasources'); + }); + + it('a conventional custom basePath/version behaves identically under both expressions', async () => { + const { table, expectedBase } = await bootPlugin({ api: { api: { basePath: '/gateway', version: 'v3' } } }); + expect(expectedBase).toBe('/gateway/v3'); + for (const { method, suffix } of DIRECT_MOUNT_SUFFIXES) { + expect(mountedKeys(table)).toContain(`${method} /gateway/v3${suffix}`); + } + }); +}); From 047d2479b09133753409fa546af90a79663595de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 17:51:15 +0000 Subject: [PATCH 3/5] =?UTF-8?q?docs(changeset):=20@objectstack/rest=20patc?= =?UTF-8?q?h=20=E2=80=94=20direct-mount=20=E8=B7=AF=E7=94=B1=E8=B7=9F?= =?UTF-8?q?=E9=9A=8F=20apiPath=20(#6306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .changeset/direct-mount-follows-apipath.md | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .changeset/direct-mount-follows-apipath.md diff --git a/.changeset/direct-mount-follows-apipath.md b/.changeset/direct-mount-follows-apipath.md new file mode 100644 index 0000000000..36d22b60da --- /dev/null +++ b/.changeset/direct-mount-follows-apipath.md @@ -0,0 +1,35 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): 设置了 `api.apiPath` 时,9 条 direct-mount 路由跟随同一个 API base(#6306) + +`RestServer.getApiBasePath()` 回答 `api.apiPath ?? `${basePath}/${version}``, +而 `rest-api-plugin.ts` 为两个 direct-mount registrar(`packages.*` ×4、 +`datasources/:name/external/*` ×5)自行重算了一次 `${basePath}/${version}`, +从不读取 `apiPath`。两个表达式只在 `apiPath` 未设时相等——于是设置了 +`apiPath` 的部署同时出现两个 API 前缀:实测 92 条路由中 83 条迁到 +`{apiPath}`,9 条滞留 `/api/v1`,且 `{apiPath}/openapi.json` 的 +`isUnderBase` 过滤把这 9 条排除在文档之外(71 paths),`/discovery` +也如实通告了滞留位置。 + +按 maintainer 裁定(Option 1,单一真相源):registrar 现在直接消费 +`restServer.getApiBasePath()` 的返回值——共享同一个值,而不是把 `??` +表达式复制到第二处(复制正是这个缺陷的成因)。`getApiBasePath()` 因此 +从 `private` 变为 public,职责写入其 doc comment。 + +**行为变化(仅限设置了 `api.apiPath` 的部署,该键只有程序化组合 +`createRestApiPlugin` 的 embedder 可达,`defineStack` / `os serve` 均无 +authoring 路径)**:这 9 条路由的 URL 从 `/api/v1/...` 移到 +`{apiPath}/...`,旧前缀不再服务(无兼容双挂载)。今天这些部署本就是 +split-brain——SDK 自 #6633 / PR #6712 起跟随 `/discovery` 通告的 base, +通告又是已录制挂载的投影,因此客户端按构造跟随本次移动。 +`{apiPath}/openapi.json` 现在完整列出这 9 条(实测 71 → 79 paths)。 + +默认配置(未设 `apiPath`)逐字节不变:两个表达式在该情形下同值,实测 +修复前后默认挂载表完全一致(92 条)。 + +另修复同一来源的第二处分歧:插件旧表达式用 `||` 兜底(空串 `basePath` +⇒ `/api`),`RestServer` 规范化用 `??`(空串保留)——`basePath: ''` 时 +route-manager 面挂 `/v1` 而 9 条挂 `/api/v1`,同样的分裂不需要 `apiPath` +也会出现。读同一个值后该分歧不复存在。 From f34a931050c7add9aa9be82ceab5060f43334e43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 22:40:54 +0000 Subject: [PATCH 4/5] test(rest): the slot-lookup double inherits RestServer instead of re-listing it (#6306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rest-api-plugin-slot-lookups.test.ts` replaced `RestServer` with a hand-written class carrying exactly two members — the constructor it captures args from, and a no-op `registerRoutes`. That is a contract restated by hand, and it goes stale the moment the composition root calls one more method on the instance: #6306 threads `getApiBasePath()` into the direct-mount registrars, and all five cases here died with `TypeError: restServer.getApiBasePath is not a function` — a file about slot WIRING failing on a question it does not ask. Extend the real class and override only `registerRoutes`, which is the one expensive thing the double existed to suppress. The rest of the contract is inherited, so the next collaborator call lands on the production method. Nothing is paid for it: the real constructor is field assignment plus `new RouteManager(server)` (a `Map`). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .../src/rest-api-plugin-slot-lookups.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/rest-api-plugin-slot-lookups.test.ts b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts index e936ef838b..5a0321aa0d 100644 --- a/packages/rest/src/rest-api-plugin-slot-lookups.test.ts +++ b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts @@ -33,15 +33,27 @@ const captured = vi.hoisted(() => ({ ctorArgs: [] as unknown[][] })); // Capture RestServer's constructor arguments without registering ~hundreds of // routes. Everything else in the module (RestEnvRegistry & co) stays real, so // the plugin's imports resolve normally. +// +// The double EXTENDS the real class rather than replacing it: `registerRoutes` +// is the only expensive thing here, so it is the only thing suppressed, and +// every other method the composition root calls stays the production one. That +// matters beyond tidiness — the plugin also asks the instance for the API base +// (`getApiBasePath()`, #6306), and a hand-written stub that lists only the +// methods the plugin happened to call the day it was written turns each new +// collaborator call into a `TypeError` here, in a file about slot WIRING that +// has no opinion on the base. Inheriting the contract keeps this test measuring +// its own subject. The real constructor is field assignment plus +// `new RouteManager(server)` (a `Map`), so nothing is paid for it. vi.mock('./rest-server.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - RestServer: class { + RestServer: class extends actual.RestServer { constructor(...args: unknown[]) { + super(...(args as ConstructorParameters)); captured.ctorArgs.push(args); } - registerRoutes(): void { + override registerRoutes(): void { /* routes are not under test here */ } }, From 5e726d189bd0e149a5bc94a938d2cfc5b3f2bd89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:07:15 +0000 Subject: [PATCH 5/5] docs(changeset): judge the bump `minor`, and re-measure every number on the merged tree (#6306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numbers in the body are re-measured on this branch after merging `origin/main` (the earlier ones were taken before ~77 commits landed and are void): with `apiPath: '/backend/api/v9'`, 92 routes mount, 83 under `{apiPath}` and exactly 9 at `/api/v1`; `{apiPath}/openapi.json` carries 71 paths before and 79 after; the default mount table, its OpenAPI document and its `/discovery` advertisement diff clean before vs after. Bump judged `minor`. Not `patch`: beyond fixing the defect this changes an observable URL surface under a real config key, and it adds public API — `RestServer.getApiBasePath()` goes `private` → public, which is the thing that carries the single source of truth. Not `major`: nothing authorable is removed or renamed, there is no metadata an author must migrate (so ADR-0087 has nothing to register), default deployments are byte-identical, and affected deployments' clients follow the moved base by construction since PR #6712. The only FROM → TO lands on the operator's own proxy config, and those deployments are split-brain today — this makes `apiPath` honoured in full rather than withdrawing a promise that was ever kept. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .changeset/direct-mount-follows-apipath.md | 49 +++++++++++++++------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/.changeset/direct-mount-follows-apipath.md b/.changeset/direct-mount-follows-apipath.md index 36d22b60da..1a6de7f1b9 100644 --- a/.changeset/direct-mount-follows-apipath.md +++ b/.changeset/direct-mount-follows-apipath.md @@ -1,5 +1,5 @@ --- -"@objectstack/rest": patch +"@objectstack/rest": minor --- fix(rest): 设置了 `api.apiPath` 时,9 条 direct-mount 路由跟随同一个 API base(#6306) @@ -8,28 +8,47 @@ fix(rest): 设置了 `api.apiPath` 时,9 条 direct-mount 路由跟随同一个 而 `rest-api-plugin.ts` 为两个 direct-mount registrar(`packages.*` ×4、 `datasources/:name/external/*` ×5)自行重算了一次 `${basePath}/${version}`, 从不读取 `apiPath`。两个表达式只在 `apiPath` 未设时相等——于是设置了 -`apiPath` 的部署同时出现两个 API 前缀:实测 92 条路由中 83 条迁到 -`{apiPath}`,9 条滞留 `/api/v1`,且 `{apiPath}/openapi.json` 的 -`isUnderBase` 过滤把这 9 条排除在文档之外(71 paths),`/discovery` -也如实通告了滞留位置。 +`apiPath` 的部署同时出现两个 API 前缀。实测(`apiPath: '/backend/api/v9'`, +真实 `createRestApiPlugin(...).start()` 组合、记录型 host server 枚举全部 +挂载):**92 条路由中 83 条迁到 `{apiPath}`,恰好 9 条滞留 `/api/v1`**; +`{apiPath}/openapi.json` 的 `isUnderBase` 过滤把这 9 条排除在文档之外 +(**71 paths**);`/discovery` 也如实通告了滞留位置 +(`routes.packages: '/api/v1/packages'`)——通告没有说谎,是挂载本身分裂了。 按 maintainer 裁定(Option 1,单一真相源):registrar 现在直接消费 `restServer.getApiBasePath()` 的返回值——共享同一个值,而不是把 `??` 表达式复制到第二处(复制正是这个缺陷的成因)。`getApiBasePath()` 因此 从 `private` 变为 public,职责写入其 doc comment。 -**行为变化(仅限设置了 `api.apiPath` 的部署,该键只有程序化组合 -`createRestApiPlugin` 的 embedder 可达,`defineStack` / `os serve` 均无 -authoring 路径)**:这 9 条路由的 URL 从 `/api/v1/...` 移到 -`{apiPath}/...`,旧前缀不再服务(无兼容双挂载)。今天这些部署本就是 -split-brain——SDK 自 #6633 / PR #6712 起跟随 `/discovery` 通告的 base, -通告又是已录制挂载的投影,因此客户端按构造跟随本次移动。 -`{apiPath}/openapi.json` 现在完整列出这 9 条(实测 71 → 79 paths)。 +**行为变化,仅限设置了 `api.apiPath` 的部署**:这 9 条路由的 URL 从 +`/api/v1/...` 移到 `{apiPath}/...`,旧前缀不再服务(无兼容双挂载)。 +修复后实测 92 条全部挂在 `{apiPath}` 下,`{apiPath}/openapi.json` +完整列出这 9 条(**71 → 79 paths**),`/discovery` 通告 `{apiPath}/packages` +与 `{apiPath}/datasources`。 -默认配置(未设 `apiPath`)逐字节不变:两个表达式在该情形下同值,实测 -修复前后默认挂载表完全一致(92 条)。 +需要动手的只有**基础设施配置**:若反向代理、健康检查或外部监控里硬编码了 +`/api/v1/packages` 或 `/api/v1/datasources/*/external/*`,改成 `{apiPath}/…`。 +**SDK 与应用代码无需改动**:`@objectstack/client` 自 #6633 / PR #6712 起从 +`/discovery` 通告的 base 派生这两个面,而通告是已录制挂载的投影,因此客户端 +按构造跟随本次移动。该键也没有 authoring 路径可达 +(`defineStack({server:{api:…}})` 被 strict 块 loud 拒绝,`api:{apiPath}` 被 +静默 strip,`os serve` 只转发两个 scoping 键),只有程序化组合 +`createRestApiPlugin` 的 embedder 能设到它。 + +**默认配置(未设 `apiPath`)逐字节不变**:两个表达式在该情形下同值;实测 +修复前后默认挂载表(92 条)、`{base}/openapi.json`(79 paths)与 +`/discovery` 通告完全一致,逐行 diff 无差异。 另修复同一来源的第二处分歧:插件旧表达式用 `||` 兜底(空串 `basePath` ⇒ `/api`),`RestServer` 规范化用 `??`(空串保留)——`basePath: ''` 时 route-manager 面挂 `/v1` 而 9 条挂 `/api/v1`,同样的分裂不需要 `apiPath` -也会出现。读同一个值后该分歧不复存在。 +也会出现(实测 83/9)。读同一个值后该分歧不复存在。 + +Bump 判定为 `minor` 而非 `patch` / `major`。不是 `patch`:除了修缺陷,它 +改变了一个真实配置键下可观测的 URL 表面,并且新增了公共 API 面 +(`RestServer.getApiBasePath()` 由 `private` 转 public,是这次单一真相源的 +承载物)。不是 `major`:没有任何可授权(authorable)的键被移除或重命名, +没有需要作者迁移的元数据(因而 ADR-0087 无可登记项),默认部署逐字节不变, +受影响部署的客户端按构造跟随;唯一的 FROM → TO 落在部署方自己的代理配置上, +而这些部署今天本就是 split-brain——本次是让 `apiPath` 被完整遵守,不是收回 +一个曾被兑现的承诺。