Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/29364-fallback-date-bucket-refused-alike.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the analytics service's existing date-bucket door, already in front of the engine, the draft preview and the dry run, now also stands in front of the delegated fallback service. A refused query is fixed in the query (or by storing the value on a date or datetime field), so there is nothing for a migration to rewrite and no tombstone. -->

**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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<unknown>) => {
const err = (await p.then(
() => { throw new Error(`${face}: expected a refusal, but it resolved`); },
(e: unknown) => e,
)) as Error & Record<string, unknown>;
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<string, { type?: string }> } | 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<string, unknown> = {};
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<string, unknown> = { 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);
});
});
32 changes: 32 additions & 0 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 as DatasetScopedStrategyContext).declaredFieldType?.(object, field),
);
}

/**
* FallbackDelegateStrategy — Internal strategy for fallback service delegation.
*
Expand All @@ -5026,10 +5056,12 @@ class FallbackDelegateStrategy implements AnalyticsStrategy {
}

async execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult> {
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading