@sema-agent/server 7.10.0 → 7.11.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 (59) hide show
  1. package/dist/adoption/plan.d.ts +152 -0
  2. package/dist/adoption/plan.js +513 -0
  3. package/dist/adoption/runner.d.ts +54 -0
  4. package/dist/adoption/runner.js +505 -0
  5. package/dist/adoption/sql.d.ts +76 -0
  6. package/dist/adoption/sql.js +106 -0
  7. package/dist/adoption/wire.d.ts +250 -0
  8. package/dist/adoption/wire.js +153 -0
  9. package/dist/approval-card.d.ts +24 -0
  10. package/dist/approval-card.js +32 -0
  11. package/dist/boot/adoption.d.ts +30 -0
  12. package/dist/boot/adoption.js +57 -0
  13. package/dist/boot/coordinators.d.ts +4 -0
  14. package/dist/boot/coordinators.js +3 -1
  15. package/dist/boot/parked-revive-gate.d.ts +38 -5
  16. package/dist/boot/parked-revive-gate.js +53 -6
  17. package/dist/boot/runner-deps.d.ts +10 -2
  18. package/dist/boot/runner-deps.js +12 -1
  19. package/dist/config-types.d.ts +16 -1
  20. package/dist/config.js +5 -1
  21. package/dist/http/routes/adoption.d.ts +26 -0
  22. package/dist/http/routes/adoption.js +120 -0
  23. package/dist/http/routes/capabilities.js +12 -0
  24. package/dist/http/routes/rules.d.ts +23 -0
  25. package/dist/http/routes/rules.js +117 -0
  26. package/dist/http/routes/shared-memory.d.ts +31 -0
  27. package/dist/http/routes/shared-memory.js +181 -0
  28. package/dist/http/routes/trace-usage.js +139 -3
  29. package/dist/http/server.d.ts +19 -1
  30. package/dist/http/server.js +42 -0
  31. package/dist/main.js +52 -3
  32. package/dist/observability/fail-open.d.ts +8 -0
  33. package/dist/observability/fail-open.js +8 -0
  34. package/dist/plugins/adoption-log-sql.d.ts +191 -0
  35. package/dist/plugins/adoption-log-sql.js +273 -0
  36. package/dist/plugins/checkpoint-store-sql.d.ts +13 -0
  37. package/dist/plugins/checkpoint-store-sql.js +11 -0
  38. package/dist/plugins/local-checkpoint-store.d.ts +10 -0
  39. package/dist/plugins/local-checkpoint-store.js +8 -0
  40. package/dist/plugins/permission-rule-store-sql.d.ts +242 -0
  41. package/dist/plugins/permission-rule-store-sql.js +817 -0
  42. package/dist/plugins/pg-pool.js +37 -0
  43. package/dist/plugins/session-policy-store-sql.d.ts +6 -0
  44. package/dist/plugins/session-policy-store-sql.js +7 -1
  45. package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
  46. package/dist/plugins/shared-memory-store-sql.js +516 -0
  47. package/dist/plugins/store-backend.d.ts +30 -0
  48. package/dist/plugins/store-backend.js +14 -0
  49. package/dist/plugins/tidb-pool.js +47 -0
  50. package/dist/rules-consent.d.ts +126 -0
  51. package/dist/rules-consent.js +198 -0
  52. package/dist/shared-memory-scope-authorizer.d.ts +29 -0
  53. package/dist/shared-memory-scope-authorizer.js +17 -0
  54. package/dist/tool-approval.d.ts +55 -0
  55. package/dist/tool-approval.js +124 -5
  56. package/dist/trace/core-keyset-guard.d.ts +2 -2
  57. package/dist/trace/project.d.ts +1 -0
  58. package/dist/trace/project.js +1 -0
  59. package/package.json +3 -3
