Skip to content

Commit f696c3a

Browse files
committed
fix(client,runtime): reconcile the four dispatcher↔client shape mismatches (#3584)
The #3563 audit ledgered four routes as 'mismatch'. Re-verification showed the two analytics shapes the client spoke (GET /analytics/meta/:cube, POST /analytics/explain) were served by NOTHING — not the dispatcher, not @objectstack/rest, not service-analytics — so both methods 404ed against every deployment and had zero call sites. Split resolution: - analytics ×2 — client aligns to the dispatcher (not breaking in practice: a universally-404ing method has no working callers). analytics.meta(cube?) now calls GET /analytics/meta with an optional ?cube= filter, threaded through the dispatcher into AnalyticsService.getMeta(cubeName?) which always supported it; analytics.explain keeps its name and calls POST /analytics/sql. Ledger rows reclassified mismatch → sdk. - storage ×2 — deliberate disposition, no code moves. The presigned/chunked protocol the SDK speaks is registered autonomously by service-storage on any http-server (not REST-only as the audit assumed); reshaping the client to the bare dispatcher route would regress direct-to-cloud, resumable uploads, upload auth (#2755) and download authorization (#2970/ADR-0104). Ledger rows reclassified mismatch → server-only with the rationale; decision recorded in the audit doc §4 Resolution. Guards stay green: URL-shape tests pin the reconciled client routes, a dispatcher test pins the ?cube= threading, and the ledger hygiene tests (notes on every non-sdk row) pass unchanged. Closes #3584 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uij3WXdY7d4882DxMeaAAJ
1 parent 0164f40 commit f696c3a

9 files changed

Lines changed: 146 additions & 19 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/client": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(client,runtime): analytics.meta/explain now call routes that actually exist (#3584)
7+
8+
The route audit (#3563) ledgered four dispatcher↔client shape mismatches.
9+
Re-verification showed the two analytics shapes the client spoke —
10+
`GET /analytics/meta/:cube` and `POST /analytics/explain` — were served by
11+
**nothing**: not the dispatcher, not `@objectstack/rest`, not
12+
`service-analytics`. Both methods 404ed against every deployment.
13+
14+
- `analytics.meta(cube?)` — FROM `GET /analytics/meta/:cube` TO
15+
`GET /analytics/meta[?cube=<name>]`. The cube argument is now optional; when
16+
given, the dispatcher threads it into `AnalyticsService.getMeta(cubeName?)`,
17+
which always supported the filter. Responses now use the dispatcher envelope
18+
(`{ success, data }`).
19+
- `analytics.explain(payload)` — FROM `POST /analytics/explain` TO
20+
`POST /analytics/sql` (the dispatcher's SQL dry-run route, backed by
21+
`generateSql`). Method name unchanged.
22+
23+
No migration is expected in practice: a method that unconditionally 404ed can
24+
have no working callers (none exist in objectstack or objectui). Anyone who
25+
had hand-rolled fetches against the imaginary shapes should switch to the
26+
routes above.
27+
28+
The two storage rows from the same audit are deliberately NOT reshaped: the
29+
presigned/chunked protocol the SDK speaks is registered autonomously by
30+
`service-storage` on any http-server and stays canonical; the dispatcher's
31+
bare `POST /storage/upload` / `GET /storage/file/:id` are reclassified in the
32+
route ledger as a `server-only` low-level compat surface.

content/docs/api/client-sdk.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,10 @@ const result = await client.analytics.query({
243243
limit: 100,
244244
});
245245

246-
// Get cube metadata for a specific cube
246+
// Get cube metadata — all cubes, or one with meta('account')
247247
const meta = await client.analytics.meta('account');
248248

249-
// Explain a query plan
249+
// Dry-run a query to its generated SQL (POST /analytics/sql)
250250
const explained = await client.analytics.explain({
251251
cube: 'account',
252252
measures: ['revenue.sum'],

docs/audits/2026-07-dispatcher-client-route-coverage.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,33 @@ These are ledgered as `mismatch`, not `sdk`: the method exists but does not
8484
speak the dispatcher's dialect. Reconciliation (pick one shape, alias the
8585
other) is its own follow-up.
8686

87+
### Resolution (#3584) — split decision, not one letter for all four
88+
89+
Re-verification for #3584 corrected one premise of this table: the two
90+
analytics shapes the client spoke (`GET /analytics/meta/:cube`,
91+
`POST /analytics/explain`) were served by **nothing** — not the dispatcher,
92+
not `@objectstack/rest` (which only mounts `/analytics/dataset/query`), not
93+
`service-analytics` (which registers no HTTP routes). "Only REST understands"
94+
was wrong; those two client methods 404ed against every server in the repo and
95+
had zero call sites in `objectstack` and `objectui`.
96+
97+
- **Analytics ×2 → client aligned to the dispatcher** (the "Option B"
98+
direction, but not breaking in practice — a universally-404ing method has no
99+
working consumers). `analytics.meta(cube?)` now calls `GET /analytics/meta`
100+
with an optional `?cube=` filter (honored server-side —
101+
`AnalyticsService.getMeta(cubeName?)` always supported it; the dispatcher
102+
now threads it through). `analytics.explain(payload)` keeps its name and
103+
calls `POST /analytics/sql`. Both ledger rows are now `sdk`.
104+
- **Storage ×2 → documented deliberate disposition** (`server-only`). The
105+
presigned/chunked protocol is **not** REST-only: `service-storage` registers
106+
it autonomously on any `http-server` service
107+
(`storage-service-plugin.ts:283`, `storage-routes.ts`). Rewriting the client
108+
to the dispatcher's bare `POST /storage/upload` would regress
109+
direct-to-cloud upload, chunked/resumable transfer, upload auth (#2755) and
110+
download authorization (#2970 / ADR-0104). The protocol is canonical; the
111+
dispatcher's two plain routes remain a low-level redirect/stream compat
112+
surface, deliberately outside the SDK.
113+
87114
## 5. Client-internal findings
88115

89116
- **`trigger` vs `execute`** hit different URLs for the same intent
@@ -136,7 +163,7 @@ three-column method is the next tranche of #3563.
136163
2. **`keys` / `share-links` / `security`** surfaces — 7 gaps, small and mechanical.
137164
3. **Packages lifecycle** (11 gaps) — publish/drafts/commits/revert/export.
138165
4. **Meta drafts/published/FSM + automation descriptors** (6 gaps).
139-
5. **Mismatch reconciliation** (§4) — server-side aliases or client-side fixes.
166+
5. **Mismatch reconciliation** (§4) — done in #3584: analytics client aligned to the dispatcher, storage protocol documented as canonical (see §4 Resolution).
140167
6. **Docs**: delete or regenerate README surface table + CLIENT_SPEC_COMPLIANCE.md; extend client-sdk.mdx.
141168
7. **Deprecate `DEFAULT_DISPATCHER_ROUTES`**; point at the ledger.
142169
8. **REST-surface tranche** (§8) with the same ledger+guard treatment.

packages/client/src/client.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,3 +1404,36 @@ describe('data.batchTransaction', () => {
14041404
expect(body.atomic).toBe(true);
14051405
});
14061406
});
1407+
1408+
// [#3584] The analytics surface speaks the DISPATCHER dialect. The previous
1409+
// shapes (`GET /analytics/meta/:cube`, `POST /analytics/explain`) were served
1410+
// by nothing — not the dispatcher, not @objectstack/rest — and 404ed against
1411+
// every deployment. These pin the reconciled URLs so they cannot drift back.
1412+
describe('Analytics namespace (#3584 dispatcher alignment)', () => {
1413+
it('meta() lists all cubes via GET /analytics/meta', async () => {
1414+
const { client, fetchMock } = createMockClient({ data: [] });
1415+
await client.analytics.meta();
1416+
expect(fetchMock).toHaveBeenCalledWith(
1417+
'http://localhost:3000/api/v1/analytics/meta',
1418+
expect.anything(),
1419+
);
1420+
});
1421+
1422+
it('meta(cube) filters with ?cube=, URL-encoded', async () => {
1423+
const { client, fetchMock } = createMockClient({ data: [] });
1424+
await client.analytics.meta('sales leads');
1425+
expect(fetchMock).toHaveBeenCalledWith(
1426+
'http://localhost:3000/api/v1/analytics/meta?cube=sales%20leads',
1427+
expect.anything(),
1428+
);
1429+
});
1430+
1431+
it('explain() dry-runs via POST /analytics/sql', async () => {
1432+
const { client, fetchMock } = createMockClient({ data: { sql: 'SELECT 1', params: [] } });
1433+
await client.analytics.explain({ cube: 'leads' });
1434+
expect(fetchMock).toHaveBeenCalledWith(
1435+
'http://localhost:3000/api/v1/analytics/sql',
1436+
expect.objectContaining({ method: 'POST', body: JSON.stringify({ cube: 'leads' }) }),
1437+
);
1438+
});
1439+
});

packages/client/src/index.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -580,14 +580,25 @@ export class ObjectStackClient {
580580
});
581581
return res.json();
582582
},
583-
meta: async (cube: string) => {
583+
/**
584+
* Cube metadata listing. Pass `cube` to filter to a single cube
585+
* (`?cube=` — [#3584] the dispatcher shape; the old `/meta/:cube` path
586+
* segment was served by nothing and 404ed everywhere).
587+
*/
588+
meta: async (cube?: string) => {
584589
const route = this.getRoute('analytics');
585-
const res = await this.fetch(`${this.baseUrl}${route}/meta/${cube}`);
590+
const qs = cube ? `?cube=${encodeURIComponent(cube)}` : '';
591+
const res = await this.fetch(`${this.baseUrl}${route}/meta${qs}`);
586592
return res.json();
587593
},
594+
/**
595+
* Dry-run a query to its generated SQL (`POST /analytics/sql` — [#3584]
596+
* the dispatcher route; the old `/explain` route name was served by
597+
* nothing and 404ed everywhere).
598+
*/
588599
explain: async (payload: any) => {
589600
const route = this.getRoute('analytics');
590-
const res = await this.fetch(`${this.baseUrl}${route}/explain`, {
601+
const res = await this.fetch(`${this.baseUrl}${route}/sql`, {
591602
method: 'POST',
592603
body: JSON.stringify(payload)
593604
});

packages/runtime/src/domains/analytics.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function createAnalyticsDomain(deps: DomainHandlerDeps): DomainRoute {
1616
return {
1717
prefix: '/analytics',
1818
handler: (req, context) =>
19-
handleAnalyticsRequest(deps, req.path.substring(10), req.method, req.body, context),
19+
handleAnalyticsRequest(deps, req.path.substring(10), req.method, req.body, context, req.query),
2020
};
2121
}
2222

@@ -27,6 +27,7 @@ export async function handleAnalyticsRequest(
2727
method: string,
2828
body: any,
2929
context: HttpProtocolContext,
30+
query?: any,
3031
): Promise<HttpDispatcherResult> {
3132
const analyticsService = await deps.getService(CoreServiceName.enum.analytics);
3233
if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
@@ -45,9 +46,13 @@ export async function handleAnalyticsRequest(
4546
return { handled: true, response: deps.success(result) };
4647
}
4748

48-
// GET /analytics/meta
49+
// GET /analytics/meta[?cube=<name>]
4950
if (subPath === 'meta' && m === 'GET') {
50-
const result = await analyticsService.getMeta();
51+
// [#3584] Optional single-cube filter. `AnalyticsService.getMeta`
52+
// already accepts `cubeName?`; degraded fallbacks that ignore the
53+
// argument keep returning the full listing, which is still correct.
54+
const cube = typeof query?.cube === 'string' && query.cube !== '' ? query.cube : undefined;
55+
const result = await analyticsService.getMeta(cube);
5156
return { handled: true, response: deps.success(result) };
5257
}
5358

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,25 @@ describe('HttpDispatcher', () => {
504504
expect(result.response?.body?.data?.tables).toEqual(['users', 'orders']);
505505
});
506506

507+
// [#3584] GET /analytics/meta?cube=<name> — the optional single-cube
508+
// filter the client's analytics.meta(cube) sends. The cube name must
509+
// reach AnalyticsService.getMeta(cubeName?); absent/empty stays a
510+
// full listing (getMeta called with undefined).
511+
it('threads the ?cube= filter into getMeta, full dispatch path included', async () => {
512+
const mockAnalytics = {
513+
getMeta: vi.fn().mockResolvedValue([{ name: 'leads' }]),
514+
};
515+
(kernel as any).getService = vi.fn().mockResolvedValue(mockAnalytics);
516+
517+
const viaDispatch = await dispatcher.dispatch('GET', '/analytics/meta', undefined, { cube: 'leads' }, { request: {} });
518+
expect(viaDispatch.handled).toBe(true);
519+
expect(mockAnalytics.getMeta).toHaveBeenCalledWith('leads');
520+
521+
mockAnalytics.getMeta.mockClear();
522+
await dispatcher.handleAnalytics('meta', 'GET', undefined, { request: {} }, { cube: '' });
523+
expect(mockAnalytics.getMeta).toHaveBeenCalledWith(undefined);
524+
});
525+
507526
it('should return unhandled when analytics service is not registered', async () => {
508527
(kernel as any).getService = vi.fn().mockResolvedValue(null);
509528
(kernel as any).services = new Map();

packages/runtime/src/http-dispatcher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -897,8 +897,8 @@ export class HttpDispatcher {
897897
* path: sub-path after /analytics/
898898
*/
899899
/** Thin delegate — body extracted to `./domains/analytics.ts` (D11③ PR-2). */
900-
async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
901-
return handleAnalyticsRequest(this.domainDeps, path, method, body, context);
900+
async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise<HttpDispatcherResult> {
901+
return handleAnalyticsRequest(this.domainDeps, path, method, body, context, query);
902902
}
903903

904904
/**

packages/runtime/src/route-ledger.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
8080

8181
// ── analytics ─────────────────────────────────────────────────────────────
8282
{ route: 'POST /analytics/query', domain: '/analytics', disposition: 'sdk', client: 'analytics.query' },
83-
{ route: 'GET /analytics/meta', domain: '/analytics', disposition: 'mismatch', client: 'analytics.meta',
84-
note: 'dispatcher serves GET /analytics/meta; client calls GET /analytics/meta/:cube — extra path segment only the REST server understands' },
85-
{ route: 'POST /analytics/sql', domain: '/analytics', disposition: 'mismatch', client: 'analytics.explain',
86-
note: 'dispatcher serves POST /analytics/sql; client calls POST /analytics/explain — different route name entirely' },
83+
{ route: 'GET /analytics/meta', domain: '/analytics', disposition: 'sdk', client: 'analytics.meta',
84+
note: 'optional ?cube= filter honored server-side; was mismatch — the client called /meta/:cube, a shape no server ever mounted (#3584)' },
85+
{ route: 'POST /analytics/sql', domain: '/analytics', disposition: 'sdk', client: 'analytics.explain',
86+
note: 'client keeps the explain name but calls /sql; the /explain route it used to call was served by nothing (#3584)' },
8787

8888
// ── i18n ──────────────────────────────────────────────────────────────────
8989
{ route: 'GET /i18n/locales', domain: '/i18n', disposition: 'sdk', client: 'i18n.getLocales' },
@@ -105,10 +105,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
105105
note: 'raw secret returned once; user_id pinned server-side' },
106106

107107
// ── storage ───────────────────────────────────────────────────────────────
108-
{ route: 'POST /storage/upload', domain: '/storage', disposition: 'mismatch', client: 'storage.upload',
109-
note: 'dispatcher serves plain POST /storage/upload; client speaks the REST presigned/chunked protocol (/upload/presigned, /upload/complete, /upload/chunked/*)' },
110-
{ route: 'GET /storage/file/:id', domain: '/storage', disposition: 'mismatch', client: 'storage.getDownloadUrl',
111-
note: 'dispatcher serves /storage/file/:id; client calls /storage/files/:id/url (REST shape)' },
108+
{ route: 'POST /storage/upload', domain: '/storage', disposition: 'server-only',
109+
note: 'low-level compat surface; the SDK deliberately speaks the storage protocol (/upload/presigned, /upload/complete, /upload/chunked/*) that service-storage registers autonomously on any http-server — direct-to-cloud, chunked/resumable, auth-gated (#3584)' },
110+
{ route: 'GET /storage/file/:id', domain: '/storage', disposition: 'server-only',
111+
note: 'ditto — the SDK downloads via /storage/files/:id/url from the service-storage protocol (authorization-gated signed URLs); this route stays as the low-level redirect/stream path (#3584)' },
112112

113113
// ── ui ────────────────────────────────────────────────────────────────────
114114
{ route: 'GET /ui/view/:object/:type?', domain: '/ui', disposition: 'sdk', client: 'meta.getView',

0 commit comments

Comments
 (0)