@sema-agent/server 7.59.0 → 7.61.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.
- package/README.md +4 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +8 -1
- package/dist/boot/stores.d.ts +1 -0
- package/dist/boot/stores.js +4 -1
- package/dist/http/routes/approvals-assistant.js +3 -0
- package/dist/http/routes/capabilities.js +2 -0
- package/dist/http/routes/diagnostics.js +1 -0
- package/dist/http/server.d.ts +6 -0
- package/dist/main.js +10 -4
- package/dist/plugins/approval-ask-store-sql.d.ts +9 -9
- package/dist/plugins/approval-ask-store-sql.js +1 -1
- package/dist/plugins/device-store-sql.d.ts +11 -7
- package/dist/plugins/device-store-sql.js +6 -6
- package/dist/plugins/file-history-store-sql.d.ts +15 -3
- package/dist/plugins/file-history-store-sql.js +32 -29
- package/dist/plugins/image-bake-store-sql.js +1 -1
- package/dist/plugins/permission-rule-store-file.d.ts +4 -4
- package/dist/plugins/permission-rule-store-file.js +4 -4
- package/dist/plugins/permission-rule-store-sql.d.ts +15 -11
- package/dist/plugins/permission-rule-store-sql.js +7 -11
- package/dist/plugins/retention-lane-store-sql.d.ts +2 -2
- package/dist/plugins/retention-store-sql.d.ts +5 -13
- package/dist/plugins/retention-store-sql.js +8 -3
- package/dist/plugins/session-policy-store-sql.d.ts +9 -2
- package/dist/plugins/shared-memory-store-sql.d.ts +25 -19
- package/dist/plugins/shared-memory-store-sql.js +17 -15
- package/dist/plugins/sql-driver.d.ts +80 -26
- package/dist/plugins/sql-driver.js +96 -14
- package/dist/plugins/store-backend.d.ts +6 -2
- package/dist/rules-consent.d.ts +54 -12
- package/dist/rules-consent.js +4 -4
- package/dist/sql-engine-posture.d.ts +49 -0
- package/dist/sql-engine-posture.js +13 -0
- package/dist/tool-approval.d.ts +18 -0
- package/dist/tool-approval.js +1 -0
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/project.d.ts +25 -0
- package/dist/trace/project.js +26 -0
- package/package.json +3 -3
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { type PermissionRuleWriter, type
|
|
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
|
-
*
|
|
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:
|
|
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
|
|
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)`
|
|
133
|
-
* —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
|
|
135
|
+
* `forPrincipal(undefined)` 恒解析成**零规则**分区(core 硬条款:未鉴权任务解析到零规则而不是一只
|
|
136
|
+
* 共享桶 —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
|
|
134
137
|
*/
|
|
135
|
-
export declare class
|
|
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):
|
|
140
|
-
forLocalOwner():
|
|
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
|
|
434
|
+
/** 三个 SQL 面的一次性装配束(one driver, three faces)。`durable` = [ref] 的 durable 分区后端;
|
|
435
|
+
* 引擎接缝上的统一店由 `main.ts` 用 `createPermissionRuleStoreProvider({ durable })` 合成一次。 */
|
|
432
436
|
export interface PermissionRuleStores {
|
|
433
|
-
|
|
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
|
|
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
|
|
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
|
|
503
|
-
return new
|
|
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
|
|
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
|
-
|
|
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
|
-
* -
|
|
25
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
*/
|
|
@@ -360,7 +352,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
360
352
|
* **可删**的会话 id:本域、过视界、且四条活引用腿**一条都不命中**(保护谓词下推,扣在 LIMIT 之前 ——
|
|
361
353
|
* 理由与 {@link expiryCandidates} 逐字相同:头部被保护的树不许饿死它后面的树)。
|
|
362
354
|
*
|
|
363
|
-
* `
|
|
355
|
+
* `forUpdate = true`(删除事务里的读)追加 `FOR UPDATE`,这一格是**承重**的(codex 交叉复审 R1-[high]):
|
|
364
356
|
* · 会话行是**存在**的行 ⇒ 这把锁与 hold 哨兵不同,它真的锁得住;
|
|
365
357
|
* · 锁住之后,并发的 `touch()`(把会话重新拉进视界)与 session-sync 的 **owner 改写**(importEntries /
|
|
366
358
|
* replaceEntries / staged commit 都会重写 `session_meta.owner`)必须排队等我们提交 —— 否则「按旧候选集
|
|
@@ -291,11 +291,12 @@ export class SqlRetentionStore {
|
|
|
291
291
|
`AND ${this.liveReferenceExists()} LIMIT $3) x`), [...w.params, new Date(input.cutoffMs), RETENTION_SESSION_BATCH]);
|
|
292
292
|
return Number(rows[0]?.n ?? 0);
|
|
293
293
|
}
|
|
294
|
-
async sessionCandidates(exec, input,
|
|
294
|
+
async sessionCandidates(exec, input, forUpdate = false) {
|
|
295
295
|
const w = this.domainWhere("sm.owner", input.domain, 1);
|
|
296
|
+
const suffix = forUpdate ? " FOR UPDATE" : "";
|
|
296
297
|
const { rows } = await exec.query(this.q(`SELECT sm.session_id FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < ? AND NOT ${this.liveReferenceExists()} ` +
|
|
297
|
-
`ORDER BY sm.updated_at ASC LIMIT ?${
|
|
298
|
-
`ORDER BY sm.updated_at ASC LIMIT $3${
|
|
298
|
+
`ORDER BY sm.updated_at ASC LIMIT ?${suffix}`, `SELECT sm.session_id FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < $2 AND NOT ${this.liveReferenceExists()} ` +
|
|
299
|
+
`ORDER BY sm.updated_at ASC LIMIT $3${suffix}`), [...w.params, new Date(input.cutoffMs), RETENTION_SESSION_BATCH]);
|
|
299
300
|
return rows.map((r) => String(r.session_id));
|
|
300
301
|
}
|
|
301
302
|
async countOrphanToolResultCandidates(exec, input) {
|
|
@@ -367,6 +368,10 @@ export class SqlRetentionStore {
|
|
|
367
368
|
const s = set("owner_session_id", 1);
|
|
368
369
|
await conn.query(`DELETE FROM tool_result WHERE ${s.sql}`, s.params);
|
|
369
370
|
}
|
|
371
|
+
{
|
|
372
|
+
const s = set("scope", 1);
|
|
373
|
+
await conn.query(`SELECT scope FROM file_history_scope WHERE ${s.sql} ORDER BY scope FOR UPDATE`, s.params);
|
|
374
|
+
}
|
|
370
375
|
for (const [table, col] of [
|
|
371
376
|
["resume_anchor", "session_id"],
|
|
372
377
|
["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
|
-
/**
|
|
33
|
-
*
|
|
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
|
-
* 事务与锁
|
|
57
|
+
* 事务与锁
|
|
58
58
|
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
59
|
-
*
|
|
60
|
-
* `SELECT … FOR UPDATE`
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
* 盘状态 +
|
|
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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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,59 @@ 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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
-
|
|
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
|
+
* 本池上**最近一次连接初始化的结果**(状态按池存,所以共享一条池的二十多只店 driver 报同一份事实)。
|
|
165
|
+
* 三种读数,含义各不相同:
|
|
166
|
+
* · `null`(还没借出过连接)—— 没有可报的东西,**不猜**;
|
|
167
|
+
* · 事实 —— 最近一次初始化**回读复核**过的值。每条池连接首次借出都重跑一次初始化(记忆化按连接身份),
|
|
168
|
+
* 所以池在致命错误后重连时,保证是在新连接上**重新建立**的,不是继承来的;
|
|
169
|
+
* · `null`(借出过、但最近一次初始化被**拒**)—— S-137 ④:保证此刻建立不起来,读面就报缺席。
|
|
170
|
+
* 此前这里是「一次成功即长期有效」的快照:连接被驱逐、新连接初始化被拒之后,诊断面仍在陈述一条
|
|
171
|
+
* 已经不成立的结构保证。降级由 `establishSession()` 一处完成,并留 loud warn。
|
|
172
|
+
*/
|
|
173
|
+
readonly facts: SqlEngineFacts | null;
|
|
121
174
|
connect(): Promise<SqlTxConn>;
|
|
122
175
|
}
|
|
123
|
-
/** MySQL-protocol (TiDB
|
|
124
|
-
*
|
|
176
|
+
/** MySQL-protocol driver (TiDB / MySQL / MariaDB — `dialect` is the SQL TEXT axis, `facts.engine` the
|
|
177
|
+
* runtime one). Transaction control uses mysql2's NATIVE methods — the historical call shape the
|
|
178
|
+
* fake-pool unit suites script around. */
|
|
125
179
|
export declare function mysqlDriver(pool: MySqlPool): SqlDriver;
|
|
126
180
|
/** PostgreSQL driver. Transaction control is issued as STATEMENTS (node-pg has no native verbs). */
|
|
127
181
|
export declare function pgDriver(pool: PgPool): SqlDriver;
|
|
@@ -9,22 +9,91 @@ 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 establishSession(session, pooled, c, init) {
|
|
41
|
+
if (session.initialized.has(pooled))
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
session.facts = await init(c);
|
|
45
|
+
session.initialized.add(pooled);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
const stale = session.facts;
|
|
49
|
+
session.facts = null;
|
|
50
|
+
session.initialized = new WeakSet();
|
|
51
|
+
if (stale !== null)
|
|
52
|
+
console.warn(`[sql-driver] connection initialization REFUSED — SQL engine facts invalidated (was: ${stale ? `${stale.engine} ${stale.version} ${stale.isolation} txnMode=${stale.txnMode ?? "-"}` : "<absent>"}). GET /v1/diagnostics/wiring -> sqlEngine and GET /v1/capabilities -> sql now report ABSENT, which is the honest ` +
|
|
53
|
+
`reading: this pool cannot currently establish the read semantics every "SELECT … FOR UPDATE" in this repo assumes. Cause: ${err.message}`);
|
|
54
|
+
c.release();
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function initMySqlSession(c) {
|
|
59
|
+
await c.query("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ");
|
|
60
|
+
let txnMode = await mysqlVar(c, "SELECT @@tidb_txn_mode AS v");
|
|
61
|
+
const engine = txnMode === null ? "innodb" : "tidb";
|
|
62
|
+
if (engine === "tidb" && txnMode !== "pessimistic") {
|
|
63
|
+
try {
|
|
64
|
+
await c.query("SET SESSION tidb_txn_mode = 'pessimistic'");
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw refuseSession(`this TiDB session is in "${txnMode}" transaction mode and SET SESSION tidb_txn_mode='pessimistic' was rejected (${err.message})`);
|
|
68
|
+
}
|
|
69
|
+
txnMode = await mysqlVar(c, "SELECT @@tidb_txn_mode AS v");
|
|
70
|
+
if (txnMode !== "pessimistic") {
|
|
71
|
+
throw refuseSession(`this TiDB session reports transaction mode "${txnMode ?? "<absent>"}" after SET SESSION tidb_txn_mode='pessimistic'`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const isolation = (await mysqlVar(c, "SELECT @@transaction_isolation AS v")) ?? (await mysqlVar(c, "SELECT @@tx_isolation AS v"));
|
|
75
|
+
if (isolation === null) {
|
|
76
|
+
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");
|
|
77
|
+
}
|
|
78
|
+
if (isolation !== "REPEATABLE-READ") {
|
|
79
|
+
throw refuseSession(`this session reports isolation "${isolation}" after SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ`);
|
|
80
|
+
}
|
|
81
|
+
return { engine, version: (await mysqlVar(c, "SELECT VERSION() AS v")) ?? "", isolation, txnMode: engine === "tidb" ? "pessimistic" : null };
|
|
82
|
+
}
|
|
12
83
|
export function mysqlDriver(pool) {
|
|
84
|
+
const session = poolSession(pool);
|
|
13
85
|
return {
|
|
14
86
|
dialect: "tidb",
|
|
87
|
+
get facts() {
|
|
88
|
+
return session.facts;
|
|
89
|
+
},
|
|
15
90
|
query: (sql, params) => mysqlQuery(pool, sql, params),
|
|
16
91
|
async connect() {
|
|
17
92
|
const c = await pool.getConnection();
|
|
93
|
+
await establishSession(session, c.connection ?? c, c, initMySqlSession);
|
|
18
94
|
return {
|
|
19
95
|
query: (sql, params) => mysqlQuery(c, sql, params),
|
|
20
96
|
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
97
|
commit: () => c.commit(),
|
|
29
98
|
rollback: () => c.rollback(),
|
|
30
99
|
release: () => c.release(),
|
|
@@ -36,24 +105,37 @@ async function pgQuery(q, sql, params) {
|
|
|
36
105
|
const res = await q.query(sql, params ?? []);
|
|
37
106
|
return { rows: (res.rows ?? []), affected: res.rowCount ?? 0 };
|
|
38
107
|
}
|
|
108
|
+
async function initPgSession(c) {
|
|
109
|
+
await c.query("SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED");
|
|
110
|
+
const iso = await pgQuery(c, "SHOW transaction_isolation");
|
|
111
|
+
const isolation = String(iso.rows[0]?.transaction_isolation ?? "");
|
|
112
|
+
if (isolation !== "read committed") {
|
|
113
|
+
throw refuseSession(`this PostgreSQL session reports isolation "${isolation}" after SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED`);
|
|
114
|
+
}
|
|
115
|
+
const ver = await pgQuery(c, "SHOW server_version");
|
|
116
|
+
return {
|
|
117
|
+
engine: "pg",
|
|
118
|
+
version: String(ver.rows[0]?.server_version ?? ""),
|
|
119
|
+
isolation,
|
|
120
|
+
txnMode: null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
39
123
|
export function pgDriver(pool) {
|
|
124
|
+
const session = poolSession(pool);
|
|
40
125
|
return {
|
|
41
126
|
dialect: "pg",
|
|
127
|
+
get facts() {
|
|
128
|
+
return session.facts;
|
|
129
|
+
},
|
|
42
130
|
query: (sql, params) => pgQuery(pool, sql, params),
|
|
43
131
|
async connect() {
|
|
44
132
|
const c = await pool.connect();
|
|
133
|
+
await establishSession(session, c, c, initPgSession);
|
|
45
134
|
return {
|
|
46
135
|
query: (sql, params) => pgQuery(c, sql, params),
|
|
47
136
|
begin: async () => {
|
|
48
137
|
await c.query("BEGIN");
|
|
49
138
|
},
|
|
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
139
|
commit: async () => {
|
|
58
140
|
await c.query("COMMIT");
|
|
59
141
|
},
|
|
@@ -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
|
-
*
|
|
160
|
+
* ✅ **[ref] §1 已落 File 形**(那条注写下的解除条件逐字是「先落 File 形,core 现成的
|
|
161
|
+
* File durable 分区 provider 即可当模子」——本车照办):`local` 现在返回一个**跨重启存活**的
|
|
158
162
|
* 三面束,storeWired 在单机车道上真为 true。in-memory twin 的禁令不变,它禁的是「会遗忘的店」,
|
|
159
163
|
* 不是「单机的店」。
|
|
160
164
|
*/
|