@sema-agent/server 7.82.0 → 7.83.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 (89) hide show
  1. package/USAGE.md +17 -14
  2. package/dist/bench/s1/arms.d.ts +4 -4
  3. package/dist/bench/s1/arms.js +1 -2
  4. package/dist/bench/s1/live-deps.js +0 -1
  5. package/dist/bench/s1/runner-ctx.d.ts +3 -3
  6. package/dist/bench/s1/tasks.d.ts +8 -4
  7. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  8. package/dist/boot/stage-07-capability-layer.js +1 -1
  9. package/dist/boot/stage-08-reapers.d.ts +1 -0
  10. package/dist/boot/stage-09-leader.d.ts +1 -0
  11. package/dist/boot/stage-10-http-server.d.ts +1 -0
  12. package/dist/boot/stage-10-http-server.js +2 -1
  13. package/dist/config-catalog.js +2 -3
  14. package/dist/config.js +1 -0
  15. package/dist/http/routes/capabilities.js +2 -0
  16. package/dist/http/server.d.ts +8 -0
  17. package/dist/leader/endpoint.js +2 -0
  18. package/dist/leader/leader.d.ts +33 -6
  19. package/dist/leader/leader.js +54 -45
  20. package/dist/leader/merge.d.ts +38 -29
  21. package/dist/leader/merge.js +45 -38
  22. package/dist/leader/wire.d.ts +2 -17
  23. package/dist/leader/wire.js +5 -58
  24. package/dist/observability/fail-open.d.ts +5 -5
  25. package/dist/observability/fail-open.js +5 -2
  26. package/dist/observability/run-terminal-log.d.ts +0 -7
  27. package/dist/observability/run-terminal-log.js +9 -20
  28. package/dist/plugins/adoption-log-sql.d.ts +3 -0
  29. package/dist/plugins/adoption-log-sql.js +5 -1
  30. package/dist/plugins/approval-ask-store-sql.d.ts +3 -0
  31. package/dist/plugins/approval-ask-store-sql.js +9 -5
  32. package/dist/plugins/background-agent-store-sql.d.ts +3 -0
  33. package/dist/plugins/background-agent-store-sql.js +7 -2
  34. package/dist/plugins/device-store-sql.d.ts +3 -0
  35. package/dist/plugins/device-store-sql.js +9 -4
  36. package/dist/plugins/ensure-index.d.ts +42 -0
  37. package/dist/plugins/ensure-index.js +81 -0
  38. package/dist/plugins/ensure-schema-lock-policy.d.ts +29 -0
  39. package/dist/plugins/ensure-schema-lock-policy.js +31 -0
  40. package/dist/plugins/image-bake-store-sql.d.ts +3 -0
  41. package/dist/plugins/image-bake-store-sql.js +6 -2
  42. package/dist/plugins/image-index-sql.d.ts +3 -0
  43. package/dist/plugins/image-index-sql.js +8 -4
  44. package/dist/plugins/leader-run-store-sql.d.ts +3 -0
  45. package/dist/plugins/leader-run-store-sql.js +6 -2
  46. package/dist/plugins/mailbox-store-sql.d.ts +3 -0
  47. package/dist/plugins/mailbox-store-sql.js +6 -1
  48. package/dist/plugins/memory-engine-pg.d.ts +4 -0
  49. package/dist/plugins/memory-engine-pg.js +6 -6
  50. package/dist/plugins/memory-engine-tidb.js +5 -0
  51. package/dist/plugins/memory-sync-store-pg.d.ts +5 -0
  52. package/dist/plugins/memory-sync-store-pg.js +9 -2
  53. package/dist/plugins/memory-sync-store-tidb.js +6 -0
  54. package/dist/plugins/one-time-migrations.d.ts +1 -1
  55. package/dist/plugins/one-time-migrations.js +7 -14
  56. package/dist/plugins/outcome-ledger-sql.d.ts +3 -0
  57. package/dist/plugins/outcome-ledger-sql.js +7 -3
  58. package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
  59. package/dist/plugins/permission-rule-store-sql.js +9 -5
  60. package/dist/plugins/pg-cost-quota.d.ts +3 -0
  61. package/dist/plugins/pg-cost-quota.js +5 -1
  62. package/dist/plugins/pg-pool.d.ts +23 -2
  63. package/dist/plugins/pg-pool.js +22 -37
  64. package/dist/plugins/pg-rate-limiter.d.ts +3 -0
  65. package/dist/plugins/pg-rate-limiter.js +5 -1
  66. package/dist/plugins/pg-session-storage.d.ts +3 -0
  67. package/dist/plugins/pg-session-storage.js +8 -4
  68. package/dist/plugins/remote-env-adb.d.ts +62 -3
  69. package/dist/plugins/remote-env-adb.js +27 -15
  70. package/dist/plugins/remote-env-host.js +1 -1
  71. package/dist/plugins/remote-shell.d.ts +7 -11
  72. package/dist/plugins/remote-shell.js +3 -3
  73. package/dist/plugins/retention-store-sql.d.ts +3 -0
  74. package/dist/plugins/retention-store-sql.js +7 -3
  75. package/dist/plugins/roster-store-sql.d.ts +3 -0
  76. package/dist/plugins/roster-store-sql.js +6 -1
  77. package/dist/plugins/schema-indexes.d.ts +21 -0
  78. package/dist/plugins/schema-indexes.js +25 -0
  79. package/dist/plugins/task-attachment-store.d.ts +3 -0
  80. package/dist/plugins/task-attachment-store.js +7 -2
  81. package/dist/plugins/tidb-pool.d.ts +15 -3
  82. package/dist/plugins/tidb-pool.js +38 -15
  83. package/dist/plugins/tool-result-store-sql.d.ts +3 -0
  84. package/dist/plugins/tool-result-store-sql.js +5 -1
  85. package/dist/plugins/web-search.d.ts +50 -2
  86. package/dist/plugins/web-search.js +9 -2
  87. package/dist/trace/project.d.ts +2 -1
  88. package/dist/trace/redact.d.ts +6 -0
  89. package/package.json +2 -2
@@ -41,6 +41,7 @@ import type { Pool as PgPool } from "pg";
41
41
  import type { PgQueryFn } from "./pg-query.js";
42
42
  import { type SqlDriver, type SqlDialect } from "./sql-driver.js";
43
43
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
44
+ import { type IndexSpec } from "./ensure-index.js";
44
45
  export type AskDecision = "approve" | "deny";
