@sema-agent/server 7.59.0 → 7.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +4 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +8 -1
  4. package/dist/boot/stores.d.ts +1 -0
  5. package/dist/boot/stores.js +4 -1
  6. package/dist/http/routes/approvals-assistant.js +3 -0
  7. package/dist/http/routes/capabilities.js +2 -0
  8. package/dist/http/routes/diagnostics.js +1 -0
  9. package/dist/http/server.d.ts +6 -0
  10. package/dist/main.js +10 -4
  11. package/dist/plugins/approval-ask-store-sql.d.ts +9 -9
  12. package/dist/plugins/approval-ask-store-sql.js +1 -1
  13. package/dist/plugins/device-store-sql.d.ts +11 -7
  14. package/dist/plugins/device-store-sql.js +6 -6
  15. package/dist/plugins/file-history-store-sql.d.ts +15 -3
  16. package/dist/plugins/file-history-store-sql.js +32 -29
  17. package/dist/plugins/image-bake-store-sql.js +1 -1
  18. package/dist/plugins/permission-rule-store-file.d.ts +4 -4
  19. package/dist/plugins/permission-rule-store-file.js +4 -4
  20. package/dist/plugins/permission-rule-store-sql.d.ts +15 -11
  21. package/dist/plugins/permission-rule-store-sql.js +7 -11
  22. package/dist/plugins/retention-lane-store-sql.d.ts +2 -2
  23. package/dist/plugins/retention-store-sql.d.ts +4 -12
  24. package/dist/plugins/retention-store-sql.js +4 -0
  25. package/dist/plugins/session-policy-store-sql.d.ts +9 -2
  26. package/dist/plugins/shared-memory-store-sql.d.ts +25 -19
  27. package/dist/plugins/shared-memory-store-sql.js +17 -15
  28. package/dist/plugins/sql-driver.d.ts +78 -26
  29. package/dist/plugins/sql-driver.js +97 -14
  30. package/dist/plugins/store-backend.d.ts +6 -2
  31. package/dist/rules-consent.d.ts +54 -12
  32. package/dist/rules-consent.js +4 -4
  33. package/dist/sql-engine-posture.d.ts +49 -0
  34. package/dist/sql-engine-posture.js +13 -0
  35. package/dist/tool-approval.d.ts +18 -0
  36. package/dist/tool-approval.js +1 -0
  37. package/dist/trace/core-keyset-guard.d.ts +2 -2
  38. package/dist/trace/project.d.ts +25 -0
  39. package/dist/trace/project.js +26 -0
  40. package/package.json +2 -2
@@ -1,10 +1,10 @@
1
1
  import { z } from "zod";
