Commit 35dfb81
fix(service-analytics): the ObjectQL face echoes a date bucket in the driver's own expression, so SQLite runs it (#21587)
Fixes #21441
Clause-②: yes (widening)
## What changes
The ObjectQL face's echoed `sql` and the `POST /api/v1/analytics/sql`
body now print a date-bucketed dimension in the bucket expression the
driver itself groups by for its dialect. Before, `generateSql` printed
`date_trunc('GRANULARITY', col)` on every dialect. SQLite refuses that,
and PostgreSQL answers timestamps where the face answers `2026-01`.
The route is the one the seat answered for Q1 (A), with Q2 = A from
triage: the mechanism governs on every dialect.
- **`driver-sql`**: one public member,
`SqlDriver.dateBucketSql(objectName, field, granularity)`. It returns
`knex.raw(sql, bindings).toQuery()` over the unchanged
`buildDateBucketExpr(field, granularity, objectName)`, or `null` where
that returns `null`. No change to what `buildDateBucketExpr` returns,
and no spec member.
- **`service-analytics`**:
- `strategies/types.ts`: one optional context member, `dateBucketSql`,
beside `sqlDialect`.
- `analytics-service.ts`: one optional
`AnalyticsServiceConfig.dateBucketSql` and one `baseCtx` pass-through
line.
- `plugin.ts`: wires the hook from `getDriverForObject`, as `sqlDialect`
is wired (structural read, `typeof` guard, `undefined` on every tier
that cannot answer).
- `objectql-strategy.ts`: `dimExpr` prints the hook's answer, and keeps
`date_trunc` where nothing answers. The "REPRESENTATIVE" docstring
sentence is narrowed to exactly those cases. The false comment ("the SQL
shape the driver's own bucketing implements") is corrected.
- **`driver-turso`**: the comment that said `SqlDriver` emits
`date_trunc` is corrected (comment only). `REMOTE_FACE_ANSWERS` gains
one row, `dateBucketSql: 'inherited'`. Its `satisfies` pin over every
key of `SqlDriver` fails the package's build until every public
`SqlDriver` member is classified, so the ruled driver member forces this
row. The row is not on the package's public surface: it is not exported
from the index, and tsup drops it from `dist/`.
There is no second bucketing table and no dialect branch in
`service-analytics`.
## Where the echo keeps `date_trunc`
The hook answers nothing, and the bucket stays representative, in four
cases:
1. No hook is wired.
2. The driver has no bucket expression (a non-SQL driver).
3. The granularity is one the driver buckets in memory (`week` on
SQLite: `buildDateBucketExpr` returns `null`).
4. The query has a non-UTC `timezone`.
Case 4 is the strategy's own gate (`zone && zone !== 'UTC'`). It mirrors
objectql's `tzRequiresInMemory` (ADR-0053 Phase 2, D2). A non-UTC zone
makes the engine bucket in memory on that zone's calendar, which the
driver's UTC expression does not describe. Measured: with `timezone:
'Asia/Shanghai'` the face answers `2026-01: 20, 2026-02: 8`, while the
driver's UTC expression would answer `27, 1`.
**Not gated: a measure `filter`.** The engine also buckets in memory
when a measure carries a `filter` (`hasAggregationFilter`). It does so
on the same UTC calendar the driver's expression is held to ("Must match
`bucketDateValue()` exactly"), so there the driver expression answers
the face's keys, and the echo uses it. Measured on both engines (pinned
below).
## Measured
**Premise, at `main` `0bddffd55b`.** Measured through the real
`createDispatcherPlugin` mount (`POST /api/v1/analytics/query` and
`/sql`), default composition, with `SqlDriver` on better-sqlite3 and on
a live PostgreSQL 16.14 (private cluster, stopped and removed
afterwards). Each query ran 0 raw statements and 1 engine aggregate, so
these are ObjectQL-face answers.
| cell | the driver ran | echo (= `/sql` body) | the echo, run |
|:--|:--|:--|:--|
| SQLite month / quarter (date and datetime column) | `strftime('%Y-%m',
...)` / `(strftime('%Y', ...) \|\| '-Q' \|\| ...)` | `date_trunc('month'
/ 'quarter', col)` | `no such function: date_trunc` |
| SQLite week | `select *` (in-memory bucketing) | `date_trunc('week',
col)` | `no such function: date_trunc` |
| PG month / quarter / week | `to_char((col)::timestamptz AT TIME ZONE
'UTC', 'YYYY-MM' / 'YYYY"-Q"Q' / 'IYYY"-W"IW')` | `date_trunc(...)` |
runs; keys `2026-01-01T00:00:00.000Z` where the face answers `2026-01` |
| any engine, `timezone: 'Asia/Shanghai'` | `select *` (in-memory
bucketing) | `date_trunc('month', col)` | SQLite refuses; PG answers UTC
buckets |
**After, at `a61c6d79a9`** (same harness, same mount):
- SQLite month and quarter echo `strftime(...)` on both column types,
and run with the face's row count.
- PG month, quarter and week echo the driver's `to_char(...)` and run.
- SQLite week, and the non-UTC zone on both engines, keep `date_trunc`.
- The measure-filter dataset echoes the driver expression.
The harness was a scratch copy of
`runtime/src/analytics-query-window-validity.test.ts`, deleted after the
run.
**The MySQL arm is by code read only.** No MySQL server was available.
The member renders the driver's own `date_format(convert_tz(??,
@@session.time_zone, '+00:00'), ...)` arm through the same `toQuery`.
**Turso remote face, measured with a scratch harness (deleted).** A
`TursoDriver` in remote mode over a libSQL `file:` client answers
`dateBucketSql` byte-identically to the local face, with no connection.
libSQL runs it (`2026-01`, `2026-Q1`). Week answers `null` on both
faces. Remote mode advertises an empty `queryDateGranularity`, so the
engine buckets there in memory, on the UTC keys this expression answers.
## Pins
`packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts`,
default plugin composition. SQLite runs every time; live PostgreSQL runs
behind `OS_TEST_POSTGRES_URL`, which no CI step sets for this package (a
named skip there). Run at the final head `5032b8f3e9` with live PG
16.14: **16 passed (16)**, 8 of them live PG.
- **Month and quarter** (SQLite), and **month, quarter and week** (PG),
on a `date` and a `datetime` column:
- the echo equals `generateSql` (the `/sql` body), with no params;
- it selects and groups by an expression that is not `date_trunc` and
that the driver's own aggregate statement contains;
- run through the engine's raw-SQL bridge, it answers the face's rows.
- **A measure `filter`** (the engine aggregates in memory): the echo
carries the driver expression, and run with its params it answers the
face's rows.
- **FALLBACK, week on SQLite**: the driver grouped nothing, and the echo
keeps `date_trunc('week', closed_on)`.
- **FALLBACK, non-UTC `timezone`** (both engines): the face answers the
zone's calendar, and the echo keeps `date_trunc('month', closed_at)`.
- **FALLBACK, no hook**: an `ObjectQLStrategy` whose context names no
hook echoes `date_trunc('month', closed_on)`.
The dispatch's pin "on SQLite, week bucketed echoes run" does not hold
under the ruled fallback. The SQLite driver buckets week in memory
(`dateGranularityCapabilities.week` is false), so the hook answers
nothing there and the echo keeps `date_trunc`, which SQLite refuses. It
is pinned as a fallback instead.
## Ablations
Each was predicted first, run through `scripts/ablation-replace.mjs` in
wrap mode, and its restore was proven by the tool: blob equals the HEAD
blob, and `git diff HEAD` is empty. The subject is `src`, imported
relatively by the pin, so no `dist` leg applies.
- **A. Hook wiring removed** (the plugin's `dateBucketSql,` config line
replaced by a comment).
- Predicted: red, 12 failed / 4 passed.
- Observed: red, **12 failed / 4 passed**, every failure `expected
'date_trunc(...)' not to contain 'date_trunc'`. The 4 fallback pins
stayed green.
- Restore: blob `e1378f313e49` equals HEAD's.
- **B. The non-UTC gate removed.**
- Predicted: red, 2 failed / 14 passed.
- Observed: red, **2 failed / 14 passed**: the two non-UTC pins, which
got `strftime(...)` and `to_char(...)` where they expect `date_trunc`.
- Restore: blob `3769d2062c91` equals HEAD's.
## Verification
At final head `5032b8f3e9` unless noted:
- `pnpm --filter @objectstack/service-analytics test` (no PG, as CI runs
it): 176 files, **4160 passed**, 261 skipped, exit 0 (at `1e3cc1fc72`;
the only later change is the changeset file).
- The same suite with live PG 16.14 set: 4418 passed, **1 failed**. The
failure is `read-scope-temporal-coercion.test.ts`'s premise check, "the
server is not on UTC": this private server ran `Etc/UTC`. That is
environmental, not this change.
- `pnpm --filter @objectstack/service-analytics typecheck`: exit 0. `tsc
--listFiles` reaches the new test file.
- `pnpm --filter @objectstack/driver-sql test`: 216 files, **3611
passed**, 204 skipped, exit 0. `driver-sql typecheck`: exit 0.
- `pnpm --filter @objectstack/driver-turso test`: 88 files, **2366
passed**, 33 skipped, exit 0. `driver-turso typecheck`: exit 0.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derives 67 commands. All 67 ran
at `5032b8f3e9` and exited 0. `--ran` reconciliation: "67 derived, 67
run, 0 NOT-MEASURED, 0 UNRUN". `check:dual-build-cjs-loads` first
answered `PREREQUISITE NOT MET` (no `dist`) and was re-run after a root
build.
- Lint, a declared narrowing:
1. **Population**, read from eslint's own config: of the 8 changed
paths, the 7 `.ts` files are linted, and the changeset `.md` is ignored
("no matching configuration").
2. **Count**, from `--format json`: 7 files linted, 0 errors, 0
warnings, at `5032b8f3e9`.
3. **Invariance**: `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`), so this diff cannot move an untouched
file's verdict.
The full `pnpm lint` is CI's.
## Acceptance notes
- **Comment drift left in place (excluded):** the `objectql` `engine.ts`
comment above `tzRequiresInMemory` still calls native driver bucketing
`date_trunc`. Open PR #21545 holds that file. Carrier: whoever next
edits that comment.
- **Comment drift in a governed surface (noted, not filed):**
`skills/objectstack-ui/rules/dashboards.md` ("Engine support") says
Postgres buckets with `date_trunc`. `SqlDriver` emits `to_char(... AT
TIME ZONE 'UTC', ...)`. `skills/**` is Tier H, so it is not touched
here. Carrier: none.
- **SQLite week stays unrunnable:** `date_trunc('week', ...)`, by the
ruled fallback. Closing it needs the driver to bucket week in SQL on
SQLite (a `driver-sql` capability decision), which this card does not
make.
- #21485 (the PG and MySQL `date`-column shift under a non-UTC server)
is not addressed here. Because the echo now renders the driver's
expression, its fix reaches the echo with no second edit.
- Merged `origin/main` once (`cc645f2385`, docs-only, disjoint). A later
`main` commit (`a7ab047cf6`, `packages/rest` only) is not merged; the
queue rebuilds on it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d2e687f commit 35dfb81
11 files changed
Lines changed: 444 additions & 17 deletions
File tree
- .changeset
- packages
- client/src
- drivers
- driver-sql/src
- driver-turso/src
- services/service-analytics/src
- __tests__
- strategies
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
487 | 487 | | |
488 | 488 | | |
489 | 489 | | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
490 | 496 | | |
491 | 497 | | |
492 | 498 | | |
| |||
689 | 695 | | |
690 | 696 | | |
691 | 697 | | |
692 | | - | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
693 | 703 | | |
694 | 704 | | |
695 | 705 | | |
696 | 706 | | |
| 707 | + | |
697 | 708 | | |
698 | 709 | | |
699 | 710 | | |
| |||
740 | 751 | | |
741 | 752 | | |
742 | 753 | | |
743 | | - | |
| 754 | + | |
744 | 755 | | |
745 | 756 | | |
746 | | - | |
| 757 | + | |
| 758 | + | |
747 | 759 | | |
748 | 760 | | |
749 | 761 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
| 10 | + | |
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| |||
6166 | 6166 | | |
6167 | 6167 | | |
6168 | 6168 | | |
| 6169 | + | |
| 6170 | + | |
| 6171 | + | |
| 6172 | + | |
| 6173 | + | |
| 6174 | + | |
| 6175 | + | |
| 6176 | + | |
| 6177 | + | |
| 6178 | + | |
| 6179 | + | |
| 6180 | + | |
| 6181 | + | |
| 6182 | + | |
| 6183 | + | |
| 6184 | + | |
| 6185 | + | |
| 6186 | + | |
| 6187 | + | |
| 6188 | + | |
| 6189 | + | |
| 6190 | + | |
| 6191 | + | |
| 6192 | + | |
| 6193 | + | |
| 6194 | + | |
| 6195 | + | |
| 6196 | + | |
| 6197 | + | |
| 6198 | + | |
6169 | 6199 | | |
6170 | 6200 | | |
6171 | 6201 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
940 | 940 | | |
941 | 941 | | |
942 | 942 | | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
943 | 948 | | |
944 | 949 | | |
945 | 950 | | |
| |||
1609 | 1614 | | |
1610 | 1615 | | |
1611 | 1616 | | |
1612 | | - | |
| 1617 | + | |
| 1618 | + | |
1613 | 1619 | | |
1614 | 1620 | | |
1615 | 1621 | | |
| |||
0 commit comments