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
51 changes: 51 additions & 0 deletions .changeset/route-audit-tranche-3-service-mounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@objectstack/service-storage": patch
"@objectstack/service-i18n": patch
"@objectstack/client": patch
---

fix(client,service-i18n): ledger the autonomously-mounted service routes, and repair the two i18n calls that reached nothing (#3636)

Tranche 3 of the #3563 route audit — the last un-audited server surface. The
dispatcher ledger (#3563) and the REST ledger (#3587) each stop at their own
package boundary, and two services mount routes outside both: they reach for
the `http-server` service and register straight on `IHttpServer`, so neither
`RouteManager` nor `RestServer.getRoutes()` has ever seen them. That left the
SDK's entire storage surface, plus all of i18n, in the pre-#3563 posture:
expressed, working, guarded by nothing.

**Ledgers + guards.** `storage-route-ledger.ts` (10 routes) and
`i18n-route-ledger.ts` (3) sit next to the registrars that mount them, each
enumerated for real — the registrar runs against a capturing mock
`IHttpServer` and its registration calls *are* the route set, so a new route
lands with a reviewed disposition or fails CI. The client half is
`packages/client/src/service-route-ledger-coverage.test.ts`; ledgers cross the
boundary as relative source imports, never a service→client package edge.

**Two wire-level 404s fixed.** `i18n.getTranslations` sent
`/i18n/translations?locale=xx` and `i18n.getFieldLabels` sent
`/i18n/labels/:object?locale=xx`, while every serving surface — service-i18n's
mounts, the dispatcher's HTTP mounts, and the `plugin-rest-api.zod.ts`
contract — mounts only the path form. Neither call could ever be answered.
Both had carried a green `sdk` row in the dispatcher ledger since tranche 1,
because that guard asks whether the client *method* exists, not whether it
speaks a URL anything mounts. The client now sends the path dialect, the same
resolution #3611 gave `meta.getView`, and a new suite drives the real client
at a real router so a revert cannot pass quietly.

**One response-shape fix.** service-i18n's success bodies omitted the
`success` flag that `ObjectStackClient.unwrapResponse` keys on, so the SDK
returned the raw `{ data: … }` wrapper against that provider while returning
the declared unwrapped shape against the dispatcher — one method, two shapes,
decided by which plugin mounted the route. Its three handlers now emit the
`{ success: true, data }` envelope the `i18n` route group declares. `data` did
not move, so direct body readers are unaffected.

Storage audited clean: 7 routes SDK-expressed, 3 reviewed `server-only` (the
browser capability URL objectql stamps into file-field payloads, and the two
local-driver loopbacks). The chunked-upload family, flagged for triage, turned
out fully expressed. Both ledgers ratchet `gap` and `mismatch` at zero.

Filed, not fixed: `GET {base}/_local/file/:key` is built by three call sites
and mounted by none (#3641); the cross-surface URL conformance guard that would
have caught all of the above mechanically is the capstone (#3642).
47 changes: 46 additions & 1 deletion docs/audits/2026-07-dispatcher-client-route-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,49 @@ expression exists for: `GET /search`, `POST /email/send`, `forms/:slug`
`external-datasource-routes.ts`). Auditing that surface with the same
three-column method is the next tranche of #3563.

**Closed in #3587** — `packages/rest/src/rest-route-ledger.ts` plus its
conformance guard; the 43 audited gaps and 2 mismatches both ratchet at zero.

## 9. The third surface: autonomous service mounts (#3636)

Neither ledger sees a service that reaches for the `http-server` service and
registers straight on `IHttpServer` — it bypasses `RouteManager` and
`RestServer.getRoutes()` alike. Two do: `service-storage`
(`storage-routes.ts`, 10 routes — the SDK's entire storage surface) and
`service-i18n` (`i18n-service-plugin.ts`, 3 routes). Both now carry a
per-package ledger next to the registrar, enumerated by driving the real
registrar against a capturing mock `IHttpServer`, with the client half in
`packages/client/src/service-route-ledger-coverage.test.ts` (no
service→client package edge — the tranche-1 lesson).

Dispositions: storage audited clean at 7 `sdk` / 3 `server-only` (the
browser-facing `/files/:fileId` redirect objectql stamps into file-field
payloads, and the two `_local/raw/:token` local-driver loopbacks). The chunked
upload family — flagged in #3636 as needing triage — turned out fully
SDK-expressed (`initChunkedUpload` / `uploadPart` / `completeChunkedUpload` /
`resumeUpload`), so no gap to close.

i18n audited at **two mismatches**, both fixed in the same PR:
`i18n.getTranslations` sent `/translations?locale=xx` and
`i18n.getFieldLabels` sent `/labels/:object?locale=xx`, while every serving
surface — service-i18n's mounts, the dispatcher's HTTP mounts, and the
`plugin-rest-api.zod.ts` contract — mounts only the path form. Both were
wire-level 404s, and both had carried a green `sdk` row in
`route-ledger.ts` since tranche 1: **§1 coverage rows assert the client method
exists, not that it speaks a URL anything mounts.** The same audit found
service-i18n omitting the `success` flag from its `{ data }` bodies, so
`unwrapResponse` returned the raw wrapper against that provider while
returning the declared shape against the dispatcher — one method, two shapes,
decided by which plugin mounted the route.

Also filed, not fixed: `GET {base}/_local/file/:key` is built by three call
sites and mounted by none (#3641).

**The gap all three ledgers still share** is the reverse direction — no guard
compares the URL a client method *builds* against the patterns any surface
*mounts*. Four instances of that class have now been found one at a time
(#3584 ×2, #3611, #3636 ×2). Mechanizing it is the capstone, #3642.

## Follow-up slicing (proposed)

1. **`client.actions.invoke(...)`** — closes the largest hole (3 routes).
Expand All @@ -166,7 +209,9 @@ three-column method is the next tranche of #3563.
5. **Mismatch reconciliation** (§4) — done in #3584: analytics client aligned to the dispatcher, storage protocol documented as canonical (see §4 Resolution).
6. **Docs**: delete or regenerate README surface table + CLIENT_SPEC_COMPLIANCE.md; extend client-sdk.mdx.
7. **Deprecate `DEFAULT_DISPATCHER_ROUTES`**; point at the ledger.
8. **REST-surface tranche** (§8) with the same ledger+guard treatment.
8. **REST-surface tranche** (§8) with the same ledger+guard treatment — done in #3587.
9. **Autonomous service mounts** (§9) — done in #3636.
10. **Cross-surface URL conformance** (§9, the reverse direction) — #3642.

Each gap closed must flip its ledger row to `sdk` and lower the ratchet bound
in the conformance test — the guard enforces both directions from PR-1 onward.
33 changes: 23 additions & 10 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,29 +664,42 @@ describe('i18n namespace', () => {
expect(url).toContain('/api/v1/i18n/locales');
});

it('should get translations', async () => {
it('i18n.getTranslations speaks the path-param dialect every surface mounts (#3636)', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { locale: 'zh-CN', translations: { hello: '你好' } }
});
const result = await client.i18n.getTranslations('zh-CN', { namespace: 'common' });
const result = await client.i18n.getTranslations('zh-CN');
expect(result.locale).toBe('zh-CN');
const url = fetchMock.mock.calls[0][0] as string;
expect(url).toContain('/api/v1/i18n/translations');
expect(url).toContain('locale=zh-CN');
expect(url).toContain('namespace=common');
// NOT `/translations?locale=zh-CN` — no server mounts a bare
// /translations, so the old query dialect 404'd everywhere.
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/i18n/translations/zh-CN',
);
});

it('i18n.getTranslations keeps namespace/keys as query params on the path form', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { locale: 'zh-CN', translations: { hello: '你好' } }
});
await client.i18n.getTranslations('zh-CN', { namespace: 'common', keys: ['a', 'b'] });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/i18n/translations/zh-CN?namespace=common&keys=a%2Cb',
);
});

it('should get field labels', async () => {
it('i18n.getFieldLabels puts both object and locale on the path (#3636)', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { object: 'customer', labels: { name: '名前' } }
});
const result = await client.i18n.getFieldLabels('customer', 'ja');
expect(result.object).toBe('customer');
const url = fetchMock.mock.calls[0][0] as string;
expect(url).toContain('/api/v1/i18n/labels/customer');
expect(url).toContain('locale=ja');
// NOT `/labels/customer?locale=ja` — the mount is /labels/:object/:locale.
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/i18n/labels/customer/ja',
);
});
});

Expand Down
91 changes: 91 additions & 0 deletions packages/client/src/i18n-wire-dialect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* i18n wire-dialect proof (#3636) — the SDK's i18n calls resolve against a
* REAL router, not a pinned URL string.
*
* The URL pins in `client.test.ts` say what the client sends. They cannot say
* whether anything answers: that is exactly how `i18n.getTranslations` and
* `i18n.getFieldLabels` shipped a `?locale=` dialect no server has ever
* mounted, passing every client-side test while 404-ing in production.
*
* So this suite mounts the two route patterns every serving surface declares —
* `service-i18n`'s autonomous mounts, the dispatcher's HTTP mounts, and the
* `plugin-rest-api.zod.ts` contract all agree on them — on a real Hono server,
* and drives the real client at it. The old dialect is asserted DEAD in the
* same suite, so a revert cannot pass quietly.
*
* service-i18n itself is deliberately not imported: it is not a dependency of
* `@objectstack/client` and must not become one (the tranche-1 no-package-edge
* lesson). The route PATTERNS are the contract under test, and they are
* identical on both surfaces.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
import type { IHttpServer, IHttpRequest, IHttpResponse } from '@objectstack/spec/contracts';
import { ObjectStackClient } from './index';

describe('i18n SDK calls hit a real router (#3636)', () => {
let baseUrl: string;
let kernel: LiteKernel;
let client: ObjectStackClient;

beforeAll(async () => {
kernel = new LiteKernel();
const hono = new HonoServerPlugin({ port: 0 });
kernel.use(hono);
await kernel.bootstrap();

// The same three patterns I18nServicePlugin.registerI18nRoutes mounts,
// answering with the same `{ success: true, data }` envelope it now emits
// — see packages/services/service-i18n/src/i18n-route-ledger.ts.
const server = kernel.getService<IHttpServer>('http-server');
server.get('/api/v1/i18n/locales', async (_req: IHttpRequest, res: IHttpResponse) => {
res.json({ success: true, data: { locales: [{ code: 'zh-CN', label: 'zh-CN', isDefault: true }] } });
});
server.get('/api/v1/i18n/translations/:locale', async (req: IHttpRequest, res: IHttpResponse) => {
res.json({ success: true, data: { locale: req.params.locale, translations: { hello: '你好' } } });
});
server.get('/api/v1/i18n/labels/:object/:locale', async (req: IHttpRequest, res: IHttpResponse) => {
res.json({ success: true, data: { object: req.params.object, locale: req.params.locale, labels: { name: '名称' } } });
});

baseUrl = `http://localhost:${(server as unknown as { getPort(): number }).getPort()}`;
client = new ObjectStackClient({ baseUrl });
});

afterAll(async () => {
await kernel?.shutdown();
});

it('getTranslations resolves — the locale arrives as a path param', async () => {
const res = await client.i18n.getTranslations('zh-CN');
expect(res.locale).toBe('zh-CN');
expect(res.translations).toEqual({ hello: '你好' });
});

it('getFieldLabels resolves — object AND locale arrive as path params', async () => {
const res = await client.i18n.getFieldLabels('customer', 'zh-CN');
expect(res.object).toBe('customer');
expect(res.locale).toBe('zh-CN');
expect(res.labels).toEqual({ name: '名称' });
});

it('getLocales resolves', async () => {
const res = await client.i18n.getLocales();
expect(res.locales).toHaveLength(1);
});

it('the abandoned query dialect is dead on the wire — 404, both shapes', async () => {
// This is what the client sent before #3636. Nothing routes it.
for (const dead of [
'/api/v1/i18n/translations?locale=zh-CN',
'/api/v1/i18n/labels/customer?locale=zh-CN',
]) {
const res = await fetch(`${baseUrl}${dead}`);
expect(res.status, `${dead} should not resolve on any surface`).toBe(404);
}
});
});
25 changes: 20 additions & 5 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3494,24 +3494,39 @@ export class ObjectStackClient {
},

/**
* Get translations for a locale
* Get translations for a locale.
*
* Speaks the path-param dialect (`/translations/:locale`) — the shape
* `plugin-rest-api.zod.ts` declares and the ONLY shape any serving surface
* mounts (service-i18n's autonomous routes, the dispatcher's HTTP mounts).
* The `?locale=` query form this used to send matched no route anywhere
* and 404'd on the wire; the dispatcher's domain body accepts it, but
* nothing ever routes a bare `/translations` to that body (#3636).
*/
getTranslations: async (locale: string, options?: { namespace?: string; keys?: string[] }): Promise<GetTranslationsResponse> => {
const route = this.getRoute('i18n');
const params = new URLSearchParams();
params.set('locale', locale);
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.keys) params.set('keys', options.keys.join(','));
const res = await this.fetch(`${this.baseUrl}${route}/translations?${params.toString()}`);
const query = params.toString();
const res = await this.fetch(
`${this.baseUrl}${route}/translations/${encodeURIComponent(locale)}${query ? `?${query}` : ''}`,
);
return this.unwrapResponse<GetTranslationsResponse>(res);
},

