From ee0abf936e77d244971c3e833caac6c6b7384936 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:36:20 +0000 Subject: [PATCH 1/5] fix(service-analytics): the echo prints a date bucket only in the driver's own expression ObjectQLStrategy.generateSql no longer prints a representative date_trunc for a bucket no driver expression stands for. At a UTC or unset timezone, a dateBucketSql hook that answers nothing (driver-memory, driver-mongodb, a granularity the driver leaves to the engine, a host that wires no hook) now refuses with the NOT_IMPLEMENTED / 501 declared refusal the non-UTC arm already answers, on every dialect. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../src/analytics-service.ts | 6 +- .../services/service-analytics/src/plugin.ts | 7 +- .../src/strategies/objectql-strategy.ts | 128 +++++++++++------- 3 files changed, 88 insertions(+), 53 deletions(-) diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 9b3c9f09e04..190f6cb1352 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -1145,9 +1145,9 @@ export interface AnalyticsServiceConfig { * * Answered by the plugin from the driver that executes the aggregate. Asked * only at a UTC or unset `timezone`: a non-UTC one is bucketed in memory, and - * the echo refuses it whatever is wired (#21630). At UTC, a host that wires - * nothing keeps the echo's representative `date_trunc(…)`, except on SQLite, - * where the echo refuses (#21595). + * the echo refuses it whatever is wired (#21630). At UTC, where this answers + * `undefined` or is not wired, the echo refuses the bucket too, on every + * dialect (#21647): it prints a bucket in no expression but the driver's. */ dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined; /** diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index e1378f313e4..3e34de352ee 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -1233,9 +1233,10 @@ export class AnalyticsServicePlugin implements Plugin { * single source of its bucketing and no second table lives here. * * `undefined` on every tier that cannot answer: no data engine, a driver - * without the member (memory, mongo), a granularity the driver buckets in - * memory (it answers `null`), a throw. `undefined` keeps the echo's - * representative `date_trunc(…)`. + * without the member (memory, mongo), a granularity the driver leaves to + * the engine's in-memory bucketing (it answers `null`), a throw. On + * `undefined` the echo refuses the bucket (#21647): it prints a bucket in + * no expression but the one answered here. */ const dateBucketSql = (objectName: string, field: string, granularity: string): string | undefined => { try { diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 1ebcf894298..b6ff007d337 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -37,7 +37,7 @@ import { projectedDimensions } from '../order-key-door.js'; // dataset door's post-pass (#3588) — this face orders by it, never by a copy. import { applyOrdering, applyWindow } from '../dataset-executor.js'; import { type LikeShape } from '../like-pattern.js'; -import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; +import { textMatchPredicateSql, sqlDialectFor, type AnalyticsSqlDialect } from '../text-match-sql.js'; import { resolveAnalyticsDateRangeString } from '@objectstack/core'; import { explicitDateRangeWindow } from '../date-range-array-arm.js'; import { @@ -142,26 +142,41 @@ interface CrossObjectPlan { } /** - * The echo of a date bucket that no statement the engine runs produces: it - * refuses rather than print one. + * Why the echo of a date bucket refuses: the in-memory zone, or no driver + * expression for the bucket (with the dialect the host names, `'unknown'` when + * it names none). + */ +type BucketEchoRefusalCause = { zone: string } | { dialect: AnalyticsSqlDialect }; + +/** + * The echo of a date bucket that no driver expression stands for: it refuses + * rather than print one. * - * `generateSql` prints a date-bucketed dimension in the expression the driver - * groups by (the `dateBucketSql` hook). Two cases have no such expression, and - * both refuse here: + * [#21647] One rule. `generateSql` prints a date-bucketed dimension only in + * the expression the driver itself renders for it (the `dateBucketSql` hook), + * at a UTC or unset `timezone`. Everything else refuses here, and the + * refusal names its cause: * - * - [#21630] **A non-UTC `timezone`, on every dialect.** The engine then + * - [#21630] **A non-UTC `timezone`, on every driver.** The engine then * buckets in memory on that zone's calendar (ADR-0053 Phase 2, D2; - * `tzRequiresInMemory` in objectql's `engine.ts`) on every driver: the driver - * only fetches the rows, and no statement the database runs groups by those - * keys. The representative `date_trunc('', col)` this printed - * instead groups on the database SESSION's calendar where it runs at all. - * Measured on PostgreSQL 16.14 with the server at `Asia/Shanghai`, it - * answered timestamp keys such as `2025-12-31T16:00:00.000Z` where the face - * answers `2026-01`, and at `America/New_York` it grouped 20 and 8 where the - * face groups 27 and 1. SQLite and MySQL have no `date_trunc` at all. - * - [#21595] **SQLite, where no driver expression answers**: a host whose hook - * answers nothing for a SQLite datasource. `date_trunc` is not a SQLite - * function, so the representative text is a statement SQLite refuses. + * `tzRequiresInMemory` in objectql's `engine.ts`): the driver only fetches + * the rows, and no statement the database runs groups by those keys. The + * `date_trunc('', col)` this used to print groups on the + * database SESSION's calendar where it runs at all. Measured on PostgreSQL + * 16.14 with the server at `Asia/Shanghai`, it answered timestamp keys such + * as `2025-12-31T16:00:00.000Z` where the face answers `2026-01`, and at + * `America/New_York` it grouped 20 and 8 where the face groups 27 and 1. + * - [#21647] **No driver expression for the bucket, on every driver.** The + * hook answers nothing: a driver that runs no SQL (`driver-memory`, whose + * bucket the engine computes in memory; `driver-mongodb`, which buckets in + * its own aggregation pipeline), a SQL driver with no expression for the + * granularity (the engine buckets it in memory), or a host that wires no + * hook. The echo used to print `date_trunc` there and call it + * representative. Measured on `driver-memory` at UTC, the face answered + * `2026-01` and `2026-W02` from rows the driver only fetched, while both + * faces printed `date_trunc('month', closed_at)`, a statement nothing ran. + * [#21595] SQLite, which has no `date_trunc` at all, was the first case of + * this arm. * * `NOT_IMPLEMENTED` / 501, for the reason `driver-sql`'s own bucket refusal * gives: the query is spelled correctly and served, and the gap is the @@ -174,21 +189,27 @@ interface CrossObjectPlan { * `sql`, and the dry run (`/analytics/sql`) refuses. Both are within the * declared response contracts: `sql` is optional on the query answer, and the * dry run answers in the error envelope. - * - * `zone` is the non-UTC `timezone` of the first case; `undefined` is the - * SQLite case. */ -function bucketEchoRefused(dimension: string, granularity: string, zone: string | undefined): Error { +function bucketEchoRefused(dimension: string, granularity: string, cause: BucketEchoRefusalCause): Error { const err = new Error( `[analytics] cannot render display SQL for the "${granularity}" bucket of "${dimension}"` + - (zone !== undefined - ? ` with timezone "${zone}". The query itself is SERVED: the engine buckets it in memory on that zone's ` + - `calendar, so no statement the database runs groups by those bucket keys. Refusing rather than printing ` + - `one that groups on another calendar. Run the query itself (/analytics/query) to get its rows; with ` + - `timezone "UTC" or none, a SQL driver groups the bucket itself and this dry run renders its expression.` - : ` on SQLite. The query itself is SERVED, but the driver behind this datasource renders no bucket ` + - `expression for it. Refusing rather than printing date_trunc(...), which is not a SQLite function. ` + - `Run the query itself (/analytics/query) to get its rows.`), + ('zone' in cause + ? ` with timezone "${cause.zone}". The query itself is SERVED: the engine buckets it in memory on that ` + + `zone's calendar, so no statement the database runs groups by those bucket keys. Refusing rather than ` + + `printing one that groups on another calendar. Run the query itself (/analytics/query) to get its rows; ` + + `with timezone "UTC" or none, this dry run renders the expression the driver groups the bucket by, ` + + `where the driver has one.` + : (cause.dialect === 'unknown' + ? `: the driver behind this datasource names no SQL dialect and renders no expression for it. A ` + + `driver that runs no SQL, such as the in-memory or MongoDB driver, buckets it without one: the ` + + `engine in memory, MongoDB in its own aggregation pipeline. A host that wires no dateBucketSql hook ` + + `cannot ask its driver at all.` + : `: no SQL expression for it is known on this "${cause.dialect}" datasource. Either its driver ` + + `renders none for this granularity, and the engine buckets it in memory, or this host wires no ` + + `dateBucketSql hook to ask the driver.`) + + ` The query itself is SERVED. Refusing rather than printing a bucket expression nothing here ran: ` + + `this dry run prints only the expression the driver itself groups the bucket by. Run the query itself ` + + `(/analytics/query) to get its rows.`), ) as Error & { code?: string; status?: number; refusal?: true }; err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; err.status = 501; @@ -411,7 +432,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { }); const fields = this.buildFieldMeta(query, cube); - // Echo a representative SQL alongside the rows (#3588). `NativeSQLStrategy` + // Echo the SQL the query stands for alongside the rows (#3588). `NativeSQLStrategy` // returns the statement it actually ran, and dataset responses surface that // string — it is how an author checks what their widget compiled to. This // path builds an AST, so it had nothing to echo, and the `sql` field simply @@ -476,15 +497,14 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * * [#21441] A date bucket renders the expression the driver itself groups by * for its dialect, through the `dateBucketSql` hook, so the echo runs there - * and answers the face's bucket keys. [#21630] A non-UTC `timezone`, which - * the engine buckets in memory on that zone's calendar, refuses on every - * dialect ({@link bucketEchoRefused}): no statement the engine runs groups - * by those keys. At a UTC or unset `timezone`, the bucket stays - * REPRESENTATIVE where the hook answers nothing (no hook, or a driver with no - * bucket expression, such as a non-SQL driver) and prints - * `date_trunc('', col)`. [#21595] Except on SQLite, which has no - * `date_trunc`: there the echo refuses too, so it never prints a statement - * SQLite refuses. + * and answers the face's bucket keys. [#21647] That expression is the only + * thing a bucket is ever printed as. Where there is none, the echo refuses + * ({@link bucketEchoRefused}), on every driver and every dialect: a non-UTC + * `timezone`, which the engine buckets in memory on that zone's calendar + * (#21630), and a hook that answers nothing at UTC, which is a driver that + * runs no SQL, a granularity the driver leaves to the engine, or a host that + * wires no hook (#21595 was SQLite's case of it). No bucket expression is + * written here. * * Filter VALUES are rendered as `$n` placeholders and returned in `params`, * never inlined: the echoed statement travels to the browser, and a filter @@ -601,9 +621,24 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // `tzRequiresInMemory` in objectql's `engine.ts`, the same test on the same // `timezone`), so no statement the database runs groups by those keys, and // the echo refuses on every dialect before the hook is asked - // ({@link bucketEchoRefused}). Where nothing answers at UTC, the bucket - // keeps the representative `date_trunc`, except on SQLite, which has no - // `date_trunc`: there the echo refuses too (#21595). + // ({@link bucketEchoRefused}). + // + // [#21647] Where the hook answers nothing at UTC, the echo refuses too, on + // every dialect. That is the engine's other in-memory condition, read off + // the driver rather than restated here: every `driver-sql` dialect answers + // `null` exactly where its `supports.queryDateGranularity` is false (both + // read `dateGranularityCapabilities`), which is where the engine buckets in + // memory. A driver that runs no SQL has no hook to answer: `driver-memory` + // advertises no granularity, so the engine buckets in memory, and + // `driver-mongodb` buckets in its own pipeline, which no SQL stands for + // either. The engine's predicate itself is not reachable from here. + // + // Two cells print the driver's expression while the engine buckets in + // memory, because the hook answers there and the expression, run on that + // dialect, answers the face's keys: a measure carrying its own `filter` + // (the engine aggregates every such query in memory, #10576), and + // `driver-turso`'s remote face (it advertises no granularity, and inherits + // the SQLite expression, which libSQL runs). const zone = query.timezone; const inMemoryZone = zone && zone !== 'UTC' ? zone : undefined; const driverBucketSql = (col: string, granularity: string): string | undefined => { @@ -621,11 +656,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy { const col = this.resolveFieldName(cube, dim, 'dimension'); const gran = granByDim.get(dim); if (!gran) return col; - if (inMemoryZone !== undefined) throw bucketEchoRefused(dim, gran, inMemoryZone); + if (inMemoryZone !== undefined) throw bucketEchoRefused(dim, gran, { zone: inMemoryZone }); const bucket = driverBucketSql(col, gran); - if (bucket !== undefined) return bucket; - if (sqlDialectFor(ctx, tableName) === 'sqlite') throw bucketEchoRefused(dim, gran, undefined); - return `date_trunc('${gran}', ${col})`; + if (bucket === undefined) throw bucketEchoRefused(dim, gran, { dialect: sqlDialectFor(ctx, tableName) }); + return bucket; }; if (query.dimensions) { From d0875f3a82ef3e1590b16029f5cfd41d98c2c483 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:44:48 +0000 Subject: [PATCH 2/5] test(service-analytics): enumerate the bucket echo against the engine's own bucketing A new pin runs driver x timezone class x granularity through the real plugin and engine, reading the axes off BUILTIN_DRIVER_IDS and TimeUpdateInterval, and judges each cell by where the engine actually put the bucket. The three pins that asserted the representative date_trunc of a host with no dateBucketSql hook now assert the hook's expression and the refusal. driver-memory joins the package's devDependencies, aliased to source, for the live memory cell. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../21647-echo-never-in-memory-bucket.md | 13 + .../services/service-analytics/package.json | 1 + .../cube-authored-format-granularity.test.ts | 38 +- .../dataset-selection-window.test.ts | 43 +- .../src/__tests__/objectql-daterange.test.ts | 13 +- .../objectql-echo-bucket-enumeration.test.ts | 424 ++++++++++++++++++ .../objectql-echo-date-bucket.test.ts | 39 +- .../service-analytics/vitest.config.ts | 9 + pnpm-lock.yaml | 3 + 9 files changed, 556 insertions(+), 27 deletions(-) create mode 100644 .changeset/21647-echo-never-in-memory-bucket.md create mode 100644 packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts diff --git a/.changeset/21647-echo-never-in-memory-bucket.md b/.changeset/21647-echo-never-in-memory-bucket.md new file mode 100644 index 00000000000..27b673fb25f --- /dev/null +++ b/.changeset/21647-echo-never-in-memory-bucket.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-analytics': patch +--- + +The analytics SQL echo prints a date bucket only in the expression the driver itself groups it by, and refuses everywhere else, including on the in-memory and MongoDB drivers (#21647). + +Clause-②: no + +- **What was wrong.** At a `timezone` of `UTC`, or with none, the ObjectQL face of `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` echoed a date-bucketed dimension as `date_trunc('month', col)` (or the asked granularity) wherever the driver renders no bucket expression of its own, and documented that as representative. On `driver-memory` the engine only fetches the rows and buckets them itself, answering keys such as `2026-01` and `2026-W02`, while both faces printed `date_trunc(...)`, a statement nothing ran. `driver-mongodb`, which groups the bucket in its own aggregation pipeline, took the same path. So did any host that wires no `dateBucketSql` hook. +- **What it does now.** Wherever no driver expression stands for the bucket, on every driver and dialect: + - `POST /api/v1/analytics/sql` refuses with `NOT_IMPLEMENTED` / 501, declared as a refusal so its message reaches the caller. Its message names the cause. A non-UTC `timezone` and SQLite already answered this way. + - `POST /api/v1/analytics/query` answers the same rows as before, and its answer carries no `sql`. +- **Unchanged.** On PostgreSQL, MySQL and SQLite at `UTC` or with no `timezone`, the echo still prints the expression the driver groups by: `to_char(...)`, `date_format(...)` and `strftime(...)`. diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 68d9716a9e0..02902526cd9 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -29,6 +29,7 @@ "@objectstack/types": "workspace:*" }, "devDependencies": { + "@objectstack/driver-memory": "workspace:*", "@objectstack/driver-sql": "workspace:*", "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/objectql": "workspace:*", diff --git a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts index 5b07696f1ff..418ea7e71a0 100644 --- a/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-authored-format-granularity.test.ts @@ -86,8 +86,15 @@ const dataset = DatasetSchema.parse({ type GroupByItem = string | { field: string; dateGranularity?: string }; -/** An ObjectQL-only host that records the `groupBy` every aggregate ran with. */ -function objectqlService(cubes: Cube[] = [authored]) { +/** + * An ObjectQL-only host that records the `groupBy` every aggregate ran with. + * [#21647] `dateBucketSql` is the host's driver-expression hook, unwired by + * default. + */ +function objectqlService( + cubes: Cube[] = [authored], + dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined, +) { const groupBys: GroupByItem[][] = []; const service = new AnalyticsService({ logger: silentLogger, @@ -97,6 +104,7 @@ function objectqlService(cubes: Cube[] = [authored]) { groupBys.push((options.groupBy ?? []) as GroupByItem[]); return [{ status: 'open', count: 2, revenue: 10, margin: 0.25, placed_at: '2026-07' }]; }, + dateBucketSql, }); return { service, groupBys }; } @@ -238,14 +246,34 @@ describe('analytics_cube.dimensions.granularities — the declared single granul expect(groupBys).toEqual([[]]); }); + // [#21647] This case asserted `date_trunc('month'` on a host that wires no + // `dateBucketSql` hook: a representative bucket no driver groups by. The + // echo now prints the driver's own expression or refuses, so the declared + // default is asserted as the granularity the hook is asked for and the + // expression it answers, and the no-hook host as the refusal, which only a + // bucketed dimension draws. it('`generateSql()` dry-runs the bucketed statement `query()` runs', async () => { - const { service } = objectqlService(); + const asked: string[] = []; + const { service } = objectqlService([authored], (_object, field, granularity) => { + asked.push(`${field}:${granularity}`); + return `driver_bucket('${granularity}', ${field})`; + }); const declared = await service.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }); const undeclared = await service.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['created_at'] }); - expect(declared.sql).toMatch(/date_trunc\('month'/i); - expect(undeclared.sql).not.toMatch(/date_trunc/i); + expect(asked).toEqual(['placed_at:month']); + expect(declared.sql).toContain(`GROUP BY driver_bucket('month', placed_at)`); + expect(undeclared.sql).not.toContain('driver_bucket'); + + const { service: noHook } = objectqlService(); + const refused = await noHook.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }).then( + () => { throw new Error('expected the echo to refuse'); }, + (e) => e as Error & { code?: string; status?: number; refusal?: unknown }, + ); + expect([refused.code, refused.status, refused.refusal]).toEqual(['NOT_IMPLEMENTED', 501, true]); + expect((await noHook.generateSql({ cube: 'orders', measures: ['count'], dimensions: ['created_at'] })).sql) + .toBe(undeclared.sql); }); it('native SQL declines a bucketed query, so a declared default routes to the engine path as a dataset does', async () => { diff --git a/packages/services/service-analytics/src/__tests__/dataset-selection-window.test.ts b/packages/services/service-analytics/src/__tests__/dataset-selection-window.test.ts index b96eabb6c46..c2a7bc841a8 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-selection-window.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-selection-window.test.ts @@ -48,11 +48,14 @@ type AggCall = { object: string; options: Record }; * development. Only the tests that read `result.sql` — the block asserting the * echo tells the truth — need it on; the rest of this file measures the CALL * the executor made, which the gate does not touch. + * + * [#21647] `dateBucketSql` is the host's driver-expression hook; the echo + * prints a date bucket in nothing else. */ function aggService( rows: Record[], calls: AggCall[] = [], - options: { debugSql?: boolean } = {}, + options: { debugSql?: boolean; dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined } = {}, ) { const svc = new AnalyticsService({ queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), @@ -61,6 +64,7 @@ function aggService( return rows; }, debugSql: options.debugSql, + dateBucketSql: options.dateBucketSql, }); return { svc, calls }; } @@ -347,16 +351,35 @@ describe('#3588 — ordering never corrupts a multi-query selection', () => { * state it rather than inherit whatever `NODE_ENV` the runner happens to have. */ describe('#3588 — the echoed SQL tells the truth on the ObjectQL path', () => { - it('renders date_trunc for a bucketed dimension instead of the bare column', async () => { - const { svc } = aggService([{ created_at: '2026-06', account_count: 4 }], [], { debugSql: true }); - const result = await svc.queryDataset( - accounts, - { dimensions: ['created_at'], measures: ['account_count'], dateGranularity: 'month' }, - CTX, - ); - expect(result.sql).toContain(`date_trunc('month', created_at)`); - expect(result.sql).toContain('GROUP BY'); + // [#21647] This case asserted `date_trunc('month', created_at)`, which its + // host, wiring no `dateBucketSql` hook, got as a representative bucket no + // driver groups by. The echo now prints the driver's own expression or + // nothing, so the case runs both hosts: with the hook (a stub standing for + // the driver's answer; which driver answers what is pinned in + // `objectql-echo-date-bucket.test.ts`), and without it. + it('renders the driver\'s bucket expression for a bucketed dimension instead of the bare column, or no sql where the host has none', async () => { + const DRIVER_BUCKET = `driver_bucket('month', created_at)`; + const asked: string[] = []; + const { svc } = aggService([{ created_at: '2026-06', account_count: 4 }], [], { + debugSql: true, + dateBucketSql: (_object, field, granularity) => { + asked.push(`${field}:${granularity}`); + return `driver_bucket('${granularity}', ${field})`; + }, + }); + const selection = { dimensions: ['created_at'], measures: ['account_count'], dateGranularity: 'month' }; + const result = await svc.queryDataset(accounts, selection, CTX); + expect(asked).toContain('created_at:month'); + expect(result.sql).toContain(`${DRIVER_BUCKET} AS "created_at"`); + expect(result.sql).toContain(`GROUP BY ${DRIVER_BUCKET}`); expect(result.sql).toContain('COUNT(*) AS "account_count"'); + + // No hook: the echo refuses the bucket, so the answer carries its rows + // and no `sql`, never one grouping by the bare column. + const { svc: noHook } = aggService([{ created_at: '2026-06', account_count: 4 }], [], { debugSql: true }); + const bare = await noHook.queryDataset(accounts, selection, CTX); + expect(bare.rows).toEqual([{ created_at: '2026-06', account_count: 4 }]); + expect(bare.sql).toBeUndefined(); }); it('renders the ordering and window that the response rows actually reflect', async () => { diff --git a/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts b/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts index 5e81f6cbd44..6ab5e9e88a0 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts @@ -402,7 +402,16 @@ const DECLARED_DATETIME = { describe('ObjectQLStrategy.generateSql — window rendering (#3650)', () => { it('renders the window as a parameterised half-open pair', async () => { const seen: AggOpts[] = []; - const svc = makeService(seen, DECLARED_DATETIME); + // [#21647] The bucket in this statement used to be the representative + // `date_trunc('month', close_date)` a host with no `dateBucketSql` hook + // got. That host's dry run now refuses the bucket outright, which would + // take this case's subject, the window, with it. So the host wires the + // hook (a stub standing for the driver's answer), and the bucket is + // asserted as that answer. + const svc = makeService(seen, { + ...DECLARED_DATETIME, + dateBucketSql: (_object: string, field: string, granularity: string) => `driver_bucket('${granularity}', ${field})`, + }); const { sql, params } = await svc.generateSql!({ cube: 'sales', @@ -420,7 +429,7 @@ describe('ObjectQLStrategy.generateSql — window rendering (#3650)', () => { // datetime column; a BETWEEN would hand a debugger SQL that drops the // final day's rows. expect(sql).toContain('(close_date >= $1 AND close_date < $2)'); - expect(sql).toContain("date_trunc('month', close_date)"); + expect(sql).toContain(`driver_bucket('month', close_date) AS "close_date"`); // Bounds bind as parameters — the echoed string travels to the browser. expect(params).toEqual(['2026-01-01', '2026-03-01']); expect(sql).not.toContain('2026-01-01'); diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts new file mode 100644 index 00000000000..fa02ccc3cdc --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts @@ -0,0 +1,424 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21647] The echo of a date bucket, enumerated: driver x timezone class x + * granularity. Every cell is either the driver's own bucket expression or the + * declared refusal, and which one is read off the ENGINE, not restated here. + * + * ## The rule + * + * `ObjectQLStrategy.generateSql` prints a bucket only in the expression the + * driver itself renders for it (the `dateBucketSql` hook), at a UTC or unset + * `timezone`; everything else refuses (`NOT_IMPLEMENTED` / 501, + * `refusal: true`). The family it closes: SQLite's missing `date_trunc` + * (#21441, #21595), a non-UTC zone the engine buckets in memory (#21630), and + * a driver that runs no SQL (#21647). Measured on `main` `1968d5e812` before + * this change, on `driver-memory` at UTC and with no `timezone`: the engine + * only fetched rows (`find`), the face answered `2026-01` and `2026-W02`, and + * both faces printed `date_trunc('month', closed_at)` and + * `date_trunc('week', closed_at)`, statements nothing ran. + * + * ## How a cell is judged + * + * Each cell runs the query through the real `AnalyticsServicePlugin` (its + * `sqlDialect` and `dateBucketSql` bridges) over a real `ObjectQL` engine. The + * driver's two data doors are spied, so the cell OBSERVES where the engine put + * the bucket: `aggregate` means the driver grouped it (the engine pushed it + * down), `find` means the engine fetched rows and bucketed them in memory. The + * expected echo follows from that observation and from the driver's own + * `dateBucketSql` answer: + * + * - pushed down to a driver that renders an expression for it: that + * expression, on `/analytics/sql` and as the `sql` of `/analytics/query`; + * - anything else (bucketed in memory, or grouped by a driver that runs no + * SQL): the refusal on `/analytics/sql`, and no `sql` on the query. + * + * The engine's predicate (`engine.aggregate`'s pushdown test: the driver's + * `supports.queryDateGranularity`, `tzRequiresInMemory`, per-aggregation + * filters) is not reachable from this package, so the strategy reads the + * zone and the hook. This pin is what holds the two together: a change on + * either side moves a cell. + * + * ## The axes, and what turns the pin red + * + * - **driver**: `BUILTIN_DRIVER_IDS`, the spec's driver vocabulary. + * `DRIVER_ROWS` is keyed by it (`satisfies Record`), + * and a case asserts the key sets are equal, so a new builtin with no row + * fails typecheck and this file. + * - **timezone class**: UTC, unset, and any other zone. No enum declares + * them; the engine's `tzRequiresInMemory` does, inline. `ZONE_CLASSES` + * declares each class's probes and the tier the engine gives it, and a + * case asserts that tier on a driver that groups every granularity, so a + * probe the engine moves to another class is red. `Etc/UTC` is a probe of + * the in-memory class: the engine treats only the literal `UTC` as UTC. + * - **granularity**: `TimeUpdateInterval.options`, the enum + * `timeDimensions[].granularity` parses (derived from `DateGranularity`). + * The rows are generated from it, so a new granularity has its cells the + * moment it is declared, judged as above. + * + * ## The rows + * + * - `memory`, `sqlite` (better-sqlite3), `sqlite-wasm` (sql.js): real + * drivers. `postgres` and `mysql`: real `driver-sql` instances, never + * connected; their dialect and bucket expression answer from the client + * config alone, and the spied doors mean no statement is sent. + * - `mongodb`, by code path: `driver-mongodb` publishes + * `queryDateGranularity` for every granularity + * (`MONGODB_DATE_GRANULARITIES`), so the engine pushes the bucket into its + * aggregation pipeline, and it has neither `dialectName` nor + * `dateBucketSql`. The stand-in carries exactly that surface. A live + * MongoDB reading is not required (#21647's triage). + * - `turso`, by code path, both faces: `TursoDriver extends SqlDriver` and + * inherits `dialectName` and `dateBucketSql` on both + * (`REMOTE_FACE_ANSWERS` in `turso-driver.ts`). The local face keeps + * SQLite's `supports`; the remote face publishes `queryDateGranularity: {}`, + * so the engine buckets in memory there. + * + * ## The one named exception + * + * `turso`'s remote face: the engine buckets in memory, and the echo prints the + * SQLite expression the driver renders, because the hook answers. Run on + * libSQL it answers the face's keys (the driver's own reason for inheriting + * it). It is pinned as an exception, so moving either side is red. The other + * cell of that kind, a measure carrying its own `filter`, is outside this + * table's axes and is pinned in `objectql-echo-date-bucket.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { BUILTIN_DRIVER_IDS, TimeUpdateInterval, type BuiltinDriverId, type Cube } from '@objectstack/spec/data'; +import { declaredRefusalMessage } from '@objectstack/types'; +import type { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; + +const DEAL = 'os21647_bucket_deal'; +const DEAL_OBJECT = { + name: DEAL, + label: 'Bucket enumeration deal', + fields: { + closed_at: { name: 'closed_at', type: 'datetime' as const }, + amount: { name: 'amount', type: 'number' as const }, + }, +}; +const CUBE = 'os21647_bucket_cube'; +const CUBES = [ + { + name: CUBE, + title: 'Bucket enumeration cube', + sql: DEAL, + public: true, + measures: { amount_sum: { type: 'sum', sql: 'amount', label: 'Amount' } }, + dimensions: { closed_at: { type: 'time', sql: 'closed_at', label: 'Closed at' } }, + }, +] as unknown as Cube[]; + +const GRANULARITIES = TimeUpdateInterval.options; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +type Tier = 'pushdown' | 'in-memory'; + +/** The timezone classes, each with its probes and the tier the engine gives a date bucket in it. */ +const ZONE_CLASSES: ReadonlyArray<{ label: string; probes: ReadonlyArray; tier: Tier }> = [ + { label: 'UTC', probes: ['UTC'], tier: 'pushdown' }, + { label: 'unset', probes: [undefined], tier: 'pushdown' }, + { label: 'any other zone', probes: ['Asia/Shanghai', 'America/New_York', 'Etc/UTC'], tier: 'in-memory' }, +]; + +/** The surface of a driver this pin reads: the engine's inputs and the bridges' inputs. */ +interface EnumeratedDriver { + readonly name: string; + readonly supports?: unknown; + readonly dialectName?: unknown; + dateBucketSql?(objectName: string, field: string, granularity: string): unknown; + connect(): Promise; + aggregate(...args: unknown[]): Promise; + find(...args: unknown[]): Promise; + [member: string]: unknown; +} + +interface DriverFace { + readonly face: string; + build(): EnumeratedDriver; + /** Release whatever `build` opened. */ + release?(driver: EnumeratedDriver): Promise; + /** + * The named exception: where the zone's class lets the engine push a bucket + * down, this face still buckets in memory, and the echo prints the hook's + * expression. The reason, in words. + */ + readonly printsWhileEngineBucketsInMemory?: string; +} + +const releaseKnex = async (driver: EnumeratedDriver) => { + await (driver as { knex?: { destroy(): Promise } }).knex?.destroy().catch(() => {}); +}; + +const sqliteDriver = () => + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any) as unknown as EnumeratedDriver; + +/** `driver-mongodb`'s surface on this pin's read path, by code path (see the header). */ +const mongodbByCodePath = (): EnumeratedDriver => ({ + name: 'os21647.mongodb-by-code-path', + version: '0.0.0', + supports: { + batchSchemaSync: true, + queryDateGranularity: Object.fromEntries(GRANULARITIES.map((g) => [g, true])), + }, + async connect() {}, + async disconnect() {}, + async aggregate() { return []; }, + async find() { return []; }, +}); + +const DRIVER_ROWS = { + memory: [{ face: 'driver-memory', build: () => new InMemoryDriver() as unknown as EnumeratedDriver }], + sqlite: [{ face: 'driver-sql, better-sqlite3', build: sqliteDriver, release: releaseKnex }], + 'sqlite-wasm': [ + { + face: 'driver-sqlite-wasm, sql.js', + build: () => new SqliteWasmDriver({ filename: ':memory:' }) as unknown as EnumeratedDriver, + release: releaseKnex, + }, + ], + postgres: [ + { + face: 'driver-sql, pg, never connected', + build: () => + new SqlDriver({ client: 'pg', connection: { host: '127.0.0.1', database: 'unconnected' }, pool: { min: 0 } } as any) as unknown as EnumeratedDriver, + release: releaseKnex, + }, + ], + mysql: [ + { + face: 'driver-sql, mysql2, never connected', + build: () => + new SqlDriver({ client: 'mysql2', connection: { host: '127.0.0.1', database: 'unconnected' }, pool: { min: 0 } } as any) as unknown as EnumeratedDriver, + release: releaseKnex, + }, + ], + mongodb: [{ face: 'driver-mongodb, by code path', build: mongodbByCodePath }], + turso: [ + { face: 'driver-turso local face, by code path', build: sqliteDriver, release: releaseKnex }, + { + face: 'driver-turso remote face, by code path', + build: () => { + const driver = sqliteDriver(); + // `TursoDriver.supports` in remote mode: no granularity is bucketed natively. + const supports = { ...(driver.supports as Record), queryDateGranularity: {} }; + Object.defineProperty(driver, 'supports', { value: supports }); + return driver; + }, + release: releaseKnex, + printsWhileEngineBucketsInMemory: + 'it advertises no granularity, and inherits the SQLite expression, which libSQL runs and which answers the face\'s keys', + }, + ], +} satisfies Record; + +/** The error `p` rejects with; a resolution fails the case. */ +const refusalOf = (p: Promise) => + p.then( + () => { throw new Error('expected the echo to refuse'); }, + (e) => e as Error & { code?: string; status?: number; refusal?: unknown }, + ); + +/** The bucket expression an echo selects for `dim`: everything between `SELECT ` and ` AS ""`. */ +const selectedBucket = (sql: string, dim: string) => sql.slice('SELECT '.length, sql.indexOf(` AS "${dim}"`)); + +const bucketed = (granularity: string, timezone: string | undefined) => ({ + cube: CUBE, + measures: ['amount_sum'], + timeDimensions: [{ dimension: 'closed_at', granularity }], + ...(timezone === undefined ? {} : { timezone }), +}); + +/** One driver face served through the real plugin and engine, with its two data doors spied. */ +async function serve(face: DriverFace) { + const driver = face.build(); + const doors: Tier[] = []; + // Spies, not passthroughs: the engine's choice is made before either door is + // called, so the rows are not asked here (the live block below asks them). + driver.connect = async () => {}; + driver.aggregate = async () => { doors.push('pushdown'); return []; }; + driver.find = async () => { doors.push('in-memory'); return []; }; + + const engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver as any, true); + await engine.init(); + engine.registry.registerObject(DEAL_OBJECT as any); + + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: CUBES, debugSql: true } as any).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); + const analytics = registered.analytics as AnalyticsService; + + /** Ask one cell: where the engine put the bucket, and both faces' echoes. */ + const ask = async (granularity: string, timezone: string | undefined) => { + doors.length = 0; + const query = bucketed(granularity, timezone); + const res = await analytics.query(query as any); + const tiers = [...doors]; + const dryRun = await analytics.generateSql(query as any).then( + (r) => ({ sql: r.sql, refusal: undefined }), + (e) => ({ sql: undefined, refusal: e as Error & { code?: string; status?: number; refusal?: unknown } }), + ); + return { tiers, querySql: res.sql, dryRun }; + }; + + /** The driver's own answer for this bucket, as the bridge receives it. */ + const expression = (granularity: string): string | undefined => { + if (typeof driver.dateBucketSql !== 'function') return undefined; + const answered = driver.dateBucketSql(DEAL, 'closed_at', granularity); + return typeof answered === 'string' && answered !== '' ? answered : undefined; + }; + + return { ask, expression, release: async () => { await face.release?.(driver); } }; +} + +describe('[#21647] the echo of a date bucket: driver x timezone class x granularity', () => { + it('has a row for every builtin driver, and every row has a face', () => { + expect(Object.keys(DRIVER_ROWS).sort()).toEqual([...BUILTIN_DRIVER_IDS].sort()); + for (const faces of Object.values(DRIVER_ROWS)) expect(faces.length).toBeGreaterThan(0); + }); + + it('enumerates the declared granularities and at least one probe per timezone class', () => { + expect(GRANULARITIES.length).toBeGreaterThan(0); + for (const zoneClass of ZONE_CLASSES) expect(zoneClass.probes.length).toBeGreaterThan(0); + }); + + // The class map, asserted on the engine: on a driver that groups every + // granularity, each probe lands in its class's tier. + describe('each timezone probe is in the class the engine gives it (driver-sql, better-sqlite3)', () => { + let served: Awaited>; + beforeAll(async () => { served = await serve(DRIVER_ROWS.sqlite[0]); }); + afterAll(async () => { await served?.release(); }); + + for (const zoneClass of ZONE_CLASSES) { + for (const probe of zoneClass.probes) { + it(`${probe ?? 'unset'}: ${zoneClass.tier}`, async () => { + for (const granularity of GRANULARITIES) { + expect(served.expression(granularity), `this driver renders ${granularity}`).toBeDefined(); + const { tiers } = await served.ask(granularity, probe); + expect(tiers, `${granularity}`).toEqual([zoneClass.tier]); + } + }); + } + } + }); + + for (const [driverId, faces] of Object.entries(DRIVER_ROWS) as Array<[BuiltinDriverId, readonly DriverFace[]]>) { + for (const face of faces) { + describe(`${driverId} (${face.face})`, () => { + let served: Awaited>; + beforeAll(async () => { served = await serve(face); }); + afterAll(async () => { await served?.release(); }); + + for (const zoneClass of ZONE_CLASSES) { + for (const probe of zoneClass.probes) { + it.each(GRANULARITIES)(`timezone ${probe ?? 'unset'} (${zoneClass.label}), %s`, async (granularity) => { + const { tiers, querySql, dryRun } = await served.ask(granularity, probe); + expect(tiers, 'the engine reached the driver exactly once').toHaveLength(1); + const [tier] = tiers; + const expression = served.expression(granularity); + const printed = + expression !== undefined && + (tier === 'pushdown' || + (face.printsWhileEngineBucketsInMemory !== undefined && zoneClass.tier === 'pushdown')); + + if (face.printsWhileEngineBucketsInMemory !== undefined && zoneClass.tier === 'pushdown') { + // The named exception is pinned as one: the engine does bucket in memory here. + expect(tier, face.printsWhileEngineBucketsInMemory).toBe('in-memory'); + } + + if (printed) { + expect(dryRun.refusal).toBeUndefined(); + expect(selectedBucket(dryRun.sql!, 'closed_at')).toBe(expression); + expect(dryRun.sql).toContain(`GROUP BY ${expression}`); + expect(querySql).toBe(dryRun.sql); + } else { + const err = dryRun.refusal; + expect(err, `expected the refusal; the dry run printed ${dryRun.sql}`).toBeDefined(); + expect([err!.code, err!.status, err!.refusal]).toEqual(['NOT_IMPLEMENTED', 501, true]); + expect(declaredRefusalMessage(err!)).toBe(err!.message); + expect(querySql).toBeUndefined(); + } + }); + } + } + }); + } + } +}); + +/** + * [#21647] The rows, on the real `driver-memory`: `/analytics/query` serves + * them with no `sql`, and the dry run refuses. The driver is only asked for + * rows; the engine buckets them in memory. d2 (20:00 UTC on 31 January) stays + * in January at UTC. + */ +describe('[#21647] driver-memory, live: the query serves its rows with no `sql`, and the dry run refuses', () => { + const DEALS = [ + { id: 'd1', closed_at: '2026-01-10T10:00:00.000Z', amount: 20 }, + { id: 'd2', closed_at: '2026-01-31T20:00:00.000Z', amount: 7 }, + { id: 'd3', closed_at: '2026-02-03T08:00:00.000Z', amount: 1 }, + ]; + const ROWS: Record> = { + month: [['2026-01', 27], ['2026-02', 1]], + week: [['2026-W02', 20], ['2026-W05', 7], ['2026-W06', 1]], + }; + let engine: ObjectQL; + let analytics: AnalyticsService; + const doors: string[] = []; + + beforeAll(async () => { + const driver = new InMemoryDriver(); + const aggregate = driver.aggregate.bind(driver); + const find = driver.find.bind(driver); + (driver as any).aggregate = async (...args: any[]) => { doors.push('aggregate'); return (aggregate as any)(...args); }; + (driver as any).find = async (...args: any[]) => { doors.push('find'); return (find as any)(...args); }; + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver as any, true); + await engine.init(); + engine.registry.registerObject(DEAL_OBJECT as any); + await engine.syncSchemas(); + for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); + + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: CUBES, debugSql: true } as any).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); + analytics = registered.analytics as AnalyticsService; + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + for (const timezone of ['UTC', undefined]) { + it.each(Object.keys(ROWS))(`timezone ${timezone ?? 'unset'}, %s`, async (granularity) => { + const query = { ...bucketed(granularity, timezone), order: { closed_at: 'asc' } }; + doors.length = 0; + const res = await analytics.query(query as any); + expect(doors, 'the driver only fetched rows').toEqual(['find']); + expect((res.rows as Array>).map((r) => [r.closed_at, Number(r.amount_sum)])).toEqual(ROWS[granularity]); + expect(res.sql).toBeUndefined(); + + const err = await refusalOf(analytics.generateSql(query as any)); + expect([err.code, err.status, err.refusal]).toEqual(['NOT_IMPLEMENTED', 501, true]); + expect(declaredRefusalMessage(err)).toBe(err.message); + }); + } +}); diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts index 8d4f043a5ca..c6439755325 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts @@ -46,9 +46,12 @@ * on SQLite and PostgreSQL, and the dialect matrix below pins it on SQLite, * PostgreSQL and MySQL through each driver's own hooks, with no server. * - * At UTC, where the hook answers nothing (a host that wires no hook), the - * bucket keeps the representative `date_trunc`. [#21595] Except on SQLite, - * which has no `date_trunc`: there the echo refuses too. + * [#21647] At UTC, where the hook answers nothing (a host that wires no hook, + * or a driver that renders no expression), the echo refuses too, on every + * dialect. It used to print a representative `date_trunc` there, except on + * SQLite (#21595). The block at the end pins the no-hook host, and + * `objectql-echo-bucket-enumeration.test.ts` pins every builtin driver, + * timezone class and granularity against the engine's own bucketing. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -247,6 +250,11 @@ for (const cell of CELLS) { }); } + // [#21647] One of the two cells where the engine buckets in memory and + // the echo still prints, because the hook answers and the expression, + // run here, answers the face's rows (the last assertion). The other is + // `driver-turso`'s remote face, pinned by code path in + // `objectql-echo-bucket-enumeration.test.ts`. it('a measure filter, which the engine aggregates in memory: the echo keeps the driver expression and runs with its params', async () => { const query = { cube: FILTERED.name, measures: ['big_sum'], timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }], order: { closed_on: 'asc' } }; const { res, ran } = await ask(query); @@ -349,12 +357,23 @@ describe('[#21630] the echo of a date bucket, per dialect: a non-UTC timezone re } }); -describe('[#21441] FALLBACK: a host that wires no dateBucketSql hook', () => { - it('echoes the bucket as `date_trunc`', async () => { - const ctx = { getCube: (name: string) => (name === CUBE ? CUBES[0] : undefined) } as unknown as StrategyContext; - const { sql } = await new ObjectQLStrategy().generateSql(bucketed('closed_on', 'month') as any, ctx); - expect(selectedBucket(sql, 'closed_on')).toBe("date_trunc('month', closed_on)"); - }); +/** + * [#21647] A host that wires no `dateBucketSql` hook. This block was the + * FALLBACK: at UTC it echoed the bucket as `date_trunc('month', closed_on)`, an + * expression no driver groups by. It now refuses at every timezone and on + * every dialect, as the declared refusal the other two arms answer. + */ +describe('[#21647] REFUSAL: a host that wires no dateBucketSql hook', () => { + it.each([['UTC', 'UTC'], ['unset', undefined]] as const)( + '[#21647] timezone %s: refuses, where it used to echo the bucket as `date_trunc`', + async (_label, timezone) => { + const ctx = { getCube: (name: string) => (name === CUBE ? CUBES[0] : undefined) } as unknown as StrategyContext; + const query = bucketed('closed_on', 'month', timezone === undefined ? {} : { timezone }); + const err = await refusalOf(new ObjectQLStrategy().generateSql(query as any, ctx)); + expect([err.code, err.status, err.refusal]).toEqual(['NOT_IMPLEMENTED', 501, true]); + expect(declaredRefusalMessage(err)).toBe(err.message); + }, + ); it('[#21630] REFUSAL: with a non-UTC timezone, it refuses: the engine buckets that in memory whatever the host wires', async () => { const ctx = { getCube: (name: string) => (name === CUBE ? CUBES[0] : undefined) } as unknown as StrategyContext; @@ -363,7 +382,7 @@ describe('[#21441] FALLBACK: a host that wires no dateBucketSql hook', () => { expect(declaredRefusalMessage(err)).toBe(err.message); }); - it('[#21595] REFUSAL: on a SQLite datasource, it refuses rather than echo `date_trunc`', async () => { + it('[#21595] REFUSAL: on a SQLite datasource, it refuses (the first case of the one rule)', async () => { const ctx = { getCube: (name: string) => (name === CUBE ? CUBES[0] : undefined), sqlDialect: () => 'sqlite', diff --git a/packages/services/service-analytics/vitest.config.ts b/packages/services/service-analytics/vitest.config.ts index 8c97122c185..23a2a04a29c 100644 --- a/packages/services/service-analytics/vitest.config.ts +++ b/packages/services/service-analytics/vitest.config.ts @@ -35,6 +35,15 @@ export default defineConfig({ find: /^@objectstack\/objectql$/, replacement: path.resolve(__dirname, '../../objectql/src/index.ts'), }, + { + // [#21647] `objectql-echo-bucket-enumeration.test.ts` serves a bucketed + // query through the REAL `driver-memory` under the engine above, so the + // driver is read from source for the same reason: its advertised + // capabilities decide where the engine buckets, and a stale `dist` + // would answer with old ones. Anchored, as above. + find: /^@objectstack\/driver-memory$/, + replacement: path.resolve(__dirname, '../../drivers/driver-memory/src/index.ts'), + }, ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb0fc439dbf..827556dea27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2373,6 +2373,9 @@ importers: specifier: workspace:* version: link:../../types devDependencies: + '@objectstack/driver-memory': + specifier: workspace:* + version: link:../../drivers/driver-memory '@objectstack/driver-sql': specifier: workspace:* version: link:../../drivers/driver-sql From 03f830f7f7f0a5b85025a1dd65b56b6f883045b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:51:02 +0000 Subject: [PATCH 3/5] test(service-analytics): keep the selection's granularity a literal type Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../src/__tests__/dataset-selection-window.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/__tests__/dataset-selection-window.test.ts b/packages/services/service-analytics/src/__tests__/dataset-selection-window.test.ts index c2a7bc841a8..70839720dc9 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-selection-window.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-selection-window.test.ts @@ -367,7 +367,7 @@ describe('#3588 — the echoed SQL tells the truth on the ObjectQL path', () => return `driver_bucket('${granularity}', ${field})`; }, }); - const selection = { dimensions: ['created_at'], measures: ['account_count'], dateGranularity: 'month' }; + const selection = { dimensions: ['created_at'], measures: ['account_count'], dateGranularity: 'month' as const }; const result = await svc.queryDataset(accounts, selection, CTX); expect(asked).toContain('created_at:month'); expect(result.sql).toContain(`${DRIVER_BUCKET} AS "created_at"`); From 1ef5b5e1985ad616a0c347b503fc2d5d0a7d07c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:58:45 +0000 Subject: [PATCH 4/5] test(service-analytics): pin the memory row by code path, adding no driver-memory consumer check:driver-memory-census ledgers every consumer of @objectstack/driver-memory, and a new one is a maintainer ruling rather than a test's choice. The memory row and the rows case now carry InMemoryDriver's declared surface (supports {}, no dialectName, no dateBucketSql) under the real engine, as the mongodb row does, and the devDependency, its source alias and its lockfile entry are withdrawn. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../services/service-analytics/package.json | 1 - .../objectql-echo-bucket-enumeration.test.ts | 63 ++++++++++++------- .../service-analytics/vitest.config.ts | 9 --- pnpm-lock.yaml | 3 - 4 files changed, 39 insertions(+), 37 deletions(-) diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 02902526cd9..68d9716a9e0 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -29,7 +29,6 @@ "@objectstack/types": "workspace:*" }, "devDependencies": { - "@objectstack/driver-memory": "workspace:*", "@objectstack/driver-sql": "workspace:*", "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/objectql": "workspace:*", diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts index fa02ccc3cdc..dd6b86fed6e 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts @@ -58,10 +58,17 @@ * * ## The rows * - * - `memory`, `sqlite` (better-sqlite3), `sqlite-wasm` (sql.js): real - * drivers. `postgres` and `mysql`: real `driver-sql` instances, never - * connected; their dialect and bucket expression answer from the client - * config alone, and the spied doors mean no statement is sent. + * - `sqlite` (better-sqlite3), `sqlite-wasm` (sql.js): real drivers. + * `postgres` and `mysql`: real `driver-sql` instances, never connected; + * their dialect and bucket expression answer from the client config alone, + * and the spied doors mean no statement is sent. + * - `memory`, by code path: `InMemoryDriver` declares `supports = {}`, so + * the engine buckets every granularity in memory, and it has neither + * `dialectName` nor `dateBucketSql`. The stand-in carries exactly that + * surface. The real driver is not imported: a new consumer of + * `@objectstack/driver-memory` is a maintainer ruling + * (`check:driver-memory-census`), not a test's choice. #21647's own + * measurement ran on the real driver, before and after this change. * - `mongodb`, by code path: `driver-mongodb` publishes * `queryDateGranularity` for every granularity * (`MONGODB_DATE_GRANULARITIES`), so the engine pushes the bucket into its @@ -88,7 +95,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; -import { InMemoryDriver } from '@objectstack/driver-memory'; import { BUILTIN_DRIVER_IDS, TimeUpdateInterval, type BuiltinDriverId, type Cube } from '@objectstack/spec/data'; import { declaredRefusalMessage } from '@objectstack/types'; import type { AnalyticsService } from '../analytics-service.js'; @@ -160,22 +166,32 @@ const releaseKnex = async (driver: EnumeratedDriver) => { const sqliteDriver = () => new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any) as unknown as EnumeratedDriver; -/** `driver-mongodb`'s surface on this pin's read path, by code path (see the header). */ -const mongodbByCodePath = (): EnumeratedDriver => ({ - name: 'os21647.mongodb-by-code-path', +/** + * A driver that runs no SQL, by code path: its `supports`, and the two data + * doors, with no `dialectName` and no `dateBucketSql`. `find` answers `rows`. + */ +const noSqlDriverByCodePath = (name: string, supports: Record, rows: unknown[] = []): EnumeratedDriver => ({ + name, version: '0.0.0', - supports: { - batchSchemaSync: true, - queryDateGranularity: Object.fromEntries(GRANULARITIES.map((g) => [g, true])), - }, + supports, async connect() {}, async disconnect() {}, async aggregate() { return []; }, - async find() { return []; }, + async find() { return rows.map((row) => ({ ...(row as object) })); }, }); +/** `driver-memory`'s surface on this pin's read path, by code path (see the header). */ +const memoryByCodePath = (rows: unknown[] = []) => noSqlDriverByCodePath('os21647.memory-by-code-path', {}, rows); + +/** `driver-mongodb`'s surface on this pin's read path, by code path (see the header). */ +const mongodbByCodePath = () => + noSqlDriverByCodePath('os21647.mongodb-by-code-path', { + batchSchemaSync: true, + queryDateGranularity: Object.fromEntries(GRANULARITIES.map((g) => [g, true])), + }); + const DRIVER_ROWS = { - memory: [{ face: 'driver-memory', build: () => new InMemoryDriver() as unknown as EnumeratedDriver }], + memory: [{ face: 'driver-memory, by code path', build: () => memoryByCodePath() }], sqlite: [{ face: 'driver-sql, better-sqlite3', build: sqliteDriver, release: releaseKnex }], 'sqlite-wasm': [ { @@ -360,12 +376,13 @@ describe('[#21647] the echo of a date bucket: driver x timezone class x granular }); /** - * [#21647] The rows, on the real `driver-memory`: `/analytics/query` serves - * them with no `sql`, and the dry run refuses. The driver is only asked for - * rows; the engine buckets them in memory. d2 (20:00 UTC on 31 January) stays - * in January at UTC. + * [#21647] The rows, on `driver-memory`'s surface (by code path, see the + * header): `/analytics/query` serves them with no `sql`, and the dry run + * refuses. The driver is only asked for rows, and the real engine buckets them + * in memory. d2 (20:00 UTC on 31 January) stays in January at UTC. These are + * the rows #21647 measured on the real driver. */ -describe('[#21647] driver-memory, live: the query serves its rows with no `sql`, and the dry run refuses', () => { +describe('[#21647] driver-memory\'s surface: the query serves its rows with no `sql`, and the dry run refuses', () => { const DEALS = [ { id: 'd1', closed_at: '2026-01-10T10:00:00.000Z', amount: 20 }, { id: 'd2', closed_at: '2026-01-31T20:00:00.000Z', amount: 7 }, @@ -380,17 +397,15 @@ describe('[#21647] driver-memory, live: the query serves its rows with no `sql`, const doors: string[] = []; beforeAll(async () => { - const driver = new InMemoryDriver(); + const driver = memoryByCodePath(DEALS); const aggregate = driver.aggregate.bind(driver); const find = driver.find.bind(driver); - (driver as any).aggregate = async (...args: any[]) => { doors.push('aggregate'); return (aggregate as any)(...args); }; - (driver as any).find = async (...args: any[]) => { doors.push('find'); return (find as any)(...args); }; + driver.aggregate = async (...args: unknown[]) => { doors.push('aggregate'); return aggregate(...args); }; + driver.find = async (...args: unknown[]) => { doors.push('find'); return find(...args); }; engine = new ObjectQL({ logger: quiet } as any); engine.registerDriver(driver as any, true); await engine.init(); engine.registry.registerObject(DEAL_OBJECT as any); - await engine.syncSchemas(); - for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); const registered: Record = {}; await new AnalyticsServicePlugin({ cubes: CUBES, debugSql: true } as any).init({ diff --git a/packages/services/service-analytics/vitest.config.ts b/packages/services/service-analytics/vitest.config.ts index 23a2a04a29c..8c97122c185 100644 --- a/packages/services/service-analytics/vitest.config.ts +++ b/packages/services/service-analytics/vitest.config.ts @@ -35,15 +35,6 @@ export default defineConfig({ find: /^@objectstack\/objectql$/, replacement: path.resolve(__dirname, '../../objectql/src/index.ts'), }, - { - // [#21647] `objectql-echo-bucket-enumeration.test.ts` serves a bucketed - // query through the REAL `driver-memory` under the engine above, so the - // driver is read from source for the same reason: its advertised - // capabilities decide where the engine buckets, and a stale `dist` - // would answer with old ones. Anchored, as above. - find: /^@objectstack\/driver-memory$/, - replacement: path.resolve(__dirname, '../../drivers/driver-memory/src/index.ts'), - }, ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 827556dea27..fb0fc439dbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2373,9 +2373,6 @@ importers: specifier: workspace:* version: link:../../types devDependencies: - '@objectstack/driver-memory': - specifier: workspace:* - version: link:../../drivers/driver-memory '@objectstack/driver-sql': specifier: workspace:* version: link:../../drivers/driver-sql From 29c4a18396d48e5b340a33b73464009321241bb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 02:02:26 +0000 Subject: [PATCH 5/5] test: ledger the bucket echo enumeration's producer calls and turn the runtime dry-run pin The client envelope-caller census gains a NOT_SDK row for the two analytics.query sites in service-analytics' new bucket echo enumeration pin (section 2: 9 to 11 service sites; section 3: 9 to 11 not-SDK), and the file is declared by name in @objectstack/client#test's cross-package test inputs, beside the date-bucket echo pin. runtime's authored-cube pin asserted 200 and date_trunc from POST /analytics/sql on a host that wires no dateBucketSql hook. That dry run now answers the declared refusal, NOT_IMPLEMENTED / 501, whose kept message names the bucket (the declared month, the stated year) and the cause. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../client/src/envelope-caller-census.test.ts | 17 ++++++++++--- ...s-authored-cube-format-granularity.test.ts | 25 +++++++++++++++---- scripts/cross-package-test-inputs.mjs | 6 +++++ turbo.json | 3 ++- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/client/src/envelope-caller-census.test.ts b/packages/client/src/envelope-caller-census.test.ts index 384a426f591..be1811eb8bd 100644 --- a/packages/client/src/envelope-caller-census.test.ts +++ b/packages/client/src/envelope-caller-census.test.ts @@ -493,6 +493,12 @@ const LEDGER: readonly LedgerRow[] = [ method: 'analytics.query', receiver: 'service', count: 1, verdict: 'NOT_SDK', why: 'the real AnalyticsService that AnalyticsServicePlugin registers over a live engine, called directly (no HTTP, no dispatcher envelope) to read the ObjectQL face\'s rows and the echoed `sql` it runs against them', }, + // ── [#21647] the bucket echo's driver x timezone x granularity enumeration: producer reads only ── + { + file: 'packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts', + method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK', + why: 'the real AnalyticsService that AnalyticsServicePlugin registers over an ObjectQL engine with the driver\'s data doors spied, called directly (no HTTP, no dispatcher envelope) to read whether the ObjectQL face\'s answer carries an echoed `sql`, and its rows', + }, { file: 'packages/client/src/analytics-automation-json-erasure.test.ts', method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT', @@ -699,11 +705,15 @@ describe('#13079 §2 — positive controls on the matcher itself', () => { // the real AnalyticsService that `AnalyticsServicePlugin` registers // over a live engine, to read the ObjectQL face's rows beside its // echoed `sql`. Its receiver is that service, not the client. - expect(service.length, literalNote()).toBe(9); + // [#21647] A fifth, two sites: the bucket echo's enumeration pin calls + // the same plugin-registered AnalyticsService, over an engine with the + // driver's data doors spied, to read whether the answer carries `sql`. + expect(service.length, literalNote()).toBe(11); expect([...new Set(service.map((s) => s.file))].sort()).toEqual([ 'packages/client/src/analytics-automation-json-erasure.test.ts', 'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts', 'packages/rest/src/analytics-nested-relation-filter.test.ts', + 'packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts', 'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts', ]); expect(service.every((s) => s.method === 'analytics.query')).toBe(true); @@ -751,11 +761,12 @@ describe('#13079 §3 — every call site is classified', () => { expect(production, literalNote()).toEqual([]); }); - it('records the split: 18 payload pins, 10 result-insensitive, 9 not-SDK', () => { + it('records the split: 18 payload pins, 10 result-insensitive, 11 not-SDK', () => { expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18); expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10); // [#21441] 8 -> 9: the date-bucket echo pin's producer call (§2). - expect(verdictTotal('NOT_SDK')).toBe(9); + // [#21647] 9 -> 11: the bucket echo enumeration's two producer calls (§2). + expect(verdictTotal('NOT_SDK')).toBe(11); // The three above are LEDGER sums and cannot move on a census reading; // this one is census-derived, so it carries the note. [#13874] expect(sdkSites.length, literalNote()).toBe(28); diff --git a/packages/runtime/src/analytics-authored-cube-format-granularity.test.ts b/packages/runtime/src/analytics-authored-cube-format-granularity.test.ts index 4e9716661e1..8242f9e8932 100644 --- a/packages/runtime/src/analytics-authored-cube-format-granularity.test.ts +++ b/packages/runtime/src/analytics-authored-cube-format-granularity.test.ts @@ -144,7 +144,19 @@ describe('an authored time dimension\'s single declared granularity is its defau expect(res.body.data.rows).toEqual([{ placed_at: '2026-07', count: 2 }]); }); - it('POST /analytics/sql dry-runs the bucketed statement, and a stated granularity still wins', async () => { + // [#21647] This case asserted 200 and `date_trunc('month'` / `date_trunc('year'`: + // the representative bucket the SQL echo printed for a host with no + // `dateBucketSql` hook, which no driver groups by. This host composes the + // service directly, with no hook and no driver behind it, so the dry run now + // answers the service's declared refusal (`NOT_IMPLEMENTED` / 501, + // `refusal: true` at throw time). This exit reads that declaration to keep + // the producer's message instead of withholding a 5xx as a fault, so the + // message reaching the wire IS the declaration's effect: it names the bucket + // it refused and the cause, the declared default for the first request and + // the stated granularity for the second. Where a hook answers, the + // service-level pin (`service-analytics` `cube-authored-format-granularity.test.ts`) + // asserts the driver's expression. + it('POST /analytics/sql dry-runs the bucket at the declared granularity, and a stated granularity still wins (no dateBucketSql hook: the declared refusal)', async () => { const declared = await post(analytics().service, 'sql', { cube: 'orders', measures: ['count'], dimensions: ['placed_at'] }); const stated = await post(analytics().service, 'sql', { cube: 'orders', @@ -153,9 +165,12 @@ describe('an authored time dimension\'s single declared granularity is its defau timeDimensions: [{ dimension: 'placed_at', granularity: 'year' }], }); - expect(declared.statusCode).toBe(200); - expect(declared.body.data.sql).toMatch(/date_trunc\('month'/i); - expect(stated.statusCode).toBe(200); - expect(stated.body.data.sql).toMatch(/date_trunc\('year'/i); + for (const [res, granularity] of [[declared, 'month'], [stated, 'year']] as const) { + expect(res.statusCode, granularity).toBe(501); + expect(res.body.success, granularity).toBe(false); + expect(res.body.error.code, granularity).toBe('NOT_IMPLEMENTED'); + expect(res.body.error.message, granularity).toContain(`"${granularity}" bucket of "placed_at"`); + expect(res.body.error.message, granularity).toContain('dateBucketSql'); + } }); }); diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 3d8349306c1..c32d442d7ff 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -765,6 +765,12 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // `service`). A call added to or removed from that file moves the census // verdict, so a change to it has to re-run this suite. 'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts', + // [#21647] Declared by name for the same reason: the census LEDGER + // carries a NOT_SDK row for service-analytics' bucket echo enumeration + // pin, which calls `analytics.query(` twice on the real AnalyticsService + // (receiver `service`). A call added to or removed from that file moves + // the census verdict, so a change to it has to re-run this suite. + 'packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts', ], heldBy: { // `scripts/**` is rostered TODAY through the census's own diff --git a/turbo.json b/turbo.json index f85028436ee..8571e12da60 100644 --- a/turbo.json +++ b/turbo.json @@ -262,7 +262,8 @@ "$TURBO_ROOT$/scripts/**", "$TURBO_ROOT$/packages/rest/src/analytics-nested-relation-filter.test.ts", "$TURBO_ROOT$/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts", - "$TURBO_ROOT$/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts" + "$TURBO_ROOT$/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts", + "$TURBO_ROOT$/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts" ] }, "@objectstack/lint#test": {