45
46
  export interface AskRow {
46
47
  askId: string;
@@ -449,6 +450,8 @@ export declare const TIDB_APPROVAL_ASK_STATEMENTS: readonly string[];
449
450
  /** {@link TIDB_APPROVAL_ASK_STATEMENTS} 的遍历壳——生产路径走 `tidb-pool.ts` 的中央 `ensureSchema`
450
451
  * (那里展开了同一份数组),本函数留给只需要这两张表的集成测试。 */
451
452
  export declare function ensureTiDBApprovalAskSchema(pool: MySqlPool): Promise<void>;
453
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgApprovalAskSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
454
+ export declare const APPROVAL_ASK_INDEXES: readonly IndexSpec[];
452
455
  export declare function ensurePgApprovalAskSchema(q: PgQueryFn): Promise<void>;
453
456
  /**
454
457
  * [ref] 升级前置断言 —— **拒启**,不是 warn(形照 [ref] 的 `assertPermissionRuleApprovalSchema` /
@@ -1,6 +1,7 @@
1
1
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
2
  import { canAskTransition } from "../approval-ask-machine.js";
3
3
  import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
4
+ import { ensureIndexes } from "./ensure-index.js";
4
5
  export function assertIntegralEpochMs(field, value) {
5
6
  if (!Number.isSafeInteger(value)) {
6
7
  throw new TypeError(`approval-ask store: ${field} must be an integer epoch-ms (BIGINT column), got ${String(value)}`);
@@ -126,6 +127,13 @@ export async function ensureTiDBApprovalAskSchema(pool) {
126
127
  for (const stmt of TIDB_APPROVAL_ASK_STATEMENTS)
127
128
  await pool.query(stmt);
128
129
  }
130
+ export const APPROVAL_ASK_INDEXES = [
131
+ { table: `${APPROVAL_BATCH_TABLE}`, name: "idx_batch_task", columns: ["task_id"] },
132
+ { table: `${APPROVAL_ASK_TABLE}`, name: "idx_ask_task_state", columns: ["task_id", "state"] },
133
+ { table: `${APPROVAL_ASK_TABLE}`, name: "idx_ask_session_state", columns: ["session_id", "state"] },
134
+ { table: `${APPROVAL_ASK_TABLE}`, name: "idx_ask_batch", columns: ["batch_id"] },
135
+ { table: `${APPROVAL_ASK_TABLE}`, name: "idx_ask_state", columns: ["state"] },
136
+ ];
129
137
  export async function ensurePgApprovalAskSchema(q) {
130
138
  await q(`CREATE TABLE IF NOT EXISTS ${APPROVAL_BATCH_TABLE} (
131
139
  batch_id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -137,7 +145,6 @@ export async function ensurePgApprovalAskSchema(q) {
137
145
  updated_at_ms BIGINT NOT NULL,
138
146
  PRIMARY KEY (batch_id)
139
147
  )`);
140
- await q(`CREATE INDEX IF NOT EXISTS idx_batch_task ON ${APPROVAL_BATCH_TABLE} (task_id)`);
141
148
  await q(`CREATE TABLE IF NOT EXISTS ${APPROVAL_ASK_TABLE} (
142
149
  ask_id VARCHAR(64) COLLATE "C" NOT NULL,
143
150
  task_id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -179,10 +186,7 @@ export async function ensurePgApprovalAskSchema(q) {
179
186
  -- (task_id, idempotency_key) 复合唯一——MySQL twin 逐字同形(改动理由见那侧注)。
180
187
  CONSTRAINT uq_approval_ask_idem UNIQUE (task_id, idempotency_key)
181
188
  )`);
182
- await q(`CREATE INDEX IF NOT EXISTS idx_ask_task_state ON ${APPROVAL_ASK_TABLE} (task_id, state)`);
183
- await q(`CREATE INDEX IF NOT EXISTS idx_ask_session_state ON ${APPROVAL_ASK_TABLE} (session_id, state)`);
184
- await q(`CREATE INDEX IF NOT EXISTS idx_ask_batch ON ${APPROVAL_ASK_TABLE} (batch_id)`);
185
- await q(`CREATE INDEX IF NOT EXISTS idx_ask_state ON ${APPROVAL_ASK_TABLE} (state)`);
189
+ await ensureIndexes(q, "pg", APPROVAL_ASK_INDEXES);
186
190
  }
187
191
  export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
188
192
  try {
@@ -32,6 +32,7 @@
32
32
  import type { Pool } from "mysql2/promise";
33
33
  import { type BackgroundAgentRecord, type BackgroundAgentRowSummary, type BackgroundAgentStore } from "@sema-agent/core";
34
34
  import type { PgQueryFn } from "./pg-query.js";
35
+ import { type IndexSpec } from "./ensure-index.js";
35
36
  export declare const BACKGROUND_AGENT_TABLE = "background_agent";
36
37
  type ReapOpts = {
37
38
  maxAgeMs?: number;
@@ -43,6 +44,8 @@ type ListOpts = {
43
44
  limit?: number;
44
45
  };
45
46
  export declare function ensureTiDBBackgroundAgentSchema(pool: Pool): Promise<void>;
47
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgBackgroundAgentSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
48
+ export declare const BACKGROUND_AGENT_INDEXES: readonly IndexSpec[];
46
49
  export declare function ensurePgBackgroundAgentSchema(q: PgQueryFn): Promise<void>;
47
50
  export declare class TiDBBackgroundAgentStore implements BackgroundAgentStore {
48
51
  private readonly pool;
@@ -1,6 +1,7 @@
1
1
  import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, } from "@sema-agent/core";
2
2
  import { pgSafeJsonStringify, pgSanitizeText } from "./pg-safe-json.js";
3
3
  import { isMysqlDupKeyError } from "./sql-errors.js";
4
+ import { ensureIndexes } from "./ensure-index.js";
4
5
  export const BACKGROUND_AGENT_TABLE = "background_agent";
5
6
  const SUMMARY_COLS = "handle, owner, session_scoped, session_id, parent_session_id, root_session_id, name, agent_type, status, spawned_at_ms, updated_at_ms, settled_at_ms, usage_json";
6
7
  const PROJ_COLS = "scope, owner, session_scoped, session_id, parent_session_id, root_session_id, session_anchor, name, agent_type, status, spawned_at_ms, updated_at_ms, settled_at_ms, usage_json, rev, parked_checkpoint_token, park_claim_id, admitted_org_scopes, admitted_org_write_scope, record_json";
@@ -135,7 +136,12 @@ export async function ensureTiDBBackgroundAgentSchema(pool) {
135
136
  KEY idx_bga_anchor (scope_key, session_anchor, spawned_at_ms),
136
137
  KEY idx_bga_status (scope_key, status, updated_at_ms)
137
138
  ) COLLATE utf8mb4_bin`);
139
+ await ensureIndexes((text, params) => pool.query(text, params), "mysql", BACKGROUND_AGENT_INDEXES);
138
140
  }
141
+ export const BACKGROUND_AGENT_INDEXES = [
142
+ { table: `${BACKGROUND_AGENT_TABLE}`, name: "idx_bga_anchor", columns: ["scope_key", "session_anchor", "spawned_at_ms"] },
143
+ { table: `${BACKGROUND_AGENT_TABLE}`, name: "idx_bga_status", columns: ["scope_key", "status", "updated_at_ms"] },
144
+ ];
139
145
  export async function ensurePgBackgroundAgentSchema(q) {
140
146
  await q(`CREATE TABLE IF NOT EXISTS ${BACKGROUND_AGENT_TABLE} (
141
147
  scope_key VARCHAR(190) COLLATE "C" NOT NULL,
@@ -165,8 +171,7 @@ export async function ensurePgBackgroundAgentSchema(q) {
165
171
  record_json TEXT COLLATE "C" NOT NULL,
166
172
  PRIMARY KEY (scope_key, handle)
167
173
  )`);
168
- await q(`CREATE INDEX IF NOT EXISTS idx_bga_anchor ON ${BACKGROUND_AGENT_TABLE} (scope_key, session_anchor, spawned_at_ms)`);
169
- await q(`CREATE INDEX IF NOT EXISTS idx_bga_status ON ${BACKGROUND_AGENT_TABLE} (scope_key, status, updated_at_ms)`);
174
+ await ensureIndexes(q, "pg", BACKGROUND_AGENT_INDEXES);
170
175
  }
171
176
  export class TiDBBackgroundAgentStore {
172
177
  pool;
@@ -47,6 +47,7 @@ import type { Pool as MySqlPool } from "mysql2/promise";
47
47
  import type { Pool as PgPool } from "pg";
48
48
  import { type SqlDriver } from "./sql-driver.js";
49
49
  import { type AcquireConnectionInput, type AcquireConnectionResult, type AppendAuditInput, type BindSessionInput, type BindSessionResult, type ConsumeEnrollTokenInput, type ConsumeEnrollTokenResult, type DeviceAdmissionView, type DeviceAuditRow, type DeviceEnrollTokenRow, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type HeartbeatConnectionInput, type IssueEnrollTokenInput, type ListAuditFilter, type RebindSessionInput, type RebindSessionResult, type RevokeDeviceInput, type RevokeDeviceResult } from "../device-store.js";
50
+ import { type IndexSpec } from "./ensure-index.js";
50
51
  /**
51
52
  * ── 命名(**落库一律单数**,与设计稿的复数形不同)──────────────────────────────────────────────────
52
53
  * design v2 §6 的 DDL 写的是 `devices` / `device_sessions` / `device_enrollment_tokens`(复数),但本仓有一条
@@ -63,6 +64,8 @@ export declare const TIDB_DEVICE_STATEMENTS: readonly string[];
63
64
  /** {@link TIDB_DEVICE_STATEMENTS} 的遍历壳(集成测试用;生产装配由中央 ensureSchema 展开同一份数组)。 */
64
65
  export declare function ensureTiDBDeviceSchema(pool: MySqlPool): Promise<void>;
65
66
  type PgQuery = (sql: string, params?: unknown[]) => Promise<unknown>;
67
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgDeviceSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
68
+ export declare const DEVICE_INDEXES: readonly IndexSpec[];
66
69
  export declare function ensurePgDeviceSchema(q: PgQuery): Promise<void>;
67
70
  /**
68
71
  * O4 升级前置探针 —— **拒启,不是 warn**([ref]/[ref]/[ref] `assert*Schema` 族先例):`device_audit` 的
@@ -1,6 +1,7 @@
1
1
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
2
  import { isDupKeyError, isCheckViolationError } from "./sql-errors.js";
3
3
  import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRebindShape, assertRevokeShape, assertRootSessionIdShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
4
+ import { ensureIndexes } from "./ensure-index.js";
4
5
  export const DEVICES_TABLE = "device";
5
6
  export const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token";
6
7
  export const DEVICE_SESSIONS_TABLE = "device_session";
@@ -84,7 +85,14 @@ export const TIDB_DEVICE_STATEMENTS = [
84
85
  export async function ensureTiDBDeviceSchema(pool) {
85
86
  for (const stmt of TIDB_DEVICE_STATEMENTS)
86
87
  await pool.query(stmt);
88
+ await ensureIndexes((text, params) => pool.query(text, params), "mysql", DEVICE_INDEXES);
87
89
  }
90
+ export const DEVICE_INDEXES = [
91
+ { table: `${DEVICES_TABLE}`, name: "idx_device_owner", columns: ["owner_tenant", "owner_subject"] },
92
+ { table: `${DEVICE_SESSIONS_TABLE}`, name: "idx_device_session_device", columns: ["device_id"] },
93
+ { table: `${DEVICE_AUDIT_TABLE}`, name: "idx_device_audit_device", columns: ["device_id", "at"] },
94
+ { table: `${DEVICE_AUDIT_TABLE}`, name: "idx_device_audit_event", columns: ["event", "at"] },
95
+ ];
88
96
  export async function ensurePgDeviceSchema(q) {
89
97
  await q(`CREATE TABLE IF NOT EXISTS ${DEVICES_TABLE} (
90
98
  device_id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -112,7 +120,6 @@ export async function ensurePgDeviceSchema(q) {
112
120
  PRIMARY KEY (device_id),
113
121
  CONSTRAINT ${UQ_DEVICES_PUBKEY} UNIQUE (owner_tenant, pubkey)
114
122
  )`);
115
- await q(`CREATE INDEX IF NOT EXISTS idx_device_owner ON ${DEVICES_TABLE} (owner_tenant, owner_subject)`);
116
123
  await q(`CREATE TABLE IF NOT EXISTS ${DEVICE_ENROLL_TOKENS_TABLE} (
117
124
  token_hash VARCHAR(128) COLLATE "C" NOT NULL,
118
125
  target_tenant VARCHAR(190) COLLATE "C" NOT NULL,
@@ -135,7 +142,6 @@ export async function ensurePgDeviceSchema(q) {
135
142
  rev BIGINT NOT NULL DEFAULT 0,
136
143
  PRIMARY KEY (root_session_id)
137
144
  )`);
138
- await q(`CREATE INDEX IF NOT EXISTS idx_device_session_device ON ${DEVICE_SESSIONS_TABLE} (device_id)`);
139
145
  await q(`CREATE TABLE IF NOT EXISTS ${DEVICE_AUDIT_TABLE} (
140
146
  id BIGSERIAL PRIMARY KEY,
141
147
  at TIMESTAMPTZ(3) NOT NULL DEFAULT now(),
@@ -146,8 +152,7 @@ export async function ensurePgDeviceSchema(q) {
146
152
  detail JSONB,
147
153
  CONSTRAINT ck_device_audit_event CHECK (event IN (${AUDIT_EVENT_CHECK}))
148
154
  )`);
149
- await q(`CREATE INDEX IF NOT EXISTS idx_device_audit_device ON ${DEVICE_AUDIT_TABLE} (device_id, at)`);
150
- await q(`CREATE INDEX IF NOT EXISTS idx_device_audit_event ON ${DEVICE_AUDIT_TABLE} (event, at)`);
155
+ await ensureIndexes(q, "pg", DEVICE_INDEXES);
151
156
  }
152
157
  export async function assertDeviceAuditRebindEventSchema(db) {
153
158
  const conn = await db.connect();
@@ -0,0 +1,42 @@
1
+ /** 本原语认得的两种方言(闭集;新方言进来是编译期的事,不是运行期的 `default` 臂)。 */
2
+ export type SqlDialect = "mysql" | "pg";
3
+ /**
4
+ * 一条索引的**声明**(纯数据)。两方言共用同一份声明 —— 这就是"一处属主"的兑现物:
5
+ * per-store 文件各导出自己那份 `readonly IndexSpec[]`,PG 侧由该 store 的 `ensurePg*Schema` 应用,
6
+ * MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 统一应用(与 `SCHEMA_STATEMENTS` 的组合姿势同形)。
7
+ */
8
+ export interface IndexSpec {
9
+ /** 表名(与两方言的 `CREATE TABLE` 同名——本仓两方言表名逐字相同)。 */
10
+ readonly table: string;
11
+ /** 索引名(两方言同名;MySQL 侧它同时是 `CREATE TABLE` 里那条内联 `KEY` 的名字)。 */
12
+ readonly name: string;
13
+ /** 列序(等值前缀在前、排序列在后;与 MySQL 内联 `KEY` 的列序**必须**逐字一致)。 */
14
+ readonly columns: readonly string[];
15
+ /** UNIQUE 索引(MySQL 内联 `UNIQUE KEY` 的孪生)。缺省 = 普通索引。 */
16
+ readonly unique?: boolean;
17
+ }
18
+ /**
19
+ * 两方言的查询面。mysql2 的 `conn.query` 答 `[rows, fields]`,node-pg 答 `{ rows }` —— 两种形的读法
20
+ * 都关在本文件里(调用点只管把自己手上那只 `query` 传进来)。
21
+ *
22
+ * 🔴 **本原语一条绑定参数都不发**(探针与 DDL 全是字面量)。两条理由:
23
+ * ① 它的输入是**声明**不是数据 —— `IndexSpec` 的表名/索引名都是编译期常量,没有用户输入可注入,
24
+ * 字面量与占位符在安全性上等价;
25
+ * ② PG 那半的 `q` 是**调用方递进来的** `PgQueryFn`(十来个 `ensurePg*Schema` 都收它),而那些 ensure
26
+ * 腿在本批之前**从不带参**,于是仓里真有丢掉 `params` 只转发 sql 的包装(如
27
+ * `test/permission-rule-store-db-integration.test.ts` 的 `(sql) => db.query(sql, [])`)。一条带 `$1`
28
+ * 的探针会在那种包装上报 `there is no parameter $1` —— 而那是**调用方**的形,不是本原语能管的。
29
+ * 不带参 ⇒ 这一整类接缝脆弱性结构上不存在。两方言同律,不留一边带参一边不带的不对称。
30
+ */
31
+ export type IndexQueryFn = (sql: string, params?: unknown[]) => Promise<unknown>;
32
+ /**
33
+ * 幂等地保证一条索引在场。**这是本仓唯一允许发加索引 DDL 的地方**(SCHEMA POLICY 窄放的全部内容)。
34
+ *
35
+ * @param q 方言对应的查询面(mysql2 conn/pool 的 `query`,或裸 `PgQueryFn`)
36
+ * @param dialect 方言闭集
37
+ * @param spec 索引声明(两方言共用同一份)
38
+ */
39
+ export declare function ensureIndex(q: IndexQueryFn, dialect: SqlDialect, spec: IndexSpec): Promise<void>;
40
+ /** {@link ensureIndex} 的**逐条串行**遍历(DDL 并发打同一张表会踩 catalog 竞争,故不并行)。 */
41
+ export declare function ensureIndexes(q: IndexQueryFn, dialect: SqlDialect, specs: readonly IndexSpec[]): Promise<void>;
42
+ //# sourceMappingURL=ensure-index.d.ts.map
@@ -0,0 +1,81 @@
1
+ import { recordFailOpen } from "../observability/fail-open.js";
2
+ function rowsOf(res) {
3
+ if (Array.isArray(res)) {
4
+ const first = res[0];
5
+ return Array.isArray(first) ? first : [];
6
+ }
7
+ if (res && typeof res === "object" && Array.isArray(res.rows)) {
8
+ return res.rows;
9
+ }
10
+ return [];
11
+ }
12
+ const quoteId = (dialect, id) => dialect === "mysql" ? `\`${id.replace(/`/g, "``")}\`` : `"${id.replace(/"/g, '""')}"`;
13
+ const quoteLiteral = (s) => `'${s.replace(/'/g, "''")}'`;
14
+ const columnList = (dialect, spec) => spec.columns.map((c) => quoteId(dialect, c)).join(", ");
15
+ function lacksConcurrentlySupport(err) {
16
+ const code = err?.code;
17
+ if (code === "0A000" || code === "42601")
18
+ return true;
19
+ return typeof code !== "string";
20
+ }
21
+ async function pgIndexValid(q, name) {
22
+ const rows = rowsOf(await q(`SELECT i.indisvalid FROM pg_index i WHERE i.indexrelid = to_regclass(${quoteLiteral(name)})`));
23
+ if (rows.length === 0)
24
+ return undefined;
25
+ return rows[0].indisvalid !== false;
26
+ }
27
+ const refusal = (spec, unique, what) => `ensureIndex: PostgreSQL 索引 ${spec.name}(表 ${spec.table})${what} —— 拒绝带病启动。` +
28
+ "(一个 INVALID 索引会让此后每一次 CREATE INDEX … IF NOT EXISTS 静默跳过它,而规划器永不使用它。)" +
29
+ ` 运维配方:DROP INDEX CONCURRENTLY IF EXISTS ${quoteId("pg", spec.name)}; 然后在维护窗口手工 ` +
30
+ `CREATE ${unique}INDEX CONCURRENTLY ${quoteId("pg", spec.name)} ON ${quoteId("pg", spec.table)} (${columnList("pg", spec)});` +
31
+ " 常见根因 = 池级 statement_timeout(DB_QUERY_TIMEOUT_MS)把构建切断(见 cause),或 UNIQUE 索引撞上存量重复行。";
32
+ export async function ensureIndex(q, dialect, spec) {
33
+ switch (dialect) {
34
+ case "mysql": {
35
+ const present = rowsOf(await q("SELECT 1 AS present FROM information_schema.statistics WHERE table_schema = DATABASE() " +
36
+ `AND table_name = ${quoteLiteral(spec.table)} AND index_name = ${quoteLiteral(spec.name)} LIMIT 1`));
37
+ if (present.length > 0)
38
+ return;
39
+ await q(`ALTER TABLE ${quoteId("mysql", spec.table)} ADD ${spec.unique ? "UNIQUE " : ""}INDEX ` +
40
+ `${quoteId("mysql", spec.name)} (${columnList("mysql", spec)})`);
41
+ return;
42
+ }
43
+ case "pg": {
44
+ const unique = spec.unique ? "UNIQUE " : "";
45
+ const name = quoteId("pg", spec.name);
46
+ const tail = `${unique}INDEX CONCURRENTLY IF NOT EXISTS ${name} ON ${quoteId("pg", spec.table)} (${columnList("pg", spec)})`;
47
+ try {
48
+ await q(`CREATE ${tail}`);
49
+ }
50
+ catch (err) {
51
+ if (!lacksConcurrentlySupport(err))
52
+ throw err;
53
+ recordFailOpen("server.ensure-index.concurrently-unsupported", `${spec.table}.${spec.name}`);
54
+ await q(`CREATE ${unique}INDEX IF NOT EXISTS ${name} ON ${quoteId("pg", spec.table)} (${columnList("pg", spec)})`);
55
+ return;
56
+ }
57
+ const valid = await pgIndexValid(q, spec.name);
58
+ if (valid !== false)
59
+ return;
60
+ await q(`DROP INDEX CONCURRENTLY IF EXISTS ${name}`);
61
+ try {
62
+ await q(`CREATE ${tail}`);
63
+ }
64
+ catch (err) {
65
+ throw new Error(refusal(spec, unique, "重建时失败"), { cause: err });
66
+ }
67
+ if ((await pgIndexValid(q, spec.name)) === false)
68
+ throw new Error(refusal(spec, unique, "重建后仍是 INVALID"));
69
+ return;
70
+ }
71
+ default: {
72
+ const _never = dialect;
73
+ throw new Error(`ensureIndex: 未知方言 ${String(_never)}`);
74
+ }
75
+ }
76
+ }
77
+ export async function ensureIndexes(q, dialect, specs) {
78
+ for (const spec of specs)
79
+ await ensureIndex(q, dialect, spec);
80
+ }
81
+ //# sourceMappingURL=ensure-index.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 两方言启动锁(`ensureSchema` / `oneTimeMigrations` 的 MySQL `GET_LOCK(?,0)` 与 PG `pg_try_advisory_lock`)的
3
+ * **轮询策略唯一属主**(7.83.0 修复验证 车HC HC-1/HC-2 后收口:此前两只孪生各抄一份循环,一份改了「永不放弃」
4
+ * 另一份还是「40 次放弃后无锁跑」,而 S-287 之后无锁跑的那条腿发的是不带 `IF NOT EXISTS` 的 `ADD INDEX`,两副本
5
+ * 双发 ⇒ 输的那只拒启)。方言只提供「试一次」的探针,循环、抖动、退避、进展 warn、坏连接上抛全在这里。
6
+ *
7
+ * 三条律:
8
+ * · **按进展轮询,不按次数放弃** —— 持锁方还活着(两方言的锁都是连接级,随连接消失,不会遗留)就一直等,
9
+ * 每 {@link LOCK_PROBE_WARN_EVERY} 次 warn 一行说明在等谁;**永不**无锁跑(「响亮地等」严格优于「静默地竞争 DDL」)。
10
+ * · **坏连接不是争用**(HC-2,对 7.82.1 的回归):探针自己**连续**抛 {@link MAX_CONSECUTIVE_LOCK_PROBE_ERRORS} 次 =
11
+ * 这条连接已不可用(`_queryable=false` / 半开 TCP),与「谁持锁」无关 ⇒ 上抛让启动响亮失败,不许无界轮。
12
+ * 偶发一次抛(TiDB 悲观锁抖动)照旧继续轮,计数在一次成功探针后归零。
13
+ * · 轮询间隙进程不持任何快照(每次探针是亚毫秒的独立语句)⇒ 与 PG `CREATE INDEX CONCURRENTLY` 的
14
+ * `WaitForOlderSnapshots` 永不互等(理由全文见 `pg-pool.ts` `acquirePgEnsureSchemaLock` 头注)。
15
+ */
16
+ export declare const LOCK_PROBE_INITIAL_JITTER_MS = 400;
17
+ export declare const LOCK_PROBE_WARN_EVERY = 40;
18
+ export declare const MAX_CONSECUTIVE_LOCK_PROBE_ERRORS = 5;
19
+ /** 抖动退避(~0.1s..0.4s + 至多 0.15s 抖动)—— 两方言同一条曲线。 */
20
+ export declare function lockProbeBackoffMs(attempt: number): number;
21
+ /**
22
+ * 轮到拿到为止。`tryOnce` = 方言自己的非阻塞探针:`true` 拿到、`false` 别人持着(继续轮)、抛 = 探针本身失败。
23
+ * 初始抖动把 ~20 个在 t=0 同时开火的调用方(集成套扇出 / 同步的舰队启动)铺开。
24
+ */
25
+ export declare function pollUntilLocked(tryOnce: () => Promise<boolean>, opts: {
26
+ tag: string;
27
+ label: string;
28
+ }): Promise<void>;
29
+ //# sourceMappingURL=ensure-schema-lock-policy.d.ts.map
@@ -0,0 +1,31 @@
1
+ export const LOCK_PROBE_INITIAL_JITTER_MS = 400;
2
+ export const LOCK_PROBE_WARN_EVERY = 40;
3
+ export const MAX_CONSECUTIVE_LOCK_PROBE_ERRORS = 5;
4
+ export function lockProbeBackoffMs(attempt) {
5
+ return 100 + Math.min(attempt, 3) * 100 + Math.floor(Math.random() * 150);
6
+ }
7
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+ export async function pollUntilLocked(tryOnce, opts) {
9
+ await sleep(Math.floor(Math.random() * LOCK_PROBE_INITIAL_JITTER_MS));
10
+ let consecutiveErrors = 0;
11
+ for (let attempt = 0;; attempt++) {
12
+ try {
13
+ if (await tryOnce())
14
+ return;
15
+ consecutiveErrors = 0;
16
+ }
17
+ catch (err) {
18
+ consecutiveErrors += 1;
19
+ const msg = err?.message ?? String(err);
20
+ console.warn(`[${opts.tag}] ${opts.label} probe attempt ${attempt + 1} threw (${consecutiveErrors}/${MAX_CONSECUTIVE_LOCK_PROBE_ERRORS}):`, msg);
21
+ if (consecutiveErrors >= MAX_CONSECUTIVE_LOCK_PROBE_ERRORS) {
22
+ throw new Error(`[${opts.tag}] ${opts.label}: lock probe threw ${MAX_CONSECUTIVE_LOCK_PROBE_ERRORS} times in a row — the connection itself is unusable, refusing to poll forever (last: ${msg})`, { cause: err });
23
+ }
24
+ }
25
+ if (attempt > 0 && attempt % LOCK_PROBE_WARN_EVERY === 0) {
26
+ console.warn(`[${opts.tag}] still waiting for ${opts.label} (another replica is running the serialized DDL; attempt ${attempt})`);
27
+ }
28
+ await sleep(lockProbeBackoffMs(attempt));
29
+ }
30
+ }
31
+ //# sourceMappingURL=ensure-schema-lock-policy.js.map
@@ -2,6 +2,7 @@ import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool, PoolClient } from "pg";
3
3
  import { type SqlDriver } from "./sql-driver.js";