2
- import { type PermissionRuleWriter, type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
2
+ import { type PermissionRuleWriter, type DurableRulePartition, type DurableRulePartitionProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
3
3
  import type { Pool as MySqlPool } from "mysql2/promise";
4
4
  import type { PgQueryFn } from "./pg-query.js";
5
5
  import { type SqlDriver } from "./sql-driver.js";
6
6
  /**
7
- * 一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
7
+ * 一只 durable 分区的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
8
8
  *
9
9
  * 与 core 的 `writerOf` 的**唯一**区别是判据更严:core 只看 `apply`/`nextDot` 两个方法在不在(它服务的是
10
10
  * 引擎自己的兑付腿),本函数还要求 `readRaw` —— 本仓的消费点(测试/取证)读的是**完整**写面,少一个方法
@@ -13,7 +13,7 @@ import { type SqlDriver } from "./sql-driver.js";
13
13
  * 结构检查而不是断言:没有这个函数,每个调用点都会各写一次 `as unknown as {…}`,那既是三处宽松断言,
14
14
  * 也是三份会各自漂的形状假设。
15
15
  */
16
- export declare function writerOfSqlRuleStore(store: PermissionRuleStore): PermissionRuleWriter | undefined;
16
+ export declare function writerOfSqlRuleStore(store: DurableRulePartition): PermissionRuleWriter | undefined;
17
17
  /**
18
18
  * 382P1(core 7.0.0 [ref] §4.3)—— 这是 **DURABLE 二员面**的 at-rest 形:`{global, project-with-root}`,
19
19
  * 与 core `isValidDurableScope` 逐格等价(`test/permission-rule-durable-face-pin.test.ts` 探针表钉住)。
@@ -127,17 +127,20 @@ export declare function buildRuleBucketChecksum(body: {
127
127
  observedVector?: RuleSyncFrontier;
128
128
  }): string;
129
129
  /**
130
- * SQL 双方言的 {@link PermissionRuleStoreProvider}
130
+ * SQL 双方言的 {@link DurableRulePartitionProvider} —— [ref] 里的 **durable 分区后端**
131
+ * (`user` + `project` 两源的 OR-Set CRDT),**不是**引擎接缝本身:
132
+ * `RunnerDeps.permissionRuleStore` 是 `createPermissionRuleStoreProvider({ durable })` 合成出来的
133
+ * 统一店,合成点在 `main.ts` 一处(分区构成是**部署**决定,不是 backend 决定)。
131
134
  *
132
- * `forPrincipal(undefined)` 恒解析成**零规则**店(core 硬条款:未鉴权任务解析到零规则而不是一只共享桶
133
- * —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
135
+ * `forPrincipal(undefined)` 恒解析成**零规则**分区(core 硬条款:未鉴权任务解析到零规则而不是一只
136
+ * 共享桶 —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
134
137
  */
135
- export declare class SqlPermissionRuleStoreProvider implements PermissionRuleStoreProvider {
138
+ export declare class SqlDurableRulePartitionProvider implements DurableRulePartitionProvider {
136
139
  private readonly db;
137
140
  private readonly now;
138
141
  constructor(db: SqlDriver, now?: () => number);
139
- forPrincipal(principal: string | undefined): PermissionRuleStore;
140
- forLocalOwner(): PermissionRuleStore;
142
+ forPrincipal(principal: string | undefined): DurableRulePartition;
143
+ forLocalOwner(): DurableRulePartition;
141
144
  }
142
145
  /**
143
146
  * durable 审批记录。CAS **按 rev**,不按 state —— core 的原话:批记录的第二个候选会 redeemed→redeemed,
@@ -428,9 +431,10 @@ export declare const RULE_PENDING_APPROVAL_RETENTION_MS: number;
428
431
  * 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
429
432
  */
430
433
  export declare const RULE_REAP_BATCH = 500;
431
- /** 三个 SQL 店的一次性装配束(one driver, three faces) */
434
+ /** 三个 SQL 面的一次性装配束(one driver, three faces)。`durable` = [ref] 的 durable 分区后端;
435
+ * 引擎接缝上的统一店由 `main.ts` 用 `createPermissionRuleStoreProvider({ durable })` 合成一次。 */
432
436
  export interface PermissionRuleStores {
433
- provider: SqlPermissionRuleStoreProvider;
437
+ durable: SqlDurableRulePartitionProvider;
434
438
  approvals: SqlRuleApprovalRecordStore;
435
439
  tickets: SqlRuleImportTicketStore;
436
440
  /** [ref] §3 —— boot 期休眠行审计的**窄读口**:库里已有几只桶(`permission_rule` 一行一桶)。
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
3
+ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
4
4
  import { dialectProtocolJsonEncoder } from "./sql-driver.js";
5
5
  import { isMissingColumnError } from "./sql-errors.js";
6
6
  import { canonicalJson } from "../canonical-json.js";
@@ -332,7 +332,7 @@ function parseColumn(schema, raw, column, ownerKey) {
332
332
  throw new Error(`permission_rule.${column} for bucket ${ownerKey} does not carry a readable shape: ${parsed.error.message}`);
333
333
  return parsed.data;
334
334
  }
335
- class SqlPermissionRuleStore {
335
+ class SqlDurableRulePartition {
336
336
  db;
337
337
  owner;
338
338
  now;
@@ -490,7 +490,7 @@ class SqlPermissionRuleStore {
490
490
  },
491
491
  };
492
492
  }
493
- export class SqlPermissionRuleStoreProvider {
493
+ export class SqlDurableRulePartitionProvider {
494
494
  db;
495
495
  now;
496
496
  constructor(db, now = Date.now) {
@@ -499,17 +499,13 @@ export class SqlPermissionRuleStoreProvider {
499
499
  }
500
500
  forPrincipal(principal) {
501
501
  if (typeof principal !== "string" || principal === "")
502
- return ZERO_RULE_STORE;
503
- return new SqlPermissionRuleStore(this.db, { kind: "principal", principal }, this.now);
502
+ return EMPTY_DURABLE_RULE_PARTITION;
503
+ return new SqlDurableRulePartition(this.db, { kind: "principal", principal }, this.now);
504
504
  }
505
505
  forLocalOwner() {
506
- return new SqlPermissionRuleStore(this.db, { kind: "local-owner" }, this.now);
506
+ return new SqlDurableRulePartition(this.db, { kind: "local-owner" }, this.now);
507
507
  }
508
508
  }
509
- const ZERO_RULE_STORE = {
510
- list: async () => ({ rules: [], tombstones: [], rev: 0 }),
511
- durability: "process-local",
512
- };
513
509
  export class SqlRuleApprovalRecordStore {
514
510
  db;
515
511
  now;
@@ -744,7 +740,7 @@ export const RULE_REAP_BATCH = 500;
744
740
  export function createSqlPermissionRuleStores(db, now) {
745
741
  const q = (tidb, pg) => (db.dialect === "tidb" ? tidb : pg);
746
742
  return {
747
- provider: new SqlPermissionRuleStoreProvider(db, now),
743
+ durable: new SqlDurableRulePartitionProvider(db, now),
748
744
  approvals: new SqlRuleApprovalRecordStore(db, now),
749
745
  tickets: new SqlRuleImportTicketStore(db),
750
746
  reapExpired: async (nowMs) => {
@@ -21,8 +21,8 @@
21
21
  * - upsert:`ON DUPLICATE KEY UPDATE col = VALUES(col)` vs `ON CONFLICT (…) DO UPDATE SET col = EXCLUDED.col`;
22
22
  * - 条件自增:`IF(cond, 0, 1)` vs `CASE WHEN cond THEN 0 ELSE 1 END`(本文件不用 —— 见 `acquire` 顶注:
23
23
  * 自增判据在**应用层**算,因为它同时要产出返回给调用方的 token);
24
- * - 事务动词:平凡 `begin()`(理由逐字见车1 `SqlRetentionStore.tx` 的头注:`BEGIN PESSIMISTIC` 是 TiDB
25
- * 专有语法,发到普通 MySQL 上是语法错,而发车门的 canonical `TIDB_TEST_URL` 指向的正是普通 MySQL)。
24
+ * - 事务动词:只有 `begin()` 一个;读语义的判据在 {@link SqlTxConn.begin} `@contract txn.read-semantics`
25
+ * (本店唯一的读是 `acquire` 的租约行 `FOR UPDATE` —— 锁读在两引擎都是当前读)。
26
26
  * - schema 属主:**四张表全部由车1 的 `retention-store-sql.ts` 建**(`TIDB_RETENTION_STATEMENTS` /
27
27
  * `PG_RETENTION_SCHEMA`)。本文件一条 DDL 都不发 —— 「一张表恰好一个 DDL 属主」(db-gate.ts 顶注
28
28
  * 的实测结论:第二个属主的 `IF NOT EXISTS` 会静默吞掉列/索引差异)。
@@ -36,8 +36,7 @@
36
36
  * - JSON 提取(workflow_run 的 originatingSessionId):`JSON_UNQUOTE(JSON_EXTRACT(run,'$.x'))` vs
37
37
  * `run::json->>'x'`
38
38
  * - 表级 `COLLATE utf8mb4_bin` vs 逐列 `COLLATE "C"`(`isolation-key-collation` 门执法)
39
- * - 事务动词:两条方言都是平凡 `begin()` —— **不是** `beginPessimistic()`(它是 TiDB 专有语法,发到
40
- * 普通 MySQL 上语法错;完整判据见 `SqlRetentionStore.tx` 的头注)
39
+ * - 事务动词:只有 `begin()` 一个;读语义的判据在 {@link SqlTxConn.begin} `@contract txn.read-semantics`
41
40
  * - schema 属主:MySQL DDL 由本文件导出、展开进 `tidb-pool.ts` 的 `SCHEMA_STATEMENTS`;PG DDL 由本文件的
42
41
  * `ensurePgRetentionSchema` 导出、由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合(与 leader-run 同姿势)。
43
42
  *
@@ -191,16 +190,9 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
191
190
  params: unknown[];
192
191
  };
193
192
  /**
194
- * 事务壳 —— **平凡 `begin()`,不是 `beginPessimistic()`**(真库实测定谳,不是口味):
195
- *
196
- * · MySQL-protocol 腿:`BEGIN PESSIMISTIC` 是 **TiDB 专有语法**,发到普通 MySQL 上是语法错
197
- * (本机 MySQL 9.7 实测 `ER_PARSE_ERROR near 'PESSIMISTIC'`)—— 而发车门 2.6 的 canonical
198
- * `TIDB_TEST_URL` 默认指向的正是一台**普通 MySQL**。用那条动词等于让本店在标准工作流上必红。
199
- * · 语义上也不需要它:本店要的只是 `SELECT … FOR UPDATE` 是**当前读**。InnoDB 的加锁读在 RR 下本来
200
- * 就读最新已提交版本;TiDB 自 3.0.8 起事务默认就是悲观模式,同样是当前读。显式动词只在「部署把
201
- * TiDB 改回乐观模式」这一种配置下才多出保障,而那种部署会同时打破仓内其它 FOR UPDATE 腿
202
- * (run-store 的 task_active 门、checkpoint 的 approval 门),不是本店能独自承担的假设。
203
- * · PG:READ COMMITTED 逐语句取新快照,平凡 `BEGIN` 即所需。
193
+ * 事务壳。本店要的是「`SELECT FOR UPDATE` 是**当前读**」—— 判据不在这里,在
194
+ * {@link SqlTxConn.begin} 的 `@contract txn.read-semantics`(隔离级与 TiDB 悲观模式是**连接的事实**,
195
+ * 每条池连接初始化时钉一次,设不上就拒启)。
204
196
  *
205
197
  * `protected` = 真库套件的语句录制注入点(判据「hold 锁读是事务首条 SQL」靠它自证)。
206
198
  */
@@ -367,6 +367,10 @@ export class SqlRetentionStore {
367
367
  const s = set("owner_session_id", 1);
368
368
  await conn.query(`DELETE FROM tool_result WHERE ${s.sql}`, s.params);
369
369
  }
370
+ {
371
+ const s = set("scope", 1);
372
+ await conn.query(`SELECT scope FROM file_history_scope WHERE ${s.sql} ORDER BY scope FOR UPDATE`, s.params);
373
+ }
370
374
  for (const [table, col] of [
371
375
  ["resume_anchor", "session_id"],
372
376
  ["approval_exemption", "session_id"],
@@ -29,8 +29,15 @@ export declare class SqlSessionPolicyStore implements SessionPolicyStore {
29
29
  * Return shape aligned to core 1.423's optional interface seam (`Promise<void>`) — the affectedRows count was
30
30
  * incidental and unconsumed; the integration test asserts the post-state (listBySession empty) instead. */
31
31
  deleteBySession(sessionId: string): Promise<void>;
32
- /** `begin()` (NOT `beginPessimistic()`) — matches both twins' original call shape: TiDB's native
33
- * `beginTransaction()` (optimistic), PG's plain `BEGIN`. Normalized by SqlDriver's shared tx plumbing. */
32
+ /**
33
+ * The store's one read-check-write transaction. Its load-bearing read is line ~106's
34
+ * `SELECT rules, rev … FOR UPDATE` — a LOCKING read, therefore a CURRENT read on both engines, which is
35
+ * what makes the loosen-diff decision see the prior it is diffing against. That "therefore" is not a
36
+ * property of the verb: it holds because {@link SqlTxConn.begin}'s `@contract txn.read-semantics` pins
37
+ * TiDB to pessimistic transactions at connection init (an optimistic session would read `FOR UPDATE` off
38
+ * the start-ts snapshot). Second line of defence, unchanged: the `rev` re-check plus the first write's
39
+ * PK race.
40
+ */
34
41
  private tx;
35
42
  }
36
43
  /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
@@ -54,18 +54,18 @@
54
54
  * live library both re-scans the prefix on every page and skips rows when a document is inserted mid-walk.
55
55
  *
56
56
  * ─────────────────────────────────────────────────────────────────────────────────────────────────
57
- * 事务与锁 —— 为什么用 `begin()` 而不是 `beginPessimistic()`
57
+ * 事务与锁
58
58
  * ─────────────────────────────────────────────────────────────────────────────────────────────────
59
- * 三个写方法(putStore / putDocument / deleteStore)都是「先读注册行、再按结果写」的形状,并且都用
60
- * `SELECT … FOR UPDATE` 拿同一把行锁,于是彼此串行。事务动词取**普通** `begin()`:
61
- * · `BEGIN PESSIMISTIC` TiDB 专有语法,在 MySQL / MariaDB 上是**语法错误**——而本仓 `kind: "mysql"`
62
- * 按定义覆盖任何 MySQL 协议服务端(本机验收台就是真 MySQL 9.7)。用它等于让这个店在多数目标上直接
63
- * 不可用,那比它要防的那道窄窗口严重得多;
64
- * · MySQL InnoDB 的加锁读(`FOR UPDATE`)在 REPEATABLE READ 下**恒是 current read**,读到的是最新已提交
65
- * 版本,并且对不存在的键取 gap/next-key ⇒ 并发登记天然串行。TiDB 自 v5 起默认就是悲观事务,同理;
66
- * · 唯一残留的窄窗口是「TiDB 集群被显式配成乐观模式」。那一格由**第二道防线**兜底:`store_id` 的主键
67
- * 唯一性是真正的裁决者,撞键被 {@link isDupKey} 翻成一句响亮的拒绝( putStore)。也就是说即便快照
68
- * 读陈旧,结果也只会是「一方被响亮拒绝」,绝不会是一次静默的换属主。
59
+ * **四个**写方法(putStore / putDocument / deleteDocument / deleteStore)都是「先读注册行、再按结果写」
60
+ * 的形状,并且都用 `SELECT … FOR UPDATE` 拿同一把行锁,于是彼此串行。判据只有一处:
61
+ * {@link SqlTxConn.begin} `@contract txn.read-semantics` —— 加锁读在两引擎都是当前读,而「当前读」
62
+ * 的前提(TiDB 悲观模式、MySQL 会话级 REPEATABLE READ)是**连接初始化**的结构保证,不是服务器默认值。
63
+ *
64
+ * 本店的**第二道防线**照旧在:`store_id` 的主键唯一性才是最终裁决者,撞键被 {@link isDupKey} 翻成一句
65
+ * 响亮的拒绝(见 putStore)—— 即便某一天读语义出岔,结果也只会是「一方被响亮拒绝」,绝不会是一次静默
66
+ * 的换属主。⚠️ 另一处不可混线的分歧:InnoDB 的加锁读对**不存在的键**取 gap/next-key 锁,TiDB 悲观事务
67
+ * **没有** gap (retention-store 的「先 ON DUPLICATE 造行、再 FOR UPDATE 锁行」就是为它而写)
68
+ * 「加锁读=当前读」两引擎同真,「gap 锁」只有 InnoDB 有 —— 两条性质不共享一个「同理」。
69
69
  */
70
70
  import type { Pool as MySqlPool } from "mysql2/promise";
71
71
  import type { Pool as PgPool } from "pg";
@@ -154,15 +154,21 @@ export declare class SqlSharedMemoryStore implements SharedMemoryStoreProvider {
154
154
  signal?: AbortSignal;
155
155
  }): Promise<SharedMemorySnapshot>;
156
156
  /**
157
- * 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)。
157
+ * 盘状态 + 库登记,**一条语句一个快照**。
158
+ *
159
+ * 🔴 S-131(codex 对抗复审 r1 [high] 采,[ref] 的终局):这一对读要的不是新鲜度,而是「scope 行与
160
+ * store 行读齐、不撕」——一个 importer 若落在两条读之间,快照会把**旧的 connected 盘态**和**新登记的
161
+ * 库**拼在一起,于是壳拿到一个「已连接」的盘和一份不完整的库,而 reader 只复核 (store, scope) 归属、
162
+ * 从不复核盘态。
163
+ *
164
+ * 此前这条保证挂在**隔离级**上(轮2 F3 起用事务,[ref] 又把两臂都钉成 REPEATABLE READ)。S-131 把
165
+ * 隔离级收进连接初始化之后,那条路在 PG 腿上走不通:PG 的 REPEATABLE READ 是快照隔离,把会话钉过去
166
+ * 会让本仓每一条 `SELECT … FOR UPDATE` 从「等锁后读当前」变成 `40001` 序列化失败(`@contract
167
+ * txn.read-semantics` ② 的 PG 段),而 PG 的 `BEGIN` 是 READ COMMITTED、**每条语句各自取快照**。
158
168
  *
159
- * 🔴 [ref](两臂不对称,验真后修):此前 PG 臂显式抬隔离级别、**MySQL 臂只靠服务端默认值**,
160
- * 注里写的是「MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 天然同快照」。那句话对**默认配置**
161
- * 为真,但 `transaction_isolation` 是可设的服务端/会话变量:一台跑 READ COMMITTED 的实例(托管
162
- * MySQL 的厂商默认、中间层代理、运维手改)会把「一个事务一个快照」悄悄降成「每条语句各自取快照」,
163
- * 而本方法**不会报任何错**——它只是读到一对撕裂的 scope/store 行。「靠默认值成立」不是结构保证。
164
- * 两臂现在都走 {@link SqlTxConn.beginRepeatableRead}(语句文本与**摆放位置**的方言分歧写在那里:
165
- * MySQL 必须在 BEGIN **之前**发、PG 必须在 BEGIN **之后**发)。
169
+ * 改用**一条语句**读齐两张表(`UNION ALL` + `kind` 判别列)。一条语句在**任何**引擎、任何隔离级
170
+ * 下都是一个快照,于是这条保证不再依赖隔离级、不再需要事务、也不必为一次纯读去拿行锁(锁读会让
171
+ * 并发读被写者串起来,而这是会话附着的热路径)。规则少了一条:全店从此只有写方法开事务。
166
172
  */
167
173
  private readBinding;
168
174
  private readerFor;
@@ -147,21 +147,23 @@ export class SqlSharedMemoryStore {
147
147
  };
148
148
  }
149
149
  async readBinding(scopes) {
150
- const conn = await this.db.connect();
151
- try {
152
- await conn.beginRepeatableRead();
153
- const scopeRows = await conn.query(this.q(`SELECT scope_key, state, message FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")})`, `SELECT scope_key, state, message FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${i + 1}`).join(", ")})`), [...scopes]);
154
- const storeRows = await conn.query(this.q(`SELECT store_id, scope_key, description, writable FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")}) ORDER BY store_id`, `SELECT store_id, scope_key, description, writable FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${i + 1}`).join(", ")}) ORDER BY store_id`), [...scopes]);
155
- await conn.commit();
156
- return { scopeRows: scopeRows.rows, storeRows: storeRows.rows };
157
- }
158
- catch (err) {
159
- await rollbackPreservingError(conn, err);
160
- throw err;
161
- }
162
- finally {
163
- conn.release();
164
- }
150
+ const n = scopes.length;
151
+ const kindOrder = "ORDER BY kind, store_id";
152
+ const rows = await this.db.query(this.q(`SELECT 'scope' AS kind, scope_key, state, message, NULL AS store_id, NULL AS description, NULL AS writable
153
+ FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")})
154
+ UNION ALL
155
+ SELECT 'store' AS kind, scope_key, NULL, NULL, store_id, description, writable
156
+ FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")})
157
+ ${kindOrder}`, `SELECT 'scope' AS kind, scope_key, state, message, NULL::varchar AS store_id, NULL::varchar AS description, NULL::smallint AS writable
158
+ FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${i + 1}`).join(", ")})
159
+ UNION ALL
160
+ SELECT 'store' AS kind, scope_key, NULL::varchar, NULL::varchar, store_id, description, writable
161
+ FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${n + i + 1}`).join(", ")})
162
+ ${kindOrder}`), [...scopes, ...scopes]);
163
+ return {
164
+ scopeRows: rows.rows.filter((r) => r.kind === "scope"),
165
+ storeRows: rows.rows.filter((r) => r.kind === "store"),
166
+ };
165
167
  }
