Skip to content

Commit 5d095a0

Browse files
fix(driver-sql,service-analytics): bucket the ISO week natively on SQLite, and the SQL echo refuses a bucket SQLite cannot run (#21629)
Fixes #21595 Clause-②: no ## What changes **(a) `week` on SQLite, in `driver-sql`.** The SQLite capability row now advertises `week`, and `SqlDriver.buildDateBucketExpr`'s SQLite arm renders the ISO 8601 week as `YYYY-Www`, the label the PostgreSQL arm's `IYYY"-W"IW` and the MySQL arm's `%x-W%v` answer: ```sql (strftime('%Y', ARG, '-3 days', 'weekday 4') || '-W' || printf('%02d', (cast(strftime('%j', ARG, '-3 days', 'weekday 4') as integer) - 1) / 7 + 1)) ``` It is the Thursday rule. `'-3 days', 'weekday 4'` moves any day to the Thursday of its Monday-start week, so that Thursday's `%Y` is the week-numbering year and its day of year gives the week number. It uses no `%V` or `%G`, and both date modifiers predate every SQLite the platform ships. `ARG` is the existing `sqliteTemporalArg`: a bare column for TEXT storage, and the legacy repair for an un-backfilled `Field.datetime`. No zone is read, so a `Field.date` stays its calendar day (H2 holds). Both callers follow without a second edit: `aggregate()`'s GROUP BY and the public `dateBucketSql()` the analytics echo reads. **(b) A non-UTC zone on SQLite, in `service-analytics`' echo.** `ObjectQLStrategy.generateSql` no longer prints `date_trunc(...)` on a SQLite datasource, since SQLite has no such function. Where no driver expression answers and the executing driver's dialect (`sqlDialectFor`) is `sqlite`, the echo refuses with `NOT_IMPLEMENTED` / 501 and `refusal: true`. The shipped composition reaches that branch for a non-UTC `timezone`: the engine then buckets in memory on that zone's calendar, and SQLite has no time-zone database. The refusal reaches each door as follows: - `POST /api/v1/analytics/sql` answers the refusal in the error envelope. `refusal: true` is the declared channel (`ApiErrorSchema.refusal`) for a 5xx whose prose is addressed to the caller, so the boundaries keep the message. - `POST /api/v1/analytics/query` still answers the rows. `execute()` already calls `generateSql` inside a `try`, so the answer simply carries no `sql`. This is the reach the existing cross-field decline has. - With `timezone: 'UTC'`, or none, the echo still prints the driver's expression. On a dialect the host cannot name, it still prints the representative `date_trunc`. **Riders.** - R1: `packages/objectql/src/engine.ts`. The ADR-0053 D2 comment above `tzRequiresInMemory` now names driver-sql's `to_char` / `date_format` / `strftime`. In the same block, the comment's example "partial-support dialects (e.g. SQLite + week)" is false after this change; it now names the Turso remote transport, which advertises none. Comments only: 8 changed lines, 0 non-comment. - R2: `driver-turso`'s `REMOTE_FACE_ANSWERS` row `dateBucketSql: 'inherited'` is now pinned by running it. The remote face renders the expression with no connection, and the pin sends it through the remote face's own `execute()` to a real `@libsql/client` `file:` client, beside a better-sqlite3 local control. All five granularities answer the local labels, and `week` answers the written-out ISO weeks of the boundary days. The row's text needed no change (H4). **Comment corrections carried from #21485's ACCEPT 5971379160.** `objectql-strategy.ts`, `strategies/types.ts` and the echo pin's docblock quoted PostgreSQL's `to_char((col)::timestamptz AT TIME ZONE 'UTC', ...)` as the bucket expression. Since 440cd32 that is the `datetime` expression only; each now names `(col)::date::timestamp` for a `date`. ## Measured first: each SQLite client's bundled SQLite | client | package | `sqlite_version()` | `strftime('%V', '2021-01-03')` | |:--|:--|:--|:--| | better-sqlite3 13.0.3 | `driver-sql`, `driver-turso` local face | 3.53.4 | `53` | | `@libsql/client` 0.18.0 | `driver-turso` remote face (`file:` client) | **3.45.1** | **NULL** | | sql.js 1.14.2 | `driver-sqlite-wasm` | 3.49.1 | `53` | The platform ships three SQLite clients, not two: `SqliteWasmDriver` is a `SqlDriver` subclass that overrides `isSqlite`, so it inherits this arm. libSQL bundles 3.45.1, so by triage's rule `%V` is out, and the expression is version-independent. ## The expression, measured before it was written - **On each client:** 12,454 inputs per client, with 0 mismatches against `@objectstack/core`'s `bucketDateKey` on better-sqlite3, libSQL and sql.js alike. The inputs are every day from 1999-01-01 to 2032-12-31, datetimes at 00:00, 12:00 and 23:59:59.999Z on the ISO-year boundary days, the years 0001 / 0049 / 0050 / 0999 / 1000 / 9999, and unparseable and empty values, which answer NULL as the in-memory face does. - **Against PostgreSQL's own arm:** PostgreSQL 16.14 `to_char(d::date::timestamp, 'IYYY"-W"IW')` over 1999-01-01..2032-12-31, 12,419 days, 0 mismatches. The boundary days: 2020-12-31 and 2021-01-03 are `2020-W53`, 2021-01-04 is `2021-W01`, 2024-12-30 is `2025-W01`, 2026-12-28 and 2027-01-03 are `2026-W53`. - **MySQL's `%x-W%v`:** NOT MEASURED here, because no MySQL server is available in this container. It is pinned to the same `bucketDateKey` by CI's live Temporal Conformance. ## (b): the declared response contract, read first - `/analytics/sql`: `AnalyticsSqlResponseSchema` declares `data: { sql: z.string(), params }` over `BaseResponseSchema`, whose `error` is the refusal envelope. A `null` statement with a reason is **not** admitted, so it is not the shape, and the contract is not widened. A refusal **is** admitted, and this door already refuses a cross-field comparison (the #7598 ruling, "一致的响亮答案,不半渲染"). - `/analytics/query`: `sql` is `z.string().optional()`, "Generated SQL (if available)", so an answer without it is admitted. So the shape is the refusal. The status is `NOT_IMPLEMENTED` / 501 rather than a 400: the query is spelled correctly and served, and the gap is the backend's. This is the reasoning driver-sql's own bucket refusal gives (#6212 / #5907), and the `/meta/:type/:name/references` 501 is the precedent for `refusal: true`. ## Fixture triage: pins that held the removed branch | file | disposition | |:--|:--| | `driver-sql/src/sql-driver-date-bucket.test.ts` | `caps.week` is now `true`, and `week` joins the parity sweep. The #6212 refusal pin moves off SQLite `week` to the population that still reaches it: an unmodeled client (`mssql`, whose capability row is empty; the refusal is raised before any connection). Code, status and message as before, with the tail now `Bucketed here: none (dialect 'mssql')`. | | `driver-sql/src/sql-driver-date-bucket-storage.test.ts` | `week` joins both storage forms. One new case covers `week` over the legacy mixed-form column (epoch INTEGER, ISO TEXT, zone-naive TEXT, NULL), where the repair is expanded twice. | | `driver-sql/src/sql-driver-temporal-dialect.test.ts` | `week` joins the dialect-gating granularities, so the legacy repair, the plain column form and the binding count are pinned for the new arm. New assertion: no SQLite arm reads a `Field.date` through `'localtime'` or `'utc'` (H2). | | `driver-sql/src/sql-driver-21485-date-bucket-calendar-day.test.ts` | SQLite's `BUCKETED_IN_SQL` gains `week`, so `aggregate()` and the `dateBucketSql` echo run it on the date/datetime boundary rows. | | `driver-sql/src/sql-driver-13714-...test.ts` | Comment only. The sweep is data-driven by the capability row and now serves `week`. | | `driver-sqlite-wasm/src/sqlite-wasm-driver-date-bucket.test.ts` | `caps.week` is now `true`, and `week` joins the parity sweep on sql.js. Its refusal twin is removed: no granularity reaches that refusal on this driver any more. | | `driver-turso/src/remote-transport-groupby-node.test.ts` | The #5240 parity case ("one condition, one wording") compares the remote transport with an unmodeled `SqlDriver`, the only SqlDriver face that still declines. The `month` asymmetry case is parameterized over `month` and `week`. The case that pinned the SQLite face's `week` refusal tail is removed. | | `qa/dogfood/test/date-bucket-parity-turso.test.ts` | The tripwire made a local driver advertise `week` because SqlDriver could not run it. It now keeps the real row (which advertises `week`) and makes the `week` arm answer `null`, the incoherent pair a revert of the arm alone would ship. `checkDateBucketParity` still names it. | | `service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts` | The SQLite cell groups `week`. The in-memory-granularity FALLBACK case is removed, because its only input was SQLite `week`. The non-UTC case is per cell: on SQLite it pins the refusal (rows served with no `sql`; `/analytics/sql` refuses with `NOT_IMPLEMENTED`, 501 and `refusal: true`; `declaredRefusalMessage(err) === err.message`; a UTC control renders). On PostgreSQL it is unchanged (see the finding below). A unit case pins the refusal for a SQLite host that wires no hook. | Also swept and left alone: `checkDateBucketParity`'s real-driver half in `date-bucket-parity-conformance.test.ts` now exercises `week` on better-sqlite3 and sql.js with no edit. Its `week: false` fake is self-contained. `timedimension-granularity-driver-alias.test.ts` is data-driven and green; see the Acceptance notes. ## Reverse verification (commit first, then revert only the arm) Taken at head `59a751f4ad`, through `scripts/ablation-replace.mjs` (wrap mode: literal anchor, restore armed on exit, INT and TERM). The mutation replaced the whole SQLite `week` block with the original `case 'week': return null; // see capabilities note`. Anchor 1 to 0, blob `5f6b143dbe1a` to `5e80a2795453`. The capability row stayed `week: true`. - Rebuilt `@objectstack/driver-sql`, then `ablation-dist-preflight.mjs @objectstack/driver-sql "'-3 days', 'weekday 4'" --absent`: exit 0, so the arm was out of the `dist/` that driver-turso, driver-sqlite-wasm, service-analytics and dogfood consume. - **Red on every client (the predicted direction):** - better-sqlite3 (`driver-sql`): 3 failed, the three `week` cases. - libSQL (`driver-turso` R2): 2 failed, `week: libSQL answers the labels the local face answers` and `week: libSQL answers the ISO week of each boundary day`. - sql.js (`driver-sqlite-wasm`): 1 failed, `granularity=week`. - The echo (`service-analytics`): 2 failed, `closed_on, week` and `closed_at, week`. - Dogfood conformance: 2 failed, both drivers. - **Restore:** `git checkout HEAD -- ABSOLUTE_PATH`. The blob after restore is `5f6b143dbe1a`, equal to HEAD, `git diff HEAD` is empty and `git status --porcelain` shows 0 entries. Then a rebuild and the presence preflight: the marker is in 2 built files and the tree reading is clean. ## Tests Package suites at head `9328c3c6cc` (the merge of `origin/main` came after; its incoming commits touch none of these packages except driver-mongodb, where this diff is comments only): - `@objectstack/driver-sql`, with the live PostgreSQL cell at `TZ=America/New_York`: 225 files passed, 3 skipped; 4571 tests passed, 104 skipped (the MySQL cells: no server here). - `@objectstack/service-analytics`, with the live PostgreSQL cell: 176 files passed; 4421 tests passed, 2 skipped. - `@objectstack/driver-turso`: 88 files passed; 2373 tests passed, 33 skipped. - `@objectstack/driver-sqlite-wasm`: 36 files passed; 675 tests passed. - typecheck: `driver-sql`, `driver-sqlite-wasm`, `driver-turso`, `service-analytics`, `driver-mongodb` (with `check:test-typecheck`) and `objectql` (all three `tsc` programs and `check:test-typecheck`) are all clean. On the merged head `0e7e975a32`, after a post-merge turbo build and spec `check:generated` (15 of 15 artifacts up to date), every changed pin passed again: | package | files | tests | |:--|:--|:--| | driver-sql | 5 | 99 passed, 2 skipped | | driver-turso | 2 | 109 | | driver-sqlite-wasm | 1 | 8 | | service-analytics | 2 | 31 | | dogfood | 2 | 13 | | driver-mongodb `mongodb-date-bucket-parity` | 1 | 39 | Declared narrowings, so they read as such: - `objectql` and `driver-mongodb` suites were not run in full. Their diffs are comment-only (`engine.ts` 8 changed lines, `mongodb-aggregation.ts` 14 and its test 4, all comment lines), and both packages typecheck. - `packages/qa/dogfood` was not run in full; the two date-bucket files it changes were. The rest is CI's Dogfood Regression Gate. - MySQL cells were not run. The live PostgreSQL was a throwaway 16.14 instance this run started on a random port with server `TimeZone` Asia/Shanghai; it has been stopped and removed. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` with no paths, at head `0e7e975a32`: 101 derived (the 58 the dispatch named plus 43 the change set adds). All 101 ran on that head and each exited 0. `--ran` reconciliation: "101 derived famil(ies) accounted for — 101 run, 0 NOT-MEASURED (a DERIVED zero — all 101 recorded an exit code and none of them is 3)". On the first run at `59a751f4ad`, `check:skill-examples` and `check:dual-build-cjs-loads` answered exit 3 (PREREQUISITE NOT MET, unbuilt packages). After a full build both passed, and on the final head all 101 are exit 0. Driver-card ledger, `pnpm check:driver-conformance`: before (at BASE `5c9138b4b6`) and after (at `0e7e975a32`), both 50 covered cells, 0 in the DEBT ledger, 0 exempt. No new DEBT. **Lint, a proven narrowing at `0e7e975a32`:** - Population, read from eslint's own config: the 16 changed `.ts` files, run as `pnpm lint` runs (`--no-inline-config`). None was reported as ignored. - `--format json` count: 16 files, 0 errors, 0 warnings. - Invariance: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on an untouched file. ## File surface, and what this adds to the claim's The claim named `sql-driver.ts`, the `driver-turso` pin, the `service-analytics` echo, its pin and two comment files, `engine.ts` R1, tests and the changeset. Each addition below is forced by this change, and none is a behaviour change outside the claim: - Test files that pinned the removed branch (see the triage table): `driver-sqlite-wasm`, `driver-turso`'s `remote-transport-groupby-node.test.ts`, and `qa/dogfood`'s Turso parity tripwire. - Statements this change makes false, each corrected as a sentence: - `driver-mongodb`'s TSDoc on the exported `MONGODB_DATE_GRANULARITIES` ("driver-sql sets `week: false`", which ships in its `.d.ts`), and its test's comment. - `content/docs/data-modeling/queries.mdx` ("SQLite reports `week: false`, for instance"). - The second `engine.ts` comment, in R1's own block. Changeset `.changeset/21595-sqlite-week-bucket.md`: `patch` for `@objectstack/driver-sql` and `@objectstack/service-analytics`. `driver-turso` ships no code change, so it gets none, and the `driver-mongodb` and `objectql` changes are comments. ## Acceptance notes - Not filed, and left outside this diff: - `service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts`'s header still says SQLite declines `week`. The test is data-driven and green; the file is outside the declared cross-lane set. - The spec's `queryDateGranularity` TSDoc example (`{ day: true, week: false }` "for a SQLite build whose `strftime` lacks ISO week") is still a valid hypothetical, but no shipped SQLite driver declares it now. - `objectql/src/engine-aggregate-timezone.test.ts`'s header names native bucketing `date_trunc`, R1's drift in a test file. None of these is made false by behaviour in this PR in a way any test reads. - `skills/objectstack-ui/rules/dashboards.md`'s `week` label row ("ISO date of the bucket (`YYYY-MM-DD`)"; the key is `YYYY-Www`) and `skills/objectstack-query/rules/aggregation.md` line 82 ("`DATE_TRUNC` etc.") were inaccurate before this change. They belong with #21588, the open card for the same `dashboards.md` engine sentence, on a Tier H surface this PR does not touch. - **For the seat to file, (b)'s foreseen follow-up on PostgreSQL and MySQL.** On PostgreSQL the echo for a non-UTC zone still prints `date_trunc('month', closed_at)`; this PR leaves it, and the echo pin's PostgreSQL cell still asserts it. That statement was run on PostgreSQL 16.14 (server `TimeZone` Asia/Shanghai) over the pin's five rows: - It answers `2026-01-01 00:00:00+08`-shaped keys on the session zone's calendar, where the face answers `2026-01` on the request zone's. - For `timezone: 'America/New_York'` the groupings differ too: the echo gives January 20 and February 8, where the face gives January 27 and February 1. - On MySQL `date_trunc` is not a function. That is NOT MEASURED here (no server). --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f1bc81 commit 5d095a0

18 files changed

Lines changed: 397 additions & 162 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/service-analytics': patch
4+
---
5+
6+
On SQLite, a `week` date bucket is grouped in SQL, and the analytics SQL echo never prints a bucket statement that SQLite refuses (#21595).
7+
8+
Clause-②: no
9+
10+
- **What was wrong.** `driver-sql` grouped `day`, `month`, `quarter` and `year` in SQL on SQLite, but not `week`. Its `supports.queryDateGranularity` said `week: false`, so the engine bucketed weeks in memory, and the ObjectQL face of `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql` echoed the bucket as `date_trunc('week', col)`. SQLite has no `date_trunc`, so that echo could not run. A non-UTC `timezone` on SQLite gave the same echo for every granularity.
11+
- **What it does now.**
12+
- SQLite advertises all five granularities. `week` buckets as `YYYY-Www`, the ISO 8601 week that the PostgreSQL and MySQL arms answer. The expression does not use `strftime('%V')`, which needs SQLite 3.46: `@libsql/client` 0.18.0 bundles SQLite 3.45.1, where `%V` answers NULL. It runs on better-sqlite3, on libSQL (`driver-turso`) and on sql.js (`driver-sqlite-wasm`). A `Field.date` still buckets as its own calendar day.
13+
- The echo prints that expression for a `week` bucket on SQLite, and the statement runs.
14+
- With a non-UTC `timezone` on SQLite, `POST /api/v1/analytics/sql` refuses with `NOT_IMPLEMENTED` / 501, declared as a refusal so its message reaches the caller. `POST /api/v1/analytics/query` still answers the rows, and its answer carries no `sql`. The engine buckets on that zone's calendar in memory, and SQLite has no time-zone database, so no SQLite statement produces those keys.
15+
- **Where it shows.** `aggregate()` with a `week` group on SQLite, `SqlDriver.dateBucketSql()`, and the analytics SQL echo. A query sent with `timezone: 'UTC'`, or with no `timezone`, still echoes the driver's own expression.

‎content/docs/data-modeling/queries.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -677,9 +677,9 @@ A `groupBy` entry is either a bare field name or a structured node that buckets
677677

678678
`dateGranularity` accepts `day` | `week` | `month` | `quarter` | `year`. The engine
679679
pushes bucketing down to the driver only when that driver advertises the granularity
680-
via `supports.queryDateGranularity` — SQLite reports `week: false`, for instance, so a
681-
weekly bucket falls back to fetching rows and bucketing in memory. The fallback is
682-
transparent to the query — you get the same buckets either way.
680+
via `supports.queryDateGranularity` — the Turso remote transport reports none, for
681+
instance, so every bucket there falls back to fetching rows and bucketing in memory. The
682+
fallback is transparent to the query — you get the same buckets either way.
683683

684684
<Callout type="warn">
685685
Buckets are computed in **UTC**. A non-UTC reference timezone is only reachable through

‎packages/drivers/driver-mongodb/src/mongodb-aggregation.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,14 @@ export interface AggregationInput {
5757
* `mongodb-date-bucket-parity.test.ts` pins the identity so a future edit to one
5858
* cannot silently miss the other.
5959
*
60-
* ## Why all five, when `driver-sql` on SQLite advertises four
61-
*
62-
* SQLite has no ISO-week format specifier, so `driver-sql` sets `week: false`
63-
* and lets the engine bucket weeks in memory. MongoDB's `$dateToString` has both
64-
* halves of the ISO-8601 week date — `%G` (ISO week-YEAR) and `%V` (ISO week
65-
* number, zero-padded to 2) — which is exactly the label
66-
* `bucketDateValue` computes by hand. The dialect difference is real, so the
67-
* records differ.
60+
* ## Why `week` too
61+
*
62+
* MongoDB's `$dateToString` has both halves of the ISO-8601 week date — `%G`
63+
* (ISO week-YEAR) and `%V` (ISO week number, zero-padded to 2) — which is
64+
* exactly the label `bucketDateValue` computes by hand. (`driver-sql` on
65+
* SQLite advertises all five too, but cannot use `%V`: the SQLite libSQL
66+
* bundles predates it, so its `week` arm computes the ISO week from the
67+
* week's Thursday instead.)
6868
*
6969
* ⚠️ **Documentation-derived, not observed** (#5517). Every `$dateToString`
7070
* format specifier and every `$convert`/`$concat`/`$switch` null rule this

‎packages/drivers/driver-mongodb/src/mongodb-date-bucket-parity.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,8 @@ describe('the published capability record and the lowering are the same set', ()
294294

295295
it('advertises exactly the five granularities @objectstack/spec declares', () => {
296296
// MongoDB has both halves of the ISO-8601 week date (`%G`/`%V`), which is why
297-
// this record carries `week: true` where `driver-sql` on SQLite carries
298-
// `week: false` — the dialects genuinely differ, and the records say so.
297+
// this record carries `week: true`. (`driver-sql` on SQLite carries it too,
298+
// computing the ISO week without `%V`.)
299299
expect(MONGODB_DATE_GRANULARITIES).toEqual({
300300
day: true, week: true, month: true, quarter: true, year: true,
301301
});

‎packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@
8484
* `DateGranularity.options` — the spec's own list — and a granularity the spec
8585
* grows joins it without an edit here.
8686
*
87-
* ⚠️ A dialect that does not bucket a granularity natively (SQLite + `week`,
88-
* which is capped in `dateGranularityCapabilities` because `%V` needs SQLite
89-
* 3.46) is NOT a failure and is asserted as its own declared answer: the #6212
87+
* ⚠️ A dialect that does not bucket a granularity natively (none of the three
88+
* cells since SQLite gained `week` in #21595; a client `driver-sql` does not
89+
* model declines every one) is NOT a failure and is asserted as its own declared answer: the #6212
9090
* `NOT_IMPLEMENTED`/501 capability refusal, which `engine.aggregate` reads off
9191
* `supports.queryDateGranularity` and serves in memory instead. The invariant
9292
* that spans both answers is the one this card is about: no shape answers

‎packages/drivers/driver-sql/src/sql-driver-21485-date-bucket-calendar-day.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,11 @@ const GRANULARITIES: readonly Granularity[] = ['day', 'week', 'month', 'quarter'
8888
/**
8989
* The granularities each dialect buckets in SQL. Declared rather than read off
9090
* the driver, and checked against it below, so the set the tests iterate cannot
91-
* shrink without a red. SQLite buckets `week` in memory (#21595 owns that).
91+
* shrink without a red. Every dialect buckets all five: SQLite's `week` arm
92+
* landed with #21595.
9293
*/
9394
const BUCKETED_IN_SQL: Record<DialectId, readonly Granularity[]> = {
94-
sqlite: ['day', 'month', 'quarter', 'year'],
95+
sqlite: GRANULARITIES,
9596
pg: GRANULARITIES,
9697
mysql: GRANULARITIES,
9798
};

‎packages/drivers/driver-sql/src/sql-driver-date-bucket-storage.test.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2626
import { SqlDriver } from '../src/index.js';
2727
import { LegacyStorageDriver } from '../src/legacy-datetime-storage.testkit.js';
2828

29-
type Granularity = 'day' | 'month' | 'quarter' | 'year';
29+
type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
3030

31-
/** Every granularity SQLite advertises natively (week is bucketed in-memory). */
32-
const GRANULARITIES: Granularity[] = ['day', 'month', 'quarter', 'year'];
31+
/** Every granularity SQLite advertises natively: all five, `week` included since #21595. */
32+
const GRANULARITIES: Granularity[] = ['day', 'week', 'month', 'quarter', 'year'];
3333

3434
/**
3535
* ⚠️ Keep in sync with `packages/objectql/src/in-memory-aggregation.ts#bucketDateValue`.
@@ -54,6 +54,17 @@ function bucketDateValue(value: unknown, g: Granularity): string | null {
5454
case 'quarter': return `${y}-Q${Math.floor((m - 1) / 3) + 1}`;
5555
case 'month': return `${y}-${String(m).padStart(2, '0')}`;
5656
case 'day': return `${y}-${String(m).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
57+
case 'week': {
58+
// The same ISO-week arm as `sql-driver-date-bucket.test.ts`'s copy.
59+
const target = new Date(Date.UTC(y, d.getUTCMonth(), d.getUTCDate()));
60+
const dayNum = (target.getUTCDay() + 6) % 7;
61+
target.setUTCDate(target.getUTCDate() - dayNum + 3);
62+
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
63+
const weekNo = 1 + Math.round(
64+
((target.getTime() - firstThursday.getTime()) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7,
65+
);
66+
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
67+
}
5768
}
5869
}
5970

@@ -243,6 +254,18 @@ describe('SqlDriver date bucketing over a MIXED-form datetime column (#3773)', (
243254
expect(byMonth['1970-01']).toBeUndefined(); // TEXT never divided by 1000
244255
});
245256

257+
it('[#21595] buckets each row by its own stored form at `week` too, where the repair is read twice', async () => {
258+
// The `week` arm references the column twice, so the legacy repair is
259+
// expanded twice and binds twice as many identifiers. A binding shifted
260+
// between the two copies would put a row in another week, or in 1970.
261+
expect(await bucketSums(driver, 'closed_at', 'week')).toEqual({
262+
'2026-W02': 1, // INTEGER epoch ms, Saturday 2026-01-10
263+
'2026-W07': 2, // ISO TEXT, Saturday 2026-02-14
264+
'2026-W08': 4, // zone-naive TEXT, Friday 2026-02-20
265+
[EMPTY]: 8,
266+
});
267+
});
268+
246269
it('leaves a NULL instant in its own bucket', async () => {
247270
const byMonth = await bucketSums(driver, 'closed_at', 'month');
248271
// SQL NULL propagates through the bucket expression, so the row lands in the

‎packages/drivers/driver-sql/src/sql-driver-date-bucket.test.ts‎

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,13 @@ describe('SqlDriver date bucket (dateGranularity)', () => {
107107
expect(caps.month).toBe(true);
108108
expect(caps.quarter).toBe(true);
109109
expect(caps.year).toBe(true);
110-
// SQLite-specific: ISO week (%V) is not assumed.
111-
expect(caps.week).toBe(false);
110+
// [#21595] SQLite buckets the ISO week too: the Thursday rule through
111+
// date modifiers, so no `%V` (which needs SQLite 3.46) is assumed.
112+
expect(caps.week).toBe(true);
112113
});
113114
});
114115

115-
describe.each<Granularity>(['day', 'month', 'quarter', 'year'])(
116+
describe.each<Granularity>(['day', 'week', 'month', 'quarter', 'year'])(
116117
'granularity=%s — native SQL matches bucketDateValue',
117118
(g) => {
118119
it('produces the same label set as the in-memory reference', async () => {
@@ -141,35 +142,47 @@ describe('SqlDriver date bucket (dateGranularity)', () => {
141142

142143
describe('unsupported granularity', () => {
143144
/**
144-
* [#6212] The subject is unchanged — week is not bucketed in SQL on SQLite,
145-
* so the engine must be pushed back to in-memory bucketing — but the refusal
146-
* now carries a wire identity, so the assertion moved with it. It used to be
147-
* `rejects.toThrow(/dateGranularity 'week' not supported/)`: a bare `Error`
148-
* with `code`/`status` both `undefined`, which `mapDataError` served as an
149-
* opaque 500 for a named capability gap. `code` and `status` are asserted
150-
* here for the #6144 reason — the un-fixed driver threw for this input too,
151-
* so a `toThrow()` alone was green before and after and could never see the
152-
* defect. The remote face's twin, and the parity between them, live in
153-
* driver-turso's `remote-transport-groupby-node.test.ts`.
145+
* [#6212] A granularity a dialect has no bucket expression for is refused
146+
* with a wire identity, so the engine is pushed back to in-memory
147+
* bucketing. It used to be `rejects.toThrow(/dateGranularity 'week' not
148+
* supported/)`: a bare `Error` with `code`/`status` both `undefined`, which
149+
* `mapDataError` served as an opaque 500 for a named capability gap. `code`
150+
* and `status` are asserted here for the #6144 reason — the un-fixed driver
151+
* threw for this input too, so a `toThrow()` alone was green before and
152+
* after and could never see the defect. The remote face's twin, and the
153+
* parity between them, live in driver-turso's
154+
* `remote-transport-groupby-node.test.ts`.
155+
*
156+
* [#21595] This used to be SQLite `week`. SQLite now buckets all five, so
157+
* the pin moved to the population that still reaches the refusal: a client
158+
* this driver does not model, whose capability row is empty. `mssql` is
159+
* one (`tedious` is a declared dependency), and the refusal is raised while
160+
* the statement is built, so no server is needed.
154161
*/
155-
it('refuses week on SQLite with NOT_IMPLEMENTED / 501 (so engine routes to in-memory)', async () => {
156-
const err = await driver
157-
.aggregate('events', {
158-
groupBy: [{ field: 'ts', dateGranularity: 'week' }],
159-
aggregations: [{ function: 'count', alias: 'n' }],
160-
})
161-
.then(
162-
() => { throw new Error('expected the driver to refuse week on SQLite'); },
163-
(e) => e as Error & { code?: string; status?: number },
164-
);
165-
166-
expect(err.code).toBe('NOT_IMPLEMENTED');
167-
expect(err.status).toBe(501);
168-
expect(err.message.startsWith("Date bucketing by 'week' is not supported by this backend.")).toBe(true);
169-
// The message names what this dialect DOES bucket, so a reader is told
170-
// where the boundary is rather than only that they crossed it.
171-
expect(err.message).toContain('Bucketed here: day, month, quarter, year');
172-
expect(err.message).toContain('supports.queryDateGranularity');
162+
it('refuses a granularity on a client with no bucket expression, with NOT_IMPLEMENTED / 501', async () => {
163+
const unmodeled = new SqlDriver({ client: 'mssql' });
164+
try {
165+
expect(unmodeled.supports.queryDateGranularity).toEqual({});
166+
const err = await unmodeled
167+
.aggregate('events', {
168+
groupBy: [{ field: 'ts', dateGranularity: 'week' }],
169+
aggregations: [{ function: 'count', alias: 'n' }],
170+
})
171+
.then(
172+
() => { throw new Error('expected the driver to refuse week on mssql'); },
173+
(e) => e as Error & { code?: string; status?: number },
174+
);
175+
176+
expect(err.code).toBe('NOT_IMPLEMENTED');
177+
expect(err.status).toBe(501);
178+
expect(err.message.startsWith("Date bucketing by 'week' is not supported by this backend.")).toBe(true);
179+
// The message names what this dialect DOES bucket, so a reader is told
180+
// where the boundary is rather than only that they crossed it.
181+
expect(err.message).toContain("Bucketed here: none (dialect 'mssql')");
182+
expect(err.message).toContain('supports.queryDateGranularity');
183+
} finally {
184+
await unmodeled.disconnect().catch(() => {});
185+
}
173186
});
174187
});
175188

‎packages/drivers/driver-sql/src/sql-driver-temporal-dialect.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ describe('temporalFilterValue dialect gating', () => {
150150
* bucketing silently — the loud failure SQLite did not give us.
151151
*/
152152
describe('buildDateBucketExpr dialect gating (#3773)', () => {
153-
const GRANULARITIES = ['day', 'month', 'quarter', 'year'] as const;
153+
// [#21595] `week` joined when SQLite gained a `week` arm: every dialect now
154+
// renders all five, so each gate below holds for the new expression too.
155+
const GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'] as const;
154156
const expr = (d: ProbeDriver, field: string, g: string, table?: string) =>
155157
(d as any).buildDateBucketExpr(field, g, table) as { sql: string; bindings: any[] } | null;
156158

@@ -190,6 +192,11 @@ describe('buildDateBucketExpr dialect gating (#3773)', () => {
190192
expect(expr(d, 'anything', g, 't')!.sql).not.toContain('unixepoch');
191193
// No table key at all (a caller outside the aggregate path) → plain form.
192194
expect(expr(d, 'at', g)!.sql).not.toContain('unixepoch');
195+
// [#21595] A `Field.date` is its calendar day on SQLite, as on the other
196+
// dialects since #21485: no arm, `week` included, reads it through a zone.
197+
for (const zoned of [`'localtime'`, `'utc'`]) {
198+
expect(expr(d, 'on', g, 't')!.sql.toLowerCase(), `sqlite ${g}`).not.toContain(zoned);
199+
}
193200
}
194201
});
195202

@@ -231,7 +238,7 @@ describe('buildDateBucketExpr dialect gating (#3773)', () => {
231238
for (const client of ['pg', 'mysql2']) {
232239
const d = makeDriver(client);
233240
d.seedDate('t', 'on');
234-
for (const g of [...GRANULARITIES, 'week']) {
241+
for (const g of GRANULARITIES) {
235242
const sql = expr(d, 'on', g, 't')!.sql.toLowerCase();
236243
for (const zoned of ['timestamptz', 'time zone', 'convert_tz', 'time_zone']) {
237244
expect(sql, `${client} ${g}`).not.toContain(zoned);

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6101,9 +6101,14 @@ export class SqlDriver implements IDataDriver {
61016101
return { day: true, month: true, quarter: true, year: true, week: true };
61026102
}
61036103
if (this.isSqlite) {
6104-
// SQLite's strftime gained ISO week (%V) in 3.46 (2024-05-23); play it safe
6105-
// and bucket week in-memory. Day/month/year/quarter are universally available.
6106-
return { day: true, month: true, quarter: true, year: true, week: false };
6104+
// [#21595] All five, `week` included. The ISO week is computed by the
6105+
// Thursday rule through date modifiers (the `week` arm of
6106+
// `buildDateBucketExpr`), never `strftime('%V')` / `'%G'`: those arrived
6107+
// in SQLite 3.46, and libSQL (`driver-turso`) bundles an older engine.
6108+
// Measured: better-sqlite3 13.0.3 runs 3.53.4, `@libsql/client` 0.18.0
6109+
// runs 3.45.1 (where `%V` answers NULL), sql.js 1.14.2
6110+
// (`driver-sqlite-wasm`) runs 3.49.1. The expression runs on all three.
6111+
return { day: true, month: true, quarter: true, year: true, week: true };
61076112
}
61086113
return {};
61096114
}
@@ -6185,7 +6190,22 @@ export class SqlDriver implements IDataDriver {
61856190
case 'month': return fmt('%Y-%m');
61866191
case 'day': return fmt('%Y-%m-%d');
61876192
case 'quarter': return { sql: `(strftime('%Y', ${arg}) || '-Q' || ((cast(strftime('%m', ${arg}) as integer) - 1) / 3 + 1))`, bindings: [...argBindings, ...argBindings] };
6188-
case 'week': return null; // see capabilities note
6193+
case 'week': {
6194+
// [#21595] The ISO 8601 week, `YYYY-Www`: the label the PostgreSQL
6195+
// arm's `IYYY"-W"IW` and the MySQL arm's `%x-W%v` answer. A week
6196+
// belongs to the year of its Thursday. `'-3 days', 'weekday 4'` moves
6197+
// any day to the Thursday of its Monday-start week (a Monday moves
6198+
// +3, a Sunday -3), so that Thursday's `%Y` is the week-numbering
6199+
// year and `(%j - 1) / 7 + 1` of it is the week number. Both
6200+
// modifiers predate every SQLite this platform ships, unlike `%V`
6201+
// (see `dateGranularityCapabilities`). No zone is read, so a
6202+
// `Field.date` stays its calendar day.
6203+
const thursday = (f: string) => `strftime('${f}', ${arg}, '-3 days', 'weekday 4')`;
6204+
return {
6205+
sql: `(${thursday('%Y')} || '-W' || printf('%02d', (cast(${thursday('%j')} as integer) - 1) / 7 + 1))`,
6206+
bindings: [...argBindings, ...argBindings],
6207+
};
6208+
}
61896209
}
61906210
}
61916211

@@ -6195,10 +6215,9 @@ export class SqlDriver implements IDataDriver {
61956215
/**
61966216
* [#21441] The date-bucket expression this dialect groups `field` by at
61976217
* `granularity` (the one {@link aggregate} runs), rendered as SQL text, or
6198-
* `null` where {@link buildDateBucketExpr} has none: a granularity this
6199-
* dialect buckets in memory (`week` on SQLite, see
6200-
* {@link dateGranularityCapabilities}), or a client this driver does not
6201-
* model.
6218+
* `null` where {@link buildDateBucketExpr} has none: a client this driver
6219+
* does not model, whose {@link dateGranularityCapabilities} row is empty.
6220+
* SQLite, PostgreSQL and MySQL render all five granularities.
62026221
*
62036222
* It is for callers that PRINT the statement an aggregate stands for rather
62046223
* than run it. `service-analytics`' ObjectQL face echoes a date-bucketed

0 commit comments

Comments
 (0)