4
4
  import { type BakeStatus, type BakeState, type BakeRecord, type BakeEvent, type CreateBakeInput, type BakeTerminal } from "./store-contracts.js";
5
+ import { type IndexSpec } from "./ensure-index.js";
5
6
  /** PG translation of the image_bake / image_bake_event / image_bake_lease DDL in tidb-pool.ts SCHEMA_STATEMENTS
6
7
  * (JSON→JSONB, TINYINT→SMALLINT, DATETIME(3)→TIMESTAMPTZ(3), inline UNIQUE/KEY→named CONSTRAINT + CREATE INDEX).
7
8
  * TINYINT(1) 归一(clay 批 2026-07-26):布尔列的 PG 映射统一 SMALLINT 0/1(此前本家族用 BOOLEAN)——
@@ -9,6 +10,8 @@ import { type BakeStatus, type BakeState, type BakeRecord, type BakeEvent, type
9
10
  * 读侧 `!!`/`Boolean()` 保持,store 出口 JS 类型逐字节不变(真双库套件的严格值断言为证)。
10
11
  * The line UNIQUE is given the explicit name uq_image_bake_event_line so a 23505 can be attributed to it via err.constraint. */
11
12
  export declare const PG_IMAGE_BAKE_SCHEMA: string[];
13
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgImageBakeSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
14
+ export declare const IMAGE_BAKE_INDEXES: readonly IndexSpec[];
12
15
  /** Idempotent self-contained PG schema apply (for the integration test; central aggregation is done separately). */
