From cf460c161dcb4299567f0756d37b56df4d19c274 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:56:11 +0000 Subject: [PATCH 1/2] fix(service-analytics)!: the delegated fallback face refuses a date bucket over a formula or non-date field, as the engine face does FallbackDelegateStrategy now runs the service's date-bucket door (assertDatasetDateBucketsTemporal) before it hands a query or a dry run to the host's fallback analytics service, with the keys the ObjectQL strategy's direct path would build and the context's declaredFieldType. Claude-Session: https://claude.ai/code/session_01ADHkmNhTaQLPMwKLDKtX46 Co-authored-by: Claude --- ...9364-fallback-date-bucket-refused-alike.md | 19 ++ ...fallback-delegate-date-bucket-type.test.ts | 223 ++++++++++++++++++ .../src/analytics-service.ts | 32 +++ .../src/dataset-date-bucket-door.ts | 7 + 4 files changed, 281 insertions(+) create mode 100644 .changeset/29364-fallback-date-bucket-refused-alike.md create mode 100644 packages/services/service-analytics/src/__tests__/fallback-delegate-date-bucket-type.test.ts diff --git a/.changeset/29364-fallback-date-bucket-refused-alike.md b/.changeset/29364-fallback-date-bucket-refused-alike.md new file mode 100644 index 00000000000..54259556bb2 --- /dev/null +++ b/.changeset/29364-fallback-date-bucket-refused-alike.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics)!: the delegated fallback face refuses a date bucket over a formula or a field that is not a date, as the engine face does (#29364) + +Clause-②: no (narrowing) — a cube query (or `/analytics/sql` dry run) that `FallbackDelegateStrategy` hands to the host's fallback analytics service is refused `INVALID_FIELD` / 400 at `groupBy.N.dateGranularity` when it buckets a `formula` field, or a field declared anything but a date or a datetime, with the engine face's refusal field for field, before the fallback service is called. It answered 200 with one `null` group before. Only a host that composes the fallback face sees the change; no shipped composition reaches it. Nothing is widened: no export, public member, answer member, error code or status is added. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing on the analytics fallback face (`@objectstack/service-analytics`), for hosts that compose it. + +- **Who sees it.** Only a host that composes the fallback face: `new AnalyticsService({ fallbackService })`, or `AnalyticsServicePlugin` capturing an analytics service registered before it (for example `MemoryAnalyticsService` from `@objectstack/driver-memory`) under a `queryCapabilities` override with `objectqlAggregate: false`. The default composition sends every bucketed query to the engine, which already refuses these, and is unchanged. +- **Before.** The fallback service folded the bucket itself and read no field type. `MemoryAnalyticsService` put every row into one `null` group for a `formula` bucket, and a `null` group beside whatever text parsed as a date for a `text` bucket, under an ordinary 200. The dry run handed back the fallback service's statement. +- **Now.** Both the query and the dry run answer the engine's refusal (`INVALID_FIELD` / 400, `param: 'groupBy'`, `path: 'groupBy.N.dateGranularity'`, the same message), and the fallback service is not called. The field's type is read from the host's `sourceFieldMeta`, which `AnalyticsServicePlugin` answers from the data engine. +- **Unchanged.** A date or datetime bucket is delegated as before. A host that wired no field types (`sourceFieldMeta`) is not judged, as on the draft preview and the dry run. + +**The remedy.** Bucket a date or datetime field. For a formula, store the value on a date or datetime field written when its source changes, and bucket that. diff --git a/packages/services/service-analytics/src/__tests__/fallback-delegate-date-bucket-type.test.ts b/packages/services/service-analytics/src/__tests__/fallback-delegate-date-bucket-type.test.ts new file mode 100644 index 00000000000..61383c61fdb --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/fallback-delegate-date-bucket-type.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29364] The delegated fallback face (`FallbackDelegateStrategy`) refuses a + * date bucket over a field that is not a date or a datetime, as the engine face + * refuses it, field for field. + * + * ## The shape this closes + * + * `FallbackDelegateStrategy` hands a cube query to the host's fallback + * `IAnalyticsService` (`MemoryAnalyticsService` from `@objectstack/driver-memory` + * is the in-repo one), which folds a bucketed time dimension itself and reads + * no field type. Measured on `origin/main` `aca58cdb` with + * `MemoryAnalyticsService` as the delegate, a month bucket over: + * + * | field | the fallback face answered | + * |:--|:--| + * | `formula` (virtual: no stored column) | 200, one `null` group holding every row | + * | `text` | 200, a `null` group beside whatever text parsed as a date | + * | `datetime` (the control) | 200, the right buckets | + * + * while the engine face (the default composition: real ObjectQL + SqlDriver on + * SQLite) refused the first two with `INVALID_FIELD` / 400 at + * `groupBy.N.dateGranularity`. No shipped composition reaches this face; the + * public construction paths do, and both are pinned below: + * `new AnalyticsService({ fallbackService })`, and `AnalyticsServicePlugin` + * capturing a pre-registered analytics service under a `queryCapabilities` + * override with `objectqlAggregate: false`. + * + * The delegate here is a recording double: the pin is the service's face (the + * refusal, and that the delegate was never called), not the delegate's fold. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { Cube } from '@objectstack/spec/data'; +import type { AnalyticsResult, IAnalyticsService } from '@objectstack/spec/contracts'; +import { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const DEAL = 'os29364_deal'; +const CUBE = 'os29364_cube'; + +const CUBES = [ + { + name: CUBE, + title: 'Fallback date bucket cube', + sql: DEAL, + public: true, + measures: { amount_sum: { type: 'sum', sql: 'amount', label: 'Amount' } }, + dimensions: { + calc: { type: 'time', sql: 'calc', label: 'Calc' }, + region: { type: 'time', sql: 'region', label: 'Region' }, + closed_at: { type: 'time', sql: 'closed_at', label: 'Closed at' }, + }, + }, +] as unknown as Cube[]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +type Query = Record; +const bucket = (...dims: Array<[dimension: string, granularity: string]>): Query => ({ + cube: CUBE, + measures: [`${CUBE}.amount_sum`], + timeDimensions: dims.map(([d, granularity]) => ({ dimension: `${CUBE}.${d}`, granularity })), +}); + +/** The rows and statement the recording delegate answers with. */ +const DELEGATED: AnalyticsResult = { + rows: [{ [`${CUBE}.closed_at`]: '2026-03', [`${CUBE}.amount_sum`]: 10 }], + fields: [{ name: `${CUBE}.closed_at`, type: 'string' }, { name: `${CUBE}.amount_sum`, type: 'number' }], +}; +const DELEGATED_SQL = { sql: 'SELECT 1 -- delegated', params: [] as unknown[] }; + +function recordingDelegate() { + return { + query: vi.fn(async () => DELEGATED), + generateSql: vi.fn(async () => DELEGATED_SQL), + getMeta: vi.fn(async () => []), + }; +} + +/** The refusal `p` rejects with, as the keys a caller reads; a resolution fails the case. */ +const refusalOf = async (face: string, p: Promise) => { + const err = (await p.then( + () => { throw new Error(`${face}: expected a refusal, but it resolved`); }, + (e: unknown) => e, + )) as Error & Record; + return { + message: err.message, code: err.code, status: err.status, httpStatus: err.httpStatus, + object: err.object, param: err.param, path: err.path, field: err.field, fields: err.fields, + }; +}; + +describe('[#29364] the fallback face refuses a date bucket over a field that is not a date, as the engine face does', () => { + let engine: ObjectQL; + /** The engine face: the default composition, no `queryCapabilities` override. */ + let engineFace: AnalyticsService; + + /** A field's declaration on the engine, as the plugin's `sourceFieldMeta` reads it. */ + const fieldMeta = (object: string, field: string) => { + const f = (engine as unknown as { getObject(o: string): { fields?: Record } | undefined }) + .getObject(object)?.fields?.[field]; + return f ? { type: f.type } : undefined; + }; + + beforeAll(async () => { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never); + engine = new ObjectQL({ logger: quiet } as never); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registry.registerObject({ + name: DEAL, + label: 'Deal', + fields: { + region: { name: 'region', type: 'text' }, + amount: { name: 'amount', type: 'number' }, + closed_at: { name: 'closed_at', type: 'datetime' }, + closed_on: { name: 'closed_on', type: 'date' }, + // A formula the table stores no column for, whose result is a date. + calc: { name: 'calc', type: 'formula', expression: 'closed_on', returnType: 'date' }, + }, + } as never); + await engine.syncSchemas(); + await installDefaultOrganization(engine as never); + await engine.insert(DEAL, { + id: 'd1', region: 'amer', amount: 10, closed_at: '2026-03-01T00:00:00.000Z', closed_on: '2026-03-02', + } as never); + + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: CUBES } as never).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + engineFace = registered.analytics as AnalyticsService; + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + /** `new AnalyticsService({ fallbackService })` with the host's field types. */ + const constructedFace = () => { + const delegate = recordingDelegate(); + const face = new AnalyticsService({ + cubes: CUBES, + logger: quiet as never, + fallbackService: delegate as unknown as IAnalyticsService, + sourceFieldMeta: fieldMeta, + } as never); + return { face, delegate }; + }; + + /** `AnalyticsServicePlugin` capturing a pre-registered analytics service, `objectqlAggregate: false`. */ + const pluginFace = async () => { + const delegate = recordingDelegate(); + const registered: Record = { analytics: delegate }; + await new AnalyticsServicePlugin({ + cubes: CUBES, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: false }), + } as never).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + return { face: registered.analytics as AnalyticsService, delegate }; + }; + + const FACES = [ + ['new AnalyticsService({ fallbackService })', constructedFace], + ['AnalyticsServicePlugin, objectqlAggregate: false', pluginFace], + ] as const; + + const REFUSED: Array<[label: string, query: Query, field: string, path: string]> = [ + ['a formula by month', bucket(['calc', 'month']), 'calc', 'groupBy.0.dateGranularity'], + ['a text column by month', bucket(['region', 'month']), 'region', 'groupBy.0.dateGranularity'], + ['a formula after a datetime bucket', bucket(['closed_at', 'year'], ['calc', 'month']), 'calc', 'groupBy.1.dateGranularity'], + ]; + + for (const [faceLabel, makeFace] of FACES) { + describe(faceLabel, () => { + it.each(REFUSED)('refuses %s with the engine face\'s refusal, on the query and the dry run, and never calls the delegate', async (_l, query, field, path) => { + const { face, delegate } = await makeFace(); + const asEngine = await refusalOf('engine', engineFace.query({ ...query } as never)); + // The engine face's own refusal, so a reader can see what is being matched. + expect(asEngine).toMatchObject({ code: 'INVALID_FIELD', status: 400, object: DEAL, param: 'groupBy', path, field, fields: [field] }); + + expect(await refusalOf('fallback query', face.query({ ...query } as never)), 'the fallback query vs the engine face').toEqual(asEngine); + expect(await refusalOf('fallback sql', face.generateSql({ ...query } as never)), 'the fallback dry run vs the engine face').toEqual(asEngine); + expect(delegate.query).not.toHaveBeenCalled(); + expect(delegate.generateSql).not.toHaveBeenCalled(); + }); + + it('serves a datetime bucket through the delegate, as before (the control)', async () => { + const { face, delegate } = await makeFace(); + const query = bucket(['closed_at', 'month']); + const result = await face.query({ ...query } as never); + expect(delegate.query).toHaveBeenCalledTimes(1); + expect(result.rows).toEqual(DELEGATED.rows); + expect(await face.generateSql({ ...query } as never)).toEqual(DELEGATED_SQL); + expect(delegate.generateSql).toHaveBeenCalledTimes(1); + }); + }); + } + + it('does not judge a bucket the host gave no field type for (no sourceFieldMeta), as the preview and the dry run', async () => { + const delegate = recordingDelegate(); + const face = new AnalyticsService({ + cubes: CUBES, + logger: quiet as never, + fallbackService: delegate as unknown as IAnalyticsService, + } as never); + await face.query({ ...bucket(['calc', 'month']) } as never); + expect(delegate.query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 4a00f263035..de8ad407a44 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -5009,6 +5009,36 @@ export function inferredCallerMeasureSql(measure: string, cubeName: string): str return inferMeasure(key, { member: measure, cube: cubeName }).sql; } +/** + * [#29364] The service's date-bucket door (`dataset-date-bucket-door.ts`) in + * front of the delegated service, on both the query and the dry run. + * + * The delegate (`MemoryAnalyticsService` is the in-repo one) folds a bucketed + * time dimension itself and reads no field type, so on `origin/main` `aca58cdb` + * a month bucket over a `formula` answered 200 with one `null` group holding + * every row, and over a `text` column one `null` group beside whatever text + * happened to parse as a date — while the engine face refused both. It is + * judged here with the keys the ObjectQL strategy's direct path would build + * and the context's `declaredFieldType` (the host's `sourceFieldMeta`), so this + * face answers the engine's refusal field for field. A host that wired no + * field types is not judged, as on the preview and the dry run. + * + * Reached by no shipped composition (the auto-mounted plugin always bridges + * `executeAggregate`, so every bucketed query goes to the engine); reached by + * a host composing `new AnalyticsService({ fallbackService })`, or a + * `queryCapabilities` override with `objectqlAggregate: false`. + */ +function assertDelegatedDateBucketsTemporal(query: AnalyticsQuery, ctx: StrategyContext): void { + const cube = ctx.getCube(query.cube!); + if (!cube) return; + const object = aggregateObjectOf(cube); + assertDatasetDateBucketsTemporal( + object, + aggregateGroupKeys(query, cube), + (field) => ctx.declaredFieldType?.(object, field), + ); +} + /** * FallbackDelegateStrategy — Internal strategy for fallback service delegation. * @@ -5026,10 +5056,12 @@ class FallbackDelegateStrategy implements AnalyticsStrategy { } async execute(query: AnalyticsQuery, ctx: StrategyContext): Promise { + assertDelegatedDateBucketsTemporal(query, ctx); return ctx.fallbackService!.query(query); } async generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{ sql: string; params: unknown[] }> { + assertDelegatedDateBucketsTemporal(query, ctx); if (ctx.fallbackService?.generateSql) { return ctx.fallbackService.generateSql(query); } diff --git a/packages/services/service-analytics/src/dataset-date-bucket-door.ts b/packages/services/service-analytics/src/dataset-date-bucket-door.ts index 8b623cdaaf1..551aba5a64c 100644 --- a/packages/services/service-analytics/src/dataset-date-bucket-door.ts +++ b/packages/services/service-analytics/src/dataset-date-bucket-door.ts @@ -37,6 +37,13 @@ * missing-column backstop. All three now answer the engine's * `INVALID_FIELD` / 400 at `groupBy.N.dateGranularity`, field for field. * + * [#29364] …and a third face that hands no engine the keys: the delegated + * fallback service behind `FallbackDelegateStrategy`, which folds a bucket in + * memory and read no field type. It is judged with the keys the strategy's + * direct path would build and the strategy context's `declaredFieldType`, on + * both its query and its dry run (`analytics-service.ts`, + * `assertDelegatedDateBucketsTemporal`). + * * Why a copy of the sentence: `@objectstack/objectql` is a dev dependency of * this package, never a runtime one, and its root does not export the door. * The message, code, status and keys are the engine's, and From 1074fbba63d89cc178a9f8fbc3f579333f89c16b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:00:22 +0000 Subject: [PATCH 2/2] fix(service-analytics): read declaredFieldType off the dataset-scoped strategy context, as the other strategies do Claude-Session: https://claude.ai/code/session_01ADHkmNhTaQLPMwKLDKtX46 Co-authored-by: Claude --- packages/services/service-analytics/src/analytics-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index de8ad407a44..362478fe16f 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -5035,7 +5035,7 @@ function assertDelegatedDateBucketsTemporal(query: AnalyticsQuery, ctx: Strategy assertDatasetDateBucketsTemporal( object, aggregateGroupKeys(query, cube), - (field) => ctx.declaredFieldType?.(object, field), + (field) => (ctx as DatasetScopedStrategyContext).declaredFieldType?.(object, field), ); }