From bef6f359cbb036e69db46fd45fee63326eaa9902 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:57:09 +0000 Subject: [PATCH 1/8] fix(service-analytics): the ObjectQL face echoes a date bucket in the driver's own expression ObjectQLStrategy.generateSql printed every date-bucketed dimension as date_trunc('', col). SQLite refuses that, and PostgreSQL answers timestamps where the face answers 2026-01. The echo now reads the bucket from a dateBucketSql strategy-context hook, which the plugin fills from SqlDriver.dateBucketSql: the rendered, unchanged buildDateBucketExpr. Where nothing answers (no hook, a non-SQL driver, a granularity the driver buckets in memory, a non-UTC timezone) the echo keeps date_trunc. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- packages/drivers/driver-sql/src/sql-driver.ts | 32 ++++++++++- .../drivers/driver-turso/src/turso-driver.ts | 3 +- .../src/analytics-service.ts | 13 +++++ .../services/service-analytics/src/plugin.ts | 37 +++++++++++++ .../src/strategies/objectql-strategy.ts | 53 +++++++++++++++---- .../service-analytics/src/strategies/types.ts | 19 +++++++ 6 files changed, 144 insertions(+), 13 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index ae07547bcd6..0a627938b52 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7,7 +7,7 @@ * Supports PostgreSQL, MySQL, SQLite, and other SQL databases. */ -import type { DriverOptions, FilterCondition, SchemaMode } from '@objectstack/spec/data'; +import type { DateGranularityValue, DriverOptions, FilterCondition, SchemaMode } from '@objectstack/spec/data'; // The ONE introspection contract (ADR-0015 / `ISchemaDiffService`). This // driver's introspection types are DERIVED from these rather than // re-declared next to them — see the `Introspection Types` region below. @@ -6166,6 +6166,36 @@ export class SqlDriver implements IDataDriver { return null; } + /** + * [#21441] The date-bucket expression {@link aggregate} groups `field` by at + * `granularity`, rendered as SQL text in this driver's dialect, or `null` + * where {@link buildDateBucketExpr} has none: a granularity this dialect + * buckets in memory (`week` on SQLite, see + * {@link dateGranularityCapabilities}), or a client this driver does not + * model. + * + * It is for callers that PRINT the statement an aggregate stands for rather + * than run it. `service-analytics`' ObjectQL face echoes a date-bucketed + * query as SQL (`/analytics/sql`). While it spelled the bucket itself it + * printed `date_trunc(…)` on every dialect: SQLite refuses that, and + * PostgreSQL answers a timestamp where this driver answers `2026-01`. + * + * Reading the expression from here keeps this driver the one source of its + * bucketing. The text is `buildDateBucketExpr`'s, unchanged, with each `??` + * rendered by knex (`Raw.toQuery`) through this dialect's own identifier + * quoting. The expression binds identifiers only, so the text carries no + * value placeholder. `objectName` goes in as the coercion key, as + * `aggregate` passes it, so a SQLite `Field.datetime` column that may still + * hold pre-canonical values renders the repair the GROUP BY runs (#3773). + * + * Read structurally by its caller, like {@link dialectName}. It is not a + * member of the `IDataDriver` contract. + */ + public dateBucketSql(objectName: string, field: string, granularity: DateGranularityValue): string | null { + const bucket = this.buildDateBucketExpr(field, granularity, objectName); + return bucket ? this.knex.raw(bucket.sql, bucket.bindings).toQuery() : null; + } + /** * Schema ownership mode (ADR-0015). When not `'managed'`, all * schema-mutating DDL is rejected by {@link assertSchemaMutable}. The diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index cc15138aba3..37c602c856d 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -1609,7 +1609,8 @@ export class TursoDriver extends SqlDriver { batchSchemaSync: true, // Remote transport does NOT do native date bucketing. SqlDriver's - // `aggregate` — which emits `date_trunc`/`strftime` for structured + // `aggregate` — which emits its dialect's bucket expression (`strftime` + // here; see `SqlDriver.buildDateBucketExpr`) for structured // `{ field, dateGranularity }` groupBy items — is only reached in // local/replica mode; remote mode delegates `aggregate` to // `RemoteTransport.aggregate`, which accepts only string group-by diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index ae5535888d8..b9fb64d62f2 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -1137,6 +1137,16 @@ export interface AnalyticsServiceConfig { * "cannot answer, do not block". */ sqlDialect?: (object: string) => AcceptedSqlDialect | undefined; + /** + * [#21441] The date-bucket expression the driver backing `object` groups + * `field` by at `granularity`, as SQL text in its own dialect, or + * `undefined` when the host cannot answer. See + * `DatasetScopedStrategyContext.dateBucketSql` (`strategies/types.ts`). + * + * Answered by the plugin from the driver that executes the aggregate. A host + * that wires nothing keeps the echo's representative `date_trunc(…)`. + */ + dateBucketSql?: (object: string, field: string, granularity: string) => string | undefined; /** * [#19995, ruling C] The data engine's judge-only `where` admission, * `IObjectQLEngine.judgeFilter` (#20157): would the engine admit this @@ -1490,6 +1500,9 @@ export class AnalyticsService implements IAnalyticsService { this.diagnoseSqlDialectAnswer(object, answered); return answered; }, + // [#21441] The executing driver's own bucket expression, for the ObjectQL + // face's echo; passed through untouched, `undefined` included. + dateBucketSql: config.dateBucketSql, // [#19995, ruling C] The engine's own admission verdict on a read scope, // asked by `ObjectQLStrategy` at its engine-bound merges. The host's // answer is passed through untouched, `undefined` included. A host that diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 6ca3353bac7..e1378f313e4 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -97,6 +97,16 @@ type TemporalDriverSurface = Pick< */ type DialectNamingDriver = { readonly dialectName?: unknown }; +/** + * [#21441] The slice of a SQL driver that renders its OWN date-bucket + * expression, `SqlDriver.dateBucketSql`. Read structurally, as + * {@link DialectNamingDriver} is and for the same reason: bucketing SQL is a + * property of the SQL driver family, not of every driver. + */ +type DateBucketingDriver = { + dateBucketSql?(objectName: string, field: string, granularity: string): unknown; +}; + /** * Re-parse a bridge-supplied aggregation `method` as the engine contract's * `AggregationFunction` before it is forwarded as `function`, refusing @@ -1215,6 +1225,30 @@ export class AnalyticsServicePlugin implements Plugin { } }; + /** + * [#21441] The expression the driver that owns the object groups a + * date-bucketed dimension by, as SQL text: what `ObjectQLStrategy.generateSql` + * echoes for the bucket. Asked of the DRIVER through the same + * `getDriverForObject` seam `sqlDialect` uses, so the driver stays the + * 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(…)`. + */ + const dateBucketSql = (objectName: string, field: string, granularity: string): string | undefined => { + try { + const svc = ctx.getService('data'); + const driver = svc?.getDriverForObject?.(objectName) as DateBucketingDriver | undefined; + if (typeof driver?.dateBucketSql !== 'function') return undefined; + const rendered = driver.dateBucketSql(objectName, field, granularity); + return typeof rendered === 'string' && rendered !== '' ? rendered : undefined; + } catch { + return undefined; + } + }; + /** * [#21080] The data engine's answer to "is a middleware registered for * this object?" (`IObjectQLEngine.hasObjectMiddleware`), resolved per call @@ -1339,6 +1373,9 @@ export class AnalyticsServicePlugin implements Plugin { getObjectDatasource: (objectName: string) => dataEngine()?.resolveEffectiveDatasource?.(objectName), // [#15684] The executing driver's own dialect — see `sqlDialect` above. sqlDialect, + // [#21441] The executing driver's own bucket expression — see + // `dateBucketSql` above. + dateBucketSql, // [#19995, ruling C] The executing engine's own `where` admission — see // `judgeFilter` beside the `executeAggregate` auto-bridge above. judgeFilter, diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index eedaeae5aaa..3769d2062c9 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -163,10 +163,11 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // Build groupBy from dimensions, honouring `timeDimensions` granularity. // A date dimension with a granularity becomes a STRUCTURED groupBy item - // `{ field, dateGranularity }` — which `engine.aggregate()` buckets (driver - // date_trunc or in-memory). Without this the ObjectQL path grouped raw - // timestamps (one bucket per row) and date-bucketed dataset widgets never - // matched their legacy `categoryGranularity` counterpart. + // `{ field, dateGranularity }` — which `engine.aggregate()` buckets (the + // driver's own SQL expression, or in memory). Without this the ObjectQL + // path grouped raw timestamps (one bucket per row) and date-bucketed + // dataset widgets never matched their legacy `categoryGranularity` + // counterpart. type GroupByItem = string | { field: string; dateGranularity: string }; const granByDim = new Map(); for (const td of query.timeDimensions ?? []) { @@ -408,14 +409,23 @@ export class ObjectQLStrategy implements AnalyticsStrategy { } /** - * Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query. + * Render the SQL statement an ObjectQL aggregate query stands for. * * This path executes through `engine.aggregate()`, not raw SQL, so the string * is documentation rather than the literal statement — but it must be an * honest account of what the query does, because dataset responses echo it * and authors read it to verify their widget options landed (#3588). It - * therefore renders date bucketing (`date_trunc`), the WHERE predicate, - * ordering, and the row window. + * therefore renders date bucketing, the WHERE predicate, ordering, and the + * row window. + * + * [#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. The bucket stays REPRESENTATIVE where + * the hook answers nothing: no hook, a driver with no bucket expression (a + * non-SQL driver), a granularity the driver buckets in memory (`week` on + * SQLite), or a non-UTC `timezone`, which the engine buckets in memory on + * that zone's calendar. There it prints `date_trunc('', col)`, + * which SQLite refuses. * * Filter VALUES are rendered as `$n` placeholders and returned in `params`, * never inlined: the echoed statement travels to the browser, and a filter @@ -478,9 +488,9 @@ export class ObjectQLStrategy implements AnalyticsStrategy { const groupByParts: string[] = []; const params: unknown[] = []; - // Date-bucketed dimensions render as `date_trunc('', col)` — - // the SQL shape the driver's own bucketing implements — so a `month` trend - // no longer reads as if it grouped by the raw column. + // Date-bucketed dimensions render as a bucket expression, so a `month` + // trend does not read as if it grouped by the raw column — see `dimExpr` + // below for which expression. const granByDim = new Map(); for (const td of query.timeDimensions ?? []) { if (td.granularity) granByDim.set(td.dimension, td.granularity); @@ -515,6 +525,26 @@ export class ObjectQLStrategy implements AnalyticsStrategy { ); const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd])); const joinClauses: string[] = []; + // [#21441] The bucket expression the driver itself groups by for its + // dialect, read from the `dateBucketSql` hook: `strftime('%Y-%m', …)` on + // SQLite, `to_char(… AT TIME ZONE 'UTC', 'YYYY-MM')` on PostgreSQL. The + // echo then runs there and answers the face's bucket keys. It used to + // print `date_trunc('', col)` on every dialect, calling that + // the driver's own bucketing: no driver buckets with it, SQLite refuses it + // (`no such function`), and PostgreSQL answers a timestamp where the face + // answers `2026-01`. + // + // Asked only for a UTC or unset `timezone`. The driver's expression is a + // UTC bucket, and a non-UTC zone makes the engine bucket in memory on that + // zone's calendar instead (ADR-0053 Phase 2, D2; `tzRequiresInMemory` in + // objectql's `engine.ts`), which no driver expression describes. Where + // nothing answers, the bucket keeps the representative `date_trunc`. + const zone = query.timezone; + const driverBucketSql = (col: string, granularity: string): string | undefined => { + if (zone && zone !== 'UTC') return undefined; + const answered = (ctx as DatasetScopedStrategyContext).dateBucketSql?.(tableName, col, granularity); + return typeof answered === 'string' && answered !== '' ? answered : undefined; + }; const dimExpr = (dim: string): string => { const cd = crossByDim.get(dim); if (cd) { @@ -525,7 +555,8 @@ export class ObjectQLStrategy implements AnalyticsStrategy { } const col = this.resolveFieldName(cube, dim, 'dimension'); const gran = granByDim.get(dim); - return gran ? `date_trunc('${gran}', ${col})` : col; + if (!gran) return col; + return driverBucketSql(col, gran) ?? `date_trunc('${gran}', ${col})`; }; if (query.dimensions) { diff --git a/packages/services/service-analytics/src/strategies/types.ts b/packages/services/service-analytics/src/strategies/types.ts index b5a6e690925..43163d14108 100644 --- a/packages/services/service-analytics/src/strategies/types.ts +++ b/packages/services/service-analytics/src/strategies/types.ts @@ -160,6 +160,25 @@ export interface DatasetScopedStrategyContext extends StrategyContext { * know the hook keeps the behaviour it had — "cannot answer, do not block". */ sqlDialect?(objectName: string): string | undefined; + /** + * [#21441] The date-bucket expression the driver backing `objectName` + * groups `field` by at `granularity`, as SQL text in that driver's dialect: + * `strftime('%Y-%m', …)` on SQLite, `to_char(… AT TIME ZONE 'UTC', + * 'YYYY-MM')` on PostgreSQL. `undefined` when the host cannot answer: no + * hook wired, a driver with no bucket expression (a non-SQL driver), or a + * granularity the driver buckets in memory (`week` on SQLite). + * + * `ObjectQLStrategy.generateSql` prints a date-bucketed dimension in this + * expression, so its echo runs on that dialect and answers the face's + * bucket keys. The service answers it from + * `AnalyticsServiceConfig.dateBucketSql`, which the plugin fills from the + * driver that EXECUTES the aggregate: the driver stays the single source of + * its bucketing, the posture `sqlDialect` takes. Declared HERE rather than + * on the spec's {@link StrategyContext} for the reason `declaredFieldType` + * is: nothing about it is an authorable surface, and a strategy that does + * not know the hook keeps the behaviour it had. + */ + dateBucketSql?(objectName: string, field: string, granularity: string): string | undefined; /** * [#19995] The ENGINE's own `where` admission verdict for `objectName`, * `IObjectQLEngine.judgeFilter` (#20157), or `undefined` when the host From a61c6d79a935a094bb560d36a917ac16a120a34a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:59:45 +0000 Subject: [PATCH 2/8] fix(driver-turso): classify SqlDriver.dateBucketSql on the remote face as inherited REMOTE_FACE_ANSWERS names every public SqlDriver member. The new dateBucketSql renders the SQLite bucket expression through Knex's compiler, which needs no connection, so the inherited answer is true on the remote face. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- packages/drivers/driver-sql/src/sql-driver.ts | 8 ++++---- packages/drivers/driver-turso/src/turso-driver.ts | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 0a627938b52..164cb6b66b3 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -6167,10 +6167,10 @@ export class SqlDriver implements IDataDriver { } /** - * [#21441] The date-bucket expression {@link aggregate} groups `field` by at - * `granularity`, rendered as SQL text in this driver's dialect, or `null` - * where {@link buildDateBucketExpr} has none: a granularity this dialect - * buckets in memory (`week` on SQLite, see + * [#21441] The date-bucket expression this dialect groups `field` by at + * `granularity` (the one {@link aggregate} runs), rendered as SQL text, or + * `null` where {@link buildDateBucketExpr} has none: a granularity this + * dialect buckets in memory (`week` on SQLite, see * {@link dateGranularityCapabilities}), or a client this driver does not * model. * diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 37c602c856d..0f732b98aa8 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -940,6 +940,11 @@ export const REMOTE_FACE_ANSWERS = { // Pure functions of the registries the remote arms read themselves. temporalFilterValue: 'inherited', temporalFilterColumnSql: 'inherited', + // The SQLite bucket expression, rendered by Knex's compiler, which needs no + // connection; libSQL runs it. The remote `aggregate` buckets nothing (its + // `queryDateGranularity` is empty), so the engine buckets in memory, on the + // UTC calendar whose keys this expression answers. + dateBucketSql: 'inherited', } as const satisfies Record; // ── Remote operation timeout ───────────────────────────────────────────────── From 47375ee4d4cb4e03a2c9a02cc5f3661b41b81e0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:02:58 +0000 Subject: [PATCH 3/8] test(service-analytics): pin the ObjectQL face's date-bucket echo on SQLite and live PostgreSQL Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../objectql-echo-date-bucket.test.ts | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts 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 new file mode 100644 index 00000000000..c0eb312b251 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts @@ -0,0 +1,253 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21441] The ObjectQL face echoes a date-bucketed dimension in the bucket + * expression the driver itself groups by, so the echo runs on that dialect and + * answers the face's rows. + * + * ## The shape this closes + * + * `ObjectQLStrategy.generateSql` printed `date_trunc('', col)` on + * every dialect. Measured at `POST /api/v1/analytics/query` and + * `POST /api/v1/analytics/sql` on `main` `0bddffd55`, default composition (the + * native face declines a granularity, so every bucketed query lands here): + * + * | cell | the driver grouped by | that echo, run | + * |:--|:--|:--| + * | SQLite, month | `strftime('%Y-%m', …)` | `no such function: date_trunc` | + * | SQLite, quarter | `(strftime('%Y', …) \|\| '-Q' \|\| …)` | `no such function: date_trunc` | + * | PostgreSQL 16.14, month | `to_char((…)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM')` | `2026-01-01T00:00:00.000Z` where the face answers `2026-01` | + * + * The rows were right. The echo now reads the bucket from the `dateBucketSql` + * hook, which the plugin fills from `SqlDriver.dateBucketSql`: the driver's + * own `buildDateBucketExpr`, rendered. No second bucketing table. + * + * ## The cells + * + * - **sqlite** (better-sqlite3), every run. Month and quarter are grouped by + * the driver; week is bucketed in memory (the driver declares no `week`). + * - **postgres** where `OS_TEST_POSTGRES_URL` is set, a named skip otherwise. + * Month, quarter and week are grouped by the driver. No CI step provisions + * that variable for this package, so the live cell is red-capable and + * un-run in CI. + * + * Where the hook answers nothing, the bucket keeps `date_trunc`: a granularity + * the driver buckets in memory, a non-UTC `timezone` (the engine buckets in + * memory on that zone's calendar), and a host that wires no hook. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { Cube } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; +import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; +import type { StrategyContext } from '../strategies/types.js'; + +const DEAL = 'os21441_bucket_deal'; + +const DEAL_OBJECT = { + name: DEAL, + label: 'Bucket echo deal', + fields: { + closed_on: { name: 'closed_on', type: 'date' as const }, + closed_at: { name: 'closed_at', type: 'datetime' as const }, + amount: { name: 'amount', type: 'number' as const }, + }, +}; + +// d2 closes at 20:00 UTC on 31 January, which is 1 February in Asia/Shanghai. +const DEALS = [ + { id: 'd1', closed_on: '2026-01-10', closed_at: '2026-01-10T10:00:00.000Z', amount: 20 }, + { id: 'd2', closed_on: '2026-01-25', closed_at: '2026-01-31T20:00:00.000Z', amount: 7 }, + { id: 'd3', closed_on: '2026-02-03', closed_at: '2026-02-03T08:00:00.000Z', amount: 1 }, + { id: 'd4', closed_on: '2026-03-14', closed_at: '2026-03-14T12:00:00.000Z', amount: 10 }, + { id: 'd5', closed_on: '2026-04-02', closed_at: '2026-04-02T00:30:00.000Z', amount: 5 }, +] as const; + +const CUBE = 'os21441_bucket_cube'; +const CUBES = [ + { + name: CUBE, + title: 'Bucket echo cube', + sql: DEAL, + public: true, + measures: { amount_sum: { type: 'sum', sql: 'amount', label: 'Amount' } }, + dimensions: { + closed_on: { type: 'time', sql: 'closed_on', label: 'Closed on' }, + closed_at: { type: 'time', sql: 'closed_at', label: 'Closed at' }, + }, + }, +] as unknown as Cube[]; + +/** A dataset whose measure carries its own `filter`, which the engine aggregates in memory. */ +const FILTERED = DatasetSchema.parse({ + name: 'os21441_bucket_filtered', + label: 'Bucket echo filtered', + object: DEAL, + dimensions: [{ name: 'closed_on', label: 'Closed on', field: 'closed_on', type: 'date' }], + measures: [{ name: 'big_sum', label: 'Big', aggregate: 'sum', field: 'amount', filter: { amount: { $ne: 7 } } }], +}); + +const bucketed = (dim: string, granularity: string, extra: Record = {}) => ({ + cube: CUBE, + measures: ['amount_sum'], + timeDimensions: [{ dimension: dim, granularity }], + order: { [dim]: 'asc' }, + ...extra, +}); + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; + /** The granularities the driver groups by in SQL; the rest it buckets in memory. */ + driverGrouped: readonly string[]; +} + +const CELLS: readonly Cell[] = [ + { + id: 'sqlite', + label: 'sqlite', + env: null, + config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + driverGrouped: ['month', 'quarter'], + }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + driverGrouped: ['month', 'quarter', 'week'], + }, +]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +type Row = Record; + +/** Rows as tuples of the named columns, in arrival order; a numeric cell reads as a number on every dialect. */ +const tuples = (rows: unknown, columns: readonly string[]) => + (rows as Row[]).map((row) => columns.map((c) => (typeof row[c] === 'number' || /^-?\d+(\.\d+)?$/.test(String(row[c])) ? Number(row[c]) : row[c]))); + +/** 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}"`)); + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#21441] the ObjectQL face echoes a date bucket in the driver's own expression (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: any; + let engine: ObjectQL; + let analytics: AnalyticsService; + /** Every statement the driver ran, in order. */ + const driverRan: string[] = []; + + const dropTables = async () => { + if (cell.id !== 'pg') return; + await driver?.execute(`drop table if exists ${DEAL}`).catch(() => {}); + }; + + /** One `query()` and the statements the driver ran for it. */ + const ask = async (query: Record) => { + const before = driverRan.length; + const res = await analytics.query(query as any); + return { res, ran: driverRan.slice(before) }; + }; + + /** Run an echo through the engine's raw-SQL bridge, as the native face runs its own statement. */ + const run = async (sql: string, params: unknown[]) => { + const result = await (engine as any).execute(sql.replace(/\$(\d+)/g, '?'), { args: params, object: DEAL }); + return Array.isArray(result) ? result : (result as { rows: Row[] }).rows; + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTables(); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, 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); + driver.knex.on('query', (q: { sql: string }) => { driverRan.push(q.sql); }); + + // The default composition: no `queryCapabilities` override. + 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; + analytics.registerDataset(FILTERED); + }); + + afterAll(async () => { + await dropTables(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + for (const dim of ['closed_on', 'closed_at']) { + it.each(cell.driverGrouped)(`${dim}, %s: the echo selects and groups by the expression the driver ran, and it runs`, async (granularity) => { + const query = bucketed(dim, granularity); + const { res, ran } = await ask(query); + const echo = res.sql!; + // `generateSql` is the body `POST /analytics/sql` answers with. + const dryRun = await analytics.generateSql(query as any); + expect(dryRun.sql).toBe(echo); + expect(dryRun.params).toEqual([]); + + const bucket = selectedBucket(echo, dim); + expect(bucket).not.toContain('date_trunc'); + expect(echo).toContain(`GROUP BY ${bucket}`); + expect(ran, 'the driver grouped by that expression').toHaveLength(1); + expect(ran[0]).toContain(bucket); + + expect(tuples(await run(echo, dryRun.params), [dim, 'amount_sum'])).toEqual(tuples(res.rows, [dim, 'amount_sum'])); + }); + } + + 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); + expect(ran.some((sql) => /group by/i.test(sql)), 'the driver grouped nothing').toBe(false); + expect(tuples(res.rows, ['closed_on', 'big_sum'])).toEqual([['2026-01', 20], ['2026-02', 1], ['2026-03', 10], ['2026-04', 5]]); + const dryRun = await analytics.generateSql(query as any); + expect(dryRun.sql).toBe(res.sql); + expect(selectedBucket(dryRun.sql, 'closed_on')).not.toContain('date_trunc'); + expect(tuples(await run(dryRun.sql, dryRun.params), ['closed_on', 'big_sum'])).toEqual(tuples(res.rows, ['closed_on', 'big_sum'])); + }); + + it.each(['month', 'quarter', 'week'].filter((g) => !cell.driverGrouped.includes(g)))( + 'FALLBACK: %s, which the driver buckets in memory, keeps `date_trunc`', + async (granularity) => { + const { res, ran } = await ask(bucketed('closed_on', granularity)); + expect(ran.some((sql) => /group by/i.test(sql)), 'the driver grouped nothing').toBe(false); + expect(selectedBucket(res.sql!, 'closed_on')).toBe(`date_trunc('${granularity}', closed_on)`); + }, + ); + + it('FALLBACK: a non-UTC timezone buckets in memory on that zone\'s calendar, and the echo keeps `date_trunc`', async () => { + const { res, ran } = await ask(bucketed('closed_at', 'month', { timezone: 'Asia/Shanghai' })); + expect(ran.some((sql) => /group by/i.test(sql)), 'the driver grouped nothing').toBe(false); + expect(tuples(res.rows, ['closed_at', 'amount_sum'])).toEqual([['2026-01', 20], ['2026-02', 8], ['2026-03', 10], ['2026-04', 5]]); + expect(selectedBucket(res.sql!, 'closed_at')).toBe("date_trunc('month', closed_at)"); + }); + }, + ); +} + +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)"); + }); +}); From aa1f1a3f7c986ab32337c529a7e842a40d1421ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:03:51 +0000 Subject: [PATCH 4/8] chore(changeset): the ObjectQL face's date-bucket echo and SqlDriver.dateBucketSql Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21441-objectql-echo-date-bucket.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/21441-objectql-echo-date-bucket.md diff --git a/.changeset/21441-objectql-echo-date-bucket.md b/.changeset/21441-objectql-echo-date-bucket.md new file mode 100644 index 00000000000..fabebfafef1 --- /dev/null +++ b/.changeset/21441-objectql-echo-date-bucket.md @@ -0,0 +1,19 @@ +--- +"@objectstack/service-analytics": minor +"@objectstack/driver-sql": minor +"@objectstack/driver-turso": minor +--- + +fix(service-analytics): the ObjectQL face echoes a date-bucketed dimension in the bucket expression the driver itself groups by, so SQLite runs the statement it prints + +Clause-②: no + +**Before**, the ObjectQL strategy printed every date-bucketed dimension as `date_trunc('', col)` in the `sql` it echoes and in the `POST /analytics/sql` body, on every dialect. The native strategy declines a granularity, so every bucketed query lands on this face. Measured through `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` in the default composition: the rows were right. On SQLite the echo failed with `no such function: date_trunc` (month, quarter and week). On PostgreSQL 16.14 it ran but answered `2026-01-01T00:00:00.000Z` where the face answers `2026-01`. The driver groups by `strftime('%Y-%m', …)` on SQLite and `to_char((…)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM')` on PostgreSQL. + +**Now** the echo prints the driver's own expression, so it runs on that dialect and answers the face's bucket keys. + +- **`@objectstack/driver-sql`**: `SqlDriver.dateBucketSql(objectName, field, granularity)` returns the expression `aggregate` groups by, rendered as SQL text: the existing `buildDateBucketExpr`, unchanged, with each identifier quoted by the dialect. It returns `null` for a granularity the dialect buckets in memory (`week` on SQLite). The MySQL arm (`date_format(convert_tz(…))`) is checked by code read only, because no MySQL server was available. +- **`@objectstack/service-analytics`**: the new optional `AnalyticsServiceConfig.dateBucketSql` hook carries the expression to the ObjectQL strategy. `AnalyticsServicePlugin` wires it from the driver that serves the object, as it wires `sqlDialect`. +- **`@objectstack/driver-turso`**: `REMOTE_FACE_ANSWERS` lists `dateBucketSql` as inherited. The remote face renders the same SQLite expression with no connection. + +**Unchanged.** The rows every face answers. The echo keeps `date_trunc(…)` where nothing answers: a host that wires no hook, a driver with no bucket expression (memory, MongoDB), a granularity the driver buckets in memory, and a query with a non-UTC `timezone`, which the engine buckets in memory on that zone's calendar. From 5032b8f3e96ba269f338bdf2811547089c24ea6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:43:08 +0000 Subject: [PATCH 5/8] chore(changeset): driver-turso ships a comment correction only, graded patch REMOTE_FACE_ANSWERS is not exported from the package index and tsup drops it from dist, so the new inherited row publishes nothing. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21441-objectql-echo-date-bucket.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/21441-objectql-echo-date-bucket.md b/.changeset/21441-objectql-echo-date-bucket.md index fabebfafef1..584c3926972 100644 --- a/.changeset/21441-objectql-echo-date-bucket.md +++ b/.changeset/21441-objectql-echo-date-bucket.md @@ -1,7 +1,7 @@ --- "@objectstack/service-analytics": minor "@objectstack/driver-sql": minor -"@objectstack/driver-turso": minor +"@objectstack/driver-turso": patch --- fix(service-analytics): the ObjectQL face echoes a date-bucketed dimension in the bucket expression the driver itself groups by, so SQLite runs the statement it prints @@ -14,6 +14,6 @@ Clause-②: no - **`@objectstack/driver-sql`**: `SqlDriver.dateBucketSql(objectName, field, granularity)` returns the expression `aggregate` groups by, rendered as SQL text: the existing `buildDateBucketExpr`, unchanged, with each identifier quoted by the dialect. It returns `null` for a granularity the dialect buckets in memory (`week` on SQLite). The MySQL arm (`date_format(convert_tz(…))`) is checked by code read only, because no MySQL server was available. - **`@objectstack/service-analytics`**: the new optional `AnalyticsServiceConfig.dateBucketSql` hook carries the expression to the ObjectQL strategy. `AnalyticsServicePlugin` wires it from the driver that serves the object, as it wires `sqlDialect`. -- **`@objectstack/driver-turso`**: `REMOTE_FACE_ANSWERS` lists `dateBucketSql` as inherited. The remote face renders the same SQLite expression with no connection. +- **`@objectstack/driver-turso`**: a comment that said `SqlDriver` buckets with `date_trunc` now names the SQLite `strftime` expression it emits. The inherited `dateBucketSql` answers on the remote face too: it renders the same SQLite expression with no connection, and libSQL runs it. **Unchanged.** The rows every face answers. The echo keeps `date_trunc(…)` where nothing answers: a host that wires no hook, a driver with no bucket expression (memory, MongoDB), a granularity the driver buckets in memory, and a query with a non-UTC `timezone`, which the engine buckets in memory on that zone's calendar. From f621b39fce5e7322444098d248817d637445fe57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:09:17 +0000 Subject: [PATCH 6/8] =?UTF-8?q?chore(changeset):=20declare=20Clause-?= =?UTF-8?q?=E2=91=A1=20yes=20(widening)=20for=20the=20dateBucketSql=20surf?= =?UTF-8?q?aces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqlDriver gains the public dateBucketSql member and AnalyticsServiceConfig gains the optional dateBucketSql key; both widen an exported surface. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21441-objectql-echo-date-bucket.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21441-objectql-echo-date-bucket.md b/.changeset/21441-objectql-echo-date-bucket.md index 584c3926972..f42ef7cf08f 100644 --- a/.changeset/21441-objectql-echo-date-bucket.md +++ b/.changeset/21441-objectql-echo-date-bucket.md @@ -6,7 +6,7 @@ fix(service-analytics): the ObjectQL face echoes a date-bucketed dimension in the bucket expression the driver itself groups by, so SQLite runs the statement it prints -Clause-②: no +Clause-②: yes (widening) **Before**, the ObjectQL strategy printed every date-bucketed dimension as `date_trunc('', col)` in the `sql` it echoes and in the `POST /analytics/sql` body, on every dialect. The native strategy declines a granularity, so every bucketed query lands on this face. Measured through `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` in the default composition: the rows were right. On SQLite the echo failed with `no such function: date_trunc` (month, quarter and week). On PostgreSQL 16.14 it ran but answered `2026-01-01T00:00:00.000Z` where the face answers `2026-01`. The driver groups by `strftime('%Y-%m', …)` on SQLite and `to_char((…)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM')` on PostgreSQL. From 66b16b1b5970e4870cdb60981a413930ca0ccfe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:26:08 +0000 Subject: [PATCH 7/8] test(client): classify the date-bucket echo pin's producer call in the envelope caller census objectql-echo-date-bucket.test.ts calls analytics.query on the real AnalyticsService that AnalyticsServicePlugin registers, not on the SDK. The census enumerates it as a service-receiver site, so the ledger gains its NOT_SDK row and the service-receiver and NOT_SDK counts move 8 to 9. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../client/src/envelope-caller-census.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/client/src/envelope-caller-census.test.ts b/packages/client/src/envelope-caller-census.test.ts index a2d4051b06d..384a426f591 100644 --- a/packages/client/src/envelope-caller-census.test.ts +++ b/packages/client/src/envelope-caller-census.test.ts @@ -487,6 +487,12 @@ const LEDGER: readonly LedgerRow[] = [ method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK', why: 'a MemoryAnalyticsService bound to `analytics`, called directly (no HTTP, no dispatcher envelope) to assert the cube face refuses a non-boolean $exists and answers find()\'s rows for true / false', }, + // ── [#21441] the ObjectQL face's date-bucket echo pin: producer reads only ── + { + file: 'packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts', + 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', + }, { file: 'packages/client/src/analytics-automation-json-erasure.test.ts', method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT', @@ -689,11 +695,16 @@ describe('#13079 §2 — positive controls on the matcher itself', () => { // nested-relation pin, and driver-memory's cube service // (`MemoryAnalyticsService`) in its own refusal suite. None of the // receivers is the client, and every file is pinned. - expect(service.length, literalNote()).toBe(8); + // [#21441] A fourth: `service-analytics`' date-bucket echo pin calls + // 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); 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-date-bucket.test.ts', ]); expect(service.every((s) => s.method === 'analytics.query')).toBe(true); }); @@ -740,10 +751,11 @@ describe('#13079 §3 — every call site is classified', () => { expect(production, literalNote()).toEqual([]); }); - it('records the split: 18 payload pins, 10 result-insensitive, 8 not-SDK', () => { + it('records the split: 18 payload pins, 10 result-insensitive, 9 not-SDK', () => { expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18); expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10); - expect(verdictTotal('NOT_SDK')).toBe(8); + // [#21441] 8 -> 9: the date-bucket echo pin's producer call (§2). + expect(verdictTotal('NOT_SDK')).toBe(9); // 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); From 2b8b2fe7f5e83b42474f452d00de38a01a91f186 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:37:22 +0000 Subject: [PATCH 8/8] test(client): declare the date-bucket echo pin as an input of the client census suite The census names the pin in its LEDGER, so a change to the pin moves the census verdict. The cross-package roster and turbo.json's @objectstack/client#test inputs now carry it by name, as they carry the rest and driver-memory siblings. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- scripts/cross-package-test-inputs.mjs | 6 ++++++ turbo.json | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index f9ca86cf950..3d8349306c1 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -759,6 +759,12 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // moves the census verdict, so a change to it has to re-run this suite. // Per-file, not `packages/**`, for the price the `scripts/**` entry records. 'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts', + // [#21441] Declared by name for the same reason: the census LEDGER + // carries a NOT_SDK row for service-analytics' date-bucket echo pin, + // which calls `analytics.query(` 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-date-bucket.test.ts', ], heldBy: { // `scripts/**` is rostered TODAY through the census's own diff --git a/turbo.json b/turbo.json index 476ef6b94da..f85028436ee 100644 --- a/turbo.json +++ b/turbo.json @@ -261,7 +261,8 @@ "$TURBO_ROOT$/scripts/js-comment-mask.d.mts", "$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/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" ] }, "@objectstack/lint#test": {