diff --git a/.changeset/20381-adhoc-cube-request-scope.md b/.changeset/20381-adhoc-cube-request-scope.md index e3871f75d4c..5575ef76f48 100644 --- a/.changeset/20381-adhoc-cube-request-scope.md +++ b/.changeset/20381-adhoc-cube-request-scope.md @@ -7,5 +7,5 @@ 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. +- **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. A cube inferred for an ADMITTED ad-hoc query is no longer registered either; the separate #20381 entry that retires inferred-cube registration describes that change. 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. diff --git a/.changeset/20381-retire-inferred-cube-source.md b/.changeset/20381-retire-inferred-cube-source.md new file mode 100644 index 00000000000..f83b96e93a0 --- /dev/null +++ b/.changeset/20381-retire-inferred-cube-source.md @@ -0,0 +1,11 @@ +--- +"@objectstack/service-analytics": patch +--- + +A cube `AnalyticsService` infers for an ad-hoc `query()` or `generateSql()` request is no longer registered in the service-wide cube registry, even when the request is admitted, so `getMeta()` and `GET /api/v1/analytics/meta` list configured cubes only (#20381). + +Clause-②: no + +- **What changes**: an ad-hoc request naming an object that no cube is configured over (`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql`) is still served from a minimal cube inferred from that request's own members. That cube now lives only in the request that inferred it, like a suffix measure a caller appends to a configured cube. Before, an admitted request left it in the shared registry, so `getMeta()` listed it to every caller, including callers who may not read the object, together with the member names the first caller used. Its contents depended on who had queried what since boot, and it was lost on restart. +- **What does not change**: every request is served as before, with the same answer, admission, read scope, refusals, codes and statuses. A repeat request for the same object infers the cube again, through the same existence and source-field checks, and gets the same answer. Configured cubes (`AnalyticsServiceConfig.cubes`) and datasets registered through `registerDataset` (the constructor's `datasets`, or an embedder) are registered and listed as before, and they are now the registry's only writers. +- **What to do**: nothing, unless something reads `getMeta()` / `GET /api/v1/analytics/meta` expecting to find a cube that only an ad-hoc query inferred. No consumer in this repository does. Author that cube explicitly (`defineCube`, or the analytics service's `cubes` config) so that it is listed, and listed the same way after a restart. 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 index cb26cd9fde3..1d6ab40f68f 100644 --- a/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts @@ -1,9 +1,10 @@ // 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). +// `POST /analytics/sql` changes nothing another member sees — refused or +// admitted. A measure the caller named on top of a configured cube, and a cube +// inferred for an object no cube is configured over, stay that request's own +// (#20381). // // ## The defect // @@ -14,17 +15,21 @@ // `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. +// for #20356), and nothing minted there leaves it. An ADMITTED request's +// inferred cube used to be published to the shared registry ("CubeRegistry +// source 3"), so `meta` listed to every member an object someone had queried +// and the member names they used; that source is retired (ruling A on +// #20381), and the registry is written by configuration alone. // // ## 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 analytics registry through the two doors B uses — the `meta` listing, +// kept as the raw response bytes as well as parsed, and B's query of the +// configured cube — taken immediately before and after each leg and compared +// for EQUALITY, so neither a partial rewrite nor an added cube can pass. // // ## The legs, on each door // @@ -41,12 +46,12 @@ // // - 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. +// - An admitted scalar metric over an object is served on a second request +// too, with the same answer: it infers again, because nothing was +// published between the two, and B's view is unchanged. +// - The administrator's admitted ad-hoc query over the walled object leaves +// B's `meta` byte-identical and B's configured-cube query unchanged, and B's +// own query of that object is still refused on both doors. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; @@ -123,7 +128,7 @@ interface Boot { } interface Observation { - meta: { status: number; body: unknown }; + meta: { status: number; body: unknown; bytes: string }; authored: { status: number; body: unknown }; } @@ -133,10 +138,16 @@ async function read(res: Response): Promise<{ status: number; body: unknown }> { return { status: res.status, body: await res.json() }; } +/** A response read as its raw bytes too — for the listing whose sameness is the pin. */ +async function readBytes(res: Response): Promise<{ status: number; body: unknown; bytes: string }> { + const bytes = await res.text(); + return { status: res.status, body: JSON.parse(bytes), bytes }; +} + /** 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')), + meta: await readBytes(await stack.apiAs(tokenB, 'GET', '/analytics/meta')), authored: await read( await stack.apiAs(tokenB, 'POST', '/analytics/query', { cube: 'open_summary', @@ -147,11 +158,16 @@ async function observeAsB(stack: VerifyStack, tokenB: string): Promise (c as { name: string }).name); +} + /** 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; @@ -257,35 +273,50 @@ describe.each(CASES)( expect(await observeAsB(stack, tokenB)).toEqual(before); }); - it('CONTROL: an admitted scalar metric over an object still works on a second request', async () => { + it('CONTROL: an admitted scalar metric over an object is served again on a second request, re-inferred, with the same answer', async () => { const { stack, tokenA, tokenB } = boots.get(key)!; const before = await observeAsB(stack, tokenB); + const answers: unknown[] = []; 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'); + answers.push(body); + // Between the two, not even the asker's own `meta` lists the name, so + // the second request cannot resolve it from the registry: it infers. + if (i === 0) { + const askersMeta = await read(await stack.apiAs(tokenA, 'GET', '/analytics/meta')); + expect(cubeNamesOf(askersMeta)).not.toContain('admission_open'); + } } + expect(answers[1]).toEqual(answers[0]); const after = await observeAsB(stack, tokenB); - expect(after.authored).toEqual(before.authored); - expect(cubesOf(after.meta)).toEqual(expect.arrayContaining(cubesOf(before.meta))); + expect(cubesOf(after.meta)).toEqual(cubesOf(before.meta)); + expect(after).toEqual(before); }); - it('CONTROL: the administrator\'s admitted ad-hoc query over the walled object widens nothing for B', async () => { + it('CONTROL: the administrator\'s admitted ad-hoc query over the walled object leaves B\'s meta byte-identical', 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. + // B still may not read the object. await expectRefused(await stack.apiAs(tokenB, 'POST', door, { cube: 'admission_walled', measures: ['count'] })); const after = await observeAsB(stack, tokenB); + // Stated first on its own, so a failure reads as the defect: B's cube list + // is EXACTLY what it was — nothing named after the walled object joined it. + expect(cubeNamesOf(after.meta)).toEqual(cubeNamesOf(before.meta)); + expect(cubesOf(after.meta)).toEqual(cubesOf(before.meta)); + expect(after.meta.bytes).toBe(before.meta.bytes); + // …and B's query of the configured cube answers as before. expect(after.authored).toEqual(before.authored); - expect(cubesOf(after.meta)).toEqual(expect.arrayContaining(cubesOf(before.meta))); + expect(countOf(after.authored.body, 'authored_total')).toBe(B_OPEN_ROWS); }); }, ); 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 index 843a93e1113..d18d9e4f512 100644 --- 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 @@ -3,8 +3,9 @@ /** * [#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. + * every caller shares: not the measures a caller names on top of a configured + * cube, and not the cube they infer for an object no cube is configured over, + * whether the request is refused or admitted. * * `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 @@ -17,9 +18,11 @@ * 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. + * nothing minted there leaves it. An admitted request's inferred cube used to + * be published to the shared registry once admitted ("CubeRegistry source 3"); + * that source is retired (ruling A on #20381), because `getMeta` then listed, + * to every caller, an object someone had queried and the member names they + * used. The shared registry is written by configuration alone. * * ## What each case is shaped to catch * @@ -30,17 +33,18 @@ * 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. + * - The ADMITTED inference leg pins the outcome — the request is served from + * the cube inferred for it, and the observer's view is EXACTLY what it was — + * and the order: the admission provider reads the registry at the moment it + * is asked, and nothing has been written by then either. + * - CONTROL: a configured cube still serves, and a second same-name request + * infers again and gets the same answer. */ 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 type { AnalyticsQuery, AnalyticsStrategy } from '@objectstack/spec/contracts'; import { AnalyticsService } from '../analytics-service.js'; const silentLogger = { @@ -102,14 +106,39 @@ const DOORS: ReadonlyArray<{ door: string; run: Door }> = [ type DriverCall = { object: string; detail: unknown }; +/** + * The cube each request's strategies were handed for its name — the request + * scope's answer, which is the only place an inferred cube now lives. A probe + * ahead of every built-in strategy records `ctx.getCube(query.cube)` and + * declines, so the chain runs exactly as it would without it. + */ +function requestCubeProbe() { + const handed: Array<{ name: string; cube: Cube | undefined }> = []; + const strategy: AnalyticsStrategy = { + name: 'RequestCubeProbe', + priority: 0, + canHandle: (query, ctx) => { + handed.push({ name: query.cube!, cube: ctx.getCube(query.cube!) }); + return false; + }, + execute: async () => { throw new Error('RequestCubeProbe never handles a query'); }, + generateSql: async () => { throw new Error('RequestCubeProbe never handles a query'); }, + }; + /** Every cube handed to a request for `name`, in request order. */ + const cubesFor = (name: string) => handed.filter((h) => h.name === name).map((h) => h.cube); + return { strategy, cubesFor }; +} + 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 probe = requestCubeProbe(); const svc: AnalyticsService = new AnalyticsService({ logger: silentLogger, + strategies: [probe.strategy], cubes: [OPEN_SUMMARY, WALLED_SUMMARY], queryCapabilities: capabilities, admitObjectRead: async (object) => { @@ -127,7 +156,7 @@ function makeService( return [row]; }, }); - return { svc, calls }; + return { svc, calls, cubesFor: probe.cubesFor }; } type Harness = ReturnType; @@ -195,31 +224,39 @@ describe.each(STRATEGY_PATHS)('[#20381] the ad-hoc doors leave the shared regist 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 () => { + it('an ADMITTED ad-hoc query over an object is served from its own inferred cube, and leaves the shared registry as configured', 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 names = h.svc.cubeRegistry.names(); const before = await observe(h); + const from = h.calls.length; - await run(h.svc, { cube: OTHER, measures: ['count'] }, CALLER_A); + const answer = 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)); + // Served, from the cube inferred for this request — over the object, with + // exactly the members it named — on `query` through the driver, on + // `generateSql` in the statement it hands back. + const [handed] = h.cubesFor(OTHER); + expect(handed).toMatchObject({ name: OTHER, sql: OTHER }); + expect(Object.keys(handed!.measures)).toEqual(['count']); + expect(JSON.stringify([answer, h.calls.slice(from)])).toContain(OTHER); + // The outcome (#20381, registry source 3 retired): that cube stayed in + // its request. The registry holds the configured cubes and nothing else, + // and the observer's discovery and query are EXACTLY what they were. + expect(h.svc.cubeRegistry.names()).toEqual(names); + expect(h.svc.cubeRegistry.get(OTHER)).toBeUndefined(); + expect(await observe(h)).toEqual(before); }); 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. + // being admitted: nothing the request minted may replace it. const authored: Cube = { ...OPEN_SUMMARY, name: OTHER, title: 'Authored other', sql: OTHER }; const holder: { svc?: AnalyticsService } = {}; const h = makeService(capabilities, (object) => { @@ -233,22 +270,40 @@ describe.each(STRATEGY_PATHS)('[#20381] the ad-hoc doors leave the shared regist }); }); - it('CONTROL: an admitted scalar metric over an object still works on a second request, through the published cube', async () => { + it('CONTROL: a second same-name request infers again and gets the same answer', 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); + const q = { cube: OTHER, measures: ['count'] }; + + const firstFrom = h.calls.length; + const first = await h.svc.query(q, CALLER_A); + const firstDriven = h.calls.slice(firstFrom); + expect(firstDriven.map((c) => c.object)).toEqual([OTHER]); + // Nothing was published: the name still resolves to nothing shared. + expect(h.svc.cubeRegistry.get(OTHER)).toBeUndefined(); + + // The same caller asks again: the same driver call, the same answer. + const secondFrom = h.calls.length; + const second = await h.svc.query(q, CALLER_A); + expect(second).toEqual(first); + expect(h.calls.slice(secondFrom)).toEqual(firstDriven); + // Each request was handed a cube inferred for it — two equal mints, not + // one shared cube. + const [firstCube, secondCube] = h.cubesFor(OTHER); + expect(secondCube).toEqual(firstCube); + expect(secondCube).not.toBe(firstCube); + + // A request naming its own suffix measure is served with it… + const suffixFrom = h.calls.length; + const withSuffix = await h.svc.query({ cube: OTHER, measures: ['count', 'amount_sum'] }, CALLER_B); + expect(withSuffix.rows).toHaveLength(1); + const driven = h.calls.slice(suffixFrom); 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']); + // …and the measure was that request's own: the next plain request's cube + // is minted from its own members, `count` alone. + await h.svc.query(q, CALLER_A); + const cubes = h.cubesFor(OTHER); + expect(Object.keys(cubes[cubes.length - 1]!.measures)).toEqual(['count']); + expect(h.svc.cubeRegistry.get(OTHER)).toBeUndefined(); }); }); diff --git a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts index 1ee6c8df071..f1dba3a3065 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Cube } from '@objectstack/spec/data'; -import type { AnalyticsQuery, AnalyticsResult, IAnalyticsService } from '@objectstack/spec/contracts'; +import type { AnalyticsQuery, AnalyticsResult, AnalyticsStrategy, IAnalyticsService } from '@objectstack/spec/contracts'; import { AnalyticsService } from '../analytics-service.js'; import { CubeRegistry } from '../cube-registry.js'; import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; @@ -561,8 +561,24 @@ describe('AnalyticsService', () => { // when no Cube has been declared for "case" — used to crash with // "Cannot read properties of undefined (reading 'sql')". const executeAggregate = vi.fn().mockResolvedValue([{ 'case.count': 7 }]); + // [#20381] The inferred cube lives only in this request — it is never + // registered — so it is read where the request's strategies read it: a + // probe ahead of them records `ctx.getCube` and declines. + const handed: Cube[] = []; + const probe: AnalyticsStrategy = { + name: 'RequestCubeProbe', + priority: 0, + canHandle: (q, ctx) => { + const cube = ctx.getCube(q.cube!); + if (cube) handed.push(cube); + return false; + }, + execute: vi.fn(), + generateSql: vi.fn(), + }; const service = new AnalyticsService({ logger: silentLogger, + strategies: [probe], queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), executeAggregate, }); @@ -580,7 +596,11 @@ describe('AnalyticsService', () => { ]), })); expect(result.rows).toBeDefined(); - expect(service.cubeRegistry.has('case')).toBe(true); + // A minimal cube was inferred for the request, over the object itself… + expect(handed).toHaveLength(1); + expect(handed[0]).toMatchObject({ name: 'case', sql: 'case' }); + // …and it stayed in the request: the shared registry does not gain it. + expect(service.cubeRegistry.has('case')).toBe(false); }); it('should auto-infer measures from suffix conventions (_sum, _avg, _max)', async () => { diff --git a/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts b/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts index 0cab0adbe4e..f243a7dadde 100644 --- a/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts @@ -50,6 +50,8 @@ function makeService(opts: { wireRegistry?: boolean; } = {}) { const aggregated: string[] = []; + /** Every name the existence gate was asked about. */ + const gateAsked: string[] = []; const service = new AnalyticsService({ logger: silentLogger, ...(opts.cubes ? { cubes: opts.cubes } : {}), @@ -60,9 +62,14 @@ function makeService(opts: { }, ...(opts.wireRegistry === false ? {} - : { isRegisteredObject: (n: string) => (opts.knownObjects ?? []).includes(n) }), + : { + isRegisteredObject: (n: string) => { + gateAsked.push(n); + return (opts.knownObjects ?? []).includes(n); + }, + }), }); - return { service, aggregated }; + return { service, aggregated, gateAsked }; } const CUBE_NOT_FOUND = { code: 'CUBE_NOT_FOUND', status: 404 }; @@ -101,13 +108,20 @@ describe('#3867 — cube auto-inference existence gate', () => { }); it('still auto-infers for a REGISTERED object — the intended KPI path is unchanged', async () => { - const { service, aggregated } = makeService({ knownObjects: ['crm_account'] }); + const { service, aggregated, gateAsked } = makeService({ knownObjects: ['crm_account'] }); const result = await service.query({ cube: 'crm_account', measures: ['count'] } as any); expect(result).toBeTruthy(); expect(aggregated).toEqual(['crm_account']); - expect(service.cubeRegistry.get('crm_account')).toBeTruthy(); + expect(gateAsked).toEqual(['crm_account']); + // [#20381] The inferred cube served its own request and was not + // registered, so the next request of the name infers again — through + // this gate again — and is served the same way. + expect(service.cubeRegistry.get('crm_account')).toBeUndefined(); + await service.query({ cube: 'crm_account', measures: ['count'] } as any); + expect(aggregated).toEqual(['crm_account', 'crm_account']); + expect(gateAsked).toEqual(['crm_account', 'crm_account']); }); it('never gates an authored cube — its `sql` is whatever it declares', async () => { diff --git a/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts b/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts index 19009a1a94b..ed3bbea4375 100644 --- a/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts @@ -28,11 +28,13 @@ * key (input shape, and parsed through `CubeSchema` the way `defineCube` does), * are listed and answered; * - the three INTERNAL producers (`inferCubeFromQuery`, `compileDataset`, - * `CubeRegistry.inferFromObject`) mint visible cubes, so the ad-hoc KPI path - * and the dataset door — whose `DatasetExecutor` queries run through the - * same gate, asked of the call's own request scope — keep answering after - * the flag became enforced, and a dataset named like a hidden cube runs as - * itself rather than answering in a way that would reveal the hidden name. + * `CubeRegistry.inferFromObject`) mint visible cubes — moot for the first, + * whose cube is never registered (#20381), so no visibility verdict reads + * it — so the ad-hoc KPI path and the dataset door (whose `DatasetExecutor` + * queries run through the same gate, asked of the call's own request scope) + * keep answering after the flag became enforced, and a dataset named like a + * hidden cube runs as itself rather than answering in a way that would + * reveal the hidden name. */ import { describe, it, expect, vi } from 'vitest'; @@ -191,17 +193,18 @@ describe('analytics_cube.public — the controls stay open', () => { }); describe('analytics_cube.public — the internal producers mint visible cubes', () => { - it('the ad-hoc KPI path: an inferred cube is answered again on the next request, and listed', async () => { + it('the ad-hoc KPI path: an inferred cube is answered on every request, and never registered or listed', async () => { const { service, aggregated } = makeService([]); await service.query({ cube: 'crm_account', measures: ['count'] }); - // The first request REGISTERED the inferred cube; the second resolves it - // from the registry, which is where a hidden verdict would now refuse it. + // [#20381] The first request's inferred cube stayed in that request, so + // the second infers again — nothing registered under the name that a + // hidden verdict could ever refuse it by. await service.query({ cube: 'crm_account', measures: ['count'] }); expect(aggregated).toEqual(['crm_account', 'crm_account']); - expect(service.cubeRegistry.get('crm_account')?.public).toBe(true); - expect((await service.getMeta()).map((c) => c.name)).toEqual(['crm_account']); + expect(service.cubeRegistry.get('crm_account')).toBeUndefined(); + expect((await service.getMeta()).map((c) => c.name)).toEqual([]); }); it('the dataset door: `queryDataset` runs its compiled cube through the same gate, and it answers', async () => { diff --git a/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts b/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts index 107b890f493..649e3ea89ef 100644 --- a/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts @@ -62,6 +62,11 @@ * live server. Block 2 is that half; `mintableMeasureKey` is the one rule both * sites call. * + * [#20381] The ad-hoc mint no longer registers what it infers — an inferred + * cube lives only in its own request — so ② above is cold again and meets the + * first site's refusal. The second site is still reached by a cube the registry + * holds, which is now configuration only; block 2's AUTHORED case is that path. + * * ## Reverse verification, direction predicted BEFORE running * * Restoring the blanket strip at either mint site turns blocks 1 and 2 RED, in @@ -94,6 +99,7 @@ import { describe, it, expect, vi } from 'vitest'; import type { Cube } from '@objectstack/spec/data'; +import type { AnalyticsStrategy } from '@objectstack/spec/contracts'; import { AnalyticsService } from '../analytics-service.js'; const silentLogger = { @@ -122,11 +128,36 @@ type Refusal = Error & { measure?: string; }; +/** + * [#20381] The cube a request's strategies read for its name. An inferred cube + * lives only in the request that minted it — it is never registered, so + * `getMeta` never lists it — and this is the window onto it that remains: a + * probe ahead of every built-in strategy records `ctx.getCube(query.cube)` and + * declines, so the chain runs exactly as it would without it. + */ +function requestCubeProbe() { + const seen: Cube[] = []; + const strategy: AnalyticsStrategy = { + name: 'RequestCubeProbe', + priority: 0, + canHandle: (query, ctx) => { + const cube = ctx.getCube(query.cube!); + if (cube) seen.push(cube); + return false; + }, + execute: async () => { throw new Error('RequestCubeProbe never handles a query'); }, + generateSql: async () => { throw new Error('RequestCubeProbe never handles a query'); }, + }; + return { strategy, seen }; +} + function makeService(opts: { native?: boolean; cubes?: Cube[] } = {}) { const sqls: string[] = []; const calls: Array<{ object: string; aggregations?: unknown }> = []; + const probe = requestCubeProbe(); const service = new AnalyticsService({ logger: silentLogger, + strategies: [probe.strategy], ...(opts.cubes ? { cubes: opts.cubes } : {}), queryCapabilities: () => ({ nativeSql: !!opts.native, @@ -144,12 +175,12 @@ function makeService(opts: { native?: boolean; cubes?: Cube[] } = {}) { isRegisteredObject: (n: string) => n === 'crm_account', getObjectFieldNames: (n: string) => (n === 'crm_account' ? ACCOUNT_FIELDS : undefined), } as any); - return { service, sqls, calls }; + return { service, sqls, calls, cubes: probe.seen }; } /** Run one query on a fresh service and report everything it produced. */ async function run(query: unknown, opts: { native?: boolean; cubes?: Cube[] } = {}) { - const { service, sqls, calls } = makeService(opts); + const { service, sqls, calls, cubes } = makeService(opts); let rows: unknown[] | undefined; let error: Refusal | undefined; try { @@ -157,15 +188,16 @@ async function run(query: unknown, opts: { native?: boolean; cubes?: Cube[] } = } catch (e) { error = e as Refusal; } - const [meta] = await service.getMeta((query as { cube: string }).cube); - const cubePrefix = `${(query as { cube: string }).cube}.`; + // The cube this request's strategies were handed — none when the mint + // refused the query before any strategy was asked. + const cube = cubes[cubes.length - 1]; return { rows, error, sqls, calls, service, - measures: (meta?.measures ?? []).map((m) => m.name.replace(cubePrefix, '')).sort(), + measures: Object.keys(cube?.measures ?? {}).sort(), }; } @@ -274,16 +306,20 @@ describe('[#5918] a dotted measure is refused, naming the caller\'s spelling', ( // ── 2. The second mint site: a WARM registry ───────────────────────────────── describe('[#5918] the warm registry gets the same answer as the cold one', () => { - it('NativeSQL — a first query registers the inferred cube; the second is still refused', async () => { - // The ad-hoc path registers what it infers, so request #2 arrives at - // `ensureCube`'s augmentation loop instead of `inferCubeFromQuery`. Before - // the ruling that loop still blanket-stripped, so a live server silently - // aggregated the wrong column from the second request onwards. + it('NativeSQL — a first query no longer warms the registry; the second infers again and is still refused', async () => { + // When #5918 landed, the ad-hoc path registered what it inferred, so + // request #2 arrived at `ensureCube`'s augmentation loop instead of + // `inferCubeFromQuery`; before the ruling that loop still blanket-stripped, + // so a live server silently aggregated the wrong column from the second + // request onwards. [#20381] retired that registration: the first request's + // cube lives only in that request, so request #2 is cold again and meets + // the inference mint's refusal. The augmentation loop is still reached by + // a cube the registry DOES hold — the AUTHORED case below. const { service, sqls } = makeService({ native: true }); await service.query({ cube: 'crm_account', measures: ['count'] } as any); expect(sqls).toEqual(['SELECT COUNT(*) AS "count" FROM "crm_account"']); - expect(service.cubeRegistry.get('crm_account')).toBeDefined(); + expect(service.cubeRegistry.get('crm_account')).toBeUndefined(); const error = await service .query({ cube: 'crm_account', measures: ['owner.region_count_distinct'] } as any) @@ -292,8 +328,9 @@ describe('[#5918] the warm registry gets the same answer as the cold one', () => expectDottedMeasureRefusal(error, 'owner.region_count_distinct'); // Nothing new executed… expect(sqls).toEqual(['SELECT COUNT(*) AS "count" FROM "crm_account"']); - // …and the registered cube was not augmented with the bogus measure either. - expect(Object.keys(service.cubeRegistry.get('crm_account')!.measures)).toEqual(['count']); + // …and no cube anywhere carries the bogus measure: the registry still + // holds nothing under the name. + expect(service.cubeRegistry.get('crm_account')).toBeUndefined(); }); it('ObjectQL — same', async () => { diff --git a/packages/services/service-analytics/src/__tests__/infer-cube-relation-traversal.test.ts b/packages/services/service-analytics/src/__tests__/infer-cube-relation-traversal.test.ts index 25421b14cbf..df7dbf6623c 100644 --- a/packages/services/service-analytics/src/__tests__/infer-cube-relation-traversal.test.ts +++ b/packages/services/service-analytics/src/__tests__/infer-cube-relation-traversal.test.ts @@ -66,6 +66,8 @@ */ import { describe, it, expect, vi } from 'vitest'; +import type { Cube } from '@objectstack/spec/data'; +import type { AnalyticsStrategy } from '@objectstack/spec/contracts'; import { AnalyticsService } from '../analytics-service.js'; const silentLogger = { @@ -86,10 +88,34 @@ const ACCOUNT_FIELDS = ['id', 'name', 'industry', 'region', 'owner', 'created_at /** What the BASE table's own `region` column answers — never the right answer here. */ const BASE_REGION = 'BASE-REGION'; +/** + * [#20381] The cube a request's strategies read for its name. An inferred cube + * lives only in the request that minted it — it is never registered, so + * `getMeta` never lists it — and this is the window onto it that remains: a + * probe ahead of every built-in strategy records `ctx.getCube(query.cube)` and + * declines, so the chain runs exactly as it would without it. A query a gate + * refuses inside `ensureCube` reaches no strategy, and the probe sees nothing. + */ +function requestCubeProbe() { + const seen: Cube[] = []; + const strategy: AnalyticsStrategy = { + name: 'RequestCubeProbe', + priority: 0, + canHandle: (query, ctx) => { + const cube = ctx.getCube(query.cube!); + if (cube) seen.push(cube); + return false; + }, + execute: async () => { throw new Error('RequestCubeProbe never handles a query'); }, + generateSql: async () => { throw new Error('RequestCubeProbe never handles a query'); }, + }; + return { strategy, seen }; +} + /** * A service with NO registered cube for `crm_account`, so every query takes the - * auto-inference path. One service per query: `ensureCube` registers what it - * infers, so a second query on the same service would find the cube. + * auto-inference path — every query, since nothing a request infers is + * registered (#20381). * * `executeAggregate` is a two-object double. It serves the base aggregate AND * the FK→attribute read the ObjectQL cross-object plan issues against `owner`, @@ -100,8 +126,10 @@ const BASE_REGION = 'BASE-REGION'; function makeService(opts: { native?: boolean; fields?: string[] } = {}) { const sqls: string[] = []; const calls: Array<{ object: string; groupBy?: unknown; filter?: unknown }> = []; + const probe = requestCubeProbe(); const service = new AnalyticsService({ logger: silentLogger, + strategies: [probe.strategy], queryCapabilities: () => ({ nativeSql: !!opts.native, objectqlAggregate: !opts.native, @@ -135,12 +163,12 @@ function makeService(opts: { native?: boolean; fields?: string[] } = {}) { getObjectFieldNames: (n: string) => n === 'crm_account' ? (opts.fields ?? ACCOUNT_FIELDS) : undefined, } as any); - return { service, sqls, calls }; + return { service, sqls, calls, cubes: probe.seen }; } /** Run one query and report everything it produced, however it settled. */ async function run(query: unknown, opts: { native?: boolean; fields?: string[] } = {}) { - const { service, sqls, calls } = makeService(opts); + const { service, sqls, calls, cubes } = makeService(opts); let rows: unknown[] | undefined; let error: (Error & { code?: string; status?: number; field?: string }) | undefined; try { @@ -148,16 +176,17 @@ async function run(query: unknown, opts: { native?: boolean; fields?: string[] } } catch (e) { error = e as Error & { code?: string }; } - const [meta] = await service.getMeta((query as { cube: string }).cube); - const cubePrefix = `${(query as { cube: string }).cube}.`; + // The cube this request's strategies were handed — none when a gate refused + // the query before any strategy was asked. + const cube = cubes[cubes.length - 1]; return { rows, error, sqls, calls, - // `getMeta` hands members out cube-prefixed; the KEY is what the mint produced. - dimensions: (meta?.dimensions ?? []).map((d) => d.name.replace(cubePrefix, '')).sort(), - measures: (meta?.measures ?? []).map((m) => m.name.replace(cubePrefix, '')).sort(), + // The KEY is what the mint produced. + dimensions: Object.keys(cube?.dimensions ?? {}).sort(), + measures: Object.keys(cube?.measures ?? {}).sort(), }; } diff --git a/packages/services/service-analytics/src/__tests__/infer-cube-where-spelling-parity.test.ts b/packages/services/service-analytics/src/__tests__/infer-cube-where-spelling-parity.test.ts index eececb464e8..f250bd64adc 100644 --- a/packages/services/service-analytics/src/__tests__/infer-cube-where-spelling-parity.test.ts +++ b/packages/services/service-analytics/src/__tests__/infer-cube-where-spelling-parity.test.ts @@ -65,7 +65,8 @@ */ import { describe, it, expect, vi } from 'vitest'; -import type { FilterCondition } from '@objectstack/spec/data'; +import type { Cube, FilterCondition } from '@objectstack/spec/data'; +import type { AnalyticsStrategy } from '@objectstack/spec/contracts'; import { AnalyticsService } from '../analytics-service.js'; const silentLogger = { @@ -79,16 +80,41 @@ const silentLogger = { /** The columns `deal` really has — the source-field gates (#4437/#5520/#5669) read these. */ const DEAL_FIELDS = ['id', 'stage', 'owner', 'amount', 'closed_at']; +/** + * [#20381] The cube a request's strategies read for its name. An inferred cube + * lives only in the request that minted it — it is never registered, so + * `getMeta` never lists it — and this is the window onto it that remains: a + * probe ahead of every built-in strategy records `ctx.getCube(query.cube)` and + * declines, so the chain runs exactly as it would without it. + */ +function requestCubeProbe() { + const seen: Cube[] = []; + const strategy: AnalyticsStrategy = { + name: 'RequestCubeProbe', + priority: 0, + canHandle: (query, ctx) => { + const cube = ctx.getCube(query.cube!); + if (cube) seen.push(cube); + return false; + }, + execute: async () => { throw new Error('RequestCubeProbe never handles a query'); }, + generateSql: async () => { throw new Error('RequestCubeProbe never handles a query'); }, + }; + return { strategy, seen }; +} + /** * A service with NO registered cube for `deal`, so every query takes the - * auto-inference path. One service per query: `ensureCube` registers what it - * infers, so a second query would find the cube and never infer again. + * auto-inference path — every query, since nothing a request infers is + * registered (#20381). `cubes` holds what each request's strategies read. */ function makeService(opts: { native?: boolean; fields?: string[] } = {}) { const sqls: string[] = []; const filters: unknown[] = []; + const probe = requestCubeProbe(); const service = new AnalyticsService({ logger: silentLogger, + strategies: [probe.strategy], queryCapabilities: () => ({ nativeSql: !!opts.native, objectqlAggregate: !opts.native, @@ -105,17 +131,17 @@ function makeService(opts: { native?: boolean; fields?: string[] } = {}) { isRegisteredObject: (n: string) => n === 'deal', getObjectFieldNames: (n: string) => (n === 'deal' ? (opts.fields ?? DEAL_FIELDS) : undefined), }); - return { service, sqls, filters }; + return { service, sqls, filters, cubes: probe.seen }; } -/** The ad-hoc cube's dimension keys, read through the public discovery API. */ +/** The ad-hoc cube's dimension keys, read from the cube the request's strategies were handed. */ async function inferredDimensions(where: unknown, opts?: { native?: boolean; fields?: string[] }) { - const { service, sqls, filters } = makeService(opts); + const { service, sqls, filters, cubes } = makeService(opts); await service.query({ cube: 'deal', measures: ['count'], where } as never); - const [meta] = await service.getMeta('deal'); + expect(cubes).toHaveLength(1); return { - // `getMeta` prefixes with the cube name; the KEY is what seeding produced. - dimensions: meta.dimensions.map((d) => d.name.replace(/^deal\./, '')).sort(), + // The KEY is what seeding produced. + dimensions: Object.keys(cubes[0].dimensions).sort(), sqls, filters, }; @@ -266,19 +292,19 @@ describe('[#5353] inferCubeFromQuery — the `where` spelling does not change th } it('seeds the `where` keys ALONGSIDE the ones `dimensions` and `measures` contribute', async () => { - const { service } = makeService(); + const { service, cubes } = makeService(); await service.query({ cube: 'deal', measures: ['amount_sum'], dimensions: ['stage'], where: [['owner', '=', 'u1']], } as never); - const [meta] = await service.getMeta('deal'); + const [cube] = cubes; - expect(meta.dimensions.map((d) => d.name).sort()).toEqual(['deal.owner', 'deal.stage']); + expect(Object.keys(cube.dimensions).sort()).toEqual(['owner', 'stage']); // The measure arm is untouched by #5353 — `amount_sum` still infers a SUM // over `amount` rather than becoming a dimension. - expect(meta.measures.map((m) => m.name).sort()).toEqual(['deal.amount_sum', 'deal.count']); + expect(Object.keys(cube.measures).sort()).toEqual(['amount_sum', 'count']); }); }); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 1da0221eb97..ed3f461f5c4 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -856,13 +856,13 @@ 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 for a request - * the object-level admission ADMITTED (#20381); + * cubes, and the datasets `registerDataset` registered (the constructor's + * `datasets`, or an embedder). Configuration writes it; no request does; * - 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). + * `ensureCube` mints during the call — the cube it infers for a name nothing + * configured, or a measure it appends to one that was. 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 @@ -870,11 +870,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. 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. + * `register` writes only to itself, and it is dropped with the call. That holds + * for an ADMITTED request as well (#20381): a cube inferred for an ad-hoc query + * serves that query and nothing after it, so what `getMeta` lists never depends + * on who queried what since boot, and the next request of the same name infers + * again, through the same gates. */ interface CubeScope { getCube(name: string): Cube | undefined; @@ -1431,7 +1431,7 @@ export class AnalyticsService implements IAnalyticsService { * Any other error propagates untouched. */ async query(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise { - return this.queryIn(this.requestScope(), queryInput, context, { publishInferred: true }); + return this.queryIn(this.requestScope(), queryInput, context); } /** @@ -1439,17 +1439,13 @@ export class AnalyticsService implements IAnalyticsService { * 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. + * same question whichever scope answers the name. Neither door publishes + * what the call mints (#20381). */ 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'); @@ -1477,9 +1473,8 @@ export class AnalyticsService implements IAnalyticsService { const tokenCtx = filterTokenContextFrom(context, new Date()); const query = this.resolveQueryTokens(queryInput, tokenCtx); - const inferred = this.ensureCube(query, scope); + 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); @@ -1581,8 +1576,9 @@ export class AnalyticsService implements IAnalyticsService { * 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}. + * compiled dataset, so nothing they mint reaches the shared registry — not + * before `callCtx` has admitted the request, and not after: an inferred cube + * is dropped with its call exactly as an appended measure is. */ private requestScope(compiled?: CompiledDataset): CubeScope { const shared = this.sharedScope; @@ -1598,25 +1594,6 @@ export class AnalyticsService implements IAnalyticsService { }; } - /** - * [#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, @@ -2213,11 +2190,10 @@ export class AnalyticsService implements IAnalyticsService { const query = this.resolveQueryTokens(queryInput, tokenCtx); // [#20381] Same request scope as `query()`: nothing minted here reaches the - // shared registry before `callCtx` has admitted the request. + // shared registry, admitted or refused. const scope = this.requestScope(); - const inferred = this.ensureCube(query, scope); + 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}`); @@ -2269,14 +2245,10 @@ export class AnalyticsService implements IAnalyticsService { * * [#20356] "Registered" means registered in `scope`: the cube is read from it * 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. + * scope, on every door, so nothing minted here reaches the shared registry + * (#20381: an inferred cube included, whatever the admission answers). */ - private ensureCube(query: AnalyticsQuery, scope: CubeScope): Cube | undefined { + private ensureCube(query: AnalyticsQuery, scope: CubeScope): void { const name = query.cube!; let cube = scope.getCube(name); @@ -2320,7 +2292,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 cube; + return; } // Cube exists — check for unknown measures referenced by the query and @@ -2329,17 +2301,20 @@ export class AnalyticsService implements IAnalyticsService { // // [#5918] This is the SECOND measure mint, and it judges a dotted spelling // exactly as the ad-hoc one does — `mintableMeasureKey` owns the rule. It - // has to: the ad-hoc path REGISTERS what it infers, so from the second - // request onwards a cube minted moments ago by `inferCubeFromQuery` is - // "registered" and arrives here. Measured on `origin/main` `01faeb13a`, - // one service, two queries: + // has to: every cube that reaches this loop is one a caller did not mint in + // this request — a configured cube, a registered or requested dataset's — + // and the same spelling must get the same answer on it. When #5918 landed, + // the ad-hoc path also REGISTERED what it inferred, so a cube minted by + // `inferCubeFromQuery` arrived here from the second request onwards. + // Measured on `origin/main` `01faeb13a`, one service, two queries: // // ① measures: ['count'] → SELECT COUNT(*) … (warms the registry) // ② measures: ['owner.region_count_distinct'] → SELECT COUNT(DISTINCT region) AS "owner.region_count_distinct" // // i.e. the silent wrong column #5918 reports, reached through this loop - // instead of that one. Refusing in only one of the two would have closed the - // cold request and left every warm one exactly as it was. + // instead of that one. [#20381] That route is gone — an inferred cube now + // lives only in its own request, so ② infers again — but the loop still + // serves every configured cube, where the same refusal holds. // // A measure the cube DECLARES is never minted, so it never reaches the // rule — including a declared DOTTED key, which `lookupMember` resolves by @@ -2383,7 +2358,6 @@ export class AnalyticsService implements IAnalyticsService { this.assertDimensionFields(query, cube, Object.keys(cube.dimensions)); this.assertWhereFields(query, cube, Object.keys(cube.dimensions)); } - return undefined; } /** @@ -2891,9 +2865,10 @@ export class AnalyticsService implements IAnalyticsService { sql: cubeName, measures, dimensions, - // Visible: this cube is minted FOR the request that names it and is - // registered, so the next request resolves it from the registry — where - // a hidden verdict would refuse the very KPI path that minted it. + // Visible, and moot: this cube lives only in the request that minted it + // and is never registered (#20381), so no visibility verdict ever reads + // it — `getMeta` never sees it, and `assertCubePublic` runs before it + // exists. Kept as the literal the platform's own mints share. public: true, }; } diff --git a/packages/services/service-analytics/src/cube-registry.ts b/packages/services/service-analytics/src/cube-registry.ts index fb871b8e123..400b55c9424 100644 --- a/packages/services/service-analytics/src/cube-registry.ts +++ b/packages/services/service-analytics/src/cube-registry.ts @@ -10,7 +10,7 @@ import type { Cube } from '@objectstack/spec/data'; * `CubeMeta` titles served by `GET /api/v1/analytics/meta`, and the strategy * chain resolves a query's cube through it. * - * Three sources write to it, all of them from `AnalyticsService`: + * Two sources write to it, both of them configuration, from `AnalyticsService`: * 1. **Manifest definitions** — `AnalyticsServiceConfig.cubes` (`registerAll`), * i.e. explicit cube definitions authored in `objectstack.config.ts`. * 2. **Compiled datasets** (ADR-0021) — `compileDataset()`'s Cube, registered @@ -19,16 +19,21 @@ import type { Cube } from '@objectstack/spec/data'; * request's dataset into that call's own scope (#20356) — a registration * from a request would replace, for every caller, whatever cube the name * held. - * 3. **Ad-hoc query inference** — `ensureCube` / `inferCubeFromQuery` mints a - * minimal Cube from the members an `AnalyticsQuery` references, once - * `assertInferableCube` (#3867) has confirmed the name is a registered - * object. It infers from the QUERY, never from the object's field schema. + * + * ⛔ No request writes it (#20381). The cube `ensureCube` / `inferCubeFromQuery` + * mints for an ad-hoc `query` / `sql` request that names no registered cube — + * from the members that QUERY references, once `assertInferableCube` (#3867) + * has confirmed the name is a registered object — lives in that call's own + * scope and is dropped with it, admitted or refused, like a measure appended + * to a configured cube. Registering it made `getMeta` list, to every caller, + * an object someone had queried and the member names they used. * * This list used to read "two sources: manifest definitions, and object schema - * inference". Neither half was right: sources 2 and 3 were missing, and object - * schema inference is `inferFromObject` below, which no path in this repository - * calls (#15019). It is described at the method rather than advertised here, - * because listing it would promise a source the platform does not deliver. + * inference". Neither half was right: the compiled datasets were missing, and + * object schema inference is `inferFromObject` below, which no path in this + * repository calls (#15019). It is described at the method rather than + * advertised here, because listing it would promise a source the platform does + * not deliver. */ export class CubeRegistry { private cubes = new Map(); @@ -81,7 +86,7 @@ export class CubeRegistry { * * ⚠️ Nothing in this repository calls this — the only in-tree caller is a unit * test, and every cube the platform registers itself comes from one of the - * three sources named on the class above (#15019). That is not the same thing + * two sources named on the class above (#15019). That is not the same thing * as unreachable: `CubeRegistry` is exported from the package entry and * `AnalyticsService.cubeRegistry` is public, so a consumer of * `@objectstack/service-analytics` can call it, and what it mints does reach