diff --git a/.changeset/20381-adhoc-cube-request-scope.md b/.changeset/20381-adhoc-cube-request-scope.md new file mode 100644 index 00000000000..e3871f75d4c --- /dev/null +++ b/.changeset/20381-adhoc-cube-request-scope.md @@ -0,0 +1,11 @@ +--- +"@objectstack/service-analytics": patch +--- + +`AnalyticsService.query()` and `generateSql()` no longer write the service-wide cube registry before the object-level read admission has admitted the request, and never write a caller-named measure into a registered cube (#20381). + +Clause-②: no + +- **What changes**: both ad-hoc doors — `query()` (`POST /api/v1/analytics/query`) and `generateSql()` (`POST /api/v1/analytics/sql`) — resolved the query's cube and recorded what `ensureCube` minted straight into the shared registry, ahead of the admission check. A request refused `PERMISSION_DENIED` still left the cube it inferred for the refused object in the registry, and a suffix measure a caller named on a registered cube (`_sum`, `_count_distinct`, …) was appended to that cube for every later reader, whether the request was refused or admitted. Both doors now run in the same request-local scope `queryDataset` runs in: what `ensureCube` mints stays with the call, and the admission, read scope and strategy all read it from there. +- **What does not change**: every request is served as before, with the same admission, read scope, refusals, codes and statuses, and a caller-named suffix measure is still served to the caller who named it. An ADMITTED ad-hoc query over an object with no configured cube still registers the cube it inferred, as before — now only after the admission has admitted the request, and never over a cube registered under the same name in the meantime. Configured cubes and datasets registered at construction (`AnalyticsServiceConfig.cubes` / `datasets`) are untouched. +- **What `getMeta()` lists, the one observable difference**: `getMeta()` and `GET /api/v1/analytics/meta` no longer list a cube inferred for a refused request, and no longer list a suffix measure some caller named on a registered cube — a registered cube is listed as it was registered. A cube inferred for an admitted request is still listed. diff --git a/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts b/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts new file mode 100644 index 00000000000..cb26cd9fde3 --- /dev/null +++ b/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// END-TO-END gate: an ad-hoc query on `POST /analytics/query` or +// `POST /analytics/sql` changes nothing another member sees unless the +// object-level admission admitted it — and even then, a measure the caller +// named on top of a configured cube stays that caller's (#20381). +// +// ## The defect +// +// Both ad-hoc doors resolved the query's cube, and minted what was missing, +// straight into the analytics service's process-wide registry — BEFORE the +// object-level read admission ran. A request refused `403 PERMISSION_DENIED` +// still left the cube it inferred for the refused object in every member's +// `GET /analytics/meta`, and a suffix measure a caller named on a configured +// cube was appended to that cube for every member, admitted or refused. The +// doors now run in a request scope of their own (the one the dataset door got +// for #20356); an inferred cube is published only once the request has been +// admitted, and an appended measure never is. +// +// ## How it is observed +// +// Two separate sign-ups, A and B, holding the same grant (read on +// `admission_open` only), plus the administrator. Member A — or the admin — +// asks; member B observes. B's observation is the whole of what B can see of +// the analytics registry through the two doors B uses — the `meta` listing +// and B's query of the configured cube — taken immediately before and after +// each leg and compared for EQUALITY, so a partial rewrite cannot pass. +// +// ## The legs, on each door +// +// - REFUSED, inferred: A's ad-hoc query over the object A may not read → +// `403 PERMISSION_DENIED`, and B's view is unchanged. +// - REFUSED, appended: A's query of the configured cube over that object, +// naming a suffix measure the cube does not declare → `403`, and B's view is +// unchanged. +// - ADMITTED, appended: the same suffix measure on the configured cube over +// the object A may read → `200`, served WITH A's measure, and B's view is +// unchanged. The negative control a fix that simply refused would lose. +// +// ## Controls +// +// - A configured cube still serves: every B observation is a `200` count of +// B's own rows. +// - An admitted scalar metric over an object still works on a second request +// — the documented "CubeRegistry source 3" path, which stays: the inferred +// cube is registered once the first request is admitted. +// - That published cube widens nothing: after the administrator's admitted +// ad-hoc query over the walled object, B's own query of that object is still +// refused on both doors, and every cube B saw before is listed unchanged. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; +import { defineStack } from '@objectstack/spec'; +import type { Cube } from '@objectstack/spec/data'; +import { + AdmissionOpen, + AdmissionWalled, + admissionFixtureSecurity, +} from './fixtures/analytics-admission-fixture.js'; + +const A_OPEN_ROWS = 3; +/** Deliberately different from A's count, so the two members' numbers cannot be confused. */ +const B_OPEN_ROWS = 2; +const WALLED_ROWS = 4; + +/** The configured cube B reads, over the object every member may read. */ +const OPEN_SUMMARY: Cube = { + name: 'open_summary', + title: 'Open summary', + sql: 'admission_open', + measures: { + authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' }, + }, + dimensions: { + region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + }, +}; + +/** A configured cube over the object no member may read. */ +const WALLED_SUMMARY: Cube = { + name: 'walled_summary', + title: 'Walled summary', + sql: 'admission_walled', + measures: { + walled_total: { name: 'walled_total', label: 'Walled total', type: 'count', sql: '*' }, + }, + dimensions: {}, +}; + +const adhocStack = defineStack({ + manifest: { + id: 'com.dogfood.analytics-adhoc-isolation', + // The fixture objects' own prefix — they are reused, not renamed. + namespace: 'admission', + version: '0.0.0', + type: 'app', + name: 'Analytics Ad-hoc Query Isolation Fixture', + description: 'The admission fixture objects, with configured cubes over each on the analytics plugin.', + }, + objects: [AdmissionOpen, AdmissionWalled], +}); + +/** A suffix measure no configured cube declares — `ensureCube` appends it. */ +const APPENDED = 'region_count_distinct'; + +const DRIVERS = ['sqlite-wasm', 'memory'] as const; +const DOORS = ['/analytics/query', '/analytics/sql'] as const; +type Door = (typeof DOORS)[number]; + +/** + * One boot per driver AND door: the registry is process-wide, so a leg on one + * door would otherwise leave behind — on a regressed build — exactly the entry + * the same leg on the other door is meant to catch, and read green. + */ +const CASES = DRIVERS.flatMap((driver) => DOORS.map((door) => ({ driver, door }))); + +interface Boot { + stack: VerifyStack; + adminToken: string; + tokenA: string; + tokenB: string; +} + +interface Observation { + meta: { status: number; body: unknown }; + authored: { status: number; body: unknown }; +} + +const boots = new Map(); + +async function read(res: Response): Promise<{ status: number; body: unknown }> { + return { status: res.status, body: await res.json() }; +} + +/** Everything member B can see of the analytics registry. */ +async function observeAsB(stack: VerifyStack, tokenB: string): Promise { + return { + meta: await read(await stack.apiAs(tokenB, 'GET', '/analytics/meta')), + authored: await read( + await stack.apiAs(tokenB, 'POST', '/analytics/query', { + cube: 'open_summary', + measures: ['authored_total'], + }), + ), + }; +} + +/** The listed cubes, whichever envelope the door uses. */ +function cubesOf(meta: Observation['meta']): unknown[] { + const payload = (meta.body as { data?: unknown })?.data ?? meta.body; + return Array.isArray(payload) ? payload : []; +} + +/** The single count a one-measure answer carries, whichever envelope the door uses. */ +function countOf(body: unknown, measure: string): number { + const payload = (body as { data?: unknown })?.data ?? body; + const rows = (payload as { rows?: Array> })?.rows ?? []; + return rows.reduce((sum, row) => sum + Number(row[measure] ?? 0), 0); +} + +/** The ADR-0112 refusal both ad-hoc doors answer for an object the caller may not read. */ +async function expectRefused(res: Response): Promise { + expect(res.status).toBe(403); + const body = (await res.json()) as { error?: { code?: string; httpStatus?: number } }; + expect(body.error?.code).toBe('PERMISSION_DENIED'); + expect(body.error?.httpStatus).toBe(403); +} + +async function bootFor(driver: (typeof DRIVERS)[number], door: Door): Promise { + const stack = await bootStack(adhocStack as never, { + security: admissionFixtureSecurity(), + databaseDriver: driver, + analytics: new AnalyticsServicePlugin({ cubes: [OPEN_SUMMARY, WALLED_SUMMARY] }), + }); + const adminToken = await stack.signIn(); + const slug = door.replace(/\W+/g, '-'); + const tokenA = await stack.signUp(`adhoc-a-${driver}${slug}@verify.test`); + const tokenB = await stack.signUp(`adhoc-b-${driver}${slug}@verify.test`); + + // Each member authors their own rows over HTTP, so `created_by` is the real + // caller and the owner policy makes each member's count their own number. + for (const [token, rows, who] of [ + [tokenA, A_OPEN_ROWS, 'a'], + [tokenB, B_OPEN_ROWS, 'b'], + ] as const) { + for (let i = 0; i < rows; i++) { + const r = await stack.apiAs(token, 'POST', '/data/admission_open', { name: `${who}-open-${i}`, region: 'west' }); + expect(r.status).toBeLessThan(300); + } + } + for (let i = 0; i < WALLED_ROWS; i++) { + const w = await stack.apiAs(adminToken, 'POST', '/data/admission_walled', { name: `walled-${i}`, region: 'west' }); + expect(w.status).toBeLessThan(300); + } + return { stack, adminToken, tokenA, tokenB }; +} + +describe.each(CASES)( + 'dogfood: an ad-hoc analytics query changes nothing another member sees before it is admitted [driver=$driver, door=$door]', + ({ driver, door }) => { + const key = `${driver} ${door}`; + + beforeAll(async () => { + boots.set(key, await bootFor(driver, door)); + }, 120_000); + + afterAll(async () => { + await boots.get(key)?.stack.stop(); + boots.delete(key); + }); + + it('baseline: B sees the configured cubes, and the open one serves B\'s own rows', async () => { + const { stack, tokenB } = boots.get(key)!; + const baseline = await observeAsB(stack, tokenB); + expect(baseline.meta.status).toBe(200); + expect(cubesOf(baseline.meta).map((c) => (c as { name: string }).name).sort()).toEqual([ + 'open_summary', + 'walled_summary', + ]); + expect(baseline.authored.status).toBe(200); + expect(countOf(baseline.authored.body, 'authored_total')).toBe(B_OPEN_ROWS); + }); + + it('REFUSED: A\'s ad-hoc query over the object A may not read answers 403 PERMISSION_DENIED, and B\'s view is unchanged', async () => { + const { stack, tokenA, tokenB } = boots.get(key)!; + const before = await observeAsB(stack, tokenB); + + await expectRefused(await stack.apiAs(tokenA, 'POST', door, { cube: 'admission_walled', measures: ['count'] })); + + expect(await observeAsB(stack, tokenB)).toEqual(before); + }); + + it('REFUSED: A\'s suffix measure on the configured cube over that object answers 403, and B\'s view is unchanged', async () => { + const { stack, tokenA, tokenB } = boots.get(key)!; + const before = await observeAsB(stack, tokenB); + + await expectRefused( + await stack.apiAs(tokenA, 'POST', door, { cube: 'walled_summary', measures: ['walled_total', APPENDED] }), + ); + + expect(await observeAsB(stack, tokenB)).toEqual(before); + }); + + it('ADMITTED: A\'s suffix measure on the configured cube over the open object is served, and B\'s view is unchanged', async () => { + const { stack, tokenA, tokenB } = boots.get(key)!; + const before = await observeAsB(stack, tokenB); + + const res = await stack.apiAs(tokenA, 'POST', door, { cube: 'open_summary', measures: ['authored_total', APPENDED] }); + expect(res.status).toBe(200); + const body = await res.json(); + // Served WITH A's own measure — in the rows on the query door, in the + // statement on the dry-run door. + expect(JSON.stringify(body)).toContain(door === '/analytics/query' ? APPENDED : 'region'); + if (door === '/analytics/query') expect(countOf(body, 'authored_total')).toBe(A_OPEN_ROWS); + + expect(await observeAsB(stack, tokenB)).toEqual(before); + }); + + it('CONTROL: an admitted scalar metric over an object still works on a second request', async () => { + const { stack, tokenA, tokenB } = boots.get(key)!; + const before = await observeAsB(stack, tokenB); + + for (let i = 0; i < 2; i++) { + const res = await stack.apiAs(tokenA, 'POST', door, { cube: 'admission_open', measures: ['count'] }); + expect(res.status).toBe(200); + const body = await res.json(); + if (door === '/analytics/query') expect(countOf(body, 'count')).toBe(A_OPEN_ROWS); + else expect(JSON.stringify(body)).toContain('admission_open'); + } + + const after = await observeAsB(stack, tokenB); + expect(after.authored).toEqual(before.authored); + expect(cubesOf(after.meta)).toEqual(expect.arrayContaining(cubesOf(before.meta))); + }); + + it('CONTROL: the administrator\'s admitted ad-hoc query over the walled object widens nothing for B', async () => { + const { stack, adminToken, tokenB } = boots.get(key)!; + const before = await observeAsB(stack, tokenB); + + const res = await stack.apiAs(adminToken, 'POST', door, { cube: 'admission_walled', measures: ['count'] }); + expect(res.status).toBe(200); + + // B still may not read the object, whatever the registry now holds under its name. + await expectRefused(await stack.apiAs(tokenB, 'POST', door, { cube: 'admission_walled', measures: ['count'] })); + const after = await observeAsB(stack, tokenB); + expect(after.authored).toEqual(before.authored); + expect(cubesOf(after.meta)).toEqual(expect.arrayContaining(cubesOf(before.meta))); + }); + }, +); diff --git a/packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts b/packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts new file mode 100644 index 00000000000..843a93e1113 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts @@ -0,0 +1,254 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20381] The two ad-hoc doors — `query()` (`POST /analytics/query`) and + * `generateSql()` (`POST /analytics/sql`) — write nothing into the registry + * every caller shares until the request has been admitted, and the measures a + * caller names on top of a configured cube are never written there at all. + * + * `ensureCube` records what it mints in the scope the call runs in. Both doors + * ran it over the SHARED scope, before `callCtx` asked the object-level + * admission, so: + * + * - a request refused `PERMISSION_DENIED` still left the cube it inferred + * (named after the refused object) in every caller's `getMeta()`; + * - a caller-named suffix measure (`amount_sum` on a cube that declares no + * such measure) was appended to the configured cube for every caller — + * refused or not. + * + * Both doors now run in a request scope (the one `queryDataset` runs in), and + * the ad-hoc door publishes an INFERRED cube to the shared registry only once + * the object-level admission has admitted the request — "CubeRegistry source + * 3", kept. An augmented cube is never published. + * + * ## What each case is shaped to catch + * + * - The OBSERVER is a second caller: its `getMeta()` and the exact call its + * query of the configured cube puts on the driver, before and after. Equality + * of the whole snapshot is the assertion. + * - Every REFUSED leg asserts the ADR-0112 envelope (`PERMISSION_DENIED` / + * 403) and that the driver never ran. + * - The ADMITTED augmentation leg is the control a lazy fix loses: the + * caller's suffix measure still reaches the strategy. + * - The ADMITTED inference leg pins the ORDER, not only the outcome: the + * admission provider reads the registry at the moment it is asked, and the + * inferred cube must not be there yet. + * - CONTROL: a configured cube still serves, and an admitted scalar metric + * over an object still works on a second request, through the published cube. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { Cube } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; +import { AnalyticsService } from '../analytics-service.js'; + +const silentLogger = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +} as any; + +const CALLER_A = { userId: 'u_a', tenantId: 'org_a' } as ExecutionContext; +const CALLER_B = { userId: 'u_b', tenantId: 'org_a' } as ExecutionContext; + +const OPEN = 'open_obj'; +/** The object no caller may read. */ +const WALLED = 'walled_obj'; +/** A registered object no cube is configured over — the ad-hoc inference target. */ +const OTHER = 'other_obj'; +const OBJECT_FIELDS = ['name', 'region', 'amount']; + +const OPEN_SUMMARY: Cube = { + name: 'open_summary', + title: 'Open summary', + sql: OPEN, + measures: { + authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' }, + }, + dimensions: { + region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + }, +}; + +/** A configured cube over the walled object. */ +const WALLED_SUMMARY: Cube = { + name: 'walled_summary', + title: 'Walled summary', + sql: WALLED, + measures: { + walled_total: { name: 'walled_total', label: 'Walled total', type: 'count', sql: '*' }, + }, + dimensions: {}, +}; + +const nativeSqlOnly = () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }); +const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }); + +const STRATEGY_PATHS = [ + { strategy: 'NativeSQLStrategy', capabilities: nativeSqlOnly }, + { strategy: 'ObjectQLStrategy', capabilities: objectqlOnly }, +] as const; + +type Door = (svc: AnalyticsService, query: AnalyticsQuery, context: ExecutionContext) => Promise; + +/** The two ad-hoc doors, each answered by what it hands back. */ +const DOORS: ReadonlyArray<{ door: string; run: Door }> = [ + { door: 'query', run: (svc, q, ctx) => svc.query(q, ctx) }, + { door: 'generateSql', run: (svc, q, ctx) => svc.generateSql(q, ctx) }, +]; + +type DriverCall = { object: string; detail: unknown }; + +function makeService( + capabilities: () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean }, + onAdmission?: (object: string) => void, +) { + const calls: DriverCall[] = []; + const row = { authored_total: 3, walled_total: 4, count: 5, amount_sum: 7 }; + const svc: AnalyticsService = new AnalyticsService({ + logger: silentLogger, + cubes: [OPEN_SUMMARY, WALLED_SUMMARY], + queryCapabilities: capabilities, + admitObjectRead: async (object) => { + onAdmission?.(object); + return object !== WALLED; + }, + isRegisteredObject: (name) => [OPEN, WALLED, OTHER].includes(name), + getObjectFieldNames: (name) => ([OPEN, WALLED, OTHER].includes(name) ? OBJECT_FIELDS : undefined), + executeRawSql: async (object, sql, params) => { + calls.push({ object, detail: { sql, params } }); + return [row]; + }, + executeAggregate: async (object, options) => { + calls.push({ object, detail: options }); + return [row]; + }, + }); + return { svc, calls }; +} + +type Harness = ReturnType; + +/** The second caller's whole view: discovery, and the driver call its query of the configured cube makes. */ +async function observe({ svc, calls }: Harness) { + const meta = await svc.getMeta(); + const from = calls.length; + await svc.query({ cube: 'open_summary', measures: ['authored_total'] }, CALLER_B); + return { meta, driven: calls.slice(from) }; +} + +describe.each(STRATEGY_PATHS)('[#20381] the ad-hoc doors leave the shared registry alone — $strategy', ({ capabilities }) => { + it('observer baseline: the configured cubes are listed, and the open one serves by name', async () => { + const h = makeService(capabilities); + const { meta, driven } = await observe(h); + expect(meta.map((c) => c.name).sort()).toEqual(['open_summary', 'walled_summary']); + expect(driven.map((c) => c.object)).toEqual([OPEN]); + }); + + describe.each(DOORS)('door: $door', ({ run }) => { + it('a REFUSED ad-hoc query over an object answers PERMISSION_DENIED / 403 and leaves no inferred cube', async () => { + const h = makeService(capabilities); + const names = h.svc.cubeRegistry.names(); + const before = await observe(h); + const from = h.calls.length; + + await expect(run(h.svc, { cube: WALLED, measures: ['count'] }, CALLER_A)).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + status: 403, + }); + expect(h.calls.slice(from)).toEqual([]); + + expect(h.svc.cubeRegistry.names()).toEqual(names); + expect(h.svc.cubeRegistry.get(WALLED)).toBeUndefined(); + expect(await observe(h)).toEqual(before); + }); + + it('a REFUSED query naming a suffix measure on a configured cube answers 403 and leaves the cube as authored', async () => { + const h = makeService(capabilities); + const before = await observe(h); + const from = h.calls.length; + + await expect( + run(h.svc, { cube: 'walled_summary', measures: ['walled_total', 'amount_sum'] }, CALLER_A), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(h.calls.slice(from)).toEqual([]); + + expect(h.svc.cubeRegistry.get('walled_summary')).toBe(WALLED_SUMMARY); + expect(await observe(h)).toEqual(before); + }); + + it('an ADMITTED query naming a suffix measure on a configured cube is served with it, and the cube stays as authored', async () => { + const h = makeService(capabilities); + const before = await observe(h); + const from = h.calls.length; + + const answer = await run(h.svc, { cube: 'open_summary', measures: ['authored_total', 'amount_sum'] }, CALLER_A); + // Served, with the caller's own measure: `SUM(amount)` reached the + // strategy — on `query` through the driver, on `generateSql` in the + // statement it hands back. + expect(JSON.stringify([answer, h.calls.slice(from)])).toContain('amount'); + + expect(h.svc.cubeRegistry.get('open_summary')).toBe(OPEN_SUMMARY); + expect(await observe(h)).toEqual(before); + }); + + it('an ADMITTED ad-hoc query over an object publishes its inferred cube only AFTER admission (source 3)', async () => { + let registryAtAdmission: string[] | undefined; + const holder: { svc?: AnalyticsService } = {}; + const h = makeService(capabilities, (object) => { + if (object === OTHER) registryAtAdmission = holder.svc!.cubeRegistry.names(); + }); + holder.svc = h.svc; + const before = await observe(h); + + await run(h.svc, { cube: OTHER, measures: ['count'] }, CALLER_A); + + // The order: when the admission was asked, nothing had been written. + expect(registryAtAdmission).toEqual(['open_summary', 'walled_summary']); + // The outcome: the admitted request's inferred cube is registered — the + // documented source 3 — with exactly the members it named. + expect(Object.keys(h.svc.cubeRegistry.get(OTHER)?.measures ?? {})).toEqual(['count']); + + const after = await observe(h); + expect(after.driven).toEqual(before.driven); + expect(after.meta).toEqual(expect.arrayContaining(before.meta)); + }); + + it('a query that loses the admission race to a registration leaves that registration in place', async () => { + // An embedder registers a cube under the name while the ad-hoc request is + // being admitted: publishing the inferred cube must not replace it. + const authored: Cube = { ...OPEN_SUMMARY, name: OTHER, title: 'Authored other', sql: OTHER }; + const holder: { svc?: AnalyticsService } = {}; + const h = makeService(capabilities, (object) => { + if (object === OTHER) holder.svc!.cubeRegistry.register(authored); + }); + holder.svc = h.svc; + + await run(h.svc, { cube: OTHER, measures: ['count'] }, CALLER_A); + + expect(h.svc.cubeRegistry.get(OTHER)).toBe(authored); + }); + }); + + it('CONTROL: an admitted scalar metric over an object still works on a second request, through the published cube', async () => { + const h = makeService(capabilities); + await h.svc.query({ cube: OTHER, measures: ['count'] }, CALLER_A); + const published = h.svc.cubeRegistry.get(OTHER); + expect(published).toBeDefined(); + + const from = h.calls.length; + const second = await h.svc.query({ cube: OTHER, measures: ['count', 'amount_sum'] }, CALLER_B); + expect(second.rows).toHaveLength(1); + const driven = h.calls.slice(from); + expect(driven.map((c) => c.object)).toEqual([OTHER]); + expect(JSON.stringify(driven[0].detail)).toContain('amount'); + + // The second request's suffix measure was its own: the published cube is + // the one the first request minted, untouched. + expect(h.svc.cubeRegistry.get(OTHER)).toBe(published); + expect(Object.keys(published!.measures)).toEqual(['count']); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 26a980464f3..95f2ef9a21f 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -856,9 +856,12 @@ const DEFAULT_CAPABILITIES: AnalyticsDriverCapabilities = { * - the SHARED scope — this service's `CubeRegistry` and compiled-dataset * registry, which every caller reads and `getMeta` publishes: the configured * cubes, the datasets `registerDataset` registered (the constructor's - * `datasets`, or an embedder), and what the ad-hoc path infers; - * - a REQUEST scope — the dataset one `queryDataset` call compiled, visible to - * that call only, under its own name, over the shared scope read-only. + * `datasets`, or an embedder), and what the ad-hoc path infers for a request + * the object-level admission ADMITTED (#20381); + * - a REQUEST scope — one call's own, over the shared scope read-only: the + * dataset a `queryDataset` call compiled, under its own name, and whatever + * `ensureCube` mints during the call. Every door runs in one — `query()` and + * `generateSql()` too (#20381). * * A request's dataset is that caller's definition, and a name it shares with a * shared cube is harmless only while the two never meet. Registering it made @@ -866,7 +869,11 @@ const DEFAULT_CAPABILITIES: AnalyticsDriverCapabilities = { * cube included — was replaced for every later reader, and the replacement * happened before any admission was asked, so a refused request left it * behind too. A request scope therefore has no path into the shared one: its - * `register` writes only to itself, and it is dropped with the call. + * `register` writes only to itself, and it is dropped with the call. The ONE + * write from a request into the shared scope is the ad-hoc doors' publication + * of an inferred cube, made by the door after `callCtx` admitted the request + * (`publishInferredCube`); a measure `ensureCube` appends to an existing cube + * is never published. */ interface CubeScope { getCube(name: string): Cube | undefined; @@ -1005,7 +1012,7 @@ export class AnalyticsService implements IAnalyticsService { // per query in `callCtx(context)` so it can resolve the active tenant. this.baseCtx = { // The shared scope's reads. `callCtx` answers them from the call's own - // scope, which for every door but `queryDataset` is this same one. + // request scope, which reads this one through (#20356, #20381). ...this.cubeReads(this.sharedScope), queryCapabilities: config.queryCapabilities || (() => DEFAULT_CAPABILITIES), executeRawSql: config.executeRawSql, @@ -1423,19 +1430,25 @@ export class AnalyticsService implements IAnalyticsService { * Any other error propagates untouched. */ async query(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise { - return this.queryIn(this.sharedScope, queryInput, context); + return this.queryIn(this.requestScope(), queryInput, context, { publishInferred: true }); } /** - * {@link query} with the cube name resolved through `scope`: the shared scope - * for `/analytics/query`, and a request scope for the queries `queryDataset` - * runs through `DatasetExecutor` (#20356). One body for both, so every gate - * below asks the same question whichever scope answers the name. + * {@link query} with the cube name resolved through `scope`, the call's own + * request scope: an empty one for `/analytics/query` (#20381), and the + * compiled dataset's for the queries `queryDataset` runs through + * `DatasetExecutor` (#20356). One body for both, so every gate below asks the + * same question whichever scope answers the name. + * + * [#20381] `publishInferred` is the ad-hoc door's alone: once `callCtx` has + * admitted the request, the cube `ensureCube` inferred for it is published + * to the shared registry. The dataset door publishes nothing. */ private async queryIn( scope: CubeScope, queryInput: AnalyticsQuery, context?: ExecutionContext, + door: { publishInferred?: boolean } = {}, ): Promise { if (!queryInput.cube) { throw new Error('Cube name is required in analytics query'); @@ -1454,8 +1467,9 @@ export class AnalyticsService implements IAnalyticsService { const tokenCtx = filterTokenContextFrom(context, new Date()); const query = this.resolveQueryTokens(queryInput, tokenCtx); - this.ensureCube(query, scope); + const inferred = this.ensureCube(query, scope); const ctx = await this.callCtx(query, context, tokenCtx, scope); + if (door.publishInferred) this.publishInferredCube(inferred); let skip: Set | undefined; for (;;) { const strategy = this.resolveStrategy(query, ctx, skip); @@ -1551,25 +1565,48 @@ export class AnalyticsService implements IAnalyticsService { } /** - * [#20356] The {@link CubeScope} one `queryDataset` call runs in: the call's + * [#20356] The {@link CubeScope} one call runs in: a `queryDataset` call's * own compiled dataset answers its name, every other name reads the shared - * scope, and what the call mints (`ensureCube`'s measure augmentation) stays - * here and is dropped with the call. + * scope, and what the call mints (`ensureCube`'s inference or measure + * augmentation) stays here and is dropped with the call. + * + * [#20381] The ad-hoc doors (`query()`, `generateSql()`) run in one with no + * compiled dataset, so nothing they mint reaches the shared registry before + * `callCtx` has admitted the request — see {@link publishInferredCube}. */ - private requestScope(compiled: CompiledDataset): CubeScope { - const name = compiled.cube.name; + private requestScope(compiled?: CompiledDataset): CubeScope { const shared = this.sharedScope; - const cubes = new Map([[name, compiled.cube]]); + const cubes = new Map(); + if (compiled) cubes.set(compiled.cube.name, compiled.cube); return { getCube: (cubeName) => cubes.get(cubeName) ?? shared.getCube(cubeName), getCompiledDataset: (cubeName) => - cubeName === name ? compiled : shared.getCompiledDataset(cubeName), + compiled && cubeName === compiled.cube.name ? compiled : shared.getCompiledDataset(cubeName), register: (cube) => { cubes.set(cube.name, cube); }, }; } + /** + * [#20381] CubeRegistry source 3, and the one place a request writes it: the + * cube an ad-hoc door inferred for a request, entered into the shared + * registry AFTER `callCtx` admitted that request. A refused request leaves no + * trace; an admitted one leaves its inferred cube, which the next request + * resolves by name as before. + * + * Only an INFERRED cube is ever published. A measure `ensureCube` appended to + * a cube the registry already holds is the caller's, and stays in the call's + * scope: publishing it rewrote a configured cube for every caller. And a name + * the registry gained while this request was being admitted keeps what it + * gained — the first registration wins, so an authored cube is never + * replaced by an inferred one. + */ + private publishInferredCube(inferred: Cube | undefined): void { + if (!inferred || this.sharedScope.getCube(inferred.name)) return; + this.sharedScope.register(inferred); + } + /** * [#20356] The face `DatasetExecutor` queries through for one `queryDataset` * call. `query()` is {@link queryIn} over the call's scope — the same body, @@ -2158,8 +2195,12 @@ export class AnalyticsService implements IAnalyticsService { const tokenCtx = filterTokenContextFrom(context, new Date()); const query = this.resolveQueryTokens(queryInput, tokenCtx); - this.ensureCube(query, this.sharedScope); - const ctx = await this.callCtx(query, context, tokenCtx, this.sharedScope); + // [#20381] Same request scope as `query()`: nothing minted here reaches the + // shared registry before `callCtx` has admitted the request. + const scope = this.requestScope(); + const inferred = this.ensureCube(query, scope); + const ctx = await this.callCtx(query, context, tokenCtx, scope); + this.publishInferredCube(inferred); const strategy = this.resolveStrategy(query, ctx); this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" → ${strategy.name}`); @@ -2194,11 +2235,15 @@ export class AnalyticsService implements IAnalyticsService { * naming a real mistake either way. * * [#20356] "Registered" means registered in `scope`: the cube is read from it - * and what this method mints is recorded in it. On the shared scope that is - * the service's registry; on a `queryDataset` call's scope it is the call's - * own, so augmenting a request's dataset never reaches the shared registry. + * and what this method mints is recorded in it — the call's own request + * scope, on every door, so nothing minted here reaches the shared registry. + * + * [#20381] Returns the cube it INFERRED (the no-cube branch), and nothing + * otherwise: the ad-hoc doors publish that one to the shared registry once + * the request is admitted ({@link publishInferredCube}). An augmented cube is + * not returned, because it is never published. */ - private ensureCube(query: AnalyticsQuery, scope: CubeScope): void { + private ensureCube(query: AnalyticsQuery, scope: CubeScope): Cube | undefined { const name = query.cube!; let cube = scope.getCube(name); @@ -2242,7 +2287,7 @@ export class AnalyticsService implements IAnalyticsService { `Define an explicit Cube in your stack for full control.`; if (isScalarMetric) this.logger.debug(message); else this.logger.warn(message); - return; + return cube; } // Cube exists — check for unknown measures referenced by the query and @@ -2305,6 +2350,7 @@ export class AnalyticsService implements IAnalyticsService { this.assertDimensionFields(query, cube, Object.keys(cube.dimensions)); this.assertWhereFields(query, cube, Object.keys(cube.dimensions)); } + return undefined; } /**