13
16
  export declare function ensurePgImageBakeSchema(pool: PgPool | PoolClient, poolName?: string): Promise<void>;
14
17
  /** Dual-dialect BAKE store. See the file header for the dialect-delta ledger. */
@@ -5,6 +5,7 @@ import { parseJsonOr as parseJson, toIso as iso } from "./sql-row-helpers.js";
5
5
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
6
6
  import { mapBakeRow as mapRow, BAKE_SELECT_COLS as SELECT_COLS, } from "./store-contracts.js";
7
7
  import { isDupKeyError } from "./sql-errors.js";
8
+ import { ensureIndexes } from "./ensure-index.js";
8
9
  export const PG_IMAGE_BAKE_SCHEMA = [
9
10
  `CREATE TABLE IF NOT EXISTS image_bake (
10
11
  bake_id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -37,8 +38,6 @@ export const PG_IMAGE_BAKE_SCHEMA = [
37
38
  PRIMARY KEY (bake_id),
38
39
  CONSTRAINT uq_image_bake_idem UNIQUE (idem_key)
39
40
  )`,
40
- `CREATE INDEX IF NOT EXISTS idx_image_bake_status ON image_bake (status)`,
41
- `CREATE INDEX IF NOT EXISTS idx_image_bake_created ON image_bake (created_at)`,
42
41
  `CREATE TABLE IF NOT EXISTS image_bake_event (
43
42
  bake_id VARCHAR(64) COLLATE "C" NOT NULL,
44
43
  seq INT NOT NULL,
@@ -62,10 +61,15 @@ export const PG_IMAGE_BAKE_SCHEMA = [
62
61
  PRIMARY KEY (pool)
63
62
  )`,
64
63
  ];
