From f696c3a3fef01cdf79383c4d32a134a01a7bd49a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:42:34 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(client,runtime):=20reconcile=20the=20fo?= =?UTF-8?q?ur=20dispatcher=E2=86=94client=20shape=20mismatches=20(#3584)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Uij3WXdY7d4882DxMeaAAJ --- .../analytics-client-dispatcher-alignment.md | 32 ++++++++++++++++++ content/docs/api/client-sdk.mdx | 4 +-- ...026-07-dispatcher-client-route-coverage.md | 29 +++++++++++++++- packages/client/src/client.test.ts | 33 +++++++++++++++++++ packages/client/src/index.ts | 17 ++++++++-- packages/runtime/src/domains/analytics.ts | 11 +++++-- packages/runtime/src/http-dispatcher.test.ts | 19 +++++++++++ packages/runtime/src/http-dispatcher.ts | 4 +-- packages/runtime/src/route-ledger.ts | 16 ++++----- 9 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 .changeset/analytics-client-dispatcher-alignment.md diff --git a/.changeset/analytics-client-dispatcher-alignment.md b/.changeset/analytics-client-dispatcher-alignment.md new file mode 100644 index 0000000000..589391da94 --- /dev/null +++ b/.changeset/analytics-client-dispatcher-alignment.md @@ -0,0 +1,32 @@ +--- +"@objectstack/client": patch +"@objectstack/runtime": patch +--- + +fix(client,runtime): analytics.meta/explain now call routes that actually exist (#3584) + +The route audit (#3563) ledgered four dispatcher↔client shape mismatches. +Re-verification showed the two analytics shapes the client spoke — +`GET /analytics/meta/:cube` and `POST /analytics/explain` — were served by +**nothing**: not the dispatcher, not `@objectstack/rest`, not +`service-analytics`. Both methods 404ed against every deployment. + +- `analytics.meta(cube?)` — FROM `GET /analytics/meta/:cube` TO + `GET /analytics/meta[?cube=]`. The cube argument is now optional; when + given, the dispatcher threads it into `AnalyticsService.getMeta(cubeName?)`, + which always supported the filter. Responses now use the dispatcher envelope + (`{ success, data }`). +- `analytics.explain(payload)` — FROM `POST /analytics/explain` TO + `POST /analytics/sql` (the dispatcher's SQL dry-run route, backed by + `generateSql`). Method name unchanged. + +No migration is expected in practice: a method that unconditionally 404ed can +have no working callers (none exist in objectstack or objectui). Anyone who +had hand-rolled fetches against the imaginary shapes should switch to the +routes above. + +The two storage rows from the same audit are deliberately NOT reshaped: the +presigned/chunked protocol the SDK speaks is registered autonomously by +`service-storage` on any http-server and stays canonical; the dispatcher's +bare `POST /storage/upload` / `GET /storage/file/:id` are reclassified in the +route ledger as a `server-only` low-level compat surface. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index f362b20f98..3388d02dff 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -243,10 +243,10 @@ const result = await client.analytics.query({ limit: 100, }); -// Get cube metadata for a specific cube +// Get cube metadata — all cubes, or one with meta('account') const meta = await client.analytics.meta('account'); -// Explain a query plan +// Dry-run a query to its generated SQL (POST /analytics/sql) const explained = await client.analytics.explain({ cube: 'account', measures: ['revenue.sum'], diff --git a/docs/audits/2026-07-dispatcher-client-route-coverage.md b/docs/audits/2026-07-dispatcher-client-route-coverage.md index f0086673bb..4921a8f357 100644 --- a/docs/audits/2026-07-dispatcher-client-route-coverage.md +++ b/docs/audits/2026-07-dispatcher-client-route-coverage.md @@ -84,6 +84,33 @@ These are ledgered as `mismatch`, not `sdk`: the method exists but does not speak the dispatcher's dialect. Reconciliation (pick one shape, alias the other) is its own follow-up. +### Resolution (#3584) — split decision, not one letter for all four + +Re-verification for #3584 corrected one premise of this table: the two +analytics shapes the client spoke (`GET /analytics/meta/:cube`, +`POST /analytics/explain`) were served by **nothing** — not the dispatcher, +not `@objectstack/rest` (which only mounts `/analytics/dataset/query`), not +`service-analytics` (which registers no HTTP routes). "Only REST understands" +was wrong; those two client methods 404ed against every server in the repo and +had zero call sites in `objectstack` and `objectui`. + +- **Analytics ×2 → client aligned to the dispatcher** (the "Option B" + direction, but not breaking in practice — a universally-404ing method has no + working consumers). `analytics.meta(cube?)` now calls `GET /analytics/meta` + with an optional `?cube=` filter (honored server-side — + `AnalyticsService.getMeta(cubeName?)` always supported it; the dispatcher + now threads it through). `analytics.explain(payload)` keeps its name and + calls `POST /analytics/sql`. Both ledger rows are now `sdk`. +- **Storage ×2 → documented deliberate disposition** (`server-only`). The + presigned/chunked protocol is **not** REST-only: `service-storage` registers + it autonomously on any `http-server` service + (`storage-service-plugin.ts:283`, `storage-routes.ts`). Rewriting the client + to the dispatcher's bare `POST /storage/upload` would regress + direct-to-cloud upload, chunked/resumable transfer, upload auth (#2755) and + download authorization (#2970 / ADR-0104). The protocol is canonical; the + dispatcher's two plain routes remain a low-level redirect/stream compat + surface, deliberately outside the SDK. + ## 5. Client-internal findings - **`trigger` vs `execute`** hit different URLs for the same intent @@ -136,7 +163,7 @@ three-column method is the next tranche of #3563. 2. **`keys` / `share-links` / `security`** surfaces — 7 gaps, small and mechanical. 3. **Packages lifecycle** (11 gaps) — publish/drafts/commits/revert/export. 4. **Meta drafts/published/FSM + automation descriptors** (6 gaps). -5. **Mismatch reconciliation** (§4) — server-side aliases or client-side fixes. +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. diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 5e5f8e6f01..6f69a05d4c 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1404,3 +1404,36 @@ describe('data.batchTransaction', () => { expect(body.atomic).toBe(true); }); }); + +// [#3584] The analytics surface speaks the DISPATCHER dialect. The previous +// shapes (`GET /analytics/meta/:cube`, `POST /analytics/explain`) were served +// by nothing — not the dispatcher, not @objectstack/rest — and 404ed against +// every deployment. These pin the reconciled URLs so they cannot drift back. +describe('Analytics namespace (#3584 dispatcher alignment)', () => { + it('meta() lists all cubes via GET /analytics/meta', async () => { + const { client, fetchMock } = createMockClient({ data: [] }); + await client.analytics.meta(); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/analytics/meta', + expect.anything(), + ); + }); + + it('meta(cube) filters with ?cube=, URL-encoded', async () => { + const { client, fetchMock } = createMockClient({ data: [] }); + await client.analytics.meta('sales leads'); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/analytics/meta?cube=sales%20leads', + expect.anything(), + ); + }); + + it('explain() dry-runs via POST /analytics/sql', async () => { + const { client, fetchMock } = createMockClient({ data: { sql: 'SELECT 1', params: [] } }); + await client.analytics.explain({ cube: 'leads' }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/analytics/sql', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ cube: 'leads' }) }), + ); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 73a0628127..7c76259959 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -580,14 +580,25 @@ export class ObjectStackClient { }); return res.json(); }, - meta: async (cube: string) => { + /** + * Cube metadata listing. Pass `cube` to filter to a single cube + * (`?cube=` — [#3584] the dispatcher shape; the old `/meta/:cube` path + * segment was served by nothing and 404ed everywhere). + */ + meta: async (cube?: string) => { const route = this.getRoute('analytics'); - const res = await this.fetch(`${this.baseUrl}${route}/meta/${cube}`); + const qs = cube ? `?cube=${encodeURIComponent(cube)}` : ''; + const res = await this.fetch(`${this.baseUrl}${route}/meta${qs}`); return res.json(); }, + /** + * Dry-run a query to its generated SQL (`POST /analytics/sql` — [#3584] + * the dispatcher route; the old `/explain` route name was served by + * nothing and 404ed everywhere). + */ explain: async (payload: any) => { const route = this.getRoute('analytics'); - const res = await this.fetch(`${this.baseUrl}${route}/explain`, { + const res = await this.fetch(`${this.baseUrl}${route}/sql`, { method: 'POST', body: JSON.stringify(payload) }); diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index d728abaa64..a1a1b3f37d 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -16,7 +16,7 @@ export function createAnalyticsDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/analytics', handler: (req, context) => - handleAnalyticsRequest(deps, req.path.substring(10), req.method, req.body, context), + handleAnalyticsRequest(deps, req.path.substring(10), req.method, req.body, context, req.query), }; } @@ -27,6 +27,7 @@ export async function handleAnalyticsRequest( method: string, body: any, context: HttpProtocolContext, + query?: any, ): Promise { const analyticsService = await deps.getService(CoreServiceName.enum.analytics); if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled @@ -45,9 +46,13 @@ export async function handleAnalyticsRequest( return { handled: true, response: deps.success(result) }; } - // GET /analytics/meta + // GET /analytics/meta[?cube=] if (subPath === 'meta' && m === 'GET') { - const result = await analyticsService.getMeta(); + // [#3584] Optional single-cube filter. `AnalyticsService.getMeta` + // already accepts `cubeName?`; degraded fallbacks that ignore the + // argument keep returning the full listing, which is still correct. + const cube = typeof query?.cube === 'string' && query.cube !== '' ? query.cube : undefined; + const result = await analyticsService.getMeta(cube); return { handled: true, response: deps.success(result) }; } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index a7921a01d0..6634547ad4 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -504,6 +504,25 @@ describe('HttpDispatcher', () => { expect(result.response?.body?.data?.tables).toEqual(['users', 'orders']); }); + // [#3584] GET /analytics/meta?cube= — the optional single-cube + // filter the client's analytics.meta(cube) sends. The cube name must + // reach AnalyticsService.getMeta(cubeName?); absent/empty stays a + // full listing (getMeta called with undefined). + it('threads the ?cube= filter into getMeta, full dispatch path included', async () => { + const mockAnalytics = { + getMeta: vi.fn().mockResolvedValue([{ name: 'leads' }]), + }; + (kernel as any).getService = vi.fn().mockResolvedValue(mockAnalytics); + + const viaDispatch = await dispatcher.dispatch('GET', '/analytics/meta', undefined, { cube: 'leads' }, { request: {} }); + expect(viaDispatch.handled).toBe(true); + expect(mockAnalytics.getMeta).toHaveBeenCalledWith('leads'); + + mockAnalytics.getMeta.mockClear(); + await dispatcher.handleAnalytics('meta', 'GET', undefined, { request: {} }, { cube: '' }); + expect(mockAnalytics.getMeta).toHaveBeenCalledWith(undefined); + }); + it('should return unhandled when analytics service is not registered', async () => { (kernel as any).getService = vi.fn().mockResolvedValue(null); (kernel as any).services = new Map(); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 05fa6c3871..b8ba9c2e25 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -897,8 +897,8 @@ export class HttpDispatcher { * path: sub-path after /analytics/ */ /** Thin delegate — body extracted to `./domains/analytics.ts` (D11③ PR-2). */ - async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise { - return handleAnalyticsRequest(this.domainDeps, path, method, body, context); + async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise { + return handleAnalyticsRequest(this.domainDeps, path, method, body, context, query); } /** diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index d9673f91ff..a6f56ee106 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -80,10 +80,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // ── analytics ───────────────────────────────────────────────────────────── { route: 'POST /analytics/query', domain: '/analytics', disposition: 'sdk', client: 'analytics.query' }, - { route: 'GET /analytics/meta', domain: '/analytics', disposition: 'mismatch', client: 'analytics.meta', - note: 'dispatcher serves GET /analytics/meta; client calls GET /analytics/meta/:cube — extra path segment only the REST server understands' }, - { route: 'POST /analytics/sql', domain: '/analytics', disposition: 'mismatch', client: 'analytics.explain', - note: 'dispatcher serves POST /analytics/sql; client calls POST /analytics/explain — different route name entirely' }, + { route: 'GET /analytics/meta', domain: '/analytics', disposition: 'sdk', client: 'analytics.meta', + note: 'optional ?cube= filter honored server-side; was mismatch — the client called /meta/:cube, a shape no server ever mounted (#3584)' }, + { route: 'POST /analytics/sql', domain: '/analytics', disposition: 'sdk', client: 'analytics.explain', + note: 'client keeps the explain name but calls /sql; the /explain route it used to call was served by nothing (#3584)' }, // ── i18n ────────────────────────────────────────────────────────────────── { route: 'GET /i18n/locales', domain: '/i18n', disposition: 'sdk', client: 'i18n.getLocales' }, @@ -105,10 +105,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: 'raw secret returned once; user_id pinned server-side' }, // ── storage ─────────────────────────────────────────────────────────────── - { route: 'POST /storage/upload', domain: '/storage', disposition: 'mismatch', client: 'storage.upload', - note: 'dispatcher serves plain POST /storage/upload; client speaks the REST presigned/chunked protocol (/upload/presigned, /upload/complete, /upload/chunked/*)' }, - { route: 'GET /storage/file/:id', domain: '/storage', disposition: 'mismatch', client: 'storage.getDownloadUrl', - note: 'dispatcher serves /storage/file/:id; client calls /storage/files/:id/url (REST shape)' }, + { route: 'POST /storage/upload', domain: '/storage', disposition: 'server-only', + 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)' }, + { route: 'GET /storage/file/:id', domain: '/storage', disposition: 'server-only', + 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)' }, // ── ui ──────────────────────────────────────────────────────────────────── { route: 'GET /ui/view/:object/:type?', domain: '/ui', disposition: 'sdk', client: 'meta.getView', From 4f6c46bf3a43f218d3755bb9d261d631dcd74e3b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:44:08 +0000 Subject: [PATCH 2/2] docs(api): document the ?cube= filter on GET /analytics/meta (#3584) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uij3WXdY7d4882DxMeaAAJ --- content/docs/api/data-api.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 9cee9750d0..bab42967d3 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -197,6 +197,9 @@ or the analytics service's `cubes` config) — a cube referenced by a query that yet registered is lazily auto-inferred from that query's shape, but metadata isn't proactively generated for every object. +Pass `?cube=` to filter the listing to a single cube (this is what +`client.analytics.meta(cube)` sends). + **Response**: `{ success: true, data: [...] }` where `data` is an array of cube definitions with measures and dimensions (time-based dimensions are `dimensions` entries with `type: "time"`).