Skip to content

Commit d46b1ff

Browse files
refactor(driver-sql): codemod pilot — generated, byte-identity-proved section split of sql-driver.ts (#29520) (#29598)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi9paTx1Lu8ENYwwssthfK3h)_ Fixes #29520 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:codemod `scripts/codemod-split-sql-driver.mjs` 生成的 `sql-driver.ts` 拆分。54 个方法体移入 18 个分节模块,127/129 个类外顶层声明移入叶模块 `sql-driver-top-level.ts`。`check-tenant-chokepoint` 扫描全部非测试 `sql-driver-*.ts`,其 dispatch-gates 声明补上 main 新增的 `sql-driver-balanced-group.ts`,自测失败时直接写出要加或删的条目。按裁决 6597555122 第 3 点,按路径读取 `sql-driver.ts` 的读者跟随代码扩到新模块:`sql-driver-compile-refusal-seam.test.ts` 的 `sources` 加入叶模块,`logger-receiver-detach.test.ts` 的 pin ③ 改为列目录读全部 `sql-driver*.ts`,liveness 台账的一条锚点改指叶模块,ADR-0053 锚点登记为三个新模块各加一条。根 `package.json` 加入 devDependency `ts-morph` `^28.0.0`(#29711 后已无包声明它),codemod 从根清单解析。生成文件未手改。 - 为什么改:落实 #29520 第三轮(裁决 6597555122 选项 A)。测试读取清单加入叶模块后,`sql-driver.ts` 从 18,334 行降到 12,460 行,低于 13,000 行的门槛。 - 风险与回滚:纯搬移。54/54 方法体、127/127 顶层声明逐字节相同;撤销拆分可逐字节还原 base;三套驱动测试计数与 base 相同(driver-sql 多 1 条,是 pin ③ 新增的下限用例);门禁读数 base = head。打包 `.d.ts` 按 6441032442 B 的第二归一化判通过,四项读数全部相等,阳性对照抓到去掉的 `private`。回滚即 revert。 - 先看:① 下方「按路径读取 `sql-driver.ts` 的读者」表(哪些读者跟随了代码);② `scripts/codemod-split-sql-driver.mjs` 的 `CLASS_PATH_READER_NEEDLES`;③ `.d.ts` 读数表。 - 两分钟验证:`pnpm install --frozen-lockfile` 后运行 `node scripts/codemod-split-sql-driver.mjs --verify --base 0b5b176`,预期 `54/54 moved bodies identical`、`127/127 moved top-level declarations identical`、`18334 -> 12460 lines`,退出码 0。 - 需要你做的:无新裁决。超过 3,000 行,按四件套交接,待您批准后在您定的窗口落地;本 PR 保持草稿。 ## What changes - **`scripts/codemod-split-sql-driver.mjs`** (round 3): the three seam-test needles move from `PATH_READER_NEEDLES` into a new `CLASS_PATH_READER_NEEDLES` table: `unsupportedFilterError(`, `withheldFilterError(` and `.INVALID_FILTER`. - The reader of these needles now reads the leaf, so a top-level declaration carrying one moves to it. That frees the 29 refusal builders and the 6 declarations that close over them. - A method carrying one still stays on the class. The test reads no section module, and it pins `SqlDriver.resolveWithheldFilterRefusal` by name. - Measured: dropping the three needles outright moves that method too (55 methods, 12,449 lines), and the test's enumeration would then go red. - A stale class needle refuses, as a stale needle does. - Two mutants are each refused with 2 failures: the method rule removed, and the class needles applied to top-level declarations. - Review fixes: `--help` / `-h` is answered before any guard (it was refused on a split tree), and `--dts` counts a line-count change as `diff` does (it read 0), naming the count in `rawDiffMethod`. - `--self-test`: 45 cases in 5 pinned batteries. A mutant of each review fix is refused. - **Root `package.json` and `pnpm-lock.yaml`**: `ts-morph` `^28.0.0` joins the root devDependencies, beside `typescript`, `yaml` and `semver`. The codemod now resolves it from the root manifest. - Why: #29711 removed `ts-morph` from `@objectstack/cli`, and no workspace package declares it. The codemod resolved it through the CLI, so on a fresh `pnpm install --frozen-lockfile` its `--verify` and `--self-test` exited 2. - The lockfile was written by `pnpm install --lockfile-only` (+25 lines, nothing else re-resolved). It adds `ts-morph@28.0.0`, `@ts-morph/common@0.29.0` and `code-block-writer@13.0.3`. `@ts-morph/common`'s `minimatch` resolves to the `10.2.6` already in the tree, under the existing `minimatch@<11.0.0` override. - Proved from clean: every `node_modules` removed, then `pnpm install --frozen-lockfile`. With only `ts-morph` set aside, the script exits 2 and names the root manifest. - **`scripts/check-tenant-chokepoint.mjs`**: `DECLARED_WATCH_HINTS` gains `sql-driver-balanced-group.ts`, which #29911 added on main. The scan lists the directory, so it already read the module. The self-test's coupling asserts now name each missing or extra entry, and the one edit that fixes it. - Why the list stays literal: the dispatch-gates extractor does accept `packages/drivers/driver-sql/src/sql-driver-*.ts` and judges it as a pattern. Through `hintCovers`, though, it covers 31 seam modules and 234 `sql-driver-*.test.ts` files, plus any `.d.ts`. A hint has no negation, and `?` and `[…]` are not admitted, so no spelling covers the modules and leaves the tests out. The gate's docblock records this. - Before the literal was added, the self-test failed with: `Fix: add 'packages/drivers/driver-sql/src/sql-driver-balanced-group.ts' to DECLARED_WATCH_HINTS in this gate`. - **`sql-driver-compile-refusal-seam.test.ts`**: `sources` gains `sql-driver-top-level.ts` under a `[#29520 codemod]` comment, in the same form as hand cuts 13, 15 and 16. The enumeration prose names the leaf too. - This migrates a path reader (#25521 ask 3). It is not an edit of generated output. - Ablation: without the new line the test fails 3 of 115 at head (the positive control and both enumeration sets). With it, 115/115 pass. - **`logger-receiver-detach.test.ts` pin ③**: it read `sql-driver.ts` only and asserts an absence, so it stayed green while 3 of the host's 21 log-channel calls moved out. It now lists its directory and reads every non-test `sql-driver*.ts` (a named list would rot at the next regeneration), with a floor case that the listing finds more than the host. It reads 55 calls at base and at head. Ablation: a planted `(this.logger.error ?? this.logger.warn)(` in `sql-driver-internal-helpers.ts` turns it red; the old host-only pin stays green on the same tree. - **`packages/spec/liveness/query.json`**: the one anchor whose symbol moved, `unsupportedAggregationFilterError`, now cites `sql-driver-top-level.ts`. Nothing else in the ledger changes; the other nine driver-sql anchors still resolve in `sql-driver.ts`. - **`scripts/adr-anchors/`**: three new ADR-0053 entries, for `sql-driver-sqlite-serialisation.ts` (`formatOutput`'s folds), `sql-driver-filter-helpers.ts` (`presentReadValue`'s datetime and audit arms) and `sql-driver-top-level.ts` (the fold helpers), in the shape #25521 cut 8 gave `sql-driver-schema-sync.ts`. 18 of the host's 33 ADR-0053 citations moved with that code; the host entry stays for the stubs and the 15 left. Removing ADR-0053 from one module turns `check:adr-anchors` red. - **Unchanged from round 2:** - lever (a): section modules may import values from `sql-driver.ts`. This round it exports no staying helper, because every helper the moved bodies read now sits in the leaf; - lever (b): one leaf that never imports back; - the stub host's bound `getBuilder`; - the widened `check-tenant-chokepoint`; - the `no-yield` rule; - no re-indentation. - **The generated split:** `--write` on origin/main, unedited. On the latest merge base `0b5b176038`, #29911 changed `sql-driver.ts` (input sha256 `2d2ebebdba87…`). The resolution took main's `packages/drivers/driver-sql/src` and kept this branch's two test files, then regenerated. The same 54 methods and 127 declarations move, and the result differs from the previous head only by #29911's own change. It produces `sql-driver.ts`, the leaf, and 18 section modules: - `lifecycle`, `crud`, `sequence-semantics`, `bulk-operations`, `aggregation`, `distinct`; - `window-functions`, `query-plan`, `schema-sync-section`, `rotation`, `managed-drift`, `internal-helpers`; - `filter-helpers`, `field-mapping`, `window-function-builder`, `column-creation`, `database-helpers`, `sqlite-serialisation`. ## Measurements | Reading | Value | |:--|:--| | `sql-driver.ts` lines (`wc -l`) | 18,334 → **12,460** (−5,874). Bar: below 13,000. **Met** | | Methods | 54 of 254 move (2,596 body lines out, 616 stub lines in, 36 import lines) | | Top-level declarations | 127 of 129 move (3,784 lines with their docblocks). 2 stay, both `path-reader`: `JSON_COLUMN_TYPES` and `MEDIA_ID_VARCHAR_CHARS`, which the cli pins read | | Leaf / new files | `sql-driver-top-level.ts` 3,999 lines; all 19 new files 9,575 lines | | Byte-identity | 54/54 bodies, 127/127 top-level declarations identical; undoing the split rebuilds the base byte-for-byte | | Determinism | output tree sha256 `088547bdc1b4e152d6298d4a1d33b007eec27871e179716a298944b673984170` on three runs: `--write` in the worktree and two plan runs on main's unsplit text. Input sha256 `2d2ebebdba87…` | | Chokepoint readings | base (main, widened gate): 24 `getBuilder()` bindings, 21 read doors, 3 inserts, 4 tenant predicates, 0 D8 arms across 15 files. Head: identical across 34 files | | Export lists | `.d.ts` 92 = 92; runtime CJS 59 = 59 and ESM 59 = 59 (CJS = ESM) | | Changed lines | 17,073 (+10,528 / −6,545, 30 files) | ### Readers of `sql-driver.ts` by path (ruling 6597555122 point 3) | Reader | Asserts | Host matches moved | Now | |:--|:--|:--|:--| | seam test enumeration | exact set | 26 / 28 | reads the leaf | | logger-receiver-detach pin ③ | absence | 3 / 21 | reads every `sql-driver*.ts` | | `check-tenant-chokepoint` | absence + floors | 18 / 24 | reads 34 files (round 2) | | liveness `#anchor` citations | presence | 1 / 14 | re-pointed | | ADR-0053 anchor registry | presence anywhere in file | 18 / 33 | 3 new entries | | 5 cli pins, `check-keyed-text-bounds` | presence (slices) | 0 | held by needles / pins | | `check-durability-degradation-log-level` | absence (same-file helper) | 2 / 9 (to `sql-driver-column-creation.ts:53` and `:84`; the host still counts 9 because two new stubs forward) | repo-wide scan, green | | ADR / QA `#symbol` anchors, `check-object-def-param-keys`, cli test cache inputs | presence | 0 | unchanged | ### `.d.ts`: ruling 6441032442 item 1, second normalisation, with ruling 6597555122's header clarification The normaliser is `dtsnorm.mjs`, sha256 `9e3ca459fb22015c7e77e4576b564ca46076cfd0f366dc00dcec521274f8e911`. The devx lane rewrote it from the ruling's text. It is a report reading, ⛔ not in `scripts/` (item 3). It does three things: - It splits each file into top-level statements with the TypeScript API. Each statement keeps its attached comments byte for byte. - A leading comment separated by a blank line is a detached file-level comment. It leaves the statement stream and is compared on its own as a multiset, so its text still must not change. - It sorts the statements and the specifier lists inside `import { }` / `export { }`, and masks chunk hashes. driver-sql emits no chunk. Base is main `0b5b176038`'s unsplit `src`, built. Head is `5f30cf9f2c`, built. The readings are the same as round 3's, hash for hash. Each reading below is the same for `index.d.ts` and `index.d.mts`. | Reading | Base | Head | Verdict | |:--|:--|:--|:--| | Raw sha256 | `0f33bf3f…` | `69d9162d…` | differ; raw diff 19 lines, `wc -l` 8,846 → 8,847 | | **Normalised bytes** | `468260520fcc1ac2…` | `468260520fcc1ac2…` | **identical** | | **Declared-surface digest** (100 declarations, 585 lines) | `50e5e5be3c1d…` | `50e5e5be3c1d…` | **equal** | | **Statement multiset** | 106 | 106 | **equal**: only in base 0, only in head 0. Before the specifier sort it is 2 / 2, the two `@objectstack/spec` imports | | **Export set** | 92 | 92 | **equal**; import specifier set 17 = 17 | | Detached comments | 2 | 2 | equal texts. The "SQL Driver for ObjectStack" header now sits before `declare class SqlDriver`, which ruling 6597555122 does not count | **Positive control (catches a dropped `private`).** I dropped `private` from one moved member's stub, `compileFilters`, rebuilt, ran the normaliser on head against control, then restored and rebuilt. The rebuilt head `.d.ts` is byte-identical to the earlier head. The control is caught on three readings: - normalised hashes differ: `468260520fcc…` vs `73e89389695f…`; - surface digests differ: `50e5e5be3c1d…` vs `acf70f0378d0…`. It names `PropertyDeclaration [private] compileFilters` → `MethodDeclaration [] compileFilters`; - the statement multiset is 1 / 1. The normaliser exits 1 on both files. Its `--self-test` passes 8 cases, among them a member reordered inside its class and a changed header text, both refused. **Premises.** - P2: driver-sql `exports` is `.` only. It names `dist/index.*` and no chunk. - P3: `git grep 'driver-sql/dist' -- scripts package.json` finds 0 hits. - (e) Dependents are green against the head dist: the driver-turso and driver-sqlite-wasm typecheck and suites ran after the head build. <details><summary>Why most methods stay (counted per method; one method can carry several)</summary> `already-moved` 78 · `no-yield` 55 · `non-public-static` 47 · `parameter-shape` 21 · `this-write` 19 · `path-reader` 18 · `no-section` 14 · `static` 11 · `comment-in-parameters` 7 · `field-call` 6 · `inferred-return-type` 6 · `type-parameters` 3 · `name-taken` 3 · `reserved-name` 1. The full table is the script's plan output. </details> ## Tests Every run below is at head `5f30cf9f2c` on merge base `0b5b176038`, after every `node_modules` was removed and `pnpm install --frozen-lockfile` re-ran. Each ran under one `os-verify-lock` hold (VERDICT command-exit 0). The base runs used main's unsplit `src`, built and then restored in place; status was clean afterwards. - Type-check: driver-sql, driver-turso and driver-sqlite-wasm each exit 0. - `@objectstack/driver-sql test`: exit 0 at both. 250 files (238 passed, 12 skipped). Base 4074 tests (3858 passed, 216 skipped); head 4075 (3859 passed, 216 skipped): the +1 is pin ③'s new floor case. - `@objectstack/driver-turso test`: exit 0 at both. 96 files; 2530 tests (2496 passed, 34 skipped). Base = head. - `@objectstack/driver-sqlite-wasm test`: exit 0 at both. 40 files; 750 tests passed. Base = head. - The five `packages/cli` pin tests that read `sql-driver.ts` by path exit 0: - unit: 4 files, 62 tests; - integration (`generate-string-family-width`): 1 file, 30 tests. - `sql-driver-compile-refusal-seam.test.ts`: 115/115. With the new `sources` line removed, 3 tests fail (the ablation above). - `logger-receiver-detach.test.ts`: 12/12; the planted shape fails it (ablation above). - `packages/spec` `check-liveness.test.ts` 74/74 and `check:liveness` exit 0. - Codemod: `--self-test` 45 cases in 5 batteries; `--verify --base 0b5b176` exits 0 (54/54, 127/127, 12,460 lines); two plan runs on base's unsplit text give tree `088547bd…` both times, byte-identical; `--help` on the split tree exits 0. - `check-tenant-chokepoint` `--self-test` and main run exit 0. `check:adr-anchors` and `check:adr-symbol-anchors` exit 0. ## Acceptance notes 1. **The bar is met: 12,460 lines.** Round 3 measured 12,456 on the older base; #29911 added 4 lines to code that stays on the class. 2. **The three needles still bind methods.** That is what keeps the test green (see What changes). Literally deleting them would move `resolveWithheldFilterRefusal` into `sql-driver-filter-helpers.ts`, which the test does not read. 3. **`.d.ts`** passes ruling 6441032442 B's second normalisation on all four readings, with the control caught. Ruling 6348891160's union-order bar alone still reads FAIL on these files. Under 6441032442 item 1, that is the case the second normalisation exists for. 4. **Full `pnpm build`** exits 2 in `@object-ui/app-shell` (`Cannot find module '@object-ui/mobile'`), outside this change. Every driver and cli closure used here builds. 5. **Size.** 17,073 changed lines is over the 3,000 threshold. Per ruling 6597555122 the PR takes the four-piece hand-off and a maintainer approval, and lands in the window the maintainer sets. It stays a draft. 6. **`skip-changeset`:** the `.d.ts` meets item 1 by ruling 6441032442, so per item 4 it takes no changeset. ## Verification - Byte-identity: `pnpm install --frozen-lockfile`, then `node scripts/codemod-split-sql-driver.mjs --verify --base 0b5b176`. - `.d.ts`: build `@objectstack/driver-sql` at main's unsplit `src` and at head. Then run `node dtsnorm.mjs --diff BASE/index.d.ts HEAD/index.d.ts`, and the same for `.d.mts`. Expect exit 0 and `"pass": true`. The #29520 report gives the normaliser's path and sha256. - Chokepoint: `node scripts/check-tenant-chokepoint.mjs` prints the readings line. - Re-running in the landing window: merge the latest `main` and take `main`'s `packages/drivers/driver-sql/src`, keeping this branch's `sql-driver-compile-refusal-seam.test.ts` and `logger-receiver-detach.test.ts`. Then run `--write` and commit. If a regeneration moves different code, re-check the reader table above. <details><summary>Gates at head 5f30cf9</summary> - `node scripts/pm/dispatch-gates.mjs --commands` derives 92 families at `5f30cf9f2c` on merge base `0b5b176038`, the same list as at `5b19e9ac05`. Each was run alone after the fresh install, with its exit captured before any pipe: 92 exit 0, `check:tenant-chokepoint` among them. - The `--ran` reconciliation reads 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - Not measured here: the OSV-Scanner job. CI runs it base-relative because the lockfile changed, and the local proxy refuses `api.osv.dev`. The only advisory-bearing package the three lockfile additions reach is `minimatch` 10.2.6, which is already in the merge base's lockfile and already covered by the `GHSA-g3hv-75wg-m249` exemption. - Also run: the codemod's `--dts` (ruling 6348891160's bar alone reads FAIL, as before), the seam and logger tests together (127/127), and spec `check:liveness`. - `check:commit-card-trailers` passed on every push. </details> Durable attribution: session `session_017ihL2gLghEqeDKTbDTC7Zd` (`domain:devx#2`, os-dev). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd <!-- signed: domain:devx#2 · marchtian · session_017ihL2gLghEqeDKTbDTC7Zd --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21510bb commit d46b1ff

30 files changed

Lines changed: 10528 additions & 6545 deletions

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@
225225
"github-slugger": "2.0.0",
226226
"semver": "^7.8.5",
227227
"svelte": "^5.57.3",
228+
"ts-morph": "^28.0.0",
228229
"tsup": "^8.5.1",
229230
"tsx": "^4.23.15",
230231
"turbo": "^2.11.10",

‎packages/drivers/driver-sql/src/logger-receiver-detach.test.ts‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,15 @@
4848
* ⓪ the doubles (and the platform's real logger) are receiver-sensitive.
4949
* ① the `info` site, driven through the real dev auto-reconcile path.
5050
* ② the durability sites, driven through the real declared-index sync.
51-
* ③ a structural pin over `sql-driver.ts` itself: no detach-then-call shape
52-
* survives anywhere in the file, in ANY of the four spellings the sweep
53-
* measured — including the two a single-line regex cannot see.
51+
* ③ a structural pin over `sql-driver.ts` itself, and every non-test
52+
* `sql-driver*.ts` module beside it (#29520: the codemod split moves bodies
53+
* out of the host): no detach-then-call shape survives anywhere in them, in
54+
* ANY of the four spellings the sweep measured — including the two a
55+
* single-line regex cannot see.
5456
*/
5557

5658
import { describe, it, expect, afterEach } from 'vitest';
57-
import { readFileSync } from 'node:fs';
59+
import { readFileSync, readdirSync } from 'node:fs';
5860
import ts from 'typescript';
5961
import { createLogger } from '@objectstack/core';
6062
import { SqlDriver } from './sql-driver.js';
@@ -493,11 +495,29 @@ describe('[#12792] ③ no detach-then-call shape survives in sql-driver.ts', ()
493495
expect(found.filter((f) => f.line >= 13)).toEqual([]);
494496
});
495497

496-
it('sql-driver.ts contains none of them', () => {
497-
const source = readFileSync(new URL('./sql-driver.ts', import.meta.url), 'utf8');
498-
expect(
499-
scan(source, 'sql-driver.ts').map((f) => `${f.shape} @${f.line}: ${f.text.trim()}`),
500-
'every log call must go through a property access so the receiver stays bound',
501-
).toEqual([]);
498+
// [#29520 codemod] The split moves `SqlDriver` method bodies (log calls
499+
// included) out of `sql-driver.ts` into generated `sql-driver-*.ts` modules,
500+
// and which modules exist depends on what the codemod moves on the `main` it
501+
// is re-run on. So the pin reads the host and EVERY non-test `sql-driver*.ts`
502+
// beside it by listing the directory, not by a named list that would rot at
503+
// the next regeneration. The pin asserts an absence, so a file it stops
504+
// reading would keep it green while it checked less; the floor below makes a
505+
// listing that finds nothing past the host fail instead.
506+
const SCANNED = readdirSync(new URL('.', import.meta.url))
507+
.filter((name) => /^sql-driver.*\.ts$/.test(name) && !name.endsWith('.test.ts'))
508+
.sort();
509+
510+
it('reads the host and every module split out of it', () => {
511+
expect(SCANNED).toContain('sql-driver.ts');
512+
expect(SCANNED.length).toBeGreaterThan(1);
513+
});
514+
515+
it('sql-driver.ts and the modules split out of it contain none of them', () => {
516+
const found = SCANNED.flatMap((name) =>
517+
scan(readFileSync(new URL(`./${name}`, import.meta.url), 'utf8'), name).map(
518+
(f) => `${name} ${f.shape} @${f.line}: ${f.text.trim()}`,
519+
),
520+
);
521+
expect(found, 'every log call must go through a property access so the receiver stays bound').toEqual([]);
502522
});
503523
});

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

Lines changed: 344 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 354 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29520 codemod] The "Column creation helper" section of `SqlDriver`: the
5+
* bodies of `decorateHashShadowDuplicate`, `schemaBytesPerChar`,
6+
* `rethrowWithRowSizeExplanation` moved out of `sql-driver.ts` whole by
7+
* `scripts/codemod-split-sql-driver.mjs`.
8+
*
9+
* A MOVE, not a redesign. Each function below is the body of the `SqlDriver`
10+
* method of the same name exactly as it stood: not re-indented, not
11+
* formatted, not edited. The method stays on the class with its docblock and
12+
* signature, so the published `dist/*.d.ts`, an instance spy and a subclass
13+
* override are what they were; the docblock there is the contract.
14+
*
15+
* The bodies reach the class through {@link ColumnCreationHost}: exactly the
16+
* `SqlDriver` members they read. Each method hands its body one literal of the
17+
* members that body reads, plus a `.call`: methods go as closures that call
18+
* them on the driver at call time (the builder factory as a bound reference),
19+
* so a subclass override or an instance spy is still the one reached; fields
20+
* and accessors go as GETTERS, read when the body reads them, as they were on
21+
* the class.
22+
*
23+
* The module imports `sql-driver.ts` for types only, so the two modules form
24+
* no evaluation cycle. `index.ts` re-exports nothing from this module.
25+
*/
26+
27+
import type { SqlDriver } from './sql-driver.js';
28+
29+
/**
30+
* The `SqlDriver` members the functions below read — every one of them, and
31+
* no other. Each is typed AS the class member (an indexed access on the
32+
* class), so a change to a member's signature on the class is a change here
33+
* too. `readonly` members are the getters.
34+
*/
35+
export interface ColumnCreationHost {
36+
explainHashShadowDuplicate: SqlDriver['explainHashShadowDuplicate'];
37+
explainRowSizeOverflow: SqlDriver['explainRowSizeOverflow'];
38+
readonly knex: SqlDriver['knex'];
39+
logDurabilityFailure: SqlDriver['logDurabilityFailure'];
40+
}
41+
42+
/** The body of `SqlDriver#decorateHashShadowDuplicate`; the method's docblock there is its contract. */
43+
export async function decorateHashShadowDuplicate(this: Pick<ColumnCreationHost, 'explainHashShadowDuplicate' | 'logDurabilityFailure'>, tableName: string, values: Record<string, unknown>, error: unknown): Promise<unknown> {
44+
let explanation: string | null = null;
45+
try {
46+
explanation = await this.explainHashShadowDuplicate(tableName, values, error);
47+
} catch {
48+
// The disambiguation is a diagnostic. If it cannot be made, the original
49+
// failure is still the failure — never mask it with this one.
50+
return error;
51+
}
52+
if (!explanation) return error;
53+
this.logDurabilityFailure(explanation);
54+
return Object.assign(new Error(`${explanation} (server said: ${String((error as { message?: string })?.message ?? error)})`), {
55+
code: (error as { code?: string })?.code,
56+
cause: error,
57+
});
58+
}
59+
60+
/** The body of `SqlDriver#schemaBytesPerChar`; the method's docblock there is its contract. */
61+
export async function schemaBytesPerChar(this: Pick<ColumnCreationHost, 'knex'>): Promise<{ bytesPerChar: number; charset: string } | null> {
62+
try {
63+
const res: any = await this.knex.raw(
64+
'select cs.CHARACTER_SET_NAME as charset, cs.MAXLEN as maxlen ' +
65+
'from information_schema.CHARACTER_SETS cs ' +
66+
'where cs.CHARACTER_SET_NAME = @@character_set_database',
67+
);
68+
const row = (Array.isArray(res) ? res[0]?.[0] : (res?.rows?.[0] ?? res?.[0])) ?? null;
69+
const maxlen = Number(row?.maxlen ?? row?.MAXLEN);
70+
const charset = String(row?.charset ?? row?.CHARSET ?? '');
71+
if (!Number.isInteger(maxlen) || maxlen <= 0 || !charset) return null;
72+
return { bytesPerChar: maxlen, charset };
73+
} catch {
74+
// Introspection is a nicety here; the advice below holds without it.
75+
return null;
76+
}
77+
}
78+
79+
/** The body of `SqlDriver#rethrowWithRowSizeExplanation`; the method's docblock there is its contract. */
80+
export async function rethrowWithRowSizeExplanation(this: Pick<ColumnCreationHost, 'explainRowSizeOverflow' | 'logDurabilityFailure'>, tableName: string, fields: Record<string, any> | undefined, keyedColumns: ReadonlyMap<string, { unique: boolean }>, addedColumns: string[] | null, e: any): Promise<never> {
81+
const overflow = await this.explainRowSizeOverflow(tableName, fields, keyedColumns, addedColumns, e);
82+
if (!overflow) throw e;
83+
const said = String(e?.sqlMessage ?? e?.message ?? e);
84+
this.logDurabilityFailure(overflow, said);
85+
throw Object.assign(new Error(`${overflow} (server said: ${said})`), {
86+
code: (e as { code?: string }).code,
87+
cause: e,
88+
});
89+
}