64
+ export const IMAGE_BAKE_INDEXES = [
65
+ { table: "image_bake", name: "idx_image_bake_status", columns: ["status"] },
66
+ { table: "image_bake", name: "idx_image_bake_created", columns: ["created_at"] },
67
+ ];
65
68
  export async function ensurePgImageBakeSchema(pool, poolName = "standard") {
66
69
  for (const stmt of PG_IMAGE_BAKE_SCHEMA)
67
70
  await pool.query(stmt);
68
71
  await pool.query("INSERT INTO image_bake_admit (pool) VALUES ($1) ON CONFLICT (pool) DO NOTHING", [poolName]);
72
+ await ensureIndexes((t, p) => pool.query(t, p), "pg", IMAGE_BAKE_INDEXES);
69
73
  }
70
74
  const isDupKey = isDupKeyError;
71
75
  function dupKeyName(dialect, err) {
@@ -2,11 +2,14 @@ import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool, PoolClient } from "pg";
3
3
  import { type ImageStatus, type ImageIndexEntry, type ImageIndexUpsert, type ImageViewer, type ImageListFilter } from "./store-contracts.js";
4
4
  import { type SqlDriver } from "./sql-driver.js";
5
+ import { type IndexSpec } from "./ensure-index.js";
5
6
  export type { ImageNestedBuildMode, ImageCapabilities, ImagePodContract, ImageStatus, ImageVisibility, ImageIndexEntry, ImageIndexUpsert, ImageViewer, ImageListFilter, ImageRow, } from "./store-contracts.js";
