/** * device lane 四表持久层(design/device-executor-lane-v2 §6)—— `DeviceStore` 双方言实现 * (`TiDBDeviceStore` / `PgDeviceStore`,SINGLE-FILE DUAL-DIALECT,[ref] A12 形:一个 * `SqlDeviceStore` 接 `SqlDriver`,两个薄 ctor 子类落方言绑定)。语义真源 = `../device-store.js` * (类型 + 闭集 + InMemory 参照实现),准入链 = `../device-enrollment.js`。 * * 四张表: * · `devices` —— 设备注册簿 + presence 投影(单设备单活连 ⇒ 1:1,省一次 join,§6 注①) * · `device_enroll_tokens` —— 一次性 enrollment 凭据(明文一现,落库只有 sha256) * · `device_sessions` —— **根会话**↔设备绑定(resume 路由真源,§4.5) * · `device_audit` —— 安全轴事件审计(§4.8 标「审计行」的 8 个 event) * 刻意**没有** `device_instructions` 表:指令 pending 表是内存态(§6 注③——指令比 run 短命,落库只会 * 造「表里 pending、run 已死」的僵尸行;at-most-once 靠 §5.3 投递边界,不靠指令落库)。 * * ── CAS 铁则(与 approval-ask-store-sql.ts 头注同源,写在这里防遗忘)───────────────────────────── * 1. 一切转移带 `WHERE <前态>`,赢 = affected 恰为 1。禁读-改-写两步(TOCTOU 窗口)。 * 2. 🔴 MySQL/TiDB `affectedRows` 坑:同值 UPDATE(SET 的新值与当前列逐字节相同)affectedRows=0 * ——**所有** CAS UPDATE 因此必须带一个恒变列(`rev = rev + 1`)。本文件每一条 CAS UPDATE 都带。 * 在本店这条尤其致命:心跳续租在「同一毫秒内重复心跳」时 SET 的值可能与当前值相同,没有 rev * 就会被判成「本连接已失权」⇒ 设备被自己的心跳踢下线。 * 3. 事务:`SqlDriver.connect()` → `begin()` → `query()` → `commit()`,失败 `rollback()`。读语义 * (隔离级 / TiDB 悲观模式 / 事务内集合读必须锁读)的判据只有一处:`sql-driver.ts` 头注的 * {@link SqlTxConn.begin} `@contract txn.read-semantics`。**失败臂的回读必须走当前这条 `conn`** * (approval 店复审 F1 的真缺陷:走池级 query 会与自己持有的连接死锁),且**先 rollback 再读** * (回滚后本连接回到 autocommit,那条 SELECT 自成事务 ⇒ 读到最新已提交视图)。 * * ── 方言差异(A12 doctrine:每处显式写在调用点)──────────────────────────────────────────────────── * - `?`(位置序) vs `$n`(显式编号);动态 WHERE(`listAudit`)靠 `ph(dialect, n)` 生成。 * - `INSERT IGNORE` vs `ON CONFLICT (pk) DO NOTHING`(`bindSession` 的首绑 CAS)。 * - `AUTO_INCREMENT` vs `BIGSERIAL`;`JSON` vs `JSONB`(+ `$n::jsonb` 显式 cast)。 * - `KEY …` 内联 vs 独立 `CREATE INDEX IF NOT EXISTS`。 * - 回读名单:两方言**同走**同事务 `UPDATE` 后 `SELECT`(PG 本可用 RETURNING,为保单一读路径不用—— * 见世代回读行注;本文件现行 SQL 零 RETURNING)。 * - 时长运算:`NOW(3) + INTERVAL ? SECOND` vs `now() + ($n::double precision * INTERVAL '1 second')`。 * - 键列字节等价:MySQL `VARBINARY` vs PG `VARCHAR … COLLATE "C"`(理由见 ../device-store.ts 头注; * `utf8mb4_bin` **不够**,它仍是 PAD SPACE collation)。 * - VARBINARY 的读回:mysql2 给 **Buffer**,PG 给 string ⇒ 一律过 `bufToStr`(mailbox 真库坑同解)。 * * ── 时间列的存取形(§4.6 task-R3-F18「时间权威 = DB」的落地)────────────────────────────────────── * 列是原生 `DATETIME(3)` / `TIMESTAMPTZ(3)`,但**跨边界一律是 epoch 毫秒**:写侧送**时长秒**由 SQL 从 * DB 的 now 起算,读侧在 SELECT 里就转成 `*_ms`。 * 🔴 为什么不让驱动把时间戳解成 JS `Date` 再比:①mysql2 按**进程**时区解析 DATETIME,DB 会话时区与 * Node 时区不同即整体偏移几小时(而这条偏移落在「租约还有效吗」上就是双主);②`Date.now()` 一旦进入 * 判据,副本时钟落后即越租。两条都在 SELECT 表达式里就地关掉。 */ import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { type SqlDriver } from "./sql-driver.js"; import { type AcquireConnectionInput, type AcquireConnectionResult, type AppendAuditInput, type BindSessionInput, type BindSessionResult, type ConsumeEnrollTokenInput, type ConsumeEnrollTokenResult, type DeviceAdmissionView, type DeviceAuditRow, type DeviceEnrollTokenRow, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type HeartbeatConnectionInput, type IssueEnrollTokenInput, type ListAuditFilter, type RebindSessionInput, type RebindSessionResult, type RevokeDeviceInput, type RevokeDeviceResult } from "../device-store.js"; import { type IndexSpec } from "./ensure-index.js"; /** * ── 命名(**落库一律单数**,与设计稿的复数形不同)────────────────────────────────────────────────── * design v2 §6 的 DDL 写的是 `devices` / `device_sessions` / `device_enrollment_tokens`(复数),但本仓有一条 * 常驻机器门:`test/schema-naming-invariants.test.ts` 判据① —— 建表的表名必须在 `TABLE_VOCABULARY` 闭集里 * **且词表全是单数形**。[ref] 车1 的 `retention_hold` / `retention_tombstone` 是同一处先例(那份设计稿同样 * 写的复数,落库单数)。所以四张表落库名为 `device` / `device_session` / `device_enroll_token` / * `device_audit`,**表结构与设计稿逐列一致**,只有名字随仓规。新表 = 先进词表再建。 */ export declare const DEVICES_TABLE = "device"; export declare const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token"; export declare const DEVICE_SESSIONS_TABLE = "device_session"; export declare const DEVICE_AUDIT_TABLE = "device_audit"; export declare const TIDB_DEVICE_STATEMENTS: readonly string[]; /** {@link TIDB_DEVICE_STATEMENTS} 的遍历壳(集成测试用;生产装配由中央 ensureSchema 展开同一份数组)。 */ export declare function ensureTiDBDeviceSchema(pool: MySqlPool): Promise; type PgQuery = (sql: string, params?: unknown[]) => Promise; /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgDeviceSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */ export declare const DEVICE_INDEXES: readonly IndexSpec[]; export declare function ensurePgDeviceSchema(q: PgQuery): Promise; /** * O4 升级前置探针 —— **拒启,不是 warn**([ref]/[ref]/[ref] `assert*Schema` 族先例):`device_audit` 的 * 事件闭集加了第 9 员 `session_rebound`,而 `CREATE TABLE IF NOT EXISTS` 对存量表是空操作 ⇒ 一台没 * 重建表就升上来的 **PG** 部署(PG 强制 CHECK;TiDB 默认 `tidb_enable_check_constraint=OFF` 不执行, * 见 AUDIT_EVENT_CHECK 头注的真库实测),每一次换绑赢 CAS 都在**审计 INSERT** 上撞 * `ck_device_audit_event` ⇒ 整事务回滚,换绑动词在运行期恒 500 —— 而启动与健康检查全绿。 * * 判据 = **能力探测**(begin → 插一行 `session_rebound` → rollback,探针行永不落地),不读 * information_schema 元数据(memory `capability-detection-not-impl-names`:判「装没装」用能力面 —— * TiDB 上 CHECK 元数据在而不执行,读元数据会把能写的表误判成不能写)。只有**可证**的 CHECK 拒 * ({@link isCheckViolationError})才换成指路;其余错误原样上抛([ref] 两轮收窄的同一条:一句指向 * 手工 DDL 的指路比它要挡的缺陷更贵)。 * * 🔴 指路刻意**不是** `DROP TABLE`(与兄弟探针不同):`device_audit` 是安全轴审计史,为扩一个词表 * 销毁全部审计行不成比例。本仓不发 `ALTER TABLE`(SCHEMA POLICY),但闭集执法的权威本就在应用层 * (读侧 `parseDeviceAuditEvent`),CHECK 只是纵深 —— 所以给运维的是**手工**约束级重建语句 * (drop + 按当前闭集 add),历史一行不丢,纵深回到当前词表。 * * 调用点:`boot/stores.ts`,**只在 device 车道真会装配时探**(`config.remoteExec?.provider === * "device"`,与 main.ts 的 `createDeviceLane` 谓词同源)——非 device 部署零审计写点,为它拒启是纯误伤 * ([ref] round2 的同一条收窄)。 */ export declare function assertDeviceAuditRebindEventSchema(db: SqlDriver): Promise; export declare class SqlDeviceStore implements DeviceStore { private readonly db; constructor(db: SqlDriver); private q; private json; issueEnrollToken(input: IssueEnrollTokenInput): Promise; getEnrollToken(tokenHash: string): Promise; /** * §6 注册协议 —— **单事务**:消费 CAS + INSERT devices + 审计行一起提交。 * * 谓词四件(缺一即 affected=0):`consumed_at IS NULL`(一次性)、`expires_at > NOW(3)`(TTL,DB 钟)、 * `target_tenant/target_subject` 等值(绑定 principal)。affected=0 的**理由**靠回滚后的回读判别 —— * 顺序刻意是「先 principal 后消费态」:token 不是你的就连「它被没被用过」都不告诉你。 */ consumeEnrollToken(input: ConsumeEnrollTokenInput): Promise; /** 事务内/持连接期间的设备回读(见 consumeEnrollToken 的 F2 注:此期间**禁**走池级读)。 */ private readDeviceOn; readDeviceForAdmission(deviceId: string, owner: DeviceOwner): Promise; getDeviceUnscoped(deviceId: string): Promise; listDevicesByOwner(owner: DeviceOwner): Promise; /** O4/S-4 operator 全量(消费点必须先过 explicit operator 门 —— 契约注见 `DeviceStore`)。 */ listDevicesUnscoped(): Promise; /** * 吊销 = 终态 CAS(`status='active'` 前态)+ **租约作废 + 世代推进**(§8-R4 在途语义): * 世代一推,在途连接的每一次带世代谓词的写(心跳/结果帧入账)当场影响 0 行 ⇒ 设备侧立即失权。 */ revokeDevice(input: RevokeDeviceInput): Promise; /** * 连接建立 = 带 expiry 的租约 CAS。谓词 `lease_expires_at IS NULL OR <= NOW(3)`:**活租约恒不被撕** * (§5.2 单活连不变量;`DEVICE_SUPERSEDE=stale_only` 的语义就在这一句),过期租约才可被新连夺取。 * 赢家 `conn_generation` 单调 +1,后续所有写都带这个世代谓词。 */ acquireConnection(input: AcquireConnectionInput): Promise; /** * 心跳续租 —— 谓词 = **世代等值 + active**。影响 0 行 = 本连接已失权(被顶替 / 被吊销)⇒ 调用方 * 主动断开(§4.6 步 2)。 * * 🔴 允许续一条**已过期但没被别人夺走**的租约(generation 未变 ⇒ 排他性仍在本连接手上);夺租者 * 一旦成功,generation 就变了,这条心跳当场 0 行。`rev = rev + 1` 是 CAS 铁则②:同毫秒重复心跳的 * SET 值可能与当前逐字节相同,没有它 MySQL 会把「成功」报成 affectedRows=0。 */ heartbeatConnection(input: HeartbeatConnectionInput): Promise; releaseConnection(deviceId: string, generation: number): Promise; /** * §4.3.2 首绑写协议 —— **单事务**:设备校验(active ∧ owner)与首绑 CAS 插入在一个事务里。 * * 🔴 `FOR UPDATE`(设备行):设备行必须是**当前读**。普通读下,一次与吊销并发的绑定会读到「还 active」 * 的旧版本,于是把 session 绑到一台**刚被吊销**的设备上(double-admit 形)。锁读在两引擎都是当前读 —— * 前提写在 {@link SqlTxConn.begin} 的 @contract txn.read-semantics。这把锁在 `devices` 表上,与下面 * 被插入的 `device_session` 行不是同一行。 * * 🔴 形 B · cas-insert(S-127 / B-013)—— 本站点是全仓 `INSERT IGNORE` 的**正当用法**: * `INSERT IGNORE` / `ON CONFLICT DO NOTHING` 的 affected(1/0)是引擎对「谁赢」的**权威**回答,而 * `ON DUPLICATE KEY UPDATE pk = pk` 的 affected 新行/既有行都是 1、分不出赢家 ⇒ 这里换不得动词。 * 代价必须付清:**同一事务此后不得对被插入的那一行做加锁读**。InnoDB 下撞重复键的 IGNORE 在既有行 * 取 **S 锁**,随后的 `SELECT … FOR UPDATE` 要升 X;两个并发首绑各持一把 S 互等 ⇒ ER_LOCK_DEADLOCK * (TiDB 悲观事务无共享锁,这一形在它上面恒绿 —— 双库门看不见)。于是两条路径分家: * · **赢家**(affected === 1):行是自己刚插的,回执用**入参 + 插入时刻**直接构造,零回读。插入时刻 * 仍由 **DB 时钟**给出(插入前一句 `SELECT NOW(3)` / `SELECT now()`,再把它当参数写进 INSERT)—— * 时间权威留在 DB,回执里的 `boundAtMs` 与落库值**逐字节同一个**,不是应用侧的近似。 * · **输家**(affected === 0):**结束当前事务**,在**新事务**里 `SELECT … FOR UPDATE` 观察。那一刻 * 赢家已提交,读到的是一把干净的 X 锁(无升级);判 同 deviceId = 幂等成功,异 deviceId = * `conflict`(无未定义态)。设备状态门在输家路径上已经过完,不需要重来。 */ bindSession(input: BindSessionInput): Promise; /** * 首绑 CAS 的**输家观察**(形 B 的后半,S-127)—— 独立事务,与那次 `INSERT IGNORE` 无锁关系。 * * 🔴 `FOR UPDATE`(**真库红修**,2026-08-28 双库跑当场抓到,mock 抓不到):TiDB 的悲观事务里**普通 * SELECT 仍是快照读**(读的是本事务 start_ts 的视图),只有加锁读才是当前读。放在新事务里读同样要 * 这把锁 —— 新事务的 start_ts 虽在赢家提交之后,但「读的是不是当前」这条判据不该靠时序巧合来满足。 * 与旧形的**唯一**差别:此刻我们手上没有那一行的 S 锁,于是这把 X 是**直接取**的,不是升级 ⇒ 两个 * 并发输家不会互等(B-013 的死锁形正是那次升级)。 * * 行不在 = 赢家的绑定在这一瞬被 `unbindSession` 删了(真实可能,不是不变量破)。这仍然是**响亮**的: * 首绑既没成、也说不出冲突对象,折成任何一种业务回执都是撒谎([ref]:未知失败不伪装成正常拒绝)。 */ private observeLostBind; /** * O4 显式换绑(2026-08-30 v2 稿 §2)—— **单事务**:目标设备门(当前读)+ 幂等短路 + 前态 CAS + * `session_rebound` 审计行一起提交。三形语义 oracle = `InMemoryDeviceStore.rebindSession`。 * * 🔴 两次 `FOR UPDATE`(bindSession 的同一条真库红的射程;当前读的前提见 * {@link SqlTxConn.begin} 的 @contract txn.read-semantics): * · 设备行必须当前读 —— 快照读会把「与吊销并发的换绑」绑到一台刚被吊销的设备上(double-admit 形); * · 绑定行必须当前读且**持锁到提交** —— 两个并发换绑(同 expectedRev)在锁上串行:赢家 rev+1 提交, * 输家的锁读看到新 rev ⇒ 走 `rev_conflict` 携当前行(单赢家,无未定义态)。锁读判等之后,CAS * UPDATE(`rev = ?` 谓词 + `rev+1` 恒变列)在持锁下不可能 0 行 —— 0 行即驱动/引擎异常,响亮抛, * 不折成业务分支([ref]:未知失败不伪装成正常拒绝)。 */ rebindSession(input: RebindSessionInput): Promise; getSessionBinding(rootSessionId: string): Promise; deleteByRootSession(rootSessionId: string): Promise; appendAudit(input: AppendAuditInput): Promise; /** 审计写的**唯一**语句(池级与事务内共用;`SqlExec` 是两者的公共面)。 */ private insertAuditOn; listAudit(filter?: ListAuditFilter): Promise; } /** 方言绑定薄壳(A12 形:ctor 收池,行为全在基类)。 */ export declare class TiDBDeviceStore extends SqlDeviceStore { constructor(pool: MySqlPool); } export declare class PgDeviceStore extends SqlDeviceStore { constructor(pool: PgPool); } export {}; //# sourceMappingURL=device-store-sql.d.ts.map