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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/21441-objectql-echo-date-bucket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/service-analytics": minor
"@objectstack/driver-sql": 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

Clause-②: yes (widening)

**Before**, the ObjectQL strategy printed every date-bucketed dimension as `date_trunc('<granularity>', 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`**: 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.
18 changes: 15 additions & 3 deletions packages/client/src/envelope-caller-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 31 additions & 1 deletion packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -6166,6 +6166,36 @@ export class SqlDriver implements IDataDriver {
return null;
}

/**
* [#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.
*
* 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
Expand Down
8 changes: 7 additions & 1 deletion packages/drivers/driver-turso/src/turso-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof SqlDriver, RemoteFaceAnswer>;

// ── Remote operation timeout ─────────────────────────────────────────────────
Expand Down Expand Up @@ -1609,7 +1614,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
Expand Down
Loading
Loading