6
7
  /** PG translation of the `sandbox_image_index` DDL in tidb-pool.ts (JSON→JSONB, DATETIME(3)→TIMESTAMPTZ(3),
7
8
  * TINYINT(1)→SMALLINT(布尔列 PG 映射归一,clay 批 2026-07-26 —— 两方言读回同为 number,写入 0/1,
8
9
  * 读侧 `!!` 保持,出口 JS 类型不变), inline UNIQUE KEY/KEY → separate CREATE UNIQUE INDEX / CREATE INDEX). */
9
10
  export declare const PG_IMAGE_INDEX_SCHEMA: string[];
11
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensureSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
12
+ export declare const IMAGE_INDEX_INDEXES: readonly IndexSpec[];
10
13
  /** Idempotent self-contained schema apply (for the integration test; central aggregation is done separately). */
11
14
  export declare function ensureSchema(pool: PgPool | PoolClient): Promise<void>;
12
15
  /** Dual-dialect INDEX store. See the file header for the dialect-delta ledger. */
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { pgSafeJsonStringify, pgSanitizeText, pgHasUnstorable, PgUnstorableError } from "./pg-safe-json.js";
3
3
  import { mapImageRow as mapRow, } from "./store-contracts.js";
4
4
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
5
+ import { ensureIndexes } from "./ensure-index.js";
5
6
  export const PG_IMAGE_INDEX_SCHEMA = [
6
7
  `CREATE TABLE IF NOT EXISTS sandbox_image_index (
7
8
  id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -27,14 +28,17 @@ export const PG_IMAGE_INDEX_SCHEMA = [
27
28
  updated_at TIMESTAMPTZ(3) NOT NULL,
28
29
  PRIMARY KEY (id)
29
30
  )`,
30
- `CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_digest ON sandbox_image_index (repo, digest)`,
31
- `CREATE INDEX IF NOT EXISTS idx_sandbox_image_index_profile_status ON sandbox_image_index (profile, status, visibility)`,
32
- `CREATE INDEX IF NOT EXISTS idx_sandbox_image_index_digest ON sandbox_image_index (digest)`,
33
- `CREATE INDEX IF NOT EXISTS idx_sandbox_image_index_tenant ON sandbox_image_index (tenant_id, status)`,
31
+ ];
32
+ export const IMAGE_INDEX_INDEXES = [
33
+ { table: "sandbox_image_index", name: "uq_repo_digest", columns: ["repo", "digest"], unique: true },
34
+ { table: "sandbox_image_index", name: "idx_sandbox_image_index_profile_status", columns: ["profile", "status", "visibility"] },
35
+ { table: "sandbox_image_index", name: "idx_sandbox_image_index_digest", columns: ["digest"] },
36
+ { table: "sandbox_image_index", name: "idx_sandbox_image_index_tenant", columns: ["tenant_id", "status"] },
34
37
  ];
