Skip to content

Commit 70ce802

Browse files
fix(service-analytics): queryDataset compiles into a request scope and never writes the shared registries (#20380)
Fixes #20356 Clause-②: no ## What this changes `AnalyticsService.queryDataset` no longer writes the service-wide `CubeRegistry` or the compiled-dataset registry. It used to register the dataset's compiled cube under the dataset's name before running the selection and before any admission was asked, so that name then meant one request's definition for every later reader until restart. - **Pure compile.** A new private `compile(dataset)` binds this service's probes and registers nothing. `registerDataset` (the configuration door: `AnalyticsServiceConfig.datasets` and embedders) is `compile` + register, unchanged in behaviour. `queryDataset` calls only `compile`. - **A request scope for every name-keyed read.** A small internal `CubeScope` (`getCube`, `getCompiledDataset`, `register`) is threaded through the query path. The shared scope reads and writes the registries. A `queryDataset` call gets a request scope: its own compiled dataset answers its name, every other name reads the shared scope read-only, and `register` writes only to the request scope. - **One body for both.** `query()` is now `queryIn(sharedScope, …)`, and the `DatasetExecutor` of a dataset call queries through a face whose `query()` is `queryIn(requestScope, …)`. Every gate is the same code; only the answer to "which cube does this name mean" differs. - Comments that described `queryDataset` as a registration door are corrected in `cube-registry.ts` and `dataset-compiler.ts`. No new refusal is added. A dataset whose name matches a configured cube is served from its own definition and no longer meets that cube (triage note 3). ## Mechanism assumptions, measured 1. **Other registry writes.** `registerDataset` was not the only request-time write reachable from `queryDataset`. `ensureCube`'s measure augmentation also registered into the shared registry, through `DatasetExecutor` → `query()`, when a selection named an undeclared suffix measure. It now writes into the request scope, which is pinned. The same two `ensureCube` writes (inference and augmentation) remain request-time shared writes on the `/analytics/query` and `/analytics/sql` doors. They are not the dataset door, and they are reported, not changed (see Acceptance notes). The boot-time writes are unchanged: `config.cubes` and `config.datasets` in the constructor. 2. **Name-keyed reads on the query path.** Each of these reads the compiled cube by name, and each now resolves through the call's scope: - `ensureCube`'s existence and source-field gates; - `queryObjects`, which is both the object-level admission set and the read-scope set; - the strategy context's `getCube` (both strategies' `canHandle`, `execute` and `generateSql`); - `getAllowedRelationships` (the NativeSQL join allowlist) and `getDatasetScope` (both strategies), which read the compiled-dataset registry. ObjectQL's `resolveFkAttr` reads no registry, and `queryCapabilities(cube)` is a config hook. `DatasetExecutor` itself reads `compiled.cube` directly, but it issues `query({ cube: name })`, which is why it needs the scoped face. 3. **Doors.** `POST /api/v1/analytics/dataset/query` (`rest-server.ts`) calls `queryDataset`. `GET /api/v1/analytics/meta` (`runtime/src/domains/analytics.ts`) calls `getMeta()`, which reads `cubeRegistry.getAll()`, the shared registry. 4. **Hidden cube.** On `main` nothing reads `Cube.public`: three producers write it and no reader exists in `service-analytics`, `runtime` or `rest`. So "stays hidden from meta" is **NOT MEASURABLE on this tree**. What is pinned is that the registry entry keeps the author's `public: false` definition (unit test) and that member B's whole `meta` answer equals B's baseline (route test). Once `analytics_cube.public` enforcement lands, that same equality asserts that the cube stays omitted. ## Tests (all on `15e21b98`) - `src/__tests__/query-dataset-request-scope.test.ts` (new, both strategy paths, 12 cases). A second caller's `getMeta()` and the exact driver calls its queries of the configured cube and of the boot-registered dataset are snapshotted before and after each of these requests: - a refused dataset under a configured cube's name (asserts `PERMISSION_DENIED` / 403, and that the driver never saw the walled object); - an admitted one, served from its own object; - a `public: false` name; - fresh names, one refused and one admitted with an augmented measure. The control is the dataset registered at construction, which still serves and keeps its compiled filter. - `packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts` (new, `bootStack`, two sign-ups, `sqlite-wasm` + `memory`, 10 cases). Member B's `meta`, B's authored-cube query and B's saved-dataset query equal B's baseline after member A's requests: refused (403 `PERMISSION_DENIED`), admitted (200 from A's definition, A's own row count), hidden-name (200, with no new refusal) and saved-name. The app's saved dataset is the control. - `dataset-i18n-label-resolution.test.ts`: the zh-CN meta pin relied on `queryDataset` registering. It now registers through `registerDataset` first, and its intent is kept: a zh-CN query leaves the published titles in the source language. - `pnpm --filter @objectstack/service-analytics typecheck` passes, and `vitest run` gives 130 files / 3053 tests, all passing. `pnpm --filter @objectstack/dogfood typecheck` passes, and the four analytics dogfood files give 28 tests, all passing. **Ablations** (each run with the fix committed first, mutation landing proven on disk by `scripts/ablation-replace.mjs`, and restore proven by blob equal to HEAD plus an empty `git diff HEAD`): - **A, request-path registry write restored (unit).** `queryDataset` calls `registerDataset` again. 10 of 12 go red. The two baselines stay green. - **B, admission set read from the shared registry (unit).** The refused leg and the fresh-name leg go red, because the request resolved instead of rejecting. This shows that a scope applied to the strategy but not to the admission set would admit a read of the walled object. - **A, route level through `dist/`.** Mutate, rebuild, then `ablation-dist-preflight` finds the marker present in 2 built files. 8 of 10 go red. In the first red leg, B's authored-cube query answers `400 INVALID_FIELD` and B's `meta` lists the request's definition. The restore leg rebuilds, the marker is absent from all 6 built files, the tree is clean, and all 10 cases pass again. **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` over this branch's 7 changed paths gives 66 commands. All 66 exited 0, and the `--ran` reconciliation reports 66/66 with 0 NOT MEASURED. `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 packages had no `dist/`). It passed after those packages were built, all from the turbo cache. `eslint --no-inline-config` over the 6 changed TS files, all in the config's `**/*.{ts,…}` population, gives 6 files, 0 errors and 0 warnings. The config enables no type-aware linting (no `parserOptions.project` or `projectService`) and no cross-file rules, so this diff cannot change the verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. ## Merge note for the later lander (PR #20348, `analytics_cube.public`) PR #20348 adds `assertCubePublic(queryInput.cube)` at the top of `query()`. It reads `this.cubeRegistry.get(name)`. Here, `query()`'s body lives in `queryIn(scope, …)`, so a textual merge lands that line in `queryIn`, still reading the shared registry. It should read `scope.getCube(name)`. Read from the shared registry, a dataset request named like a hidden cube would be refused `404 CUBE_NOT_FOUND` with the hidden-cube message, which is a new refusal on this door and an existence signal for hidden names. The dogfood HIDDEN leg asserts 200, so a merge that leaves it on the shared registry goes red there. `getMeta`'s visibility filter correctly stays on the shared registry. ## Acceptance notes - The `/analytics/query` and `/analytics/sql` doors still write the shared registry at request time, before admission (`ensureCube` inference and augmentation). This is reported in the dev report as a separate finding. The `CubeScope` seam added here is the natural place to scope them, but that changes the ad-hoc door's documented registry source and overlaps PR #20348's `inferCubeFromQuery` edit, so it is not done here. - `strategies/native-sql-strategy.ts` (the join-allowlist comment near the `getAllowedRelationships` refusal) still says `queryDataset` registers the compiled dataset first. The invariant it states still holds, through the request scope: the allowlist answers from the compiled dataset and never falls through to the hook. The file is outside this card's declared surface, so the wording is left for whoever next touches it. - The draft-preview branch still reads the pending seed rows before its own admission check. This is a read, and nothing is returned on refusal. It is unchanged. - The one observable difference is stated in the changeset: a cube that only a `queryDataset` call compiled is no longer listed by `getMeta()` or queryable by name afterwards. No in-repo caller relies on that. A search for runtime code querying a dataset name as a cube found none. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d91c9a commit 70ce802

7 files changed

Lines changed: 738 additions & 60 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
`AnalyticsService.queryDataset` no longer writes the service-wide cube and dataset registries: each call compiles its dataset into a scope of its own (#20356).
6+
7+
Clause-②: no
8+
9+
- **What changes**: a dataset query — an inline draft or a saved definition passed to `queryDataset` — used to register its compiled cube and compiled dataset under the dataset's name before it ran. From then on the name meant that request's definition for every later reader (`getMeta()` and `GET /api/v1/analytics/meta`, and every query by that name) until restart, whatever the request's own admission answered. The dataset is now compiled for the call only. The queries it runs resolve its name through a request-local lookup that overlays the shared registry read-only: the cube, the object-level admission and read-scope object sets, the join allowlist and the dataset scope all come from the call's own dataset, and a measure the call infers stays with the call.
10+
- **What does not change**: the request is served as before, from its own definition, with the same admission, read scope and refusals. `registerDataset` still compiles and registers into the shared registry — the configuration door behind `AnalyticsServiceConfig.datasets` and embedders — and configured cubes are untouched. No refusal is added for a dataset whose name matches a configured cube.
11+
- **The one observable difference**: a cube that only a `queryDataset` call ever compiled is no longer listed by `getMeta()`, and is no longer queryable by name through `query()` / `POST /api/v1/analytics/query` after that call returns. To make a dataset addressable by name, register it through `registerDataset` or `AnalyticsServiceConfig.datasets`.
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// END-TO-END gate: an INLINE dataset posted to `POST /analytics/dataset/query`
4+
// is that request's definition and nobody else's — another member's
5+
// `GET /analytics/meta` and their own queries answer exactly as they did before
6+
// it, whether the request was refused or admitted (#20356).
7+
//
8+
// ## The defect
9+
//
10+
// The dataset door compiled the posted definition and registered it in the
11+
// analytics service's process-wide registry under the dataset's name, BEFORE
12+
// the object-level admission ran. The name then meant the poster's definition
13+
// for every later reader of it, whatever the request's own verdict, until a
14+
// restart. The service now compiles each request's dataset into a scope of its
15+
// own, and the registry every caller reads is only ever written at boot.
16+
//
17+
// ## How it is observed
18+
//
19+
// Two separate sign-ups. Member A posts; member B observes. B's observation is
20+
// the whole of what B can see through the three doors — the `meta` listing,
21+
// B's query of the authored cube, and B's query of the app's saved dataset —
22+
// and each leg asserts it is EQUAL to the baseline B took before A posted
23+
// anything. Equality over the whole answer, not a spot check of one title, so
24+
// a partial replacement cannot pass.
25+
//
26+
// ## The legs
27+
//
28+
// - REFUSED: A's dataset reads an object A holds no grant on, under the name of
29+
// the authored cube → `403 PERMISSION_DENIED`, and B's view is unchanged.
30+
// - ADMITTED: the same name over an object A may read → `200`, served from A's
31+
// definition (A's measure, over A's own rows), and B's view is unchanged.
32+
// This is the negative control a fix that simply refused would lose.
33+
// - HIDDEN: the name of a cube declared `public: false` → still `200` (no new
34+
// refusal on this door: a name shared with a configured cube is harmless
35+
// once nothing is shared), and B's view is unchanged. Whether `meta` LISTS
36+
// a hidden cube at all is `analytics_cube.public`'s enforcement, which this
37+
// tree does not carry; the equality holds either way, so this leg keeps its
38+
// meaning once that lands.
39+
// - CONTROL: the saved dataset the app declares still serves by name, in the
40+
// baseline and after every leg.
41+
42+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
43+
import { bootStack, type VerifyStack } from '@objectstack/verify';
44+
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
45+
import { defineStack } from '@objectstack/spec';
46+
import type { Cube } from '@objectstack/spec/data';
47+
import {
48+
AdmissionOpen,
49+
AdmissionWalled,
50+
admissionFixtureSecurity,
51+
} from './fixtures/analytics-admission-fixture.js';
52+
53+
const A_OPEN_ROWS = 3;
54+
/** Deliberately different from A's count, so the two members' numbers cannot be confused. */
55+
const B_OPEN_ROWS = 2;
56+
const WALLED_ROWS = 4;
57+
58+
/** The configured cube other members read, over the object every member may read. */
59+
const OPEN_SUMMARY: Cube = {
60+
name: 'open_summary',
61+
title: 'Open summary',
62+
sql: 'admission_open',
63+
measures: {
64+
authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' },
65+
},
66+
dimensions: {
67+
region: { name: 'region', label: 'Region', type: 'string', sql: 'region' },
68+
},
69+
};
70+
71+
/** A configured cube its author hid. */
72+
const HIDDEN_SUMMARY: Cube = {
73+
name: 'hidden_summary',
74+
title: 'Hidden summary',
75+
sql: 'admission_open',
76+
measures: {
77+
hidden_total: { name: 'hidden_total', label: 'Hidden total', type: 'count', sql: '*' },
78+
},
79+
dimensions: {},
80+
public: false,
81+
};
82+
83+
const isolationStack = defineStack({
84+
manifest: {
85+
id: 'com.dogfood.analytics-inline-isolation',
86+
// The fixture objects' own prefix — they are reused, not renamed.
87+
namespace: 'admission',
88+
version: '0.0.0',
89+
type: 'app',
90+
name: 'Analytics Inline Dataset Isolation Fixture',
91+
description: 'The admission fixture objects plus one saved dataset, with configured cubes on the analytics plugin.',
92+
},
93+
objects: [AdmissionOpen, AdmissionWalled],
94+
datasets: [
95+
{
96+
name: 'saved_open_summary',
97+
label: 'Saved open summary',
98+
object: 'admission_open',
99+
dimensions: [],
100+
measures: [{ name: 'saved_total', label: 'Saved total', aggregate: 'count' }],
101+
},
102+
],
103+
});
104+
105+
/** A member's own dataset, posted inline under a chosen name. */
106+
const inlineDataset = (name: string, object: string) => ({
107+
name,
108+
label: `inline ${name}`,
109+
object,
110+
dimensions: [],
111+
measures: [{ name: 'inline_total', label: 'Inline total', aggregate: 'count' }],
112+
});
113+
114+
const DRIVERS = ['sqlite-wasm', 'memory'] as const;
115+
116+
interface Boot {
117+
stack: VerifyStack;
118+
tokenA: string;
119+
tokenB: string;
120+
baseline: Observation;
121+
}
122+
123+
interface Observation {
124+
meta: { status: number; body: unknown };
125+
authored: { status: number; body: unknown };
126+
saved: { status: number; body: unknown };
127+
}
128+
129+
const boots = new Map<string, Boot>();
130+
131+
async function read(res: Response): Promise<{ status: number; body: unknown }> {
132+
return { status: res.status, body: await res.json() };
133+
}
134+
135+
/** Everything member B can see of analytics through the three doors. */
136+
async function observeAsB(stack: VerifyStack, tokenB: string): Promise<Observation> {
137+
return {
138+
meta: await read(await stack.apiAs(tokenB, 'GET', '/analytics/meta')),
139+
authored: await read(
140+
await stack.apiAs(tokenB, 'POST', '/analytics/query', {
141+
cube: 'open_summary',
142+
measures: ['authored_total'],
143+
}),
144+
),
145+
saved: await read(
146+
await stack.apiAs(tokenB, 'POST', '/analytics/dataset/query', {
147+
datasetName: 'saved_open_summary',
148+
selection: { measures: ['saved_total'] },
149+
}),
150+
),
151+
};
152+
}
153+
154+
/** The single count a one-measure answer carries, whichever envelope the door uses. */
155+
function countOf(body: unknown, measure: string): number {
156+
const payload = (body as { data?: unknown })?.data ?? body;
157+
const rows = (payload as { rows?: Array<Record<string, unknown>> })?.rows ?? [];
158+
return rows.reduce((sum, row) => sum + Number(row[measure] ?? 0), 0);
159+
}
160+
161+
async function bootFor(driver: (typeof DRIVERS)[number]): Promise<Boot> {
162+
const stack = await bootStack(isolationStack as never, {
163+
security: admissionFixtureSecurity(),
164+
databaseDriver: driver,
165+
analytics: new AnalyticsServicePlugin({ cubes: [OPEN_SUMMARY, HIDDEN_SUMMARY] }),
166+
});
167+
const adminToken = await stack.signIn();
168+
const tokenA = await stack.signUp(`isolation-a-${driver}@verify.test`);
169+
const tokenB = await stack.signUp(`isolation-b-${driver}@verify.test`);
170+
171+
// Each member authors their own rows over HTTP, so `created_by` is the real
172+
// caller and the owner policy makes each member's count their own number.
173+
for (const [token, rows, who] of [
174+
[tokenA, A_OPEN_ROWS, 'a'],
175+
[tokenB, B_OPEN_ROWS, 'b'],
176+
] as const) {
177+
for (let i = 0; i < rows; i++) {
178+
const r = await stack.apiAs(token, 'POST', '/data/admission_open', { name: `${who}-open-${i}`, region: 'west' });
179+
expect(r.status).toBeLessThan(300);
180+
}
181+
}
182+
for (let i = 0; i < WALLED_ROWS; i++) {
183+
const w = await stack.apiAs(adminToken, 'POST', '/data/admission_walled', { name: `walled-${i}`, region: 'west' });
184+
expect(w.status).toBeLessThan(300);
185+
}
186+
187+
const baseline = await observeAsB(stack, tokenB);
188+
return { stack, tokenA, tokenB, baseline };
189+
}
190+
191+
describe.each(DRIVERS)(
192+
'dogfood: an inline dataset changes nothing another member sees [driver=%s]',
193+
(driver) => {
194+
beforeAll(async () => {
195+
boots.set(driver, await bootFor(driver));
196+
}, 120_000);
197+
198+
afterAll(async () => {
199+
await boots.get(driver)?.stack.stop();
200+
boots.delete(driver);
201+
});
202+
203+
it('baseline: B sees the configured cube and the saved dataset, each over B\'s own rows', () => {
204+
const { baseline } = boots.get(driver)!;
205+
expect(baseline.meta.status).toBe(200);
206+
const listed = JSON.stringify(baseline.meta.body);
207+
expect(listed).toContain('Open summary');
208+
expect(listed).not.toContain('inline ');
209+
expect(baseline.authored.status).toBe(200);
210+
expect(countOf(baseline.authored.body, 'authored_total')).toBe(B_OPEN_ROWS);
211+
// CONTROL — the app's saved dataset serves by name.
212+
expect(baseline.saved.status).toBe(200);
213+
expect(countOf(baseline.saved.body, 'saved_total')).toBe(B_OPEN_ROWS);
214+
});
215+
216+
it('REFUSED: A\'s dataset over an object A may not read answers 403 PERMISSION_DENIED, and B\'s view is unchanged', async () => {
217+
const boot = boots.get(driver)!;
218+
const res = await boot.stack.apiAs(boot.tokenA, 'POST', '/analytics/dataset/query', {
219+
dataset: inlineDataset('open_summary', 'admission_walled'),
220+
selection: { measures: ['inline_total'] },
221+
});
222+
expect(res.status).toBe(403);
223+
expect(((await res.json()) as { code?: string }).code).toBe('PERMISSION_DENIED');
224+
225+
expect(await observeAsB(boot.stack, boot.tokenB)).toEqual(boot.baseline);
226+
});
227+
228+
it('ADMITTED: the same name over an object A may read is served from A\'s definition, and B\'s view is unchanged', async () => {
229+
const boot = boots.get(driver)!;
230+
const res = await boot.stack.apiAs(boot.tokenA, 'POST', '/analytics/dataset/query', {
231+
dataset: inlineDataset('open_summary', 'admission_open'),
232+
selection: { measures: ['inline_total'] },
233+
});
234+
expect(res.status).toBe(200);
235+
// A's measure, over A's own rows — the request's definition, not B's cube.
236+
expect(countOf(await res.json(), 'inline_total')).toBe(A_OPEN_ROWS);
237+
238+
expect(await observeAsB(boot.stack, boot.tokenB)).toEqual(boot.baseline);
239+
});
240+
241+
it('HIDDEN: a dataset named like a `public: false` cube is served without a new refusal, and B\'s view is unchanged', async () => {
242+
const boot = boots.get(driver)!;
243+
const res = await boot.stack.apiAs(boot.tokenA, 'POST', '/analytics/dataset/query', {
244+
dataset: inlineDataset('hidden_summary', 'admission_open'),
245+
selection: { measures: ['inline_total'] },
246+
});
247+
expect(res.status).toBe(200);
248+
249+
const after = await observeAsB(boot.stack, boot.tokenB);
250+
expect(after).toEqual(boot.baseline);
251+
expect(JSON.stringify(after.meta.body)).not.toContain('inline hidden_summary');
252+
});
253+
254+
it('CONTROL: a dataset posted under the saved dataset\'s name leaves the saved dataset serving as before', async () => {
255+
const boot = boots.get(driver)!;
256+
const res = await boot.stack.apiAs(boot.tokenA, 'POST', '/analytics/dataset/query', {
257+
dataset: inlineDataset('saved_open_summary', 'admission_walled'),
258+
selection: { measures: ['inline_total'] },
259+
});
260+
expect(res.status).toBe(403);
261+
expect(((await res.json()) as { code?: string }).code).toBe('PERMISSION_DENIED');
262+
263+
expect(await observeAsB(boot.stack, boot.tokenB)).toEqual(boot.baseline);
264+
});
265+
},
266+
);

‎packages/services/service-analytics/src/__tests__/dataset-i18n-label-resolution.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,13 @@ describe('#6761 — /analytics/meta no longer publishes the machine name as a di
288288

289289
it('stays request-independent — a zh-CN query does not leak its locale into the registry', async () => {
290290
const svc = sqlService();
291-
// `queryDataset` re-registers the cube on every call. If the compiler baked
292-
// the request locale in, this Chinese query would leave a Chinese-labelled
293-
// cube behind and `/analytics/meta` — which takes no execution context at
294-
// all — would answer whoever queried last.
291+
// The registered cube is what `/analytics/meta` publishes, with no execution
292+
// context at all. A Chinese query of the same dataset must leave it as it
293+
// was: `queryDataset` compiles into its own request scope and writes nothing
294+
// back (#20356), and the compiler takes no locale — so neither a
295+
// re-registration nor a locale baked into the compile can make meta answer
296+
// in whichever language queried last.
297+
svc.registerDataset(dataset);
295298
await svc.queryDataset(
296299
dataset,
297300
{ dimensions: ALL_DIMENSIONS, measures: ALL_MEASURES },

0 commit comments

Comments
 (0)