/** * Backend-NEUTRAL SQL exec/transaction seam for the "single-file dual-dialect" store collapses * ([ref] A12 定型半场). Sibling of `sql-escape.ts` / `sql-row-helpers.ts` / `store-contracts.ts`: * a leaf module with ZERO store logic, so a collapsed pair never has to load the other dialect's * adapter to reach a driver call. * * What it normalizes (and ONLY this): * - `query(sql, params)` → `{ rows, affected }` — the two drivers disagree on the RESULT shape * (mysql2 `[rowsOrHeader, fields]` + `ResultSetHeader.affectedRows`; node-pg `{rows, rowCount}`). * - transaction control verbs — mysql2 has NATIVE `beginTransaction/commit/rollback` methods while * node-pg issues them as statements. Call-shape matters (the fake-pool unit suites script the * `query` calls IN ORDER), so the seam keeps each engine on its historical verb form. * * What it deliberately does NOT normalize (the A12 判据 — 方言差异必须显式): * - SQL TEXT: placeholders (`?` vs `$n`), `ON DUPLICATE KEY UPDATE` vs `ON CONFLICT … DO UPDATE`, * `CAST(? AS JSON)` vs `$n::jsonb`, `INSERT IGNORE` vs `ON CONFLICT DO NOTHING`, `<=>` vs * `IS NOT DISTINCT FROM`, VARBINARY vs `COLLATE "C"` — every one of these stays written out at * the call site so a reader sees BOTH statements side by side (the real-DB suites are the oracle). * - WHICH unique key collided (mysql2 `sqlMessage` text vs pg `err.constraint`) — that lives with * the store that needs it, because the attribution rule is store-specific, not driver-generic. * ⚠️ The dup-key predicate ITSELF is NOT store-specific and no longer lives at the call sites: * `sql-errors.ts` owns it ([ref] P1-①; 7+ hand-rolled copies had drifted into three different * recognition sets, so the same driver error got opposite verdicts in different stores). * - per-query timeouts: mysql2 takes `{sql, timeout}` while node-pg takes a `QueryConfig` with * `query_timeout`. The counter family (write-behind-counter.ts) needs those and keeps its own * exec; this seam stays on the plain (sql, params) form. * * ───────────────────────────────────────────────────────────────────────────────────────────────── * @contract txn.read-semantics — 本仓「事务里的读看到什么」的**唯一**成文处(S-131 / B-020 / B-022) * ───────────────────────────────────────────────────────────────────────────────────────────────── * 五只店的头注此前各写一套「为什么用这个事务动词」的理由(截断的、漏数的、讲历史沿革的都有)。 * 判据从今天起只有这一处,店里只留一行 `{@link SqlTxConn.begin}` 指回来。 * * **① 事务动词只有一个** —— `begin()`。曾经那个「悲观」专用动词(MySQL 腿发 TiDB 专有的 * `BEGIN PESSIMISTIC`)已删除:那条语句在真 MySQL / MariaDB 上是 `ER_PARSE_ERROR`,四店 18 个事务 * 一个都开不了(**B-020**),而它真正保证的东西现在由 ② 结构性地给出。同理,那个「可重复读」专用 * 动词也一并并入:隔离级不再由动词决定。 * * **② 隔离级与事务模式是「连接的事实」,不是「动词的参数」** —— 见 {@link SqlDriver.facts}。 * 每条池连接首次借出时初始化一次(记忆化): * · MySQL 协议腿:`SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ`;`SELECT @@tidb_txn_mode` * 有值 ⇒ TiDB(值必须是 `pessimistic`,否则会话级设定,**设不上就拒启**——本仓所有 `FOR UPDATE` * 判据都以悲观锁读为前提);`ER_UNKNOWN_SYSTEM_VARIABLE` ⇒ InnoDB(MySQL / MariaDB)。 * · PG:`SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED`,同样回读复核。 * 两腿钉的**值**不同(RR vs RC)是**引擎事实**不是口味:两边要的都是「加锁读=当前读」,而 PG 的 * REPEATABLE READ 是快照隔离,`FOR UPDATE` 撞并发提交抛 `40001` 而不是等锁 —— 把 PG 钉成 RR 会把 * 本仓每一条 `FOR UPDATE` 腿变成需要重试的形。`default_transaction_isolation` 同样是可设变量, * 所以 PG 腿也**不问服务器缺省**。 * 「服务器默认值不是结构保证」这条论证的出处是 [ref]:`transaction_isolation` 是可设变量,一台 * 跑 READ COMMITTED 的实例会把「一个事务一个快照」悄悄降成「每条语句各自取快照」,而依赖它的调用方 * 不会收到任何错。所以 MySQL 腿现在**每条连接自己钉**,不问部署。 * * **③ 事务内的集合读一律是锁读**(`… FOR UPDATE`)。普通 `SELECT` 只用于三种情形:读**本事务自己刚写 * 的行**、读**值/时钟表达式**(不触任何存量行)、以及 PG 语法明禁加锁的形(**只有**聚合 / GROUP BY / * DISTINCT / 窗口 / 集合运算;`ORDER BY … LIMIT 1` 尾行读在 PG 上**合法**加锁,不属此类——7.60.0 重扫纠)。 * 理由:TiDB 悲观事务的普通 SELECT 仍钉 `start_ts`,`lockScope` 的锁等待窗内别副本的提交看不见 * (**B-022**);InnoDB REPEATABLE READ 下则由本事务的第一条一致性读建 read view —— 两条都是 * **版本相关的副作用**,不是保证。机器门:`test/sql-txn-semantics-gate.test.ts`(G-B,例外闭集 * `INTRA_TXN_PLAIN_READS` 每条带理由);真值神谕:`test/sql-txn-semantics-integration.test.ts`(三引擎)。 * * **④ 引擎事实不进店** —— `SqlDriver.facts` 是**运维读面**(`GET /v1/diagnostics/wiring` 的 `sqlEngine` * 段与 `GET /v1/capabilities` 的 `sql` 位)的料。店里不许出现 `if (engine === …)`:引擎判别在全仓 * 只有连接初始化这一处,机器门 G-A 守着。`SqlDialect`(SQL **文本**方言,两值)与 `engine`(运行期 * 引擎事实,三值)是两条轴,刻意不合成一个三值。 * * ───────────────────────────────────────────────────────────────────────────────────────────────── * REPLICATION PLAYBOOK — collapsing the remaining tidb-/pg- twin pairs ([ref] A12) * ───────────────────────────────────────────────────────────────────────────────────────────────── * Reference collapses: image-bake-store-sql.ts (class + transactions) and checkpoint-store-sql.ts * (class, no transactions). Function-style stores follow task-list-store-sql.ts instead (inline * `dialect === "tidb" ? … : …` ternaries, no driver object) — pick the shape the pair already has. * * STEPS * 1. Read BOTH twins end to end first. List every method and mark it identical / SQL-text-only / * genuinely-divergent-algorithm. Do not start editing before that list exists. * 2. New file `-store-sql.ts`. Move the WHOLE implementation into a `Sql` class (or * `createSql` fn) taking a `SqlDriver`. Keep every doc comment — merge the two headers, * never drop the longer twin's rationale. * 3. Per statement, write BOTH texts at the call site via a 2-arg `q(tidbSql, pgSql)` helper. * Never build one text with a placeholder-numbering loop — the point is that a reviewer sees * the two statements adjacent. * 4. Genuinely-divergent algorithms get an `if (dialect === "tidb") { … } else { … }` block with a * 🔴 comment saying WHY both are correct. Never "unify" them by picking one engine's algorithm. * 5. Old files become thin re-export shims. Class names, ctor arity, exported constants/types and * helper names stay EXACTLY as they were — consumers and tests must not move. * 6. Subclass bindings at the bottom: `class TiDB extends Sql { ctor(pool: MySqlPool, …) }` * and `class Pg extends Sql { ctor(pool: PgPool, …) }`. * 7. Verify: `npm run typecheck` → the pair's unit suites → a HEAD-vs-new SQL parity harness (drive * every method on a recording fake pool for both dialects and diff the (sql, params) sequence; * `git show HEAD:` into a temp `__head_*.ts` sibling, delete it after) → the env-gated * real dual-DB suite before release. Never trust a comment-level review for SQL text. * * WHAT MUST STAY EXPLICIT (never parameterized away) * placeholders (?/$n) · upsert form (ON DUPLICATE vs ON CONFLICT) · JSON casts (CAST(? AS JSON) / * $n::jsonb) · null-safe compare (<=> vs IS NOT DISTINCT FROM) AND its placeholder-arity fallout · * key-column byte semantics (VARBINARY vs COLLATE "C") · isolation/txn verbs · dup-key ATTRIBUTION * (sqlMessage vs err.constraint; the dup-key predicate itself = `sql-errors.ts`) · PG-only protocol-byte handling * (pgSafeJsonStringify / pgProtocolJsonStringify / pgSanitizeText / pgHasUnstorable) · DDL. Each arm's * BEHAVIOR (what pgSafeJsonStringify/pgProtocolJsonStringify actually do to the bytes) stays written out * in `pg-safe-json.ts`, untouched — `dialectJsonEncoder`/`dialectProtocolJsonEncoder` below ([ref] * redesign①) only collect the identical `dialect === "tidb" ? JSON.stringify(v) : pgSafe…(v)` DISPATCH * ternary that five call sites duplicated verbatim; they are not a new abstraction over the encodings * themselves, so this does not violate the rule above. * * WHAT MAY BE PARAMETERIZED * result unwrapping (rows/affected) · transaction plumbing · numeric coercion of BIGINT-as-string * (`Number(...)` is already dialect-safe) · row→record mappers, column lists, pure predicates and * validation (those belong in store-contracts.ts / sql-row-helpers.ts / sql-escape.ts) · table * bindings (`{table, keyCol, valCol}`) when the generated text is byte-identical to the old literal * — prove that with the parity harness, don't assume it. */ import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; export type SqlDialect = "tidb" | "pg"; export type SqlRow = Record; /** Normalized statement result: `rows` for SELECTs, `affected` for INSERT/UPDATE/DELETE. */ export interface SqlResult { rows: SqlRow[]; affected: number; } export interface SqlExec { query(sql: string, params?: unknown[]): Promise; } /** A pooled connection with transaction control (released by the caller in a `finally`). */ export interface SqlTxConn extends SqlExec { /** * The ONE transaction verb (S-131). What its reads see is NOT decided here — it is decided once per * pooled connection at initialization and reported by {@link SqlDriver.facts}. Read the * `@contract txn.read-semantics` block at the top of this file before adding a transaction: the * short version is **intra-transaction collection reads are LOCKING reads**, and a plain `SELECT` * inside a transaction is only legal for this transaction's own writes, for a value/clock * expression, or where PG syntax forbids locking (machine gate: `sql-txn-semantics-gate.test.ts`). */ begin(): Promise; commit(): Promise; rollback(): Promise; release(): void; } /** * The runtime SQL **engine** — a different axis from {@link SqlDialect} (which is SQL *text*). * `"tidb"` and `"innodb"` both speak the MySQL protocol and share every statement in this repo, but * they do not share transaction semantics (TiDB has an optimistic mode and no gap locks; InnoDB has * neither an optimistic mode nor `BEGIN PESSIMISTIC`), so the two words must stay separable. */ export type SqlEngine = "tidb" | "innodb" | "pg"; /** * What connection initialization ESTABLISHED and then READ BACK on this driver's pool (S-131 §1 * 补偿面). Operator read faces: `GET /v1/diagnostics/wiring` → `sqlEngine`, `GET /v1/capabilities` * → `sql`. Store code must never read this (machine gate G-A) — engine discrimination exists in * exactly one place in this repo, and it is `connect()`. */ export interface SqlEngineFacts { readonly engine: SqlEngine; /** `VERSION()` (MySQL protocol) / `server_version` (PG), verbatim. */ readonly version: string; /** The session's transaction isolation, read back AFTER initialization set it. */ readonly isolation: string; /** TiDB's session `tidb_txn_mode`; `null` = this engine has no such indicator (NOT "optimistic"). */ readonly txnMode: "pessimistic" | null; } /** * **数据库自己的钟**(毫秒),两方言各一句 —— 本仓这口钟在 SQL 面的**唯一**铸点([ref] A7 / [ref] / * 合并复审 R2 把 S-280 的 nonce 店并进来)。 * * 用处永远是同一条:**一个时刻量的铸造点与它的判定点必须在同一个钟域**。副本各拿各的墙钟时,两台机器 * 之间的偏差没有任何一条腿强制过上界(本仓 [ref]③ 记过一次真实的 11 分钟 NTP 纠正),于是 * 「A 铸的东西 B 判着还活着」这类承诺就建立在一条没人声明过的假设上。数据库的钟是一组副本**唯一** * 共享的那口钟,所以每一条「谁生谁死」的判决都落到它身上: * · 规则导入票(`permission-rule-store-sql.ts`):`expires_at_ms` 的铸造 / 判定 / 清扫三处; * · 直连门 plan_review 的消费记录(`approval-nonce-store.ts`):受理窗的判定与记录的清扫。 * * 读不到本钟 ⇒ 语句抛错 ⇒ 调用方按不确定失败处置([ref] fail-closed:**绝不**退回本地墙钟放行)。 * * 它住在 driver 这只叶子模块里,是因为「这个引擎怎么说『现在几点』」是**方言事实**,与任何一只店无关; * 放在某一只店里会让下一个需要它的店要么 import 一整只无关的店、要么自己抄一份(抄出来的两份会漂)。 */ export declare const SQL_DB_NOW_MS: { readonly tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)"; readonly pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint"; }; /** * 问一次 {@link SQL_DB_NOW_MS} —— 所有需要「库钟此刻」的店共用这**一份**读法。 * * `subject` 只进错误文案(出问题时运维要知道是哪一张表的判决卡住了)。读不出数 ⇒ **响亮抛**: * 一次「钟读不出来」在授权面上是运维事件,不是一个可以猜的默认值,更不是掉回 `Date.now()` 的理由。 */ export declare function sqlDbNowMs(db: SqlDriver, subject: string): Promise; export interface SqlDriver extends SqlExec { readonly dialect: SqlDialect; /** * 本池上**最近一次连接初始化的结果**(状态按池存,所以共享一条池的二十多只店 driver 报同一份事实)。 * 三种读数,含义各不相同: * · `null`(还没借出过连接)—— 没有可报的东西,**不猜**; * · 事实 —— 最近一次初始化**回读复核**过的值。每条池连接首次借出都重跑一次初始化(记忆化按连接身份), * 所以池在致命错误后重连时,保证是在新连接上**重新建立**的,不是继承来的; * · `null`(借出过、但最近一次初始化被**拒**)—— S-137 ④:保证此刻建立不起来,读面就报缺席。 * 此前这里是「一次成功即长期有效」的快照:连接被驱逐、新连接初始化被拒之后,诊断面仍在陈述一条 * 已经不成立的结构保证。降级由 `establishSession()` 一处完成,并留 loud warn。 */ readonly facts: SqlEngineFacts | null; connect(): Promise; } /** MySQL-protocol driver (TiDB / MySQL / MariaDB — `dialect` is the SQL TEXT axis, `facts.engine` the * runtime one). Transaction control uses mysql2's NATIVE methods — the historical call shape the * fake-pool unit suites script around. */ export declare function mysqlDriver(pool: MySqlPool): SqlDriver; /** PostgreSQL driver. Transaction control is issued as STATEMENTS (node-pg has no native verbs). */ export declare function pgDriver(pool: PgPool): SqlDriver; /** [ref] redesign① — the "safe" JSON-column encoder DISPATCH (run-store/outcome-ledger's identical * ternary, collected): TiDB stores `JSON.stringify` verbatim; PG goes through `pgSafeJsonStringify`'s * protocol-byte defenses (a raw NUL/lone-surrogate would otherwise throw mid-write). Returns a 1-arg * encoder; call sites invoke `dialectJsonEncoder(this.db.dialect)(value)` inline — the dispatch is the * collected thing, not a cached field. */ export declare const dialectJsonEncoder: (dialect: SqlDialect) => ((v: unknown) => string); /** [ref] redesign① — the "protocol" JSON-column encoder DISPATCH (approval-store/checkpoint-store's * identical ternary, collected): TiDB stores `JSON.stringify` verbatim (the `label` arg is ignored on that * arm — TiDB has no protocol-byte envelope to name); PG goes through `pgProtocolJsonStringify(v, label)`, * whose `label` names the field in its error text on an unstorable-byte reject. */ export declare const dialectProtocolJsonEncoder: (dialect: SqlDialect) => ((v: unknown, label: string) => string); //# sourceMappingURL=sql-driver.d.ts.map