35
38
  export async function ensureSchema(pool) {
36
39
  for (const stmt of PG_IMAGE_INDEX_SCHEMA)
37
40
  await pool.query(stmt);
41
+ await ensureIndexes((t, p) => pool.query(t, p), "pg", IMAGE_INDEX_INDEXES);
38
42
  }
39
43
  const MAX_LIMIT = 200;
40
44
  const DEFAULT_LIMIT = 50;
@@ -1,6 +1,7 @@
1
1
  import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
3
  import { type SqlDriver } from "./sql-driver.js";
4
+ import { type IndexSpec } from "./ensure-index.js";
4
5
  /** 表名([ref] 单数纪律;`schema-naming-invariants` 的闭集词表执法)。 */
5
6
  export declare const LEADER_RUN_TABLE = "leader_run";
6
7
  /** leader run 的四态 —— 与 `leader/endpoint.ts` 的 `LeaderRun["status"]` 同词表(闭集)。 */
@@ -47,6 +48,8 @@ export declare function leaderIdemKey(raw: string, owner: string | null): string
47
48
  export declare const TIDB_LEADER_RUN_STATEMENTS: readonly string[];
48
49
  /** PG 方言的建表语句(MySQL 孪生的逐条翻译:JSON→JSONB,逐列 `COLLATE "C"`,内联 KEY→独立 CREATE INDEX)。 */
49
50
  export declare const PG_LEADER_RUN_SCHEMA: readonly string[];
51
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgLeaderRunSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
52
+ export declare const LEADER_RUN_INDEXES: readonly IndexSpec[];
50
53
  /** 幂等的 PG schema 应用(中央 `ensurePgSchema` 组合它;集成套件也直接调)。 */
51
54
  export declare function ensurePgLeaderRunSchema(q: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
52
55
  /** 清算腿写进 `error` 列的 WHY(消费方读到的就是这句;不是一个空的 failed)。 */
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { isDupKeyError } from "./sql-errors.js";
4
4
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
5
5
  import { parseJsonOr } from "./sql-row-helpers.js";
6
+ import { ensureIndexes } from "./ensure-index.js";
6
7
  export const LEADER_RUN_TABLE = "leader_run";
7
8
  const LEADER_RUN_STATUSES = new Set(["running", "completed", "failed", "needs_human"]);
8
9
  export const LEADER_OBJECTIVE_PREVIEW_CHARS = 160;
@@ -46,12 +47,15 @@ export const PG_LEADER_RUN_SCHEMA = [
46
47
  PRIMARY KEY (leader_run_id),
47
48
  CONSTRAINT uq_leader_run_idem UNIQUE (idem_key)
48
49
  )`,
49
- `CREATE INDEX IF NOT EXISTS idx_leader_run_owner ON ${LEADER_RUN_TABLE} (owner)`,
50
- `CREATE INDEX IF NOT EXISTS idx_leader_run_started ON ${LEADER_RUN_TABLE} (started_at_ms)`,
50
+ ];
51
+ export const LEADER_RUN_INDEXES = [
52
+ { table: `${LEADER_RUN_TABLE}`, name: "idx_leader_run_owner", columns: ["owner"] },
53
+ { table: `${LEADER_RUN_TABLE}`, name: "idx_leader_run_started", columns: ["started_at_ms"] },
51
54
  ];
52
55
  export async function ensurePgLeaderRunSchema(q) {
53
56
  for (const stmt of PG_LEADER_RUN_SCHEMA)
54
57
  await q(stmt);
58
+ await ensureIndexes(q, "pg", LEADER_RUN_INDEXES);
55
59
  }
56
60
  export const STALE_SWEEP_REASON = "leader run went stale: no replica settled it within LEADER_RUN_STALE_MS (the replica driving it most likely died). " +
57
61
  "The run was NOT taken over — re-submit with a NEW Idempotency-Key to run it again.";
@@ -45,6 +45,7 @@
45
45
  import type { Pool as MySqlPool } from "mysql2/promise";
46
46
  import type { Pool as PgPool } from "pg";
47
47
  import type { MailboxAppendMessage, MailboxLease, MailboxStore } from "@sema-agent/core";
48
+ import { type IndexSpec } from "./ensure-index.js";
48
49
  export declare const MAILBOX_TABLE = "mailbox";
49
50
  export declare const MAILBOX_MSG_TABLE = "mailbox_message";
50
51
  /** 店侧日志座(可选;boot 注入 `Logger`)。7.58.0 合并重扫 [medium]:坏 `peer_meta` 行的 fail-closed 抛必须**响亮**——core 唯一的
@@ -54,6 +55,8 @@ export interface MailboxStoreLogger {
54
55
  warn(event: string, fields: Record<string, unknown>): void;
55
56
  }
56
57
  export declare function ensureTiDBMailboxSchema(pool: MySqlPool): Promise<void>;
58
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgMailboxSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
59
+ export declare const MAILBOX_INDEXES: readonly IndexSpec[];
57
60
  export declare function ensurePgMailboxSchema(q: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
58
61
  export declare class TiDBMailboxStore implements MailboxStore {
59
62
  private readonly pool;
@@ -1,5 +1,6 @@
1
1
  import { MAILBOX_INVALID_PEER_META_CODE, MailboxStoreError, readMailboxPeerMeta } from "@sema-agent/core";
2
2
  import { pgHasUnstorable, pgSanitizeText, PgUnstorableError } from "./pg-safe-json.js";
3
+ import { ensureIndexes } from "./ensure-index.js";
3
4
  export const MAILBOX_TABLE = "mailbox";
4
5
  export const MAILBOX_MSG_TABLE = "mailbox_message";
5
6
  function assertScope(scope) {
@@ -131,7 +132,11 @@ export async function ensureTiDBMailboxSchema(pool) {
131
132
  PRIMARY KEY (scope_key, handle, seq),
132
133
  KEY idx_mbm_sent (scope_key, handle, sent_at_ms)
133
134
  ) COLLATE utf8mb4_bin`);