@@ -0,0 +1,54 @@
1
+ import { type AdoptionLogStore } from "../plugins/adoption-log-sql.js";
2
+ import type { SqlDialect } from "../plugins/sql-driver.js";
3
+ import { type AdoptionReceipt, type AdoptionRejectCode } from "./wire.js";
4
+ /** 冲突表族的逐项读数(进 409 响应体;运维据此知道**该去清理哪张表**,不是一句「冲突了」)。 */
5
+ export interface AdoptionConflict {
6
+ /** 腿标识(`<表名>#<身份轴>`)。 */
7
+ leg: string;
8
+ table: string;
9
+ /** 目的地侧与源侧共享同一逻辑键的行对数(>0 即冲突)。 */
10
+ overlapping: number;
11
+ }
12
+ /** 收编弧的三种结局。**回执与拒绝分家**:拒从不产出 200 回执(183 §7.3 拒绝可判别)。 */
13
+ export type AdoptionOutcome = {
14
+ kind: "receipt";
15
+ adoptionId: string;
16
+ receipt: AdoptionReceipt;
17
+ } | {
18
+ kind: "rejected";
19
+ code: AdoptionRejectCode;
20
+ adoptionId: string;
21
+ detail: string;
22
+ conflicts: AdoptionConflict[];
23
+ } | {
24
+ kind: "source_already_bound";
25
+ adoptionId: string;
26
+ fromPrincipal: string;
27
+ boundTo: string;
28
+ };
29
+ export interface AdoptionRunnerDeps {
30
+ store: AdoptionLogStore;
31
+ dialect: SqlDialect;
32
+ /** 注入时钟(测试要确定性;生产传 `Date.now`)。 */
33
+ now: () => number;
34
+ /** 锁轮询次数/间隔——测试把它调到 1 次好确定性地打到「取不到锁」那条臂。 */
35
+ lockAttempts?: number;
36
+ lockSleepMs?: number;
37
+ logger?: {
38
+ info?(msg: string, meta?: unknown): void;
39
+ warn?(msg: string, meta?: unknown): void;
40
+ };
41
+ }
42
+ export interface AdoptionRunner {
43
+ start(fromPrincipal: string, toPrincipal: string): Promise<AdoptionOutcome>;
44
+ get(adoptionId: string): Promise<AdoptionOutcome | null>;
45
+ /** I6 server 同族:副本 boot 的在飞扫描(续跑或响亮拒,禁静默跳过)。 */
46
+ resumeInFlight(): Promise<{
47
+ resumed: string[];
48
+ stalled: string[];
49
+ rejected: string[];
50
+ failed: string[];
51
+ }>;
52
+ }
53
+ export declare function createAdoptionRunner(deps: AdoptionRunnerDeps): AdoptionRunner;
54
+ //# sourceMappingURL=runner.d.ts.map
@@ -0,0 +1,505 @@
1
+ /**
2
+ * design/183 §3.2 根级状态机的 **form b 实施**(纯身份重绑;数据已在 SQL、承运腿为零)。
3
+ *
4
+ * 一条弧的全部判断都在这里,店(`adoption-log-sql.ts`)只负责语句,计划表(`plan.ts`)只负责「迁什么」。
5
+ *
6
+ * ── 弧 ──────────────────────────────────────────────────────────────────────────────────────────
7
+ * ① 取弧锁(advisory;取不到 ⇒ **不等死也不假装成功**,回 `stalled` 回执)
8
+ * ② 意图落库(insert-or-fetch 原子形;`from_principal` UNIQUE 仲裁并发) ⇒ phase 2
9
+ * ③ 目的地冲突 preflight(**任何 UPDATE 之前**)+ 全部重绑腿(同一事务) ⇒ phase 3
10
+ * ④ 配置账物化(server 够不到的部署配置逐项点名,`migrated:false`) ⇒ phase 4
11
+ * ⑤ 承运腿:form b **恒零**(阶段位显式在场,给 form a 上车留挂点) ⇒ phase 5
12
+ * ⑥ 永久终态:`immutableReport` 一次落库,此后逐字节恒同 ⇒ phase 6
13
+ *
14
+ * ── 崩溃续跑 ────────────────────────────────────────────────────────────────────────────────────
15
+ * 每一步都是「读当前 phase → 做这一步 → CAS 推进」。任一步崩溃,下一次(重发同参 POST、或副本 boot 的
16
+ * 在飞扫描)从库里的 phase 处接着跑;腿本身幂等(重跑改到同值 = 零行变化),所以「续跑」与「重做」在
17
+ * 数据面等价。**绝不**因为「上次跑到一半」就拒绝续跑 —— 那会把一次网络抖动变成永久半迁移。
18
+ *
19
+ * ── 拒绝 ────────────────────────────────────────────────────────────────────────────────────────
20
+ * 目的地冲突 ⇒ `rejected` 终态 + typed 409,**源/目的地两侧字节零变更**(preflight 在任何 UPDATE 之前;
21
+ * 跨轴组合形的漏网由事务里的唯一键违例整体回滚兜住)。不做逐表 merge —— 183 未裁,拒比合安全且可重来。
22
+ */
23
+ import { randomUUID } from "node:crypto";
24
+ import { ADOPTION_PHASE, adoptionLockName, } from "../plugins/adoption-log-sql.js";
25
+ import { policyKeyFor } from "../plugins/session-policy-store-sql.js";
26
+ import { AFFECTED_CONFIG_TEMPLATES, MEMORY_SCOPE_MAX_CHARS, NOT_MIGRATED_BY_DESIGN, REBIND_LEGS, buildVerbatimPair, rewriteMemoryScope, } from "./plan.js";
27
+ import { buildBlobScanSql, buildBlobWriteSql, buildConflictProbeSql, buildMemoryScopeScanSql, buildResidualCountSql, buildRebindSql, buildSessionPolicyRewriteSql, buildSessionPolicyScanSql, } from "./sql.js";
28
+ import { AdoptionConfigsSchema, AdoptionLegsSchema, AdoptionReceiptSchema, AdoptionRejectDetailSchema, AdoptionReportSchema, } from "./wire.js";
29
+ import { formatUserScope, encodeScopeSegment } from "@sema-agent/core";
30
+ /** MySQL `ER_DUP_ENTRY` / PG `unique_violation` —— 跨轴组合冲突的最后一道 fail-closed 门。 */
31
+ function isDupKey(dialect, err) {
32
+ const e = err;
33
+ return dialect === "tidb" ? e?.errno === 1062 : e?.code === "23505";
34
+ }
35
+ function msgOf(e) {
36
+ return e instanceof Error ? e.message : String(e);
37
+ }
38
+ /**
39
+ * 一根身份轴上的「旧值 → 新值」对。`verbatim` 恒一对;`memory-scope` 由库里 DISTINCT 扫出来
40
+ * (user 盘 1 个 + 每个 project 盘各 1 个)——**扫而不猜**,理由见 `sql.ts` 的 LIKE 段。
41
+ */
42
+ async function resolveValuePairs(exec, dialect, leg, fromPrincipal, toPrincipal) {
43
+ if (leg.encoding === "verbatim")
44
+ return [buildVerbatimPair(fromPrincipal, toPrincipal)];
45
+ const seg = encodeScopeSegment(fromPrincipal);
46
+ const projHead = `proj:${seg}/`;
47
+ const userProjHead = `userproj:${seg}/`;
48
+ const res = await exec.query(buildMemoryScopeScanSql(dialect, leg.table), [
49
+ formatUserScope(fromPrincipal),
50
+ projHead.length,
51
+ projHead,
52
+ userProjHead.length,
53
+ userProjHead,
54
+ ]);
55
+ const pairs = [];
56
+ for (const row of res.rows) {
57
+ const scope = String(row.scope);
58
+ const to = rewriteMemoryScope(scope, fromPrincipal, toPrincipal);
59
+ if (to !== undefined)
60
+ pairs.push({ from: scope, to });
61
+ }
62
+ return pairs;
63
+ }
64
+ export function createAdoptionRunner(deps) {
65
+ const { store, dialect, now } = deps;
66
+ /**
67
+ * phase 0 的**列宽实证**(codex R2-F2,亲核属实):路由那道 `checkAdoptionWidths` 是纯函数,只看得见
68
+ * `user:<enc(to)>`;而 memory 迁移还会重写 `proj:`/`userproj:` 键并**保留原有的项目段后缀**。源侧
69
+ * principal 短 + 项目段长的键可以正好装进 VARCHAR(190),换上更长的目的地段之后就越界 —— 那时行已经
70
+ * 铸好、失败发生在 phase 3 的腿里,弧永远停在 INTENT,每次重发和每次 boot 续跑都确定性地再撞一次。
71
+ * ⇒ 在**任何 UPDATE 之前**拿库里真实存在的 scope 值算一遍,越界就 typed 拒(可重来,不留半迁移)。
72
+ */
73
+ async function unrepresentableScopes(exec, fromPrincipal, toPrincipal) {
74
+ const bad = [];
75
+ const seen = new Set();
76
+ for (const leg of REBIND_LEGS) {
77
+ if (leg.encoding !== "memory-scope")
78
+ continue;
79
+ for (const pair of await resolveValuePairs(exec, dialect, leg, fromPrincipal, toPrincipal)) {
80
+ if (pair.to.length <= MEMORY_SCOPE_MAX_CHARS || seen.has(pair.to))
81
+ continue;
82
+ seen.add(pair.to);
83
+ bad.push(`${leg.table}: "${pair.from}" would become a ${pair.to.length}-character scope key (column caps it at ${MEMORY_SCOPE_MAX_CHARS})`);
84
+ }
85
+ }
86
+ return bad;
87
+ }
88
+ /** phase 0 探针:逐迁移表族查目的地侧是否已有与源侧重叠的逻辑键。**零 UPDATE**。 */
89
+ async function preflight(exec, fromPrincipal, toPrincipal) {
90
+ const hits = [];
91
+ for (const leg of REBIND_LEGS) {
92
+ // residualKey === undefined = 没有任何唯一键含这根轴 ⇒ 重写**结构上**不可能撞键。
93
+ // 这不是「跳过没查」:它是判定表里逐条写死的判据(plan.ts 每条腿的 residualKey 行内注记了它的唯一键)。
94
+ if (leg.residualKey === undefined)
95
+ continue;
96
+ const pairs = await resolveValuePairs(exec, dialect, leg, fromPrincipal, toPrincipal);
97
+ for (const pair of pairs) {
98
+ const res = await exec.query(buildConflictProbeSql(dialect, leg), [pair.to, pair.from]);
99
+ const n = Number(res.rows[0]?.n ?? 0);
100
+ if (n > 0)
101
+ hits.push({ leg: leg.leg, table: leg.table, overlapping: n });
102
+ }
103
+ }
104
+ return hits;
105
+ }
106
+ /**
107
+ * `blob-rewrite` 腿:逐行改写 JSON 载荷里的身份字段。
108
+ *
109
+ * 🔴 **只改值等于旧身份的字段**(不盲改):载荷里可能有同名但语义不同的字段,而且盲改会让「重跑」
110
+ * 变成一次真写。计数按**内容真变了**算 —— 重跑时新载荷与库里逐字节相同,行数报 0(诚实的幂等读数)。
111
+ * 载荷解析失败 ⇒ **原样上抛**:一条读不动的真源行,静默跳过就等于宣布它已迁移(fail-closed)。
112
+ */
113
+ async function scanBlobRows(exec, leg, fromValue, toValue) {
114
+ const blob = leg.blob;
115
+ const cells = 1 + (blob.alsoMatch?.length ?? 0);
116
+ // 两个桶:旧值(还没迁的行)+ 新值(投影迁了而载荷停在旧身份的「劈开」行)。见 buildBlobScanSql 的注。
117
+ return (await exec.query(buildBlobScanSql(dialect, leg), [...Array.from({ length: cells }, () => fromValue), ...Array.from({ length: cells }, () => toValue)])).rows;
118
+ }
119
+ /** 载荷 JSON 的解析(两方言:node-pg 对 jsonb 自己 parse,mysql2 对 JSON 列同样可能已 parse)。 */
120
+ function parseBlob(leg, row) {
121
+ const raw = row[leg.blob.column];
122
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
123
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
124
+ throw new Error(`adoption: ${leg.table}.${leg.blob.column} is not a JSON object (leg ${leg.leg}) — refusing to declare this row migrated`);
125
+ }
126
+ return parsed;
127
+ }
128
+ async function runBlobLeg(exec, leg, fromValue, toValue) {
129
+ const blob = leg.blob;
130
+ if (blob === undefined)
131
+ throw new Error(`runBlobLeg: leg ${leg.leg} has no blob spec`);
132
+ let rows = 0;
133
+ for (const row of await scanBlobRows(exec, leg, fromValue, toValue)) {
134
+ const parsed = parseBlob(leg, row);
135
+ const before = JSON.stringify(parsed);
136
+ const next = { ...parsed };
137
+ for (const field of blob.fields)
138
+ if (next[field] === fromValue)
139
+ next[field] = toValue;
140
+ // 载荷内嵌的版本号跟着列上的 `rev + 1` 走(见 BlobRewriteSpec.revInPayload 的注:两处是同一个
141
+ // CAS 的读写两端,让它们各说一个版本 = 下一次正常写的守卫读到的基准是假的)。
142
+ const bumped = { ...next };
143
+ const scannedRev = Number(row[blob.revColumn]);
144
+ // 只有真读到数才同步载荷里的版本号。读不到时**不编一个**(写进 NaN/null 比不写更坏):
145
+ // 列上的 `rev = rev + 1` 仍然是权威的那一次推进,而 CAS 的 `AND rev = ?` 会用同一个值,
146
+ // 所以读不到数的行根本写不进去(响亮抛),不存在「悄悄写了个坏版本号」这条路。
147
+ if (blob.revInPayload !== undefined && Number.isFinite(scannedRev))
148
+ bumped[blob.revInPayload] = scannedRev + 1;
149
+ const after = JSON.stringify(bumped);
150
+ // 判「有没有变」只看**身份字段**那一半:rev 是我们自己推的,拿它去判会让每次重跑都变成一次真写。
151
+ if (JSON.stringify(next) === before)
152
+ continue; // 已是新身份(重跑/本来就属新主)⇒ 语义无操作,不计数
153
+ const res = await exec.query(buildBlobWriteSql(dialect, leg), [after, ...blob.pk.map((c) => row[c]), row[blob.revColumn]]);
154
+ if (res.affected !== 1) {
155
+ // rev-CAS 落空 = 读到写之间有一次正常业务写提交(codex R2-F1)。**响亮抛** ⇒ 整个事务回滚
156
+ // ⇒ 两侧字节零变更。绝不「重读再盖」:那正是把别人的写吞掉的那条路。
157
+ throw new Error(`adoption: concurrent write on ${leg.table} (leg ${leg.leg}, pk=${blob.pk.map((c) => String(row[c])).join("/")}) — ` +
158
+ "its rev moved between read and write, so the payload rewrite was refused and the whole rebind rolled back. " +
159
+ "Adoption requires the source identity to be quiesced (design/183 I1: stop the engine first), then retry.");
160
+ }
161
+ rows += 1;
162
+ }
163
+ return rows;
164
+ }
165
+ /** phase 3 的腿:全部在**一个事务**里(见 store.rebindTransaction 的顶注)。 */
166
+ async function runLegs(exec, fromPrincipal, toPrincipal) {
167
+ const out = [];
168
+ for (const leg of REBIND_LEGS) {
169
+ if (leg.kind === "blob-rewrite") {
170
+ out.push({ store: leg.leg, action: leg.action, rows: await runBlobLeg(exec, leg, fromPrincipal, toPrincipal) });
171
+ continue;
172
+ }
173
+ if (leg.kind === "session-policy-rekey") {
174
+ const scan = await exec.query(buildSessionPolicyScanSql(dialect), [fromPrincipal]);
175
+ let rows = 0;
176
+ for (const row of scan.rows) {
177
+ const sessionId = String(row.session_id);
178
+ const oldKey = String(row.policy_key);
179
+ const newKey = policyKeyFor(sessionId, toPrincipal);
180
+ const res = await exec.query(buildSessionPolicyRewriteSql(dialect), [newKey, toPrincipal, oldKey]);
181
+ rows += res.affected;
182
+ }
183
+ out.push({ store: leg.leg, action: leg.action, rows });
184
+ continue;
185
+ }
186
+ const pairs = await resolveValuePairs(exec, dialect, leg, fromPrincipal, toPrincipal);
187
+ let rows = 0;
188
+ for (const pair of pairs) {
189
+ const res = await exec.query(buildRebindSql(dialect, leg), [...leg.columns.map(() => pair.to), pair.from]);
190
+ rows += res.affected;
191
+ }
192
+ out.push({ store: leg.leg, action: leg.action, rows });
193
+ }
194
+ return out;
195
+ }
196
+ function buildConfigs(toPrincipal) {
197
+ return AFFECTED_CONFIG_TEMPLATES.map((t) => ({
198
+ deployment: t.deployment,
199
+ key: t.key,
200
+ requiredValue: t.requiredValue.replace("%TO%", toPrincipal),
201
+ // 🔴 恒 false:这些配置住在别的部署单元,收编动作够不到 —— 183 §3.3「改不到的配置,不假装改了」。
202
+ migrated: false,
203
+ ack: false,
204
+ witnessedAtMs: null,
205
+ }));
206
+ }
207
+ // 🔴 库行 → 内域一律过 schema(禁裸 `as`,见 wire.ts 的 AdoptionLegsSchema 段)。
208
+ function parseLegs(row) {
209
+ return AdoptionLegsSchema.parse(JSON.parse(row.legsJson));
210
+ }
211
+ function parseConfigs(row) {
212
+ return AdoptionConfigsSchema.parse(JSON.parse(row.configsJson));
213
+ }
214
+ /**
215
+ * 回执的铸造。终态行 ⇒ `immutableReport` 是**落库的那份字节**原样解回(逐字节恒同的来源);
216
+ * 非终态行 ⇒ 同形投影(`status:"stalled"` 就是「这还不是冻结快照」的机读位)。
217
+ * `current` 一律**现算**(183 §6:史实与现势各住各的字段)。
218
+ */
219
+ /**
220
+ * 仍挂在旧身份名下的行数(跨全部腿求和,只读)。
221
+ * 每次回执都现算 —— 它是**现势**,不是史实(183 §6 的两半分家)。
222
+ */
223
+ async function countResidual(exec, fromPrincipal, toPrincipal) {
224
+ // 🔴 按**表**聚合,不按腿(codex R3-F4):字段名承诺的是「行数」,而一条 background_agent 行可以同时
225
+ // 在 owner 轴、scope 轴、载荷三处带旧身份 —— 逐腿累加会把它报成 3。一张表一次判定,行只算一次。
226
+ const byTable = new Map();
227
+ for (const leg of REBIND_LEGS) {
228
+ const slot = byTable.get(leg.table) ?? { match: new Set() };
229
+ if (leg.kind === "blob-rewrite")
230
+ slot.blob = leg;
231
+ else {
232
+ slot.match.add(leg.matchColumn);
233
+ if (leg.encoding === "memory-scope")
234
+ slot.memoryLeg = leg;
235
+ }
236
+ byTable.set(leg.table, slot);
237
+ }
238
+ let n = 0;
239
+ for (const [table, slot] of byTable) {
240
+ if (slot.blob !== undefined) {
241
+ // 载荷表:一次并集扫描(旧值桶 + 新值桶),行只要**投影或载荷**任一处还带旧身份就计一次。
242
+ const blob = slot.blob;
243
+ const projection = [...slot.match, blob.matchColumn, ...(blob.blob?.alsoMatch ?? [])];
244
+ for (const row of await scanBlobRows(exec, blob, fromPrincipal, toPrincipal)) {
245
+ const payloadStale = blob.blob.fields.some((f) => parseBlob(blob, row)[f] === fromPrincipal);
246
+ const projectionStale = projection.some((c) => row[c] === fromPrincipal);
247
+ if (payloadStale || projectionStale)
248
+ n += 1;
249
+ }
250
+ continue;
251
+ }
252
+ // 普通表:一条 COUNT,谓词是该表全部身份轴的并。memory 族的值对逐对各来一次(不同 scope 键 =
253
+ // 不同的行集合,不会重复计数同一行)。
254
+ const cols = [...slot.match];
255
+ const pairs = slot.memoryLeg !== undefined
256
+ ? await resolveValuePairs(exec, dialect, slot.memoryLeg, fromPrincipal, toPrincipal)
257
+ : [{ from: fromPrincipal, to: toPrincipal }];
258
+ for (const pair of pairs) {
259
+ const res = await exec.query(buildResidualCountSql(dialect, table, cols), cols.map(() => pair.from));
260
+ n += Number(res.rows[0]?.n ?? 0);
261
+ }
262
+ }
263
+ return n;
264
+ }
265
+ function receiptOf(row, residualSourceRows) {
266
+ const configs = parseConfigs(row);
267
+ const report = row.reportJson !== null
268
+ ? AdoptionReportSchema.parse(JSON.parse(row.reportJson))
269
+ : AdoptionReportSchema.parse({
270
+ adoptionId: row.adoptionId,
271
+ form: "b",
272
+ from: { kind: "principal", principal: row.fromPrincipal },
273
+ toPrincipal: row.toPrincipal,
274
+ atMs: row.updatedAtMs,
275
+ affectedDeploymentConfigs: configs,
276
+ legs: parseLegs(row),
277
+ notMigratedByDesign: [...NOT_MIGRATED_BY_DESIGN],
278
+ });
279
+ return AdoptionReceiptSchema.parse({
280
+ status: row.state === "adopted" ? "adopted" : "stalled",
281
+ immutableReport: report,
282
+ current: {
283
+ outstandingConfigs: configs.filter((c) => c.witnessedAtMs === null).map((c) => `${c.deployment}:${c.key}`),
284
+ // form b 零承运 ⇒ 无拒收行(结构位在场的理由见 wire.ts 顶注差异②)。
285
+ quarantined: 0,
286
+ residualSourceRows,
287
+ },
288
+ });
289
+ }
290
+ /** 拒绝详情的落库形 = `{detail, conflicts}` 的 JSON(一列存两件,免得再加两个只有拒才用的列)。 */
291
+ function rejectedOutcome(row) {
292
+ let detail = "destination conflict";
293
+ let conflicts = [];
294
+ if (row.rejectDetail !== null) {
295
+ // safeParse:一条**坏掉的**拒绝详情不该把「这条弧被拒了」这件更重要的事实吞掉 —— 拒仍然是拒,
296
+ // 只是失去了逐表族的明细(fail-closed 的方向:降级成粗理由,绝不降级成「成功」)。
297
+ const parsed = AdoptionRejectDetailSchema.safeParse(JSON.parse(row.rejectDetail));
298
+ if (parsed.success) {
299
+ detail = parsed.data.detail;
300
+ conflicts = parsed.data.conflicts;
301
+ }
302
+ }
303
+ const code = row.rejectCode === "adoption.destination_unrepresentable" ? "adoption.destination_unrepresentable" : "adoption.destination_conflict";
304
+ return { kind: "rejected", code, adoptionId: row.adoptionId, detail, conflicts };
305
+ }
306
+ /** 从任意 phase 把弧推到终态(幂等、可重入)。**必须在弧锁下调用**。 */
307
+ async function drive(start, sweep, conn) {
308
+ let row = start;
309
+ const reread = async () => {
310
+ const r = await store.getById(row.adoptionId, conn);
311
+ if (!r)
312
+ throw new Error(`adoption row vanished mid-arc (id=${row.adoptionId})`);
313
+ return r;
314
+ };
315
+ if (row.state === "rejected")
316
+ return rejectedOutcome(row);
317
+ if (row.state === "in_flight" && row.phase === ADOPTION_PHASE.INTENT) {
318
+ // ③a preflight —— 任何 UPDATE 之前,只读。先证「装得下」,再查「撞不撞」。
319
+ const unrepresentable = await unrepresentableScopes(conn, row.fromPrincipal, row.toPrincipal);
320
+ if (unrepresentable.length > 0) {
321
+ const detail = JSON.stringify({
322
+ detail: `the destination principal cannot be represented in every derived key: ${unrepresentable.join("; ")}`,
323
+ conflicts: [],
324
+ });
325
+ await store.finalizeRejected(row.adoptionId, "adoption.destination_unrepresentable", detail, now(), conn);
326
+ return rejectedOutcome(await reread());
327
+ }
328
+ const conflicts = await preflight(conn, row.fromPrincipal, row.toPrincipal);
329
+ if (conflicts.length > 0) {
330
+ const detail = JSON.stringify({ detail: "destination already holds rows sharing a logical key with the source", conflicts });
331
+ await store.finalizeRejected(row.adoptionId, "adoption.destination_conflict", detail, now(), conn);
332
+ return rejectedOutcome(await reread());
333
+ }
334
+ // ③b 全部腿 + phase CAS,同一个事务(任何异常 ⇒ 整体回滚 ⇒ 两侧字节零变更)。
335
+ try {
336
+ await store.rebindOn(conn, row.adoptionId, ADOPTION_PHASE.INTENT, ADOPTION_PHASE.REBOUND, now(), (exec) => runLegs(exec, row.fromPrincipal, row.toPrincipal), (legs) => JSON.stringify(legs));
337
+ }
338
+ catch (err) {
339
+ if (!isDupKey(dialect, err))
340
+ throw err;
341
+ // 跨轴组合形:preflight 按迁移**前**的库态逐轴算,两根轴的组合可能在中途撞唯一键。
342
+ // 事务已回滚 ⇒ 字节零变更;这里把它落成与 preflight 同码的拒(fail-closed,不 fail-open)。
343
+ const detail = JSON.stringify({
344
+ detail: `unique-key violation while rebinding (cross-axis destination conflict): ${msgOf(err)}`,
345
+ conflicts: [],
346
+ });
347
+ await store.finalizeRejected(row.adoptionId, "adoption.destination_conflict", detail, now(), conn);
348
+ return rejectedOutcome(await reread());
349
+ }
350
+ row = await reread();
351
+ }
352
+ if (row.state === "in_flight" && row.phase === ADOPTION_PHASE.REBOUND) {
353
+ await store.putConfigs(row.adoptionId, ADOPTION_PHASE.REBOUND, ADOPTION_PHASE.CONFIGS, JSON.stringify(buildConfigs(row.toPrincipal)), now(), conn);
354
+ row = await reread();
355
+ }
356
+ if (row.state === "in_flight" && row.phase === ADOPTION_PHASE.CONFIGS) {
357
+ // ⑤ 承运腿:form b 恒零(数据本来就在 SQL)。阶段位显式推进 —— 见本文件顶注的弧图。
358
+ await store.advancePhase(row.adoptionId, ADOPTION_PHASE.CONFIGS, ADOPTION_PHASE.CARRIED, now(), conn);
359
+ row = await reread();
360
+ }
361
+ if (row.state === "in_flight" && row.phase === ADOPTION_PHASE.CARRIED) {
362
+ const report = AdoptionReportSchema.parse({
363
+ adoptionId: row.adoptionId,
364
+ form: "b",
365
+ from: { kind: "principal", principal: row.fromPrincipal },
366
+ toPrincipal: row.toPrincipal,
367
+ atMs: now(),
368
+ affectedDeploymentConfigs: parseConfigs(row),
369
+ legs: parseLegs(row),
370
+ notMigratedByDesign: [...NOT_MIGRATED_BY_DESIGN],
371
+ });
372
+ await store.finalizeAdopted(row.adoptionId, ADOPTION_PHASE.CARRIED, JSON.stringify(report), now(), conn);
373
+ row = await reread();
374
+ }
375
+ if (row.state === "rejected")
376
+ return rejectedOutcome(row);
377
+ // ⑦ 终态**之后**的迟到行清扫(codex R1-F1 的可检测/可收敛半)。见 sweepLate 的注。
378
+ if (sweep && row.state === "adopted")
379
+ await sweepLate(row, conn);
380
+ return { kind: "receipt", adoptionId: row.adoptionId, receipt: receiptOf(row, await residualOf(row, conn)) };
381
+ }
382
+ /**
383
+ * 把收编**结束之后**又落到旧身份下的行搬过去。
384
+ *
385
+ * 🔴 为什么需要它:收编不给普通写者上栅栏 —— 183 I1 的口径是「引擎必须先停」,那是**运维前提**不是
386
+ * 机器保证(§3.2 明写「单靠锁文件是运维假设」)。真实部署里配置随迁是**别的部署单元**的活
387
+ * (`affectedDeploymentConfigs` 全部 `migrated:false`),所以在配置改完之前,BFF/cli 会继续按旧身份
388
+ * 注入、继续往旧身份下写行。终态 + 幂等短路会让回执一直说「adopted」,而那些行永远没人搬。
389
+ *
390
+ * 处置:**每次 POST**(写动词)在终态之后再跑一遍腿(幂等,干净时零行变化)。这不是「二次收编」——
391
+ * `immutableReport` 一个字节不动,变的只有 `current.residualSourceRows` 这个现势读数。
392
+ * 清扫本身不改 phase(终态是永久的),失败也不把弧打回在飞:留痕 + 让残留读数继续报真话。
393
+ */
394
+ async function sweepLate(row, conn) {
395
+ try {
396
+ const legs = await store.sweepOn(conn, (exec) => runLegs(exec, row.fromPrincipal, row.toPrincipal));
397
+ const moved = legs.reduce((n, l) => n + (l.rows ?? 0), 0);
398
+ if (moved > 0) {
399
+ deps.logger?.warn?.("adoption_late_rows_swept", {
400
+ adoptionId: row.adoptionId,
401
+ fromPrincipal: row.fromPrincipal,
402
+ rows: moved,
403
+ note: "rows kept landing under the source principal AFTER adoption terminalised — the deployment configs are still injecting the old identity (see affectedDeploymentConfigs / outstandingConfigs)",
404
+ });
405
+ }
406
+ }
407
+ catch (err) {
408
+ // 清扫是收敛动作,不是终态的一部分:失败不该把一个已完成的收编变成错误应答。
409
+ // 但也**绝不静默** —— 留痕 + 残留读数会继续报非零。
410
+ deps.logger?.warn?.("adoption_late_sweep_failed", { adoptionId: row.adoptionId, error: msgOf(err) });
411
+ }
412
+ }
413
+ async function residualOf(row, exec = store.reader) {
414
+ try {
415
+ return await countResidual(exec, row.fromPrincipal, row.toPrincipal);
416
+ }
417
+ catch (err) {
418
+ deps.logger?.warn?.("adoption_residual_count_failed", { adoptionId: row.adoptionId, error: msgOf(err) });
419
+ // 数不出来 ⇒ **不报 0**(那是「一切干净」的字面断言)。报 -1 不合 schema,故原样上抛给调用方
420
+ // 之外的唯一诚实选择是把它当成「至少有一行没数到」。fail-closed:宁可报 1 让人去查。
421
+ return 1;
422
+ }
423
+ }
424
+ async function driveUnderLock(row, sweep) {
425
+ const out = await store.withLock(adoptionLockName(row.fromPrincipal),
426
+ // 🔴 **拿到锁之后重读**(codex R2-F3):`row` 是排队**之前**读到的快照,而排队期间别的副本可能已经
427
+ // 把这条弧推完或拒掉了。拿旧快照进状态机 = 按一个已经不成立的世界做决定(实测可达的形:一条已被
428
+ // 拒的弧被排队者推去真迁数据 —— 拒**故意**停在 phase INTENT,只判 phase 的 CAS 对它是满足的)。
429
+ // 库级守卫(rebind CAS 里的 `state='in_flight'`)是第二道;这一道让常路根本走不到那儿。
430
+ async (conn) => drive((await store.getById(row.adoptionId, conn)) ?? row, sweep, conn), deps.lockAttempts, deps.lockSleepMs);
431
+ if (out !== undefined)
432
+ return out;
433
+ // 锁被别的副本持着 ⇒ 那条弧正在被推进。回**现势**回执(`stalled`),不等死也不假装成功。
434
+ const cur = (await store.getById(row.adoptionId)) ?? row;
435
+ deps.logger?.info?.("adoption_arc_locked_elsewhere", { adoptionId: cur.adoptionId, phase: cur.phase });
436
+ if (cur.state === "rejected")
437
+ return rejectedOutcome(cur);
438
+ return { kind: "receipt", adoptionId: cur.adoptionId, receipt: receiptOf(cur, await residualOf(cur)) };
439
+ }
440
+ return {
441
+ async start(fromPrincipal, toPrincipal) {
442
+ // 幂等身份 = DB 约束:并发同参 POST 由 `from_principal` UNIQUE 仲裁,禁 SELECT-then-INSERT。
443
+ const { row } = await store.ensureAdoption({ adoptionId: randomUUID(), fromPrincipal, toPrincipal, nowMs: now() });
444
+ if (row.toPrincipal !== toPrincipal) {
445
+ // 同 from 异 to = 183 D7「二次收编/多租转让」,非本协议射程 ⇒ typed 409。
446
+ return { kind: "source_already_bound", adoptionId: row.adoptionId, fromPrincipal, boundTo: row.toPrincipal };
447
+ }
448
+ return driveUnderLock(row, true);
449
+ },
450
+ async get(adoptionId) {
451
+ const row = await store.getById(adoptionId);
452
+ if (!row)
453
+ return null;
454
+ if (row.state === "rejected")
455
+ return rejectedOutcome(row);
456
+ // GET 是读动词:只报残留读数,**不**顺带清扫(清扫是 POST 的活)。
457
+ if (row.state === "adopted")
458
+ return { kind: "receipt", adoptionId, receipt: receiptOf(row, await residualOf(row)) };
459
+ // 在飞行读面**顺手续跑**:一次崩溃之后没人再发 POST 的部署,读一次状态就把弧推完
460
+ // (与 boot 扫描同一条 drive,不是第二套状态机)。
461
+ return driveUnderLock(row, false);
462
+ },
463
+ async resumeInFlight() {
464
+ const rows = await store.listInFlight();
465
+ const resumed = [];
466
+ const stalled = [];
467
+ const rejected = [];
468
+ const failed = [];
469
+ for (const row of rows) {
470
+ // 🔴 **逐弧兜住异常**(本车自测逮到):一条推不动的弧(库上有一行坏数据、一次并发写撞了 CAS…)
471
+ // 若把整趟扫描一起掀翻,**别的**弧就再也没人续跑了 —— 一条坏行绑架了全部恢复。
472
+ // 逐行 fail-loud(进 `failed` + 留痕),扫描继续。
473
+ let out;
474
+ try {
475
+ out = await driveUnderLock(row, true); // boot 是写得起的场合:顺带把迟到行扫干净
476
+ }
477
+ catch (err) {
478
+ failed.push(row.adoptionId);
479
+ deps.logger?.warn?.("adoption_resume_failed", { adoptionId: row.adoptionId, error: msgOf(err) });
480
+ continue;
481
+ }
482
+ // 闭集穷举:结局多一种 ⇒ 这里编译红,不会静默落进某个默认臂。
483
+ switch (out.kind) {
484
+ case "rejected":
485
+ rejected.push(out.adoptionId);
486
+ break;
487
+ case "source_already_bound":
488
+ // 到不了:`drive` 只从库里既有行推进,不铸新行 —— 这一支是 `start()` 的入口判决。
489
+ // 留一条**响亮**的记账而不是 `default: break`(静默吞会把一次真的状态机漂移变成看不见的丢弧)。
490
+ stalled.push(out.adoptionId);
491
+ deps.logger?.warn?.("adoption_resume_unexpected_outcome", { adoptionId: out.adoptionId, kind: out.kind });
492
+ break;
493
+ case "receipt":
494
+ if (out.receipt.status === "adopted")
495
+ resumed.push(out.adoptionId);
496
+ else
497
+ stalled.push(out.adoptionId);
498
+ break;
499
+ }
500
+ }
501
+ return { resumed, stalled, rejected, failed };
502
+ },
503
+ };
504
+ }
505
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 收编迁移语句的**双方言文本铸造**(design/183 §4.3 form b)。
3
+ *
4
+ * 🔴 为什么这里是**生成**而不是像别的店那样把两句话手写并排(sql-driver.ts 的 A12 判据):那条纪律的
5
+ * 目的是「读代码的人能一眼对比两方言」。本车的语句是从 {@link REBIND_LEGS} 计划表派生的 —— 手抄 26 条
6
+ * 腿 × 3 类语句 × 2 方言 = 156 段几乎一样的 SQL,抄漏一个列名是**静默**的(库上照样跑,只是少迁一列)。
7
+ * 所以判据换了个执行面、强度不降反升:每条腿每个方言的**成品文本**由
8
+ * `test/adoption-sql-text.test.ts` 逐字钉成 golden —— 计划表改一个字,红的是那条腿的文本对照,
9
+ * 而不是等真库上撞。
10
+ *
11
+ * 方言差异全部集中在 {@link ph}(占位符 `?` vs `$n`)与 `LEFT()` 的大小写无关形上;除此之外两方言的
12
+ * 文本**逐字相同**(本车的语句只用等值谓词、COUNT、UPDATE —— 刻意不碰任何方言分岔的语法面:
13
+ * 没有 upsert、没有 JSON 转型、没有 null-safe 比较)。
14
+ */
15
+ import type { SqlDialect } from "../plugins/sql-driver.js";
16
+ import type { RebindLegSpec } from "./plan.js";
17
+ /** 第 `i` 个占位符(1-based)。TiDB `?` / PG `$i` —— 这是本文件唯一的方言分岔点。 */
18
+ export declare function ph(dialect: SqlDialect, i: number): string;
19
+ /**
20
+ * 目的地冲突探针(phase 0,**任何 UPDATE 之前**)。
21
+ *
22
+ * 判据 = 「to 侧已有的行,与 from 侧的行,是否共享同一个**逻辑键**」。逻辑键 =
23
+ * `leg.residualKey`(该表最紧的唯一键**去掉身份轴**之后剩下的列):
24
+ * · `undefined` ⇒ 没有唯一键含这根轴 ⇒ 结构性无冲突,本函数不该被调用(调用方按 `undefined` 短路);
25
+ * · `[]` ⇒ 唯一键**就是**这根轴(PK(scope))⇒ 两侧各有任意一行即冲突,退化成笛卡尔计数;
26
+ * · 非空 ⇒ 逐列相等的自连接计数。
27
+ *
28
+ * 参数序:`[toValue, fromValue]`。
29
+ */
30
+ export declare function buildConflictProbeSql(dialect: SqlDialect, leg: RebindLegSpec): string;
31
+ /**
32
+ * 桶迁/行迁的等值 UPDATE。`columns` 里的每一列都被设成**同一个**新值(字节孪生 `*_key` 与可读列一起走),
33
+ * 谓词打在 `matchColumn` 上。参数序:`[...新值 × columns.length, 旧值]`。
34
+ *
35
+ * 幂等性来自谓词本身:重跑时旧值已经不在库里 ⇒ 零行匹配 ⇒ 零行变化。
36
+ */
37
+ export declare function buildRebindSql(dialect: SqlDialect, leg: RebindLegSpec): string;
38
+ /**
39
+ * memory 族的 scope 值扫描:把「属于这个 principal 的 scope 键」从库里**读出来**,而不是用 LIKE 去猜。
40
+ *
41
+ * 🔴 为什么不是 `LIKE '<前缀>%'`:scope 的 tenant 段是**百分号编码**的(`user%3Aweb-demo`),而 `%` 在
42
+ * LIKE 里是通配符 —— 不逐字符转义就会误命中别的租户的盘(**跨租户误迁**,不是性能问题)。`LEFT(col, n) = ?`
43
+ * 是纯等值比较,没有元字符面,两方言同形。
44
+ *
45
+ * 参数序:`[userScope, projHead.length, projHead, userProjHead.length, userProjHead]`。
46
+ */
47
+ export declare function buildMemoryScopeScanSql(dialect: SqlDialect, table: string): string;
48
+ /**
49
+ * 残留计数:某条腿上**仍挂在旧身份名下**的行数。参数序:`[fromValue]`。
50
+ *
51
+ * 用途见 wire.ts 的 `current.residualSourceRows`:收编不给普通写者上栅栏(183 I1 = 运维前提「引擎先停」),
52
+ * 所以「终态之后旧身份下又长出行」是一个真实可能的形 —— 这条查询把它变成可见读数,而不是靠祈祷。
53
+ */
54
+ export declare function buildResidualCountSql(dialect: SqlDialect, table: string, matchColumns: readonly string[]): string;
55
+ /**
56
+ * `blob-rewrite` 腿的扫描:按**旧**身份值把行连同 JSON 载荷读出来。
57
+ * 匹配面 = `matchColumn` 与 `blob.alsoMatch` 的**并**(同表两根轴都可能带旧值)。
58
+ * 参数序:`[fromValue × (1 + alsoMatch.length)]`。
59
+ */
60
+ export declare function buildBlobScanSql(dialect: SqlDialect, leg: RebindLegSpec): string;
61
+ /**
62
+ * `blob-rewrite` 腿的逐行回写。参数序:`[新载荷文本, ...pk 值]`。
63
+ *
64
+ * 幂等性:重跑时载荷里的身份字段已是新值 ⇒ 调用方算出的新载荷与库里逐字节相同 ⇒ 语义无操作
65
+ * (行数可能仍报 1,故回执的行数按「**内容真变了**才计」在调用侧收窄)。
66
+ */
67
+ export declare function buildBlobWriteSql(dialect: SqlDialect, leg: RebindLegSpec): string;
68
+ /** session_policy 重键腿的扫描:拿到 from 侧每一行的 `(policy_key, session_id)`。参数序:`[fromPrincipal]`。 */
69
+ export declare function buildSessionPolicyScanSql(dialect: SqlDialect): string;
70
+ /**
71
+ * session_policy 的逐行重键改写。`policy_key = sha256(JSON.stringify([sessionId, principal ?? null]))`,
72
+ * 身份在**主键的 hash 原像**里 ⇒ 等值 UPDATE 够不着,必须读出来重算再改写。
73
+ * 参数序:`[newPolicyKey, toPrincipal, oldPolicyKey]`。
74
+ */
75
+ export declare function buildSessionPolicyRewriteSql(dialect: SqlDialect): string;
76
+ //# sourceMappingURL=sql.d.ts.map