166
168
  readerFor(storeId, scopeKey) {
167
169
  return {
@@ -25,10 +25,43 @@
25
25
  * `query_timeout`. The counter family (write-behind-counter.ts) needs those and keeps its own
26
26
  * exec; this seam stays on the plain (sql, params) form.
27
27
  *
28
- * `beginPessimistic()` is TiDB's `BEGIN PESSIMISTIC` (a CURRENT read for the FOR UPDATE admission
29
- * checka snapshot read would double-admit) and a plain `BEGIN` on PG (READ COMMITTED already
30
- * reads the latest committed row under the sentinel lock). It is a NAMED verb precisely so the
31
- * divergence is visible rather than hidden inside `begin()`.
28
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
29
+ * @contract txn.read-semantics 本仓「事务里的读看到什么」的**唯一**成文处(S-131 / B-020 / B-022)
30
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
31
+ * 五只店的头注此前各写一套「为什么用这个事务动词」的理由(截断的、漏数的、讲历史沿革的都有)
32
+ * 判据从今天起只有这一处,店里只留一行 `{@link SqlTxConn.begin}` 指回来。
33
+ *
34
+ * **① 事务动词只有一个** —— `begin()`。曾经那个「悲观」专用动词(MySQL 腿发 TiDB 专有的
35
+ * `BEGIN PESSIMISTIC`)已删除:那条语句在真 MySQL / MariaDB 上是 `ER_PARSE_ERROR`,四店 18 个事务
36
+ * 一个都开不了(**B-020**),而它真正保证的东西现在由 ② 结构性地给出。同理,那个「可重复读」专用
37
+ * 动词也一并并入:隔离级不再由动词决定。
38
+ *
39
+ * **② 隔离级与事务模式是「连接的事实」,不是「动词的参数」** —— 见 {@link SqlDriver.facts}。
40
+ * 每条池连接首次借出时初始化一次(记忆化):
41
+ * · MySQL 协议腿:`SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ`;`SELECT @@tidb_txn_mode`
42
+ * 有值 ⇒ TiDB(值必须是 `pessimistic`,否则会话级设定,**设不上就拒启**——本仓所有 `FOR UPDATE`
43
+ * 判据都以悲观锁读为前提);`ER_UNKNOWN_SYSTEM_VARIABLE` ⇒ InnoDB(MySQL / MariaDB)。
44
+ * · PG:`SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED`,同样回读复核。
45
+ * 两腿钉的**值**不同(RR vs RC)是**引擎事实**不是口味:两边要的都是「加锁读=当前读」,而 PG 的
46
+ * REPEATABLE READ 是快照隔离,`FOR UPDATE` 撞并发提交抛 `40001` 而不是等锁 —— 把 PG 钉成 RR 会把
47
+ * 本仓每一条 `FOR UPDATE` 腿变成需要重试的形。`default_transaction_isolation` 同样是可设变量,
48
+ * 所以 PG 腿也**不问服务器缺省**。
49
+ * 「服务器默认值不是结构保证」这条论证的出处是 [ref]:`transaction_isolation` 是可设变量,一台
50
+ * 跑 READ COMMITTED 的实例会把「一个事务一个快照」悄悄降成「每条语句各自取快照」,而依赖它的调用方
51
+ * 不会收到任何错。所以 MySQL 腿现在**每条连接自己钉**,不问部署。
52
+ *
53
+ * **③ 事务内的集合读一律是锁读**(`… FOR UPDATE`)。普通 `SELECT` 只用于三种情形:读**本事务自己刚写
54
+ * 的行**、读**值/时钟表达式**(不触任何存量行)、以及 PG 语法明禁加锁的形(**只有**聚合 / GROUP BY /
55
+ * DISTINCT / 窗口 / 集合运算;`ORDER BY … LIMIT 1` 尾行读在 PG 上**合法**加锁,不属此类——7.60.0 重扫纠)。
56
+ * 理由:TiDB 悲观事务的普通 SELECT 仍钉 `start_ts`,`lockScope` 的锁等待窗内别副本的提交看不见
57
+ * (**B-022**);InnoDB REPEATABLE READ 下则由本事务的第一条一致性读建 read view —— 两条都是
58
+ * **版本相关的副作用**,不是保证。机器门:`test/sql-txn-semantics-gate.test.ts`(G-B,例外闭集
59
+ * `INTRA_TXN_PLAIN_READS` 每条带理由);真值神谕:`test/sql-txn-semantics-integration.test.ts`(三引擎)。
60
+ *
61
+ * **④ 引擎事实不进店** —— `SqlDriver.facts` 是**运维读面**(`GET /v1/diagnostics/wiring` 的 `sqlEngine`
62
+ * 段与 `GET /v1/capabilities` 的 `sql` 位)的料。店里不许出现 `if (engine === …)`:引擎判别在全仓
63
+ * 只有连接初始化这一处,机器门 G-A 守着。`SqlDialect`(SQL **文本**方言,两值)与 `engine`(运行期
64
+ * 引擎事实,三值)是两条轴,刻意不合成一个三值。
32
65
  *
33
66
  * ─────────────────────────────────────────────────────────────────────────────────────────────────
34
67
  * REPLICATION PLAYBOOK — collapsing the remaining tidb-/pg- twin pairs ([ref] A12)
@@ -90,38 +123,57 @@ export interface SqlExec {
90
123
  }
91
124
  /** A pooled connection with transaction control (released by the caller in a `finally`). */
92
125
  export interface SqlTxConn extends SqlExec {
93
- begin(): Promise<void>;
94
- /** TiDB: `BEGIN PESSIMISTIC` (current-read txn). PG: plain `BEGIN`. */
95
- beginPessimistic(): Promise<void>;
96
126
  /**
97
- * Open a transaction whose reads are pinned to ONE snapshot ([ref]).
98
- *
99
- * 🔴 Why this is a named verb and not "just `begin()` MySQL defaults to REPEATABLE READ anyway":
100
- * a *server default* is not a structural guarantee. `transaction_isolation` is a settable
101
- * server/session variable; a deployment (or a proxy, or a managed-MySQL vendor default) that runs
102
- * READ COMMITTED silently turns "one transaction one snapshot" into "each statement its own
103
- * snapshot" — and the caller that relied on it (`shared-memory-store-sql.ts` readBinding) goes on
104
- * reading a torn scope/store pair with no error anywhere. The PG arm was already explicit; the
105
- * MySQL arm was leaning on the default. Both arms now PIN it.
106
- *
107
- * The two arms diverge in WHERE the statement goes, and that is not cosmetic:
108
- * · MySQL/TiDB — `SET TRANSACTION ISOLATION LEVEL …` with no scope keyword applies to the **next**
109
- * transaction, and issuing it *inside* an open transaction is an ERROR
110
- * (`ER_CANT_CHANGE_TX_CHARACTERISTICS`). So it must precede `beginTransaction()`.
111
- * · PG — the same statement must be issued *inside* the transaction, before its first query.
112
- * Writing one "portable" form would be wrong on one of the two engines; hence one verb, two texts.
127
+ * The ONE transaction verb (S-131). What its reads see is NOT decided here — it is decided once per
128
+ * pooled connection at initialization and reported by {@link SqlDriver.facts}. Read the
129
+ * `@contract txn.read-semantics` block at the top of this file before adding a transaction: the
130
+ * short version is **intra-transaction collection reads are LOCKING reads**, and a plain `SELECT`
131
+ * inside a transaction is only legal for this transaction's own writes, for a value/clock
132
+ * expression, or where PG syntax forbids locking (machine gate: `sql-txn-semantics-gate.test.ts`).
113
133
  */
114
- beginRepeatableRead(): Promise<void>;
134
+ begin(): Promise<void>;
115
135
  commit(): Promise<void>;
116
136
  rollback(): Promise<void>;
117
137
  release(): void;
118
138
  }
139
+ /**
140
+ * The runtime SQL **engine** — a different axis from {@link SqlDialect} (which is SQL *text*).
141
+ * `"tidb"` and `"innodb"` both speak the MySQL protocol and share every statement in this repo, but
142
+ * they do not share transaction semantics (TiDB has an optimistic mode and no gap locks; InnoDB has
143
+ * neither an optimistic mode nor `BEGIN PESSIMISTIC`), so the two words must stay separable.
144
+ */
145
+ export type SqlEngine = "tidb" | "innodb" | "pg";
146
+ /**
147
+ * What connection initialization ESTABLISHED and then READ BACK on this driver's pool (S-131 §1
148
+ * 补偿面). Operator read faces: `GET /v1/diagnostics/wiring` → `sqlEngine`, `GET /v1/capabilities`
149
+ * → `sql`. Store code must never read this (machine gate G-A) — engine discrimination exists in
150
+ * exactly one place in this repo, and it is `connect()`.
151
+ */
152
+ export interface SqlEngineFacts {
153
+ readonly engine: SqlEngine;
154
+ /** `VERSION()` (MySQL protocol) / `server_version` (PG), verbatim. */
155
+ readonly version: string;
156
+ /** The session's transaction isolation, read back AFTER initialization set it. */
157
+ readonly isolation: string;
158
+ /** TiDB's session `tidb_txn_mode`; `null` = this engine has no such indicator (NOT "optimistic"). */
159
+ readonly txnMode: "pessimistic" | null;
160
+ }
119
161
  export interface SqlDriver extends SqlExec {
120
162
  readonly dialect: SqlDialect;
163
+ /**
164
+ * Engine facts, established the first time ANY driver over the same pool hands out a pooled connection
165
+ * (state is keyed by the pool, so the twenty-odd store drivers sharing one pool report ONE set of facts);
166
+ * `null` until then (a pool that has never connected has nothing to report — it does not GUESS). Every
167
+ * pooled connection re-runs initialization the first time it is borrowed (memo keyed by connection
168
+ * identity), so a pool that reconnects after a fatal error re-establishes the session guarantee on the
169
+ * new connection rather than inheriting it.
170
+ */
171
+ readonly facts: SqlEngineFacts | null;
121
172
  connect(): Promise<SqlTxConn>;
122
173
  }
123
- /** MySQL-protocol (TiDB) driver. Transaction control uses mysql2's NATIVE methods the historical
124
- * call shape the fake-pool unit suites script around. */
174
+ /** MySQL-protocol driver (TiDB / MySQL / MariaDB `dialect` is the SQL TEXT axis, `facts.engine` the
175
+ * runtime one). Transaction control uses mysql2's NATIVE methods — the historical call shape the
176
+ * fake-pool unit suites script around. */
125
177
  export declare function mysqlDriver(pool: MySqlPool): SqlDriver;
126
178
  /** PostgreSQL driver. Transaction control is issued as STATEMENTS (node-pg has no native verbs). */
127
179
  export declare function pgDriver(pool: PgPool): SqlDriver;
@@ -9,22 +9,83 @@ async function mysqlQuery(q, sql, params) {
9
9
  const [r] = (await q.query(sql, params ?? []));
10
10
  return mysqlResult(r);
11
11
  }
12
+ function isUnknownSystemVariable(err) {
13
+ const e = err;
14
+ return e?.errno === 1193 || e?.code === "ER_UNKNOWN_SYSTEM_VARIABLE";
15
+ }
16
+ const POOL_SESSIONS = new WeakMap();
17
+ function poolSession(pool) {
18
+ let st = POOL_SESSIONS.get(pool);
19
+ if (st === undefined)
20
+ POOL_SESSIONS.set(pool, (st = { initialized: new WeakSet(), facts: null }));
21
+ return st;
22
+ }
23
+ async function mysqlVar(c, sql) {
24
+ try {
25
+ const r = await mysqlQuery(c, sql);
26
+ const v = r.rows[0]?.v;
27
+ return v == null ? "" : String(v);
28
+ }
29
+ catch (err) {
30
+ if (isUnknownSystemVariable(err))
31
+ return null;
32
+ throw err;
33
+ }
34
+ }
35
+ function refuseSession(what) {
36
+ return new Error(`sql-driver: refusing this connection — ${what}. The repo's read semantics (see @contract txn.read-semantics in src/plugins/sql-driver.ts) ` +
37
+ `are established per pooled connection at initialization: session REPEATABLE READ on the MySQL protocol, plus TiDB's pessimistic transaction mode. ` +
38
+ `Neither is optional: every "SELECT … FOR UPDATE" decision point in this service is a CURRENT read only under them.`);
39
+ }
40
+ async function initMySqlSession(c) {
41
+ await c.query("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ");
42
+ let txnMode = await mysqlVar(c, "SELECT @@tidb_txn_mode AS v");
43
+ const engine = txnMode === null ? "innodb" : "tidb";
44
+ if (engine === "tidb" && txnMode !== "pessimistic") {
45
+ try {
46
+ await c.query("SET SESSION tidb_txn_mode = 'pessimistic'");
47
+ }
48
+ catch (err) {
49
+ throw refuseSession(`this TiDB session is in "${txnMode}" transaction mode and SET SESSION tidb_txn_mode='pessimistic' was rejected (${err.message})`);
50
+ }
51
+ txnMode = await mysqlVar(c, "SELECT @@tidb_txn_mode AS v");
52
+ if (txnMode !== "pessimistic") {
53
+ throw refuseSession(`this TiDB session reports transaction mode "${txnMode ?? "<absent>"}" after SET SESSION tidb_txn_mode='pessimistic'`);
54
+ }
55
+ }
56
+ const isolation = (await mysqlVar(c, "SELECT @@transaction_isolation AS v")) ?? (await mysqlVar(c, "SELECT @@tx_isolation AS v"));
57
+ if (isolation === null) {
58
+ throw refuseSession("this server exposes neither @@transaction_isolation (MySQL 8.0+ / TiDB / MariaDB 11.1+) nor @@tx_isolation (MariaDB 10.x), so the isolation level cannot be VERIFIED");
59
+ }
60
+ if (isolation !== "REPEATABLE-READ") {
61
+ throw refuseSession(`this session reports isolation "${isolation}" after SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ`);
62
+ }
63
+ return { engine, version: (await mysqlVar(c, "SELECT VERSION() AS v")) ?? "", isolation, txnMode: engine === "tidb" ? "pessimistic" : null };
64
+ }
12
65
  export function mysqlDriver(pool) {
66
+ const session = poolSession(pool);
13
67
  return {
14
68
  dialect: "tidb",
69
+ get facts() {
70
+ return session.facts;
71
+ },
15
72
  query: (sql, params) => mysqlQuery(pool, sql, params),
16
73
  async connect() {
17
74
  const c = await pool.getConnection();
75
+ const pooled = c.connection ?? c;
76
+ try {
77
+ if (!session.initialized.has(pooled)) {
78
+ session.facts = await initMySqlSession(c);
79
+ session.initialized.add(pooled);
80
+ }
81
+ }
82
+ catch (err) {
83
+ c.release();
84
+ throw err;
85
+ }
18
86
  return {
19
87
  query: (sql, params) => mysqlQuery(c, sql, params),
20
88
  begin: () => c.beginTransaction(),
21
- beginPessimistic: async () => {
22
- await c.query("BEGIN PESSIMISTIC");
23
- },
24
- beginRepeatableRead: async () => {
25
- await c.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
26
- await c.beginTransaction();
27
- },
28
89
  commit: () => c.commit(),
29
90
  rollback: () => c.rollback(),
30
91
  release: () => c.release(),
@@ -36,24 +97,46 @@ async function pgQuery(q, sql, params) {
36
97
  const res = await q.query(sql, params ?? []);
37
98
  return { rows: (res.rows ?? []), affected: res.rowCount ?? 0 };
38
99
  }
100
+ async function initPgSession(c) {
101
+ await c.query("SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED");
102
+ const iso = await pgQuery(c, "SHOW transaction_isolation");
103
+ const isolation = String(iso.rows[0]?.transaction_isolation ?? "");
104
+ if (isolation !== "read committed") {
105
+ throw refuseSession(`this PostgreSQL session reports isolation "${isolation}" after SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED`);
106
+ }
107
+ const ver = await pgQuery(c, "SHOW server_version");
108
+ return {
109
+ engine: "pg",
110
+ version: String(ver.rows[0]?.server_version ?? ""),
111
+ isolation,
112
+ txnMode: null,
113
+ };
114
+ }
39
115
  export function pgDriver(pool) {
116
+ const session = poolSession(pool);
40
117
  return {
41
118
  dialect: "pg",
119
+ get facts() {
120
+ return session.facts;
121
+ },
42
122
  query: (sql, params) => pgQuery(pool, sql, params),
43
123
  async connect() {
44
124
  const c = await pool.connect();
125
+ try {
126
+ if (!session.initialized.has(c)) {
127
+ session.facts = await initPgSession(c);
128
+ session.initialized.add(c);
129
+ }
130
+ }
131
+ catch (err) {
132
+ c.release();
133
+ throw err;
134
+ }
45
135
  return {
46
136
  query: (sql, params) => pgQuery(c, sql, params),
47
137
  begin: async () => {
48
138
  await c.query("BEGIN");
49
139
  },
50
- beginPessimistic: async () => {
51
- await c.query("BEGIN");
52
- },
53
- beginRepeatableRead: async () => {
54
- await c.query("BEGIN");
55
- await c.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
56
- },
57
140
  commit: async () => {
58
141
  await c.query("COMMIT");
59
142
  },
@@ -151,10 +151,14 @@ export interface StoreBackend {
151
151
  * 本身就是 optional,缺席 ⇒ 引擎的 `permissionRules.storeWired` 如实报 `false`,ask 帧不投 `ruleOffers`
152
152
  * (发一格按下去无处可兑的「不再询问」= wire 谎言,比缺席更坏)。
153
153
  *
154
+ * [ref](core 7.5.0):束里的规则那一格是 **durable 分区后端**(`PermissionRuleStoreBundle.durable`),
155
+ * 引擎接缝上的统一店由 `main.ts` 用 `createPermissionRuleStoreProvider({ durable })` 合成一次 ——
156
+ * 分区构成(durable / org / session)是**部署**决定,backend 只交它自己那一格。
157
+ *
154
158
  * `local` 车道**刻意不给 in-memory twin**:一条规则是「人授权过的持久事实」,进程内 Map 形会在重启时
155
159
  * 静默丢掉那份授权,而消费端(下一次同命令的 ask)读到的是「没有规则」——那是**放宽面**上的静默降级。
156
- * ✅ **[ref] §1 已落 File 形**(那条注写下的解除条件逐字是「先落 File 形,core 现成
157
- * `FilePermissionRuleStoreProvider` 即可当模子」——本车照办):`local` 现在返回一个**跨重启存活**的
160
+ * ✅ **[ref] §1 已落 File 形**(那条注写下的解除条件逐字是「先落 File 形,core 现成的
161
+ * File durable 分区 provider 即可当模子」——本车照办):`local` 现在返回一个**跨重启存活**的
158
162
  * 三面束,storeWired 在单机车道上真为 true。in-memory twin 的禁令不变,它禁的是「会遗忘的店」,
159
163
  * 不是「单机的店」。
160
164
  */