Skip to content

Commit 80064b2

Browse files
refactor(driver-sql): move date bucketing out of sql-driver.ts into sql-driver-date-bucket.ts (#25521 cut 20) (#27127)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XGbGfWGnrELnWaCK4iybm1t)_ Part of #25521 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 Ruling-ref: 6348891160 ## 维护者速读 - **改了什么**:把 `sql-driver.ts` 里「按日/周/月/季/年分组时,各数据库方言生成什么 SQL 分桶表达式」这一个机制(`buildDateBucketExpr` 的方法体)原样搬进新建的 `sql-driver-date-bucket.ts`;类上的方法、文档注释和签名全部保留,只把方法体转交过去,宿主接口 `DateBucketHost` 恰好列出方法体读的 6 个成员。 - **为什么改**:#25521 拆分超大文件的第 20 刀;第 19 刀合入后重新测量,这是仍成「一个机制」、收益最大且不碰任何系列限制的区域,`sql-driver.ts` 减少 39 行。 - **风险与回滚**:纯搬移,无行为变化;三个 SQL 驱动的测试两端逐条一致,公开类型声明哈希不变,344 条探针读数两端一致(仅 1 条栈顶接收者名不同)。回滚即还原本 PR。 - **先看**:`sql-driver-date-bucket.ts`(头注释、`DateBucketHost`、函数体);`sql-driver.ts` 中 `buildDateBucketExpr` 的转交桩;`sql-driver.ts` 新增的 3 行 import。 - **两分钟验证**:`pnpm --filter @objectstack/driver-sql exec vitest run src/sql-driver-date-bucket.test.ts src/sql-driver-21485-date-bucket-calendar-day.test.ts src/sql-driver-temporal-dialect.test.ts`,预期全部通过。 - **需要你做的**:无,席位落地。 ## What changes Cut 20 of the `sql-driver.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6456805734`. Cut 19 (#26960) merged first, as `d7f76b6d97`; this cut opens with a fresh seam table taken on `origin/main` `6845035c6b` (below) and takes the largest area that is one mechanism and moves cleanly: **date bucketing**, `buildDateBucketExpr`, from the class head. Its body moves unedited into a **new** seam module, `packages/drivers/driver-sql/src/sql-driver-date-bucket.ts`, through its host `DateBucketHost`. `buildDateBucketExpr` is the per-dialect SQL fragment a date dimension groups by at `day`, `week`, `month`, `quarter` or `year`: PostgreSQL `to_char`, MySQL `date_format`, SQLite `strftime`, with the #21485 calendar-day arm for a `Field.date` and the #21595 Thursday-rule ISO week on SQLite. It returns `null` for a granularity the dialect does not model. Its two callers stay in the class: `aggregate` (the GROUP BY) and `dateBucketSql` (the printed form `service-analytics` echoes). **Why a new module.** No existing seam module holds anything about aggregation or temporal read expressions; the nearest (`sql-driver-schema-sync.ts`) is DDL. The new module imports `sql-driver.ts` for a type only (`import type { SqlDriver }`), so it adds no evaluation cycle, and the body reads no module-level declaration, so nothing gains `export` and nothing else moves. **`DateBucketHost`** lists exactly the six members the body reads, each typed as the class member (`SqlDriver['NAME']`): the `isPostgres`, `isMysql` and `isSqlite` flags and the protected `dateGranularityCapabilities` getter (as getters), and the protected `temporalFieldKind` and `sqliteTemporalArg` methods (as late-bound closures). All six are read by members that stay as well. The change is move-only, and the card stays open for later cuts. There is no `docs/adr/**` edit, no `packages/spec/**` edit, no anchor entry change, no gate edit, no test edit and no new gate. Branch `claude/project-thread-p875md` (the platform assigned it to this card thread, as the claim records). The move is one commit, `7c6f6f1fe`, on `6845035c6b`. **Net line count (the seat's rule).** `sql-driver.ts` goes from 18,406 lines to 18,367: **-39**. - **Out:** the body, 56 lines. - **In:** the stub body, 14 lines, and 3 import lines (a 2-line comment and the `./sql-driver-date-bucket.js` import, placed after cut 16's seam import). - `sql-driver-date-bucket.ts` is new: 119 lines (a 40-line header, the type import, the 15-line host and the function with its one-line docblock). **Path readers.** No test, script, doc, ADR or anchor entry reads the body by path, so none is migrated (screen below). **Serial with the file's in-flight PRs.** I read the file list of every open PR on `objectstack-ai/objectstack` at the start (41) and again before opening this one (26). None touches `packages/drivers/driver-sql/**`. ## Seam table, fresh, on `6845035c6b` - **Size and churn.** `sql-driver.ts` is 18,406 lines. The 30-day column counts `git blame` lines from commits on or after 2026-09-28T06:00Z. `scripts/pm/git-history.mjs count --ref=origin/main` counts 77 first-parent commits on the file in 30 days and 31 in the last 7 (the split cuts are most of them). In all, 3,326 lines (18.1%) are from the last 30 days. - **Class shape.** `SqlDriver` spans lines 4247-18331 with 350 members: 243 instance methods, 13 accessors and 32 statics, all 32 private or protected. - **How it was measured.** The same TypeScript compiler AST script cuts 9-19 used, which strips casts, parentheses, `!` and `satisfies` before it reads `this`. Sections are the class's own banner comments. The file is byte-identical to #26960's head, so only the 30-day column moved from the pre-measure I took while #26960 was in the queue. | Lines | Section | First … last member | Lines | 30-day | Methods (public) | Inferred public | getBuilder sites | Statics (private/protected) | |:--|:--|:--|--:|--:|:--|--:|--:|:--| | 1-4246 | Module preamble | - | 4,246 | 698 | - | - | - | - | | 4247-5838 | Class head: fields, statics, constructor, capability getters | `name` … `assertDialectEmits` | 1,592 | 216 | 8 (1) | 1 | 0 | 18 (18) | | 5839-6270 | Lifecycle | `connect` … `disconnect` | 432 | 155 | 8 (3) | 0 | 0 | 6 (6) | | 6271-8677 | CRUD — IDataDriver core | `find` … `delete` | 2,407 | 745 | 43 (7) | 0 | 12 | 0 (0) | | 8678-9207 | Bulk & Batch Operations | `bulkCreate` … `count` | 530 | 70 | 9 (6) | 0 | 7 | 0 (0) | | 9208-9304 | Raw Execution | `rawStatementFault` … `noteRuntimeIndexDdl` | 97 | 28 | 3 (1) | 0 | 0 | 0 (0) | | 9305-9397 | Transactions | `beginTransaction` … `rollbackTransaction` | 93 | 46 | 7 (5) | 0 | 0 | 0 (0) | | 9398-9894 | Aggregation | `aggregate` … `unresolvableAggregateColumnRefusal` | 497 | 54 | 5 (1) | 0 | 1 | 0 (0) | | 9895-10156 | Distinct | `distinct` … `unresolvableDistinctColumnRefusal` | 262 | 26 | 3 (1) | 0 | 1 | 0 (0) | | 10157-10272 | Window Functions | `findWithWindowFunctions` … `findWithWindowFunctions` | 116 | 4 | 1 (1) | 0 | 1 | 0 (0) | | 10273-10371 | Query Plan Analysis | `explain` … `analyzeQuery` | 99 | 1 | 2 (2) | 0 | 1 | 0 (0) | | 10372-10430 | Schema Sync (syncSchema / init) | `syncSchema` … `reclaimSpace` | 59 | 34 | 3 (3) | 0 | 0 | 0 (0) | | 10431-11855 | Schema Sync (syncSchema / init) › Data-lifecycle rotation (ADR-0057 P2) | `rotationStateByTable` … `migrateMysqlTimeColumns` | 1,425 | 219 | 26 (4) | 0 | 0 | 0 (0) | | 11856-12036 | Schema Sync (syncSchema / init) › Deferred schema DDL (#3917) | `setDeferredDdl` … `flushDeferredSchemaDdl` | 181 | 46 | 6 (4) | 0 | 0 | 0 (0) | | 12037-12851 | Schema Sync (syncSchema / init) › Managed-schema drift & reconcile (#2186) | `dialectName` … `createNullSafeUniqueIndex` | 815 | 237 | 23 (3) | 0 | 0 | 0 (0) | | 12852-12903 | Schema Introspection | `listPhysicalColumns` … `introspectSchema` | 52 | 46 | 2 (2) | 0 | 0 | 0 (0) | | 12904-13174 | Internal helpers | `getKnex` … `auditMissingTenant` | 271 | 73 | 8 (1) | 0 | 1 | 0 (0) | | 13175-15224 | Internal helpers › Filter helpers | `tableNameForBuilder` … `applyFalseConstant` | 2,050 | 323 | 44 (2) | 0 | 0 | 0 (0) | | 15225-15400 | Internal helpers › Field mapping | `mapSortField` … `mapAggregateFunc` | 176 | 2 | 6 (0) | 0 | 0 | 0 (0) | | 15401-15448 | Internal helpers › Window function builder | `buildWindowFunction` … `buildWindowFunction` | 48 | 26 | 1 (0) | 0 | 0 | 0 (0) | | 15449-17352 | Internal helpers › Column creation helper | `nowColumnDefault` … `applyDeclaredColumnDefault` | 1,904 | 101 | 19 (0) | 0 | 0 | 8 (8) | | 17353-17650 | Internal helpers › Database helpers | `sqliteFilename` … `isJsonField` | 298 | 76 | 9 (2) | 0 | 0 | 0 (0) | | 17651-18127 | Internal helpers › SQLite serialisation | `formatInput` … `formatOutput` | 477 | 27 | 2 (0) | 0 | 0 | 0 (0) | | 18128-18331 | Internal helpers › Introspection internals | `introspectColumnOrder` … `introspectUniqueConstraints` | 204 | 73 | 5 (0) | 0 | 0 | 0 (0) | | 18332-18406 | After the class | - | 75 | 0 | - | - | - | - | ### Pricing the areas Eligibility is as cuts 11-19 defined it: an annotated return type or not public; no `getBuilder`, `this.FIELD.delete/insert/update(`, private-static, `super`, bare-`this` or `this`-write site; not `insertOnlyUpsertColumns`; not a member pinned in place (`createColumn`, `applyFilterCondition`, `formatInput`/`formatOutput`, `initObjects`, `ensureShardTable`, `computeTenantField`, `migrateMysqlDatetimeColumns`/`migrateMysqlTimeColumns`, `compileFilters`). Each area is priced per mechanism (cut 19's correction), as its stubs minus its bodies, then net of its import edits. The two leading rows were measured by running the move script on a scratch copy of the file; the rest are the stub estimate (a stub costs 4 lines, +2 per field read, +1 per method read). | Area | Bodies | Bodies net | Declarations and imports | Disposition | |:--|--:|--:|--:|:--| | **Date bucketing** (`buildDateBucketExpr`) | 1 | -42 (measured) | 0 declarations; +3 for the new module's import and its 2-line comment | **Moves (this cut).** -39 measured. | | Presumed-timestamp update (`updateWithPresumedTimestamp` -31, `resolveUpdatedAtColumnPresence` -6) | 2 | -37 (measured) | No existing module fits; a new module costs +3 | **Runner-up**, about -34. Not taken: smaller, and a second mechanism (⛔ no stitching). `attemptWithoutPoisoning`, which it calls, is shared with the autonumber module and stays. | | `migrateMysqlDatetimeColumns` -39, `migrateMysqlTimeColumns` -32 | 1 / 1 | | | Pinned: each wraps a vocabulary callee in a catch that logs through `logDurabilityFailure`, which `check:durability-log-level` resolves within one file only (cut 12). | | Filter helpers remainder (largest `presentReadValue` -29) | | | | Below -30 per mechanism; the order says not to stitch them. `compileFilters` (-75) is refused (inferred return type). | | `Internal helpers` tenant members (`auditMissingTenant` -26, `applyTenantScope` -25) | | | | Not taken: `applyTenantScope` holds 2 of the file's 4 D8 predicates (series limit); `auditMissingTenant` is below -30 alone. | | Window function builder (`buildWindowFunction`) -28, `orderKeysFor` -27, `ensureDatabaseExists` -24, `installQueryTiming` -20 | 1 each | | | Below -30 each. | | Column creation members reading private statics (`explainUnkeyableTextColumn` -83, `explainHashShadowDuplicate` -66, `createHashShadowUniqueIndex` -62, …), `ensureSequencesTable` -41 | | | | Series limits: private statics stay; `ensureSequencesTable` writes `this`. | ## Why ### Why this area, as one area - **One mechanism.** One method answers one question: "what SQL expression does this dialect group a date field by at this granularity". Its two callers and every member it reads stay in the class. - **It pays the most of any single mechanism that moves cleanly** (-39 measured, against about -34 for the runner-up). - **No series limit is touched.** The body builds through no `getBuilder`, reads no static, has no `this.FIELD.delete/insert/update(` site, uses no `super` or bare `this`, and assigns nothing to `this`. The method is protected with an annotated return type (`{ sql: string; bindings: any[] } | null`). `check:tenant-chokepoint`'s D8 predicates, measured with its own `nullArmsOf`: **4 at base and 4 at head**, all in `sql-driver.ts`, 0 in the moved body and 0 in the module. - **Durability gate.** The body has no `try` and calls no `logDurabilityFailure`. `check:durability-log-level --list` prints 165 lines at both ends, identical except that two `runWideningAlters` seams in `sql-driver.ts` move 39 lines up and `logDurabilityFailure`'s line 3 down (the import). - **Subclasses and spies.** `TursoDriver` overrides neither the method nor a member it reads. `SqliteWasmDriver` overrides the `isSqlite` getter; the host reads `isSqlite` through a getter on the driver at call time, so the override is still the one the body reads, and `sqlite-wasm-driver-date-bucket.test.ts` (in the wasm suite above) pins the SQL that driver emits. `turso-driver.ts` names `SqlDriver.buildDateBucketExpr` in prose, still true. The dogfood `date-bucket-parity-turso.test.ts` redefines `buildDateBucketExpr` on an instance and `sql-driver-temporal-dialect.test.ts` calls it through `(d as any)`; both still reach the method, which is still on the class. - **Path readers, screened.** No script, test, doc or ADR names the member by a `sql-driver.ts#…` pointer or a `sql-driver.ts::…` key, and no `scripts/adr-anchors/**` record names it or date bucketing. ## The shape (#23050 shape B, as cuts 1-19 apply it) - The class method keeps its docblock and signature, byte-identical. Its body is now one host literal plus `buildDateBucketExpr.call(host, field, granularity, table)`, marked `[#25521 cut 20]`. The method is synchronous at both ends, so a failure is still a synchronous throw (probe reading 7). - The fields go as GETTERS that read the driver at call time, and the methods as closures that call them on the driver at call time. So a subclass override, an instance spy, or a getter overridden in a subclass is still the one the body reaches (probe readings 3, 4, 5). - **Comments.** The module header says what moved, what stayed and how the body reaches the class. The `sql-driver.ts` import comment names cut 20 in 2 lines. Neither cites an `ADR-NNNN` letter. The method's docblock (which cites ADR-0053) stays in `sql-driver.ts`. - **How the move was made.** A script produced it from the source text; nothing was retyped. Each body line is re-indented two columns; no body line starts inside a template literal. ## Verification ### ADR anchors - **The count.** `check:adr-anchors` counts **1900** decision-letter citations at base `6845035c6b` (a clean detached worktree) and **1900** at the move commit, over 91 anchored files at both ends; the two outputs are byte-identical. Both runs exit 0. - **Why equal.** No comment inside the moved body or the new header cites a decision letter, and the module is not anchored. - **No entry needs a new home.** No anchor entry names the moved member. `check:adr-symbol-anchors` output is byte-identical at both ends. - **After `main` moved.** `main` moved 20 commits under the cut (to `07d18e692`, none in `packages/drivers/**`). Its tip counts **1900** letters (a clean detached worktree), and so does a local, unpushed merge of the move commit with it; both exit 0. I did not merge `main` into this branch: `git merge-tree --write-tree` of the head with it exits 0, so the tree is not stale. ### The published `.d.ts` does not change This is measured under the series bar (ruling `6348891160`, item 1). - **How each end was built.** `pnpm turbo run build --filter=@objectstack/driver-sql --force` at each end, against the same dependency tree. The base leg writes base `sql-driver.ts` into this worktree and removes the new module, under an EXIT/INT/TERM trap that restores HEAD. The restore is proved by the HEAD blob hashes, an empty `git diff HEAD` and 0 status entries (`RESTORE-OK fb5f2d3… 0bb2cca…`). - **The hashes.** The raw sha256 of `dist/index.d.ts` and `dist/index.d.mts` is the same at base and at the move commit: `0f33bf3febaac3c7aa96f68a4226d752766b8203ab7acade523b69f9aaa44d74` (unchanged since cut 8). These two are the dist's only declaration files (no chunk), so the declaration-reorder bar of ruling `6441032442` does not come into play. - **Positive control.** I dropped `protected` from `buildDateBucketExpr` in a copy of the head `index.d.ts` (1 hit, then 0). The copy hashes to `1fda05673f202a32293501dc46b90afa75edd56c1dc632f66d4699af09ef3197`, with `cmp` exit 1. - **Export lists.** CJS `require` of each dist gives 59 names (sha1 `05757ce5ae` at both ends); the ESM namespace of `index.mjs` gives the same 59 at both ends (sha1 `05757ce5ae`). Neither the moved function nor `DateBucketHost` is among them. - **The head dist carries the move.** `.call(host` sites in `index.mjs` go from 69 at base to 70 at head (the stub). ### Move-only proof The proof compares TypeScript compiler AST leaf tokens (JSDoc nodes skipped), base `6845035c6b` against the move commit `7c6f6f1fe`. It is a scratch script and is not committed. | Moved body | Body tokens | Comments | Parameter tokens less `this` | Return type tokens | Body lines | |:--|--:|--:|--:|--:|--:| | `buildDateBucketExpr` | 423 | 11 | 20 | 13 | 56 | - **The moved body.** 0 tokens differ and every comment is identical. Each body line is the base line less two leading spaces. It is not `async` at either end. - **The stub.** Leading trivia (the docblock), modifiers and the signature are identical to base. - **The class.** It has 350 members at both ends, in the same order, and the 349 unmoved members are byte-identical, trivia included. - **Top level of `sql-driver.ts`.** 179 of 180 base statements are byte-identical; the other is the class. The head adds one statement, the new import (its 2-line comment is its leading trivia). - **Result.** `PROOF OK`, exit 0. - **Negative control.** Through `scripts/ablation-replace.mjs`, I changed `'%x-W%v'` to `'%x-W%V'` in the module's MySQL `week` arm (anchor 1 hit, then 0, checked on disk). The proof then reports `buildDateBucketExpr` as differing, prints `PROOF FAILED`, and exits 1. The restore was proved (blob `0bb2ccab65` equals HEAD, empty `git diff HEAD`). ### Tests Each end ran under one `os-verify-lock` hold, after building that end's driver-sql dist. I compared the vitest JSON per test name and status, base `6845035c6b` against the move commit `7c6f6f1fe`. Every part's exit code was captured on its own (build, probe, three suites: all 0 at both legs). | Suite | Base | Head | Lost / changed / added | |:--|:--|:--|:--| | `@objectstack/driver-sql` | 4061 (3847 passed, 214 skipped; 248 files) | same | 0 / 0 / 0 | | `@objectstack/driver-turso`, against that end's built driver-sql dist | 2526 (2492 passed, 34 skipped; 95 files) | same | 0 / 0 / 0 | | `@objectstack/driver-sqlite-wasm` | 746 (746 passed; 39 files) | same | 0 / 0 / 0 | - **Type check and lint.** `tsc --noEmit -p .` in driver-sql exits 0 on the move commit. `pnpm exec eslint --no-inline-config packages/drivers/driver-sql` exits 0 (driver-sql has no `lint` script of its own). - **Late-binding probe.** A scratch script, not committed. It runs node against each end's built dist and prints 344 readings, each with the result or the error's class, message and top stack frame: 1. `buildDateBucketExpr` on SQLite, PostgreSQL, MySQL and MSSQL drivers (never connected), for all five granularities plus `hour` and none, over a declared `date`, `datetime`, `time` and `text` field, an undeclared field, and no / an unknown table; 2. the SQLite legacy-datetime repair arm (`sqliteTemporalArg`'s other branch); 3. a SUBCLASS overriding the `isSqlite` / `isPostgres` getters, and one overriding `dateGranularityCapabilities` (the overrides are the ones read); 4. subclass overrides of `temporalFieldKind` (PostgreSQL, MySQL) and of `sqliteTemporalArg` (likewise); 5. instance spies on `temporalFieldKind` and `sqliteTemporalArg` (they are the ones the body reaches, with the arguments recorded); 6. `knex` replaced on the instance after construction, then restored; 7. a failure inside the body (a throwing `temporalFieldKind`; an undefined capability row): synchronous throws; 8. the callers: `dateBucketSql` on three dialects, and real SQLite `aggregate` GROUP BYs over a `date` and a `datetime` field at all five granularities; 9. the method's shape on the prototype (type, arity). - **Probe result.** 343 of the 344 readings are identical. The other one is reading 7's undefined capability row, which differs only in the top stack frame's receiver name: `NullCaps.buildDateBucketExpr` at base and `Object.buildDateBucketExpr` at head. That is shape B's receiver, the same at every earlier cut, and no test or consumer reads it. - **Not run.** No ablation, because no tested behaviour changed. - **`test:repo`.** Not run locally; it is CI's run on this PR. ### Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives **48** families for this diff (the same 48 cuts 17-19 derived). I ran each one on its own and captured its exit code before any pipe, after a full `pnpm build` of the move commit (116 of 116 tasks). - **Move commit `7c6f6f1fe`: 48 run, all 48 exit 0.** `--ran` reconciles: `48 derived famil(ies) accounted for — 48 run, 0 NOT-MEASURED`, exit 0. (I fetched the pinned fixture commit of `check-plugin-teardown-shape.mjs --self-test` into this shallow clone first.) - **Hand gates, all exit 0:** `check-adr-anchors`, `check-adr-symbol-anchors`, `check-tenant-audit-census`, `check-platform-checklist --changed-from origin/main`, `check-commit-card-trailers --range origin/main..HEAD`, `eslint --no-inline-config packages/drivers/driver-sql`, `check-durability-degradation-log-level --list`. In a clean detached worktree at base `6845035c6b` and on the move commit, `check-adr-anchors`, `check-adr-symbol-anchors` and `check-tenant-audit-census` print byte-identical output; `check-object-def-param-keys` differs only in its file count (3388 to 3389, the new module); `check-durability-degradation-log-level --list` prints the same 165 lines, line numbers aside. - **No gate silently empty.** Each derived gate's log prints a non-zero population (for example `check:object-def-param-keys` compares 7 override parameter positions over 3389 files with every control firing; `check:tenant-chokepoint` reads 24 `getBuilder()` bindings across 3 files; `check:durability-log-level` reads 89 read seams; `check:dual-build-cjs-loads` loads 112 entry points across 69 packages; `check:driver-conformance` scores 11 of 11 dialect-scored cells). The 48 derived commands: `node scripts/check-closing-keyword-parity.mjs`, `node scripts/check-closing-keyword-parity.mjs --self-test`, `node scripts/check-comment-mask-adoption.mjs`, `node scripts/check-comment-mask-adoption.mjs --self-test`, `node scripts/check-comment-mask-corpus.mjs`, `node scripts/check-dts-emitted.mjs --self-test`, `node scripts/check-issue-citations.mjs`, `node scripts/check-plugin-teardown-shape.mjs`, `node scripts/check-plugin-teardown-shape.mjs --self-test`, `node scripts/ci/assert-turbo-remote-cache.mjs --self-test`, `node scripts/ci/plan-test-core-shards.mjs --self-test`, `node scripts/ci/prune-turbo-cache.mjs --self-test`, `node scripts/docs-audit/check-affected-docs.mjs`, `node scripts/docs-audit/check-drift-comment.mjs`, `pnpm --filter @objectstack/spec run check:duration-unit-keys`, `pnpm check:ci-filter-parity`, `pnpm check:closed-ceremony-text`, `pnpm check:cross-package-test-inputs`, `pnpm check:dispatcher-error-vocabulary`, `pnpm check:doc-authoring`, `pnpm check:driver-conformance`, `pnpm check:driver-memory-census`, `pnpm check:dts-closure`, `pnpm check:dual-build-cjs-loads`, `pnpm check:durability-log-level`, `pnpm check:error-status-conformance`, `pnpm check:gitlink-declared`, `pnpm check:issue-citations`, `pnpm check:keyed-text-bounds`, `pnpm check:lean-entry-closure`, `pnpm check:logger-receiver-detach`, `pnpm check:nul-bytes`, `pnpm check:object-def-param-keys`, `pnpm check:org-identifier`, `pnpm check:page-declaration-shape`, `pnpm check:platform-object-tenancy-census`, `pnpm check:published-files`, `pnpm check:refd-timer-probe`, `pnpm check:registry-log-declared`, `pnpm check:rest-log-spy-declared`, `pnpm check:slot-lookup`, `pnpm check:sourcemap-no-sources-content`, `pnpm check:system-context-census`, `pnpm check:tenant-chokepoint`, `pnpm check:test-source-alias`, `pnpm check:tier-file-adoption`, `pnpm check:undeclared-dep-imports`, `pnpm check:watch-hint-literal`. ## Acceptance notes - **`check:tenant-chokepoint`'s `SCAN_FILES` does not list `sql-driver-date-bucket.ts`.** The moved body holds 0 tenant predicates and builds no query (it returns `{ sql, bindings }` text). Noted, not filed: widening `SCAN_FILES` would be a gate change. - **Stale links.** `sql-driver.ts` has the same 30 `{@link X}` sites (21 names) that are neither a top-level binding nor a `SqlDriver` member at base and at head (the name set is identical). The module has none. - **The series' remaining yield, priced on this base.** After this cut, one single mechanism still pays more than -30: the presumed-timestamp update (`updateWithPresumedTimestamp` with `resolveUpdatedAtColumnPresence`, -37 in bodies, about -34 with a new module's import). Everything else is -29 or less per mechanism, or pinned or blocked by a series limit (the MySQL widenings, the tenant members, the private-static readers). ## Human Minimum - [x] 1. No `packages/spec` change. - [x] 2. One new name, `DateBucketHost`, in a new module that `index.ts` does not re-export. - [x] 3. No formatter ran; scoped `eslint --no-inline-config` passes (repo-wide `pnpm lint` is CI's run). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. No publishable behaviour change: `skip-changeset`; both declaration lines above. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to generated docs, changelogs or release notes. - [x] 8. Title, body and comments are in English (the maintainer brief excepted). --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pPv99puvEK3Nmm1Mc)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5pPv99puvEK3Nmm1Mc Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24351cf commit 80064b2

2 files changed

Lines changed: 136 additions & 56 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25521 cut 20] Date bucketing of `SqlDriver`: `buildDateBucketExpr`, the
5+
* per-dialect SQL fragment a date dimension groups by at `day`, `week`,
6+
* `month`, `quarter` or `year` (PostgreSQL `to_char`, MySQL `date_format`,
7+
* SQLite `strftime`, with the #21485 calendar-day arm for a `Field.date` and the
8+
* #21595 Thursday-rule ISO week on SQLite). It moved out of `sql-driver.ts`
9+
* whole.
10+
*
11+
* A MOVE, not a redesign. The function below is the body of the `SqlDriver`
12+
* method of the same name as it stood, re-indented one level and otherwise
13+
* unedited: the same SQL, the same bindings, the same `null` for a granularity
14+
* the dialect does not model.
15+
*
16+
* What did NOT move, and why:
17+
*
18+
* - The method's docblock stays on the class: it is the contract, and the
19+
* published `dist/*.d.ts` carries it with the method.
20+
* - Its callers stay: `aggregate` (the GROUP BY) and `dateBucketSql` (the
21+
* printed form `service-analytics` echoes).
22+
* - `dateGranularityCapabilities`, `temporalFieldKind` and `sqliteTemporalArg`
23+
* are read by members that stay too, and subclasses may override them; the
24+
* body reaches them through the host.
25+
*
26+
* ## How the moved body reaches the class
27+
*
28+
* The shape the other seam modules apply, after #23050. The class keeps the
29+
* method, because the published `dist/*.d.ts` lists its name and position, and
30+
* because an instance spy or a subclass method of the same name must still be
31+
* the one `aggregate` reaches. The method is now one literal of exactly the
32+
* {@link DateBucketHost} members the body reads, plus a `.call`. Methods go as
33+
* closures that call them on the driver at call time, so a subclass override
34+
* or an instance spy is still the one the body reaches; `isPostgres`,
35+
* `isMysql`, `isSqlite` and the `dateGranularityCapabilities` getter go as
36+
* GETTERS, so each is read when the body reads it, as it was on the class.
37+
*
38+
* The module imports `sql-driver.ts` for a type only, so the two modules form
39+
* no evaluation cycle. `index.ts` re-exports nothing from this module, so the
40+
* published surface does not move.
41+
*/
42+
43+
import type { SqlDriver } from './sql-driver.js';
44+
45+
/**
46+
* The `SqlDriver` members the function below reads — every one of them, and
47+
* no other. Each is typed AS the class member (an indexed access on the
48+
* class), so a change to a member's signature on the class is a change here
49+
* too, and naming a member the class does not have is a compile error. See
50+
* the module header for which members are getters.
51+
*/
52+
export interface DateBucketHost {
53+
readonly dateGranularityCapabilities: SqlDriver['dateGranularityCapabilities'];
54+
readonly isMysql: SqlDriver['isMysql'];
55+
readonly isPostgres: SqlDriver['isPostgres'];
56+
readonly isSqlite: SqlDriver['isSqlite'];
57+
sqliteTemporalArg: SqlDriver['sqliteTemporalArg'];
58+
temporalFieldKind: SqlDriver['temporalFieldKind'];
59+
}
60+
61+
/** The body of `SqlDriver#buildDateBucketExpr`; the method's docblock there is its contract. */
62+
export function buildDateBucketExpr(this: Pick<DateBucketHost, 'dateGranularityCapabilities' | 'isMysql' | 'isPostgres' | 'isSqlite' | 'sqliteTemporalArg' | 'temporalFieldKind'>, field: string, granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', table?: string | null): { sql: string; bindings: any[] } | null {
63+
if (!this.dateGranularityCapabilities[granularity]) return null;
64+
65+
const calendarDay = this.temporalFieldKind(table, field) === 'date';
66+
67+
if (this.isPostgres) {
68+
const arg = calendarDay ? `(??)::date::timestamp` : `(??)::timestamptz AT TIME ZONE 'UTC'`;
69+
switch (granularity) {
70+
case 'year': return { sql: `to_char(${arg}, 'YYYY')`, bindings: [field] };
71+
case 'month': return { sql: `to_char(${arg}, 'YYYY-MM')`, bindings: [field] };
72+
case 'day': return { sql: `to_char(${arg}, 'YYYY-MM-DD')`, bindings: [field] };
73+
case 'quarter': return { sql: `to_char(${arg}, 'YYYY"-Q"Q')`, bindings: [field] };
74+
case 'week': return { sql: `to_char(${arg}, 'IYYY"-W"IW')`, bindings: [field] };
75+
}
76+
}
77+
78+
if (this.isMysql) {
79+
const arg = calendarDay ? `??` : `convert_tz(??, @@session.time_zone, '+00:00')`;
80+
switch (granularity) {
81+
case 'year': return { sql: `date_format(${arg}, '%Y')`, bindings: [field] };
82+
case 'month': return { sql: `date_format(${arg}, '%Y-%m')`, bindings: [field] };
83+
case 'day': return { sql: `date_format(${arg}, '%Y-%m-%d')`, bindings: [field] };
84+
case 'quarter': return { sql: `concat(date_format(${arg}, '%Y'), '-Q', quarter(${arg}))`, bindings: [field, field] };
85+
case 'week': return { sql: `date_format(${arg}, '%x-W%v')`, bindings: [field] };
86+
}
87+
}
88+
89+
if (this.isSqlite) {
90+
// `arg` is a bare `??` for a TEXT-stored column and an epoch→julian-day
91+
// normalization for a `Field.datetime` one (#3773).
92+
const { sql: arg, bindings: argBindings } = this.sqliteTemporalArg(field, table);
93+
const fmt = (f: string) => ({ sql: `strftime('${f}', ${arg})`, bindings: [...argBindings] });
94+
switch (granularity) {
95+
case 'year': return fmt('%Y');
96+
case 'month': return fmt('%Y-%m');
97+
case 'day': return fmt('%Y-%m-%d');
98+
case 'quarter': return { sql: `(strftime('%Y', ${arg}) || '-Q' || ((cast(strftime('%m', ${arg}) as integer) - 1) / 3 + 1))`, bindings: [...argBindings, ...argBindings] };
99+
case 'week': {
100+
// [#21595] The ISO 8601 week, `YYYY-Www`: the label the PostgreSQL
101+
// arm's `IYYY"-W"IW` and the MySQL arm's `%x-W%v` answer. A week
102+
// belongs to the year of its Thursday. `'-3 days', 'weekday 4'` moves
103+
// any day to the Thursday of its Monday-start week (a Monday moves
104+
// +3, a Sunday -3), so that Thursday's `%Y` is the week-numbering
105+
// year and `(%j - 1) / 7 + 1` of it is the week number. Both
106+
// modifiers predate every SQLite this platform ships, unlike `%V`
107+
// (see `dateGranularityCapabilities`). No zone is read, so a
108+
// `Field.date` stays its calendar day.
109+
const thursday = (f: string) => `strftime('${f}', ${arg}, '-3 days', 'weekday 4')`;
110+
return {
111+
sql: `(${thursday('%Y')} || '-W' || printf('%02d', (cast(${thursday('%j')} as integer) - 1) / 7 + 1))`,
112+
bindings: [...argBindings, ...argBindings],
113+
};
114+
}
115+
}
116+
}
117+
118+
return null;
119+
}

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

Lines changed: 17 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ import { aggregateBackendFault, backendStatementFault, distinctBackendFault, unr
192192
// methods of the same names below hand each body what it reads (`PredicateEmitterHost`). The declarations
193193
// imported as types are named by docblocks here.
194194
import { applyEmptyOperator, applyJsonMembership, applyLike, applyLikePattern, applyNormalizedComparison, applyNullSafeNegative, type emptyJsonListPredicate, type emptyListUnsupportedDialectError, type likePatternPredicate, type textMatchPredicate, type undeclaredEmptyOperatorFieldError } from './sql-driver-predicate-emitters.js';
195+
// [#25521 cut 20] Date bucketing (`buildDateBucketExpr`); the method of the same name below hands the body what
196+
// it reads (`DateBucketHost`).
197+
import { buildDateBucketExpr } from './sql-driver-date-bucket.js';
195198
import { makeDatabaseDirectory, probeAbsentSqliteDatabaseFile, SqliteDatabaseFileError } from './sqlite-database-file.js';
196199
import { jsonMembershipPredicate } from '@objectstack/core';
197200
import knex, { Knex } from 'knex';
@@ -5075,62 +5078,20 @@ export class SqlDriver implements IDataDriver {
50755078
granularity: 'day' | 'week' | 'month' | 'quarter' | 'year',
50765079
table?: string | null,
50775080
): { sql: string; bindings: any[] } | null {
5078-
if (!this.dateGranularityCapabilities[granularity]) return null;
5079-
5080-
const calendarDay = this.temporalFieldKind(table, field) === 'date';
5081-
5082-
if (this.isPostgres) {
5083-
const arg = calendarDay ? `(??)::date::timestamp` : `(??)::timestamptz AT TIME ZONE 'UTC'`;
5084-
switch (granularity) {
5085-
case 'year': return { sql: `to_char(${arg}, 'YYYY')`, bindings: [field] };
5086-
case 'month': return { sql: `to_char(${arg}, 'YYYY-MM')`, bindings: [field] };
5087-
case 'day': return { sql: `to_char(${arg}, 'YYYY-MM-DD')`, bindings: [field] };
5088-
case 'quarter': return { sql: `to_char(${arg}, 'YYYY"-Q"Q')`, bindings: [field] };
5089-
case 'week': return { sql: `to_char(${arg}, 'IYYY"-W"IW')`, bindings: [field] };
5090-
}
5091-
}
5092-
5093-
if (this.isMysql) {
5094-
const arg = calendarDay ? `??` : `convert_tz(??, @@session.time_zone, '+00:00')`;
5095-
switch (granularity) {
5096-
case 'year': return { sql: `date_format(${arg}, '%Y')`, bindings: [field] };
5097-
case 'month': return { sql: `date_format(${arg}, '%Y-%m')`, bindings: [field] };
5098-
case 'day': return { sql: `date_format(${arg}, '%Y-%m-%d')`, bindings: [field] };
5099-
case 'quarter': return { sql: `concat(date_format(${arg}, '%Y'), '-Q', quarter(${arg}))`, bindings: [field, field] };
5100-
case 'week': return { sql: `date_format(${arg}, '%x-W%v')`, bindings: [field] };
5101-
}
5102-
}
5103-
5104-
if (this.isSqlite) {
5105-
// `arg` is a bare `??` for a TEXT-stored column and an epoch→julian-day
5106-
// normalization for a `Field.datetime` one (#3773).
5107-
const { sql: arg, bindings: argBindings } = this.sqliteTemporalArg(field, table);
5108-
const fmt = (f: string) => ({ sql: `strftime('${f}', ${arg})`, bindings: [...argBindings] });
5109-
switch (granularity) {
5110-
case 'year': return fmt('%Y');
5111-
case 'month': return fmt('%Y-%m');
5112-
case 'day': return fmt('%Y-%m-%d');
5113-
case 'quarter': return { sql: `(strftime('%Y', ${arg}) || '-Q' || ((cast(strftime('%m', ${arg}) as integer) - 1) / 3 + 1))`, bindings: [...argBindings, ...argBindings] };
5114-
case 'week': {
5115-
// [#21595] The ISO 8601 week, `YYYY-Www`: the label the PostgreSQL
5116-
// arm's `IYYY"-W"IW` and the MySQL arm's `%x-W%v` answer. A week
5117-
// belongs to the year of its Thursday. `'-3 days', 'weekday 4'` moves
5118-
// any day to the Thursday of its Monday-start week (a Monday moves
5119-
// +3, a Sunday -3), so that Thursday's `%Y` is the week-numbering
5120-
// year and `(%j - 1) / 7 + 1` of it is the week number. Both
5121-
// modifiers predate every SQLite this platform ships, unlike `%V`
5122-
// (see `dateGranularityCapabilities`). No zone is read, so a
5123-
// `Field.date` stays its calendar day.
5124-
const thursday = (f: string) => `strftime('${f}', ${arg}, '-3 days', 'weekday 4')`;
5125-
return {
5126-
sql: `(${thursday('%Y')} || '-W' || printf('%02d', (cast(${thursday('%j')} as integer) - 1) / 7 + 1))`,
5127-
bindings: [...argBindings, ...argBindings],
5128-
};
5129-
}
5130-
}
5131-
}
5132-
5133-
return null;
5081+
// [#25521 cut 20] The body moved to `sql-driver-date-bucket.ts` (see `DateBucketHost`).
5082+
const readDateGranularityCapabilities = () => this.dateGranularityCapabilities;
5083+
const readIsMysql = () => this.isMysql;
5084+
const readIsPostgres = () => this.isPostgres;
5085+
const readIsSqlite = () => this.isSqlite;
5086+
const host: ThisParameterType<typeof buildDateBucketExpr> = {
5087+
get dateGranularityCapabilities() { return readDateGranularityCapabilities(); },
5088+
get isMysql() { return readIsMysql(); },
5089+
get isPostgres() { return readIsPostgres(); },
5090+
get isSqlite() { return readIsSqlite(); },
5091+
sqliteTemporalArg: (...a) => this.sqliteTemporalArg(...a),
5092+
temporalFieldKind: (...a) => this.temporalFieldKind(...a),
5093+
};
5094+
return buildDateBucketExpr.call(host, field, granularity, table);
51345095
}
51355096

51365097
/**

0 commit comments

Comments
 (0)