/**
* Get translated field labels for an object
* Get translated field labels for an object.
*
* Both the object and the locale ride the path (`/labels/:object/:locale`)
* — same reason as `getTranslations` above: the `?locale=` form could
* never match the two-path-param mount (#3636).
*/
getFieldLabels: async (object: string, locale: string): Promise<GetFieldLabelsResponse> => {
const route = this.getRoute('i18n');
const res = await this.fetch(`${this.baseUrl}${route}/labels/${encodeURIComponent(object)}?locale=${encodeURIComponent(locale)}`);
const res = await this.fetch(
`${this.baseUrl}${route}/labels/${encodeURIComponent(object)}/${encodeURIComponent(locale)}`,
);
return this.unwrapResponse<GetFieldLabelsResponse>(res);
}
};
Expand Down
90 changes: 90 additions & 0 deletions packages/client/src/service-route-ledger-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Service route-ledger ↔ client-surface conformance (#3636) — the client half
* of the guards whose server halves live next to the registrars that mount the
* routes:
*
* - `packages/services/service-storage/src/storage-route-ledger.conformance.test.ts`
* - `packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts`
*
* Same contract as the two ledger guards next door
* (`route-ledger-coverage.test.ts`, `rest-route-ledger-coverage.test.ts`):
* every ledger entry that names a client method must resolve to a real
* function on an instantiated client.
*
* Both ledgers are imported as relative SOURCE files deliberately: they are
* pure data (no imports), and a client→service package edge for them would be
* backwards — the services are where the routes are declared, so the ledgers
* live there, and each package verifies its own half. That is the tranche-1
* lesson (`no runtime→client package edge`) applied verbatim: CI's per-package
* test tasks build only their own dependency closure, so a real package edge
* here would be unbuildable.
*
* With these two, all THREE server surfaces the SDK reaches are ledgered —
* dispatcher (#3563), REST (#3587), autonomous service mounts (#3636).
*/

import { describe, it, expect } from 'vitest';
import { ObjectStackClient } from './index';
import { STORAGE_ROUTE_LEDGER } from '../../services/service-storage/src/storage-route-ledger';
import { I18N_ROUTE_LEDGER } from '../../services/service-i18n/src/i18n-route-ledger';

describe('service route ledgers ↔ @objectstack/client surface', () => {
const client = new ObjectStackClient({ baseUrl: 'http://localhost:9' });

const resolve = (path: string): unknown =>
path.split('.').reduce<unknown>((o, k) => (o == null ? o : (o as Record<string, unknown>)[k]), client);

const brokenIn = (ledger: readonly { route: string; client?: string }[]): string[] =>
ledger
.filter((e) => e.client != null)
.filter((e) => typeof resolve(e.client!) !== 'function')
.map((e) => `${e.route} → client.${e.client}`);

it('every storage ledger entry naming a client method resolves to a real function', () => {
const broken = brokenIn(STORAGE_ROUTE_LEDGER);
expect(
broken,
`storage ledger entries claiming a client method that does not exist: ${broken.join('; ')}`,
).toEqual([]);
});

it('every i18n ledger entry naming a client method resolves to a real function', () => {
const broken = brokenIn(I18N_ROUTE_LEDGER);
expect(
broken,
`i18n ledger entries claiming a client method that does not exist: ${broken.join('; ')}`,
).toEqual([]);
});

it('the whole `storage` namespace is backed by a ledger row', () => {
// The reverse direction, scoped to the namespace this tranche audited:
// a client method reaching a storage route that no ledger row backs is
// the pre-#3563 posture (expressed, working, unguarded) reappearing.
const claimed = new Set(
STORAGE_ROUTE_LEDGER.map((e) => e.client).filter((c): c is string => c != null),
);
const unbacked = Object.keys((client as unknown as { storage: object }).storage)
.map((m) => `storage.${m}`)
.filter((m) => !claimed.has(m));
expect(
unbacked,
`client.storage methods no storage-route-ledger row backs: ${unbacked.join(', ')}. ` +
'Either the route is unledgered or the method targets a surface that does not exist (#3636).',
).toEqual([]);
});

it('the whole `i18n` namespace is backed by a ledger row', () => {
const claimed = new Set(
I18N_ROUTE_LEDGER.map((e) => e.client).filter((c): c is string => c != null),
);
const unbacked = Object.keys((client as unknown as { i18n: object }).i18n)
.map((m) => `i18n.${m}`)
.filter((m) => !claimed.has(m));
expect(
unbacked,
`client.i18n methods no i18n-route-ledger row backs: ${unbacked.join(', ')}.`,
).toEqual([]);
});
});
Loading
Loading