135
+ await ensureIndexes((text, params) => pool.query(text, params), "mysql", MAILBOX_INDEXES);
134
136
  }
137
+ export const MAILBOX_INDEXES = [
138
+ { table: `${MAILBOX_MSG_TABLE}`, name: "idx_mbm_sent", columns: ["scope_key", "handle", "sent_at_ms"] },
139
+ ];
135
140
  export async function ensurePgMailboxSchema(q) {
136
141
  await q(`CREATE TABLE IF NOT EXISTS ${MAILBOX_TABLE} (
137
142
  scope_key VARCHAR(190) COLLATE "C" NOT NULL,
@@ -154,7 +159,7 @@ export async function ensurePgMailboxSchema(q) {
154
159
  peer_meta TEXT COLLATE "C", -- S-90 core fc52b4d4 design/385 片2b(TiDB twin 同注:typed peer 记录 JSON;NULL = 缺席且有语义)
155
160
  PRIMARY KEY (scope_key, handle, seq)
156
161
  )`);
157
- await q(`CREATE INDEX IF NOT EXISTS idx_mbm_sent ON ${MAILBOX_MSG_TABLE} (scope_key, handle, sent_at_ms)`);
162
+ await ensureIndexes(q, "pg", MAILBOX_INDEXES);
158
163
  }
159
164
  export class TiDBMailboxStore {
160
165
  pool;
@@ -1,6 +1,7 @@
1
1
  import type { MemoryHistoryAppendRow } from "./memory-sync-store-pg.js";
2
2
  import type { PgEmbedder, PgQueryFn } from "./pg-query.js";
3
3
  import type { MemoryBackend, MemoryEntry, MemoryEntryHeader, NotePatch, PatchReport, ScoredMemoryEntry } from "@sema-agent/core";
4
+ import { type IndexSpec } from "./ensure-index.js";
4
5
  /** Table names (single source). Deliberately DISJOINT from the legacy `agent_memory*` tables —
5
6
  * the retired MemoryStore plane and this entry plane must never cross-write. */
6
7
  export declare const PG_MEMORY_ENGINE_TABLES: {
@@ -16,6 +17,9 @@ export interface PgMemoryEngineSchemaOptions {
16
17
  pgvector?: boolean;
17
18
  };
18
19
  }
20
+ /** S-287:记忆引擎 PG 面的索引**声明**(MySQL 孪生是 `memory-engine-tidb.ts` 的内联 `KEY`/`UNIQUE KEY`,
21
+ * 由 `tidb-pool.ts` 的中央 `ensureSchema` 用同一份声明补存量库)。 */
22
+ export declare const MEMORY_ENGINE_INDEXES: readonly IndexSpec[];
19
23
  /** Idempotent DDL for the entry plane. Call once at startup (or run through your migration tool). */
20
24
  export declare function ensurePgMemoryEngineSchema(query: PgQueryFn, opts?: PgMemoryEngineSchemaOptions): Promise<void>;
21
25
  /** Options for {@link PgMemoryEngineBackend}. */
@@ -4,10 +4,15 @@ import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
4
4
  import { pgSafeJsonStringify, pgHasUnstorable } from "./pg-safe-json.js";
5
5
  import { assertMemoryScopeWidth, memoryEntryKeyWidthRefusal } from "./memory-key-guards.js";
6
6
  import { ambiguousOriginRefusal, createBatchCommittedBaseline, exposureBandOf, exposureCarriage, originMarkedSqlPredicate, originWhitewashRefusal, rememberBatchCommitted, distilledWhitewashRefusal, distilledCarriage, unknownPatchOpRefusal, } from "./memory-origin-law.js";
7
+ import { ensureIndexes } from "./ensure-index.js";
7
8
  export const PG_MEMORY_ENGINE_TABLES = {
8
9
  entry: "agent_memory_engine_entry",
9
10
  cursor: "agent_memory_engine_cursor",
10
11
  };
12
+ export const MEMORY_ENGINE_INDEXES = [
13
+ { table: `${PG_MEMORY_ENGINE_TABLES.entry}`, name: `idx_${PG_MEMORY_ENGINE_TABLES.entry}_scope`, columns: ["scope"] },
14
+ { table: `${PG_MEMORY_ENGINE_TABLES.entry}`, name: `uq_${PG_MEMORY_ENGINE_TABLES.entry}_scope_slug`, columns: ["scope", "slug"], unique: true },
15
+ ];
11
16
  export async function ensurePgMemoryEngineSchema(query, opts = {}) {
12
17
  const vec = opts.memoryVector;
13
18
  if (vec !== undefined && (!Number.isInteger(vec.dimensions) || vec.dimensions < 1)) {
@@ -27,16 +32,11 @@ export async function ensurePgMemoryEngineSchema(query, opts = {}) {
27
32
  size_bytes integer NOT NULL,
28
33
  embedding ${embeddingCol}
29
34
  )`);
30
- try {
31
- await query(`CREATE INDEX IF NOT EXISTS idx_${PG_MEMORY_ENGINE_TABLES.entry}_scope ON ${PG_MEMORY_ENGINE_TABLES.entry} (scope)`);
32
- }
33
- catch {
34
- }
35
- await query(`CREATE UNIQUE INDEX IF NOT EXISTS uq_${PG_MEMORY_ENGINE_TABLES.entry}_scope_slug ON ${PG_MEMORY_ENGINE_TABLES.entry} (scope, slug)`);
36
35
  await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_TABLES.cursor} (
37
36
  scope varchar(190) COLLATE "C" PRIMARY KEY,
38
37
  cursor text COLLATE "C" NOT NULL
39
38
  )`);
39
+ await ensureIndexes(query, "pg", MEMORY_ENGINE_INDEXES);
40
40
  }
41
41
  function vectorLiteral(v) {
42
42
  return `[${v.join(",")}]`;