‎packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
* 1. **The enumeration** reads `sql-driver.ts` and its cross-field seam module
3131
* `sql-driver-cross-field-comparison.ts` (#25521 cut 13), plus the read-exit
3232
* fault module `sql-driver-read-exit-fault.ts` (#25521 cut 15) and the
33-
* predicate-emitter module `sql-driver-predicate-emitters.ts` (#25521 cut 16),
33+
* predicate-emitter module `sql-driver-predicate-emitters.ts` (#25521 cut 16)
34+
* and the generated top-level leaf `sql-driver-top-level.ts` (#29520 codemod),
3435
* and lists every function that builds an `INVALID_FILTER` error. Only three may call
3536
* `unsupportedFilterError` directly (the seam itself, the `'author'`
3637
* re-issue, and the column refusal raised from a dialect error AFTER the
@@ -290,11 +291,14 @@ function enumerateRefusalBuilders(): {
290291
// `unresolvableFilterColumnError`; the scan reads it too.
291292
// [#25521 cut 16] The leaf predicate emitters moved to a fourth, with the two
292293
// `$empty` refusals; the scan reads it too.
294+
// [#29520 codemod] The top-level declarations ahead of the class moved to a
295+
// generated leaf, with the refusal builders among them; the scan reads it too.
293296
const sources = [
294297
['sql-driver.ts', readFileSync(new URL('./sql-driver.ts', import.meta.url), 'utf8')],
295298
['sql-driver-cross-field-comparison.ts', readFileSync(new URL('./sql-driver-cross-field-comparison.ts', import.meta.url), 'utf8')],
296299
['sql-driver-read-exit-fault.ts', readFileSync(new URL('./sql-driver-read-exit-fault.ts', import.meta.url), 'utf8')],
297300
['sql-driver-predicate-emitters.ts', readFileSync(new URL('./sql-driver-predicate-emitters.ts', import.meta.url), 'utf8')],
301+
['sql-driver-top-level.ts', readFileSync(new URL('./sql-driver-top-level.ts', import.meta.url), 'utf8')],
298302
] as const;
299303
const directCallers = new Set<string>();
300304
const seamCallers = new Set<string>();
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29520 codemod] The "CRUD — IDataDriver core" section of `SqlDriver`: the
5+
* bodies of `create`, `readBackInsertedRows`, `sequenceKeyHash` moved out of
6+
* `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`.
7+
*
8+
* A MOVE, not a redesign. Each function below is the body of the `SqlDriver`
9+
* method of the same name exactly as it stood: not re-indented, not
10+
* formatted, not edited. The method stays on the class with its docblock and
11+
* signature, so the published `dist/*.d.ts`, an instance spy and a subclass
12+
* override are what they were; the docblock there is the contract.
13+
*
14+
* The bodies reach the class through {@link CrudHost}: exactly the
15+
* `SqlDriver` members they read. Each method hands its body one literal of the
16+
* members that body reads, plus a `.call`: methods go as closures that call
17+
* them on the driver at call time (the builder factory as a bound reference),
18+
* so a subclass override or an instance spy is still the one reached; fields
19+
* and accessors go as GETTERS, read when the body reads them, as they were on
20+
* the class.
21+
*
22+
* The module imports `sql-driver.ts` for types only, so the two modules form
23+
* no evaluation cycle. `index.ts` re-exports nothing from this module.
24+
*/
25+
26+
import type { DriverOptions } from '@objectstack/spec/data';
27+
import { nanoid } from 'nanoid';
28+
import { createHash } from 'node:crypto';
29+
import {
30+
AUTONUMBER_COLLISION_RETRIES, DEFAULT_ID_LENGTH, insertedRowsNotReadBackError,
31+
} from './sql-driver-top-level.js';
32+
import type { SqlDriver } from './sql-driver.js';
33+
34+
/**
35+
* The `SqlDriver` members the functions below read — every one of them, and
36+
* no other. Each is typed AS the class member (an indexed access on the
37+
* class), so a change to a member's signature on the class is a change here
38+
* too. `readonly` members are the getters.
39+
*/
40+
export interface CrudHost {
41+
applyTenantScope: SqlDriver['applyTenantScope'];
42+
applyWriteColumnMap: SqlDriver['applyWriteColumnMap'];
43+
auditMissingTenant: SqlDriver['auditMissingTenant'];
44+
collidingAutoNumberReservations: SqlDriver['collidingAutoNumberReservations'];
45+
decorateHashShadowDuplicate: SqlDriver['decorateHashShadowDuplicate'];
46+
fillAutoNumberFields: SqlDriver['fillAutoNumberFields'];
47+
formatInput: SqlDriver['formatInput'];
48+
formatOutput: SqlDriver['formatOutput'];
49+
getBuilder: SqlDriver['getBuilder'];
50+
injectTenantOnInsert: SqlDriver['injectTenantOnInsert'];
51+
readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows'];
52+
readBackInsertedRows: SqlDriver['readBackInsertedRows'];
53+
remoteColumn: SqlDriver['remoteColumn'];
54+
resolveTenantField: SqlDriver['resolveTenantField'];
55+
resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax'];
56+
rotationWriteTarget: SqlDriver['rotationWriteTarget'];
57+
stampInsertTimestamps: SqlDriver['stampInsertTimestamps'];
58+
}
59+
60+
/** The body of `SqlDriver#create`; the method's docblock there is its contract. */
61+
export async function create(this: Pick<CrudHost, 'applyWriteColumnMap' | 'auditMissingTenant' | 'collidingAutoNumberReservations' | 'decorateHashShadowDuplicate' | 'fillAutoNumberFields' | 'formatInput' | 'formatOutput' | 'getBuilder' | 'injectTenantOnInsert' | 'insertReturnsStoredRows' | 'readBackInsertedRows' | 'resyncSequenceToDataMax' | 'rotationWriteTarget' | 'stampInsertTimestamps'>, object: string, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown>> {
62+
const { _id, ...rest } = data;
63+
const toInsert = { ...rest };
64+
65+
if (_id !== undefined && toInsert.id === undefined) {
66+
toInsert.id = _id;
67+
} else if (toInsert.id === undefined) {
68+
toInsert.id = nanoid(DEFAULT_ID_LENGTH);
69+
}
70+
71+
this.auditMissingTenant(object, 'create', options, [toInsert]);
72+
this.injectTenantOnInsert(object, toInsert, options);
73+
74+
// #5495. The autonumber counter is bootstrapped from the data table exactly
75+
// once, so any row that lands by a path bypassing `fillAutoNumberFields`
76+
// leaves it permanently behind `MAX` — and every create then collided,
77+
// burned a number and failed the request until it had ground past the
78+
// seeded range one 409 at a time. Re-seed and retry instead, but ONLY when
79+
// the collision is provably this counter's; see
80+
// {@link collidingAutoNumberReservations} for how that is decided and why
81+
// it cannot be decided from the error text alone.
82+
//
83+
// Retrying is confined to the no-caller-transaction case on purpose. Inside
84+
// a caller's transaction there is nothing to repair here — the sequence
85+
// UPDATE shares that transaction and rolls back with the refused INSERT, so
86+
// no number is burned (measured) — and there is something to break: on
87+
// Postgres a constraint failure aborts the whole transaction, so a retry
88+
// issued on it could not succeed anyway. The caller owns that retry.
89+
const mayRetry = options?.transaction === undefined;
90+
for (let attempt = 0; ; attempt++) {
91+
const reservations = await this.fillAutoNumberFields(object, toInsert, options);
92+
93+
// Rotation (ADR-0057 P2): the base name is a read-only view — new rows
94+
// land in the current shard.
95+
const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options);
96+
const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
97+
this.stampInsertTimestamps(object, formatted);
98+
99+
try {
100+
if (this.insertReturnsStoredRows) {
101+
const result = await builder.insert(formatted).returning('*');
102+
return this.formatOutput(object, result[0]);
103+
}
104+
await builder.insert(formatted);
105+
} catch (error) {
106+
// #11627: on a table whose UNIQUE index is carried by a hash shadow,
107+
// `ER_DUP_ENTRY` quotes a binary digest and names the shadow index, so
108+
// neither the operator nor this driver can read WHICH value conflicted
109+
// — and a genuine duplicate is indistinguishable from a (near-
110+
// impossible) digest collision. Resolve it by reading the source
111+
// columns back, on the failure path only, and re-throw the SAME error
112+
// carrying whichever answer is true. Returns the error untouched when
113+
// this is not a shadow conflict.
114+
const shadowed = await this.decorateHashShadowDuplicate(
115+
this.rotationWriteTarget(object) ?? object,
116+
formatted,
117+
error,
118+
);
119+
if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw shadowed;
120+
const colliding = await this.collidingAutoNumberReservations(error, reservations, options);
121+
if (colliding.length === 0) throw shadowed;
122+
for (const reservation of colliding) {
123+
await this.resyncSequenceToDataMax(reservation);
124+
// Clear only what collided, so the next pass regenerates exactly
125+
// those fields. A reservation that did NOT collide keeps its value
126+
// rather than burning a second number for nothing.
127+
delete toInsert[reservation.field];
128+
}
129+
continue;
130+
}
131+
132+
// [#21227] Reached only when the INSERT landed and answered no rows. The
133+
// read sits OUTSIDE the try on purpose: a fault in it is not an insert
134+
// failure, so it must never reach the collision re-seed above, whose
135+
// retry would re-issue a write that already landed.
136+
const [stored] = await this.readBackInsertedRows(
137+
object,
138+
this.rotationWriteTarget(object) ?? object,
139+
[toInsert],
140+
options,
141+
);
142+
return stored;
143+
}
144+
}
145+
146+
/** The body of `SqlDriver#readBackInsertedRows`; the method's docblock there is its contract. */
147+
export async function readBackInsertedRows(this: Pick<CrudHost, 'applyTenantScope' | 'formatOutput' | 'getBuilder' | 'remoteColumn' | 'resolveTenantField'>, object: string, writeTable: string, written: Record<string, any>[], options?: DriverOptions): Promise<Record<string, unknown>[]> {
148+
if (written.length === 0) return [];
149+
const idColumn = this.remoteColumn(object, 'id', 'id');
150+
const tenantField = this.resolveTenantField(object);
151+
const writtenTenants = tenantField
152+
? [
153+
...new Set(
154+
written
155+
.map((row) => row[tenantField])
156+
.filter((value) => value !== undefined && value !== null && value !== '')
157+
.map(String),
158+
),
159+
]
160+
: [];
161+
const everyRowCarriesTenant =
162+
tenantField !== null
163+
&& written.every((row) => row[tenantField] !== undefined && row[tenantField] !== null && row[tenantField] !== '');
164+
const scopeOptions: DriverOptions = everyRowCarriesTenant
165+
? { ...options, tenantId: writtenTenants[0], tenantIds: writtenTenants }
166+
: { ...options };
167+
const builder = this.getBuilder(writeTable, options);
168+
this.applyTenantScope(builder, object, scopeOptions);
169+
const stored: Record<string, any>[] = await builder.whereIn(
170+
idColumn,
171+
written.map((row) => row.id),
172+
);
173+
const byId = new Map<string, Record<string, any>>();
174+
for (const row of stored) byId.set(String(row[idColumn]), row);
175+
const missing = written.filter((row) => !byId.has(String(row.id))).map((row) => row.id);
176+
if (missing.length > 0) throw insertedRowsNotReadBackError(object, missing, written.length);
177+
return written.map((row) => this.formatOutput(object, byId.get(String(row.id))));
178+
}
179+
180+
/** The body of `SqlDriver#sequenceKeyHash`; the method's docblock there is its contract. */
181+
export function sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string {
182+
return createHash('sha256')
183+
.update(`${object}\u001f${tenantId}\u001f${field}\u001f${scope}`)
184+
.digest('hex');
185+
}

0 commit comments

Comments
 (0)