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
32 changes: 32 additions & 0 deletions .changeset/analytics-client-dispatcher-alignment.md
Original file line number Diff line number Diff line change
@@ -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=<name>]`. 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.
4 changes: 2 additions & 2 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
3 changes: 3 additions & 0 deletions content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name>` 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"`).
Expand Down
29 changes: 28 additions & 1 deletion docs/audits/2026-07-dispatcher-client-route-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }) }),
);
});
});
17 changes: 14 additions & 3 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});
Expand Down
11 changes: 8 additions & 3 deletions packages/runtime/src/domains/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}

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

// GET /analytics/meta
// GET /analytics/meta[?cube=<name>]
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) };
}

Expand Down
19 changes: 19 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,25 @@ describe('HttpDispatcher', () => {
expect(result.response?.body?.data?.tables).toEqual(['users', 'orders']);
});

// [#3584] GET /analytics/meta?cube=<name> — 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();
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpDispatcherResult> {
return handleAnalyticsRequest(this.domainDeps, path, method, body, context);
async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise<HttpDispatcherResult> {
return handleAnalyticsRequest(this.domainDeps, path, method, body, context, query);
}

/**
Expand Down
16 changes: 8 additions & 8 deletions packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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',
Expand Down