@rei-standard/amsg-server 2.6.0-next.34 → 2.6.0-next.36

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 CHANGED
@@ -902,8 +902,8 @@ export default createSingleUserCloudflareWorker(buildConfig, {
902
902
  });
903
903
  ```
904
904
 
905
- - `onError` 放在工厂的第二个参数上,而不是 `buildConfig` 的返回值里:`buildConfig` 自己抛错时配置里的东西一个都读不到,而那恰恰是最需要被看见的一种故障。`stage` 在 cron 路径上是 `config`(配置构建失败 / VAPID 没配齐)或 `tick`(那一跳抛错)。VAPID 没配齐这一支没有异常对象,`error` 为 `null`、`cause.name` 是 `VapidNotConfigured`。
906
- - `scheduled()` 现在有返回值:`{ ok: true, summary }` 或 `{ ok: false, cause }`。Cloudflare 不看它,是给「自己包一层再转调 `scheduled`」的宿主和测试用的。
905
+ - `onError` 放在工厂的第二个参数上,而不是 `buildConfig` 的返回值里:`buildConfig` 自己抛错时配置里的东西一个都读不到,而那恰恰是最需要被看见的一种故障。`stage` 在 cron 路径上是 `config`(配置构建失败 / VAPID 没配齐)、`tick`(那一跳抛错)或 `cloud-cleanup`(云端数据清理那一步抛错,这一跳的消息照常投递)。VAPID 没配齐这一支没有异常对象,`error` 为 `null`、`cause.name` 是 `VapidNotConfigured`。
906
+ - `scheduled()` 现在有返回值:`{ ok: true, summary }` 或 `{ ok: false, cause }`,`ok` 只看消息投递这一段。云端数据清理那一步出错时,两种返回值上都会多一个 `cloudCleanupCause`。Cloudflare 不看它,是给「自己包一层再转调 `scheduled`」的宿主和测试用的。
907
907
 
908
908
  ## 导出 API(Exports)
909
909
 
@@ -67,7 +67,7 @@ export class D1Adapter {
67
67
  data: any;
68
68
  updatedAt: any;
69
69
  }>;
70
- putCloudDataRecord(userId: any, kind: any, id: any, data: any, { idempotencyKey, createOnly, leaseToken }?: {
70
+ putCloudDataRecord(userId: any, kind: any, id: any, data: any, { idempotencyKey, createOnly, leaseToken, work }?: {
71
71
  idempotencyKey?: any;
72
72
  createOnly?: boolean;
73
73
  }): Promise<{
@@ -105,8 +105,37 @@ export class D1Adapter {
105
105
  renewCloudDataRecordLease(userId: any, kind: any, id: any, leaseMs: any, leaseToken: any): Promise<boolean>;
106
106
  deleteCloudDataRecord(userId: any, kind: any, id: any): Promise<boolean>;
107
107
  cleanupCloudDataRecords(kind: any, beforeMs: any): Promise<any>;
108
- /** Sidecars are indexes only. Remove them only while their backing row is absent. */
109
- cleanupCloudResourceMetadata(userId?: any): Promise<any>;
108
+ listDueCloudDataOperations(now?: number, limit?: number): Promise<any[]>;
109
+ cleanupExpiredCloudDataRecords(now?: number, limit?: number): Promise<any>;
110
+ getCloudMaintenanceBatch(name: any, table: any, limit?: number): Promise<{
111
+ rows: any[];
112
+ completed: boolean;
113
+ upperBound?: undefined;
114
+ cursor?: undefined;
115
+ } | {
116
+ rows: any;
117
+ upperBound: any;
118
+ cursor: any;
119
+ completed: boolean;
120
+ }>;
121
+ finishCloudMaintenanceBatch(name: any, cursor: any, completed: any): Promise<void>;
122
+ /**
123
+ * Index a whole repair batch in one statement. Never overwrites an existing schedule, and
124
+ * skips a record that was rewritten after the batch was read.
125
+ */
126
+ indexCloudDataRecordWorkBatch(entries: any): Promise<void>;
127
+ /** Move an indexed record's schedule, unless the record was rewritten since the caller read it. */
128
+ rescheduleCloudDataRecordWork(userId: any, kind: any, id: any, work: any, expectedWriteToken?: any): Promise<boolean>;
129
+ readCloudMaintenanceRecord(row: any): Promise<{
130
+ id: any;
131
+ userId: any;
132
+ kind: any;
133
+ data: any;
134
+ updatedAt: any;
135
+ }>;
136
+ repairCloudResourceMetadata(limit?: number): Promise<void>;
137
+ /** One explicit repair, or a bounded upgrade batch; never called as a recurring sweep. */
138
+ cleanupCloudResourceMetadata(userId?: any, range?: any): Promise<any>;
110
139
  listCloudOwners(userId: any): Promise<any>;
111
140
  getCloudOwner(userId: any, owner: any): Promise<{
112
141
  active: boolean;
@@ -151,11 +180,12 @@ export class D1Adapter {
151
180
  * 构变了而老部署没跑过 initSchema 时,cron 会每分钟静默挂在缺的那一列上,
152
181
  * 界面上一切正常——这个方法就是让宿主查得出来。
153
182
  *
154
- * @returns {Promise<{ tables: Record<string, string[]>, indexes: string[] }>}
183
+ * @returns {Promise<{ tables: Record<string, string[]>, indexes: string[], triggers: string[] }>}
155
184
  */
156
185
  describeSchema(): Promise<{
157
186
  tables: Record<string, string[]>;
158
187
  indexes: string[];
188
+ triggers: string[];
159
189
  }>;
160
190
  dropSchema(): Promise<void>;
161
191
  createTask(params: any, cloudGuard?: any): Promise<any>;
@@ -42,13 +42,15 @@ export type DbAdapter = {
42
42
  */
43
43
  initSchema: () => Promise<InitSchemaResult>;
44
44
  /**
45
- * (可选)活库里现在实际有哪些表 / 列 / 索引,只读不写。
45
+ * (可选)活库里现在实际有哪些表 / 列 / 索引 / 触发器,只读不写。`triggers`
46
+ * 可以不回,不回时触发器这一项不查。
46
47
  * `getSchemaVersion` / `ensureSchema`(lib/schema-version.js)拿它跟这一版
47
48
  * 需要的清单对照;不实现的适配器调那两个函数会抛错。内置只有 D1 实现。
48
49
  */
49
50
  describeSchema?: () => Promise<{
50
51
  tables: Record<string, string[]>;
51
52
  indexes: string[];
53
+ triggers?: string[];
52
54
  }>;
53
55
  /**
54
56
  * Drop the scheduled_messages table (CASCADE).
@@ -54,15 +54,27 @@ export const SQLITE_ALL_INDEXES: {
54
54
  critical: boolean;
55
55
  }[];
56
56
  export const CLOUD_DATA_TABLES_SQL: string[];
57
+ export const CLOUD_DATA_WORK_TABLES_SQL: string[];
58
+ export const CLOUD_DATA_WORK_INDEXES: {
59
+ name: string;
60
+ sql: string;
61
+ description: string;
62
+ critical: boolean;
63
+ }[];
64
+ export const CLOUD_DATA_DELETE_TRIGGERS: {
65
+ name: string;
66
+ sql: string;
67
+ }[];
57
68
  /**
58
69
  * 这一版代码跑起来需要的表 / 列 / 索引。
59
70
  *
60
71
  * 索引只列 critical 的那几个(uidx_uuid 之类):其余索引缺了只是慢,缺了它则
61
72
  * 是正确性问题。
62
73
  *
63
- * @type {{ tables: Record<string, string[]>, indexes: string[] }}
74
+ * @type {{ tables: Record<string, string[]>, indexes: string[], triggers: string[] }}
64
75
  */
65
76
  export const SQLITE_REQUIRED_SCHEMA: {
66
77
  tables: Record<string, string[]>;
67
78
  indexes: string[];
79
+ triggers: string[];
68
80
  };
@@ -5070,6 +5070,66 @@ var CLOUD_DATA_TABLES_SQL = [
5070
5070
  lease_allowed INTEGER CONSTRAINT cloud_lease_guard CHECK (lease_allowed = 1)
5071
5071
  )`
5072
5072
  ];
5073
+ var CLOUD_DATA_WORK_TABLES_SQL = [
5074
+ `CREATE TABLE IF NOT EXISTS cloud_data_work (
5075
+ user_id TEXT NOT NULL, kind TEXT NOT NULL, id TEXT NOT NULL,
5076
+ next_run_at INTEGER, expires_at INTEGER,
5077
+ PRIMARY KEY (user_id, kind, id)
5078
+ )`,
5079
+ `CREATE TABLE IF NOT EXISTS cloud_data_maintenance (
5080
+ name TEXT PRIMARY KEY, cursor INTEGER NOT NULL, upper_bound INTEGER NOT NULL,
5081
+ completed INTEGER NOT NULL DEFAULT 0
5082
+ )`
5083
+ ];
5084
+ CLOUD_DATA_TABLES_SQL.push(...CLOUD_DATA_WORK_TABLES_SQL);
5085
+ var CLOUD_DATA_WORK_INDEXES = [
5086
+ {
5087
+ name: "idx_cloud_work_due",
5088
+ sql: `CREATE INDEX IF NOT EXISTS idx_cloud_work_due
5089
+ ON cloud_data_work (kind, next_run_at) WHERE next_run_at IS NOT NULL`,
5090
+ description: "Only due cleanup operations",
5091
+ critical: true
5092
+ },
5093
+ {
5094
+ name: "idx_cloud_work_expiry",
5095
+ sql: `CREATE INDEX IF NOT EXISTS idx_cloud_work_expiry
5096
+ ON cloud_data_work (expires_at) WHERE expires_at IS NOT NULL`,
5097
+ description: "Only expired management records",
5098
+ critical: true
5099
+ }
5100
+ ];
5101
+ SQLITE_ALL_INDEXES.push(...CLOUD_DATA_WORK_INDEXES);
5102
+ var chunkPrefixSql = "char(31)||'amsg-chunks'||char(31)";
5103
+ var logicalNamespaceSql = `CASE WHEN substr(OLD.namespace,1,13)=${chunkPrefixSql}
5104
+ THEN substr(OLD.namespace,14) ELSE OLD.namespace END`;
5105
+ var logicalKeySql = `CASE WHEN substr(OLD.namespace,1,13)=${chunkPrefixSql} AND instr(OLD.key,char(31))>0
5106
+ THEN substr(OLD.key,1,instr(OLD.key,char(31))-1) ELSE OLD.key END`;
5107
+ var CLOUD_DATA_DELETE_TRIGGERS = [
5108
+ ...[
5109
+ ["scheduled_messages", "task", "uuid"],
5110
+ ["llm_credentials", "credential", "cred_id"],
5111
+ ["message_outbox", "outbox", "message_id"],
5112
+ ["push_subscriptions", "subscription", "user_id"]
5113
+ ].map(([table, type, column]) => ({
5114
+ name: `trg_cloud_metadata_${type}_delete`,
5115
+ sql: `CREATE TRIGGER IF NOT EXISTS trg_cloud_metadata_${type}_delete AFTER DELETE ON ${table}
5116
+ BEGIN DELETE FROM cloud_resource_metadata WHERE user_id=OLD.user_id
5117
+ AND resource_key=json_array('${type}'${column ? ",OLD." + column : ""}); END`
5118
+ })),
5119
+ { name: "trg_cloud_metadata_state_delete", sql: `CREATE TRIGGER IF NOT EXISTS trg_cloud_metadata_state_delete
5120
+ AFTER DELETE ON client_state BEGIN
5121
+ DELETE FROM cloud_resource_metadata WHERE user_id=OLD.user_id
5122
+ AND resource_key=json_array('state',${logicalNamespaceSql},${logicalKeySql})
5123
+ AND NOT EXISTS (SELECT 1 FROM client_state r WHERE r.user_id=OLD.user_id
5124
+ AND r.namespace=(${logicalNamespaceSql}) AND r.key=(${logicalKeySql}))
5125
+ AND NOT EXISTS (SELECT 1 FROM client_state r WHERE r.user_id=OLD.user_id
5126
+ AND r.namespace=${chunkPrefixSql}||(${logicalNamespaceSql})
5127
+ AND r.key >= (${logicalKeySql})||char(31) AND r.key < (${logicalKeySql})||char(32)); END` },
5128
+ { name: "trg_cloud_work_delete", sql: `CREATE TRIGGER IF NOT EXISTS trg_cloud_work_delete
5129
+ AFTER DELETE ON cloud_data_records BEGIN
5130
+ DELETE FROM cloud_data_work WHERE user_id=OLD.user_id AND kind=OLD.kind AND id=OLD.id;
5131
+ DELETE FROM cloud_data_record_chunks WHERE user_id=OLD.user_id AND kind=OLD.kind AND id=OLD.id; END` }
5132
+ ];
5073
5133
  function parseTableName(sql) {
5074
5134
  const match = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)/i.exec(sql);
5075
5135
  return match ? match[1] : "";
@@ -5105,7 +5165,8 @@ var SQLITE_REQUIRED_SCHEMA = Object.freeze({
5105
5165
  describeTable(MESSAGE_OUTBOX_TABLE_SQL),
5106
5166
  ...CLOUD_DATA_TABLES_SQL.map(describeTable)
5107
5167
  ])),
5108
- indexes: Object.freeze(SQLITE_ALL_INDEXES.filter((index) => index.critical).map((index) => index.name))
5168
+ indexes: Object.freeze(SQLITE_ALL_INDEXES.filter((index) => index.critical).map((index) => index.name)),
5169
+ triggers: Object.freeze(CLOUD_DATA_DELETE_TRIGGERS.map((trigger) => trigger.name))
5109
5170
  });
5110
5171
 
5111
5172
  // src/server/adapters/d1.js
@@ -5304,7 +5365,7 @@ var D1Adapter = class {
5304
5365
  }
5305
5366
  return { id: row.id, userId: row.user_id, kind: row.kind, data, updatedAt: row.updated_at };
5306
5367
  }
5307
- async putCloudDataRecord(userId, kind, id, data, { idempotencyKey = null, createOnly = false, leaseToken } = {}) {
5368
+ async putCloudDataRecord(userId, kind, id, data, { idempotencyKey = null, createOnly = false, leaseToken, work } = {}) {
5308
5369
  const slices = [];
5309
5370
  if (data.length > 200 * 1024) for (let offset = 0; offset < data.length; offset += 200 * 1024) slices.push(data.slice(offset, offset + 200 * 1024));
5310
5371
  const rootValue = slices.length ? "amsg-record" + slices.length : data;
@@ -5331,6 +5392,12 @@ var D1Adapter = class {
5331
5392
  SELECT ?,?,?,?,?,? WHERE ${rootGate}`
5332
5393
  ).bind(userId, kind, id, index, slices[index], writeToken, userId, kind, id, writeToken));
5333
5394
  }
5395
+ const schedule = work ?? (["inventory", "plan"].includes(kind) ? { nextRunAt: null, expiresAt: now + 36e5 } : null);
5396
+ if (schedule) statements.push(this._db.prepare(
5397
+ `INSERT INTO cloud_data_work (user_id,kind,id,next_run_at,expires_at)
5398
+ SELECT ?,?,?,?,? WHERE ${rootGate}
5399
+ ON CONFLICT (user_id,kind,id) DO UPDATE SET next_run_at=excluded.next_run_at,expires_at=excluded.expires_at`
5400
+ ).bind(userId, kind, id, schedule.nextRunAt ?? null, schedule.expiresAt ?? null, userId, kind, id, writeToken));
5334
5401
  if (!this.cloudDataManagement) throw new Error("Cloud management requires transactional batches");
5335
5402
  const results = await this._db.batch(statements);
5336
5403
  if (leaseToken !== void 0 && !results[0].meta.changes) throw Object.assign(new Error("Cloud operation processing lease was lost"), { code: "CLOUD_LEASE_LOST" });
@@ -5408,23 +5475,102 @@ var D1Adapter = class {
5408
5475
  ]);
5409
5476
  return results[1].meta.changes || 0;
5410
5477
  }
5411
- /** Sidecars are indexes only. Remove them only while their backing row is absent. */
5412
- async cleanupCloudResourceMetadata(userId = null) {
5478
+ async listDueCloudDataOperations(now = Date.now(), limit = 25) {
5479
+ const result = await this._db.prepare(`SELECT r.* FROM cloud_data_work w JOIN cloud_data_records r
5480
+ ON r.user_id=w.user_id AND r.kind=w.kind AND r.id=w.id
5481
+ WHERE w.kind='operation' AND w.next_run_at IS NOT NULL AND w.next_run_at<=?
5482
+ AND (r.lease_until IS NULL OR r.lease_until<=?)
5483
+ ORDER BY w.next_run_at LIMIT ?`).bind(now, now, limit).all();
5484
+ return Promise.all((result.results || []).map(async (row) => ({ ...await this._cloudRecordForList(row), writeToken: row.write_token })));
5485
+ }
5486
+ async cleanupExpiredCloudDataRecords(now = Date.now(), limit = 100) {
5487
+ const result = await this._db.prepare(`DELETE FROM cloud_data_records WHERE rowid IN (
5488
+ SELECT r.rowid FROM cloud_data_work w JOIN cloud_data_records r
5489
+ ON r.user_id=w.user_id AND r.kind=w.kind AND r.id=w.id
5490
+ WHERE w.expires_at IS NOT NULL AND w.expires_at<=?
5491
+ AND (r.lease_until IS NULL OR r.lease_until<=?) ORDER BY w.expires_at LIMIT ?
5492
+ )`).bind(now, now, limit).run();
5493
+ return result.meta.changes || 0;
5494
+ }
5495
+ async getCloudMaintenanceBatch(name, table, limit = 100) {
5496
+ if (!["cloud_resource_metadata", "cloud_data_records"].includes(table)) throw new Error("Invalid maintenance source");
5497
+ let marker = await this._db.prepare("SELECT * FROM cloud_data_maintenance WHERE name=?").bind(name).first();
5498
+ if (!marker) {
5499
+ await this._db.prepare(`INSERT INTO cloud_data_maintenance (name,cursor,upper_bound,completed)
5500
+ SELECT ?,0,COALESCE(MAX(rowid),0),0 FROM ${table} WHERE 1 ON CONFLICT (name) DO NOTHING`).bind(name).run();
5501
+ marker = await this._db.prepare("SELECT * FROM cloud_data_maintenance WHERE name=?").bind(name).first();
5502
+ }
5503
+ if (marker.completed) return { rows: [], completed: true };
5504
+ const columns = table === "cloud_data_records" ? "rowid AS maintenance_rowid,user_id,kind,id,updated_at,write_token,CASE WHEN kind='operation' THEN data END AS data" : "rowid AS maintenance_rowid";
5505
+ const result = await this._db.prepare(`SELECT ${columns} FROM ${table}
5506
+ WHERE rowid>? AND rowid<=? ORDER BY rowid LIMIT ?`).bind(marker.cursor, marker.upper_bound, limit).all();
5507
+ return { rows: result.results || [], upperBound: marker.upper_bound, cursor: marker.cursor, completed: false };
5508
+ }
5509
+ async finishCloudMaintenanceBatch(name, cursor, completed) {
5510
+ await this._db.prepare(`UPDATE cloud_data_maintenance SET cursor=MAX(cursor,?),completed=MAX(completed,?) WHERE name=?`).bind(cursor, completed ? 1 : 0, name).run();
5511
+ }
5512
+ /**
5513
+ * Index a whole repair batch in one statement. Never overwrites an existing schedule, and
5514
+ * skips a record that was rewritten after the batch was read.
5515
+ */
5516
+ async indexCloudDataRecordWorkBatch(entries) {
5517
+ if (!entries.length) return;
5518
+ const rows = entries.map(({ userId, kind, id, work, writeToken }) => [userId, kind, id, work.nextRunAt ?? null, work.expiresAt ?? null, writeToken ?? null]);
5519
+ await this._db.prepare(`INSERT INTO cloud_data_work (user_id,kind,id,next_run_at,expires_at)
5520
+ SELECT r.user_id,r.kind,r.id,json_extract(j.value,'$[3]'),json_extract(j.value,'$[4]')
5521
+ FROM json_each(?) j JOIN cloud_data_records r ON r.user_id=json_extract(j.value,'$[0]')
5522
+ AND r.kind=json_extract(j.value,'$[1]') AND r.id=json_extract(j.value,'$[2]')
5523
+ AND r.write_token IS json_extract(j.value,'$[5]')
5524
+ WHERE 1 ON CONFLICT (user_id,kind,id) DO NOTHING`).bind(JSON.stringify(rows)).run();
5525
+ }
5526
+ /** Move an indexed record's schedule, unless the record was rewritten since the caller read it. */
5527
+ async rescheduleCloudDataRecordWork(userId, kind, id, work, expectedWriteToken = null) {
5528
+ const result = await this._db.prepare(`UPDATE cloud_data_work SET next_run_at=?,expires_at=?
5529
+ WHERE user_id=? AND kind=? AND id=? AND EXISTS (SELECT 1 FROM cloud_data_records r
5530
+ WHERE r.user_id=cloud_data_work.user_id AND r.kind=cloud_data_work.kind AND r.id=cloud_data_work.id AND r.write_token IS ?)`).bind(work.nextRunAt ?? null, work.expiresAt ?? null, userId, kind, id, expectedWriteToken).run();
5531
+ return result.meta.changes > 0;
5532
+ }
5533
+ async readCloudMaintenanceRecord(row) {
5534
+ return this._cloudRecordForList(row);
5535
+ }
5536
+ async repairCloudResourceMetadata(limit = 100) {
5537
+ const name = "metadata-sidecars-v1";
5538
+ const batch = await this.getCloudMaintenanceBatch(name, "cloud_resource_metadata", limit);
5539
+ if (batch.completed) return;
5540
+ const cursor = batch.rows.at(-1)?.maintenance_rowid ?? batch.upperBound;
5541
+ if (batch.rows.length) await this.cleanupCloudResourceMetadata(null, { after: batch.cursor, through: cursor });
5542
+ await this.finishCloudMaintenanceBatch(name, cursor, batch.rows.length < limit || cursor >= batch.upperBound);
5543
+ }
5544
+ /** One explicit repair, or a bounded upgrade batch; never called as a recurring sweep. */
5545
+ async cleanupCloudResourceMetadata(userId = null, range = null) {
5413
5546
  const targets = {
5414
- task: `SELECT 1 FROM scheduled_messages r WHERE r.user_id=m.user_id AND r.uuid=json_extract(m.resource_key,'$[1]')`,
5415
- state: `SELECT 1 FROM client_state r WHERE r.user_id=m.user_id AND
5416
- ((r.namespace=json_extract(m.resource_key,'$[1]') AND r.key=json_extract(m.resource_key,'$[2]')) OR
5417
- (r.namespace=char(31)||'amsg-chunks'||char(31)||json_extract(m.resource_key,'$[1]')
5547
+ task: [`SELECT 1 FROM scheduled_messages r WHERE r.user_id=m.user_id AND r.uuid=json_extract(m.resource_key,'$[1]')`],
5548
+ state: [
5549
+ `SELECT 1 FROM client_state r WHERE r.user_id=m.user_id
5550
+ AND r.namespace=json_extract(m.resource_key,'$[1]') AND r.key=json_extract(m.resource_key,'$[2]')`,
5551
+ `SELECT 1 FROM client_state r WHERE r.user_id=m.user_id
5552
+ AND r.namespace=char(31)||'amsg-chunks'||char(31)||json_extract(m.resource_key,'$[1]')
5418
5553
  AND r.key >= json_extract(m.resource_key,'$[2]')||char(31)
5419
- AND r.key < json_extract(m.resource_key,'$[2]')||char(32)))`,
5420
- credential: `SELECT 1 FROM llm_credentials r WHERE r.user_id=m.user_id AND r.cred_id=json_extract(m.resource_key,'$[1]')`,
5421
- outbox: `SELECT 1 FROM message_outbox r WHERE r.user_id=m.user_id AND r.message_id=json_extract(m.resource_key,'$[1]')`,
5422
- subscription: `SELECT 1 FROM push_subscriptions r WHERE r.user_id=m.user_id`
5554
+ AND r.key < json_extract(m.resource_key,'$[2]')||char(32)`
5555
+ ],
5556
+ credential: [`SELECT 1 FROM llm_credentials r WHERE r.user_id=m.user_id AND r.cred_id=json_extract(m.resource_key,'$[1]')`],
5557
+ outbox: [`SELECT 1 FROM message_outbox r WHERE r.user_id=m.user_id AND r.message_id=json_extract(m.resource_key,'$[1]')`],
5558
+ subscription: [`SELECT 1 FROM push_subscriptions r WHERE r.user_id=m.user_id`]
5423
5559
  };
5424
- const statements = Object.entries(targets).map(([type, exists]) => this._db.prepare(
5425
- `DELETE FROM cloud_resource_metadata AS m WHERE (? IS NULL OR user_id=?) AND json_valid(resource_key)
5426
- AND json_extract(resource_key,'$[0]')=? AND NOT EXISTS (${exists})`
5427
- ).bind(userId, userId, type));
5560
+ const statements = Object.entries(targets).map(([type, exists]) => {
5561
+ const conditions = ["json_valid(resource_key)", "json_extract(resource_key,'$[0]')=?"];
5562
+ const args = [type];
5563
+ if (userId !== null) {
5564
+ conditions.unshift("user_id=?");
5565
+ args.unshift(userId);
5566
+ }
5567
+ if (range) {
5568
+ conditions.unshift("m.rowid>? AND m.rowid<=?");
5569
+ args.unshift(range.after, range.through);
5570
+ }
5571
+ return this._db.prepare(`DELETE FROM cloud_resource_metadata AS m WHERE ${conditions.join(" AND ")}
5572
+ AND ${exists.map((sql) => `NOT EXISTS (${sql})`).join(" AND ")}`).bind(...args);
5573
+ });
5428
5574
  const results = await this._cloudBatch(statements, userId);
5429
5575
  return results.reduce((sum, result) => sum + (result.meta.changes || 0), 0);
5430
5576
  }
@@ -5564,6 +5710,7 @@ var D1Adapter = class {
5564
5710
  indexResults.push({ name: index.name, status: "failed", description: index.description, critical: !!index.critical, error: error.message });
5565
5711
  }
5566
5712
  }
5713
+ for (const trigger of CLOUD_DATA_DELETE_TRIGGERS) await this._db.prepare(trigger.sql).run();
5567
5714
  const criticalFailures = indexResults.filter((i) => i.critical && i.status === "failed");
5568
5715
  if (criticalFailures.length > 0) {
5569
5716
  const names = criticalFailures.map((i) => i.name).join(", ");
@@ -5587,7 +5734,7 @@ var D1Adapter = class {
5587
5734
  * 构变了而老部署没跑过 initSchema 时,cron 会每分钟静默挂在缺的那一列上,
5588
5735
  * 界面上一切正常——这个方法就是让宿主查得出来。
5589
5736
  *
5590
- * @returns {Promise<{ tables: Record<string, string[]>, indexes: string[] }>}
5737
+ * @returns {Promise<{ tables: Record<string, string[]>, indexes: string[], triggers: string[] }>}
5591
5738
  */
5592
5739
  async describeSchema() {
5593
5740
  const tableRes = await this._db.prepare(
@@ -5604,7 +5751,8 @@ var D1Adapter = class {
5604
5751
  const indexRes = await this._db.prepare(
5605
5752
  `SELECT name FROM sqlite_master WHERE type = 'index' AND name IS NOT NULL`
5606
5753
  ).all();
5607
- return { tables, indexes: (indexRes.results || []).map((row) => row.name) };
5754
+ const triggerRes = await this._db.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger'").all();
5755
+ return { tables, indexes: (indexRes.results || []).map((row) => row.name), triggers: (triggerRes.results || []).map((row) => row.name) };
5608
5756
  }
5609
5757
  async dropSchema() {
5610
5758
  await this._db.prepare("DROP TABLE IF EXISTS scheduled_messages").run();
@@ -5612,7 +5760,7 @@ var D1Adapter = class {
5612
5760
  await this._db.prepare("DROP TABLE IF EXISTS push_subscriptions").run();
5613
5761
  await this._db.prepare("DROP TABLE IF EXISTS llm_credentials").run();
5614
5762
  await this._db.prepare("DROP TABLE IF EXISTS message_outbox").run();
5615
- for (const table of ["cloud_data_record_chunks", "cloud_data_records", "cloud_data_owners", "cloud_resource_metadata", "cloud_guard_assertions"]) {
5763
+ for (const table of ["cloud_data_work", "cloud_data_maintenance", "cloud_data_record_chunks", "cloud_data_records", "cloud_data_owners", "cloud_resource_metadata", "cloud_guard_assertions"]) {
5616
5764
  await this._db.prepare(`DROP TABLE IF EXISTS ${table}`).run();
5617
5765
  }
5618
5766
  }
@@ -5890,7 +6038,8 @@ var D1Adapter = class {
5890
6038
  const cutoff = new Date(Date.now() - safeDays * 24 * 60 * 60 * 1e3).toISOString();
5891
6039
  const res = await this._db.prepare(
5892
6040
  `DELETE FROM scheduled_messages
5893
- WHERE status IN ('sent', 'failed') AND updated_at < ?`
6041
+ WHERE rowid IN (SELECT rowid FROM scheduled_messages
6042
+ WHERE status IN ('sent', 'failed') AND updated_at < ? LIMIT 100)`
5894
6043
  ).bind(cutoff).run();
5895
6044
  return res.meta.changes || 0;
5896
6045
  }
@@ -6056,7 +6205,7 @@ var D1Adapter = class {
6056
6205
  */
6057
6206
  async cleanupClientState(targets = []) {
6058
6207
  if (!Array.isArray(targets) || targets.length === 0) return 0;
6059
- const SQL = "DELETE FROM client_state WHERE namespace = ? AND updated_at < ?";
6208
+ const SQL = "DELETE FROM client_state WHERE rowid IN (SELECT rowid FROM client_state WHERE namespace = ? AND updated_at < ? LIMIT 100)";
6060
6209
  const statements = targets.map(
6061
6210
  (target) => this._db.prepare(SQL).bind(target.namespace, target.updatedBefore)
6062
6211
  );
@@ -6552,13 +6701,13 @@ var D1Adapter = class {
6552
6701
  let deleted = 0;
6553
6702
  if (Number.isFinite(ackedBeforeMs)) {
6554
6703
  const res = await this._db.prepare(
6555
- "DELETE FROM message_outbox WHERE acked_at IS NOT NULL AND acked_at < ?"
6704
+ "DELETE FROM message_outbox WHERE rowid IN (SELECT rowid FROM message_outbox WHERE acked_at IS NOT NULL AND acked_at < ? LIMIT 100)"
6556
6705
  ).bind(ackedBeforeMs).run();
6557
6706
  deleted += res.meta.changes || 0;
6558
6707
  }
6559
6708
  if (Number.isFinite(allBeforeMs)) {
6560
6709
  const res = await this._db.prepare(
6561
- "DELETE FROM message_outbox WHERE created_at < ?"
6710
+ "DELETE FROM message_outbox WHERE rowid IN (SELECT rowid FROM message_outbox WHERE created_at < ? LIMIT 100)"
6562
6711
  ).bind(allBeforeMs).run();
6563
6712
  deleted += res.meta.changes || 0;
6564
6713
  }
@@ -6879,7 +7028,7 @@ function createClientStateNamespacesHandler(ctx) {
6879
7028
  }
6880
7029
 
6881
7030
  // src/server/lib/version.js
6882
- var SERVER_VERSION = true ? "2.6.0-next.34" : "0.0.0-dev";
7031
+ var SERVER_VERSION = true ? "2.6.0-next.36" : "0.0.0-dev";
6883
7032
 
6884
7033
  // src/server/handlers/capabilities.js
6885
7034
  var SERVER_FEATURES = Object.freeze([
@@ -7301,6 +7450,15 @@ async function cloudInventory(ctx, db, userId, userKey) {
7301
7450
  resolveOwner: ctx.cloudData?.resolveOwner
7302
7451
  });
7303
7452
  }
7453
+ function operationWork(operation) {
7454
+ if (["pending", "running"].includes(operation.status)) {
7455
+ return { nextRunAt: Number(operation.nextAttemptAt) || 0, expiresAt: null };
7456
+ }
7457
+ if (["completed", "failed"].includes(operation.status)) {
7458
+ return { nextRunAt: null, expiresAt: Number(operation.updatedAt) + 30 * 864e5 };
7459
+ }
7460
+ return { nextRunAt: null, expiresAt: null };
7461
+ }
7304
7462
  async function save(db, userId, key, kind, value, options) {
7305
7463
  return db.putCloudDataRecord(
7306
7464
  userId,
@@ -7313,7 +7471,7 @@ async function save(db, userId, key, kind, value, options) {
7313
7471
  }),
7314
7472
  key
7315
7473
  ),
7316
- options
7474
+ { ...options, ...kind === "operation" ? { work: operationWork(value) } : {} }
7317
7475
  );
7318
7476
  }
7319
7477
  async function readCloudRecord(db, userId, key, kind, id) {
@@ -7675,33 +7833,104 @@ async function advanceCleanup(ctx, db, userId, key, operation) {
7675
7833
  }
7676
7834
  return operation;
7677
7835
  }
7836
+ var WORK_INDEX_METHODS = [
7837
+ "listDueCloudDataOperations",
7838
+ "cleanupExpiredCloudDataRecords",
7839
+ "rescheduleCloudDataRecordWork",
7840
+ "getCloudMaintenanceBatch",
7841
+ "finishCloudMaintenanceBatch",
7842
+ "readCloudMaintenanceRecord",
7843
+ "indexCloudDataRecordWorkBatch",
7844
+ "repairCloudResourceMetadata"
7845
+ ];
7846
+ var UNREADABLE_RETRY_MS = 15 * 6e4;
7847
+ async function repairLegacyWorkIndex(ctx) {
7848
+ const db = ctx.db;
7849
+ const name = "management-work-v1";
7850
+ const batch = await db.getCloudMaintenanceBatch(name, "cloud_data_records", 100);
7851
+ if (batch.completed) return;
7852
+ const entries = [];
7853
+ for (const row of batch.rows) {
7854
+ let work;
7855
+ if (["inventory", "plan"].includes(row.kind)) {
7856
+ work = { nextRunAt: null, expiresAt: row.updated_at + 36e5 };
7857
+ } else if (row.kind === "operation") {
7858
+ try {
7859
+ const record = await db.readCloudMaintenanceRecord(row);
7860
+ const key = await deriveUserEncryptionKey(row.user_id, ctx.masterKey);
7861
+ work = operationWork(JSON.parse(await decryptFromStorage(record.data, key)));
7862
+ } catch {
7863
+ console.warn("[amsg] Legacy cleanup operation could not be read; queued for retry");
7864
+ work = { nextRunAt: 0, expiresAt: null };
7865
+ }
7866
+ } else continue;
7867
+ entries.push({ userId: row.user_id, kind: row.kind, id: row.id, work, writeToken: row.write_token });
7868
+ }
7869
+ await db.indexCloudDataRecordWorkBatch(entries);
7870
+ const cursor = batch.rows.at(-1)?.maintenance_rowid ?? batch.upperBound;
7871
+ await db.finishCloudMaintenanceBatch(name, cursor, batch.rows.length < 100 || cursor >= batch.upperBound);
7872
+ }
7678
7873
  async function resumeCloudDataCleanups(ctx) {
7679
7874
  const db = ctx.db;
7680
7875
  if (!db?.cloudDataManagement) return;
7681
- const rows = await db.listCloudDataRecordsAcrossUsers("operation");
7682
- await db.cleanupCloudDataRecords("inventory", Date.now() - 36e5);
7683
- await db.cleanupCloudDataRecords("plan", Date.now() - 36e5);
7684
- await db.cleanupCloudResourceMetadata();
7685
- let processed = 0;
7876
+ const indexed = WORK_INDEX_METHODS.every((method) => typeof db[method] === "function");
7877
+ let rows;
7878
+ if (indexed) {
7879
+ await repairLegacyWorkIndex(ctx);
7880
+ await db.repairCloudResourceMetadata(100);
7881
+ await db.cleanupExpiredCloudDataRecords(Date.now(), 100);
7882
+ rows = await db.listDueCloudDataOperations(Date.now(), 25);
7883
+ } else {
7884
+ await db.cleanupCloudDataRecords("inventory", Date.now() - 36e5);
7885
+ await db.cleanupCloudDataRecords("plan", Date.now() - 36e5);
7886
+ rows = await db.listCloudDataRecordsAcrossUsers("operation");
7887
+ }
7686
7888
  for (const row of rows) {
7687
7889
  try {
7688
7890
  const key = await deriveUserEncryptionKey(row.userId, ctx.masterKey);
7689
7891
  const operation = JSON.parse(await decryptFromStorage(row.data, key));
7690
- if (["completed", "failed"].includes(operation.status) && operation.updatedAt < Date.now() - 30 * 864e5) {
7691
- await db.deleteCloudDataRecord(row.userId, "operation", operation.id);
7892
+ const work = operationWork(operation);
7893
+ if (indexed) {
7894
+ if (work.nextRunAt === null || work.nextRunAt > Date.now()) {
7895
+ await db.rescheduleCloudDataRecordWork(row.userId, "operation", row.id, work, row.writeToken);
7896
+ continue;
7897
+ }
7898
+ } else if (work.expiresAt !== null && work.expiresAt <= Date.now()) {
7899
+ await db.deleteCloudDataRecord(row.userId, "operation", row.id);
7692
7900
  continue;
7693
7901
  }
7694
7902
  if (["pending", "running"].includes(operation.status)) {
7695
7903
  await advanceCleanup(ctx, db, row.userId, key, operation);
7696
- if (++processed >= 100) break;
7697
7904
  }
7698
- } catch {
7905
+ } catch (error) {
7699
7906
  console.warn("[amsg] A cloud cleanup operation could not be resumed");
7907
+ if (!indexed || error?.code === "CLOUD_LEASE_LOST") continue;
7908
+ await db.rescheduleCloudDataRecordWork(
7909
+ row.userId,
7910
+ "operation",
7911
+ row.id,
7912
+ { nextRunAt: Date.now() + UNREADABLE_RETRY_MS, expiresAt: null },
7913
+ row.writeToken
7914
+ ).catch(() => {
7915
+ });
7700
7916
  }
7701
7917
  }
7702
7918
  }
7703
7919
 
7704
7920
  // src/server/handlers/cloud-data.js
7921
+ function inventorySummary(inventory) {
7922
+ const resources = inventory.entries.map((entry) => entry.resource);
7923
+ return {
7924
+ total: resources.length,
7925
+ counts: CLOUD_RESOURCE_TYPES.map((type) => ({
7926
+ type,
7927
+ count: resources.filter((resource) => resource.type === type).length,
7928
+ byteSize: resources.filter((resource) => resource.type === type).reduce((sum, resource) => sum + (resource.byteSize || 0), 0)
7929
+ })),
7930
+ complete: inventory.complete,
7931
+ gaps: inventory.gaps
7932
+ };
7933
+ }
7705
7934
  function createCloudDataHandler(ctx) {
7706
7935
  async function run(url, headers, body, method) {
7707
7936
  const tenant = await ctx.tenantManager.resolveTenant(headers);
@@ -7748,17 +7977,7 @@ function createCloudDataHandler(ctx) {
7748
7977
  const filter = { ownerType, ownerId, type };
7749
7978
  if (path.endsWith("/summary")) {
7750
7979
  const inventory = await cloudInventory(ctx, db, userId, key);
7751
- const resources = inventory.entries.map((e) => e.resource);
7752
- data = {
7753
- total: resources.length,
7754
- counts: CLOUD_RESOURCE_TYPES.map((type2) => ({
7755
- type: type2,
7756
- count: resources.filter((r) => r.type === type2).length,
7757
- byteSize: resources.filter((r) => r.type === type2).reduce((sum, r) => sum + (r.byteSize || 0), 0)
7758
- })),
7759
- complete: inventory.complete,
7760
- gaps: inventory.gaps
7761
- };
7980
+ data = inventorySummary(inventory);
7762
7981
  } else {
7763
7982
  const limit = Number(params.get("limit") || 50);
7764
7983
  if (!Number.isInteger(limit) || limit < 1 || limit > 200)
@@ -7790,6 +8009,7 @@ function createCloudDataHandler(ctx) {
7790
8009
  snapshot = {
7791
8010
  id: crypto.randomUUID(),
7792
8011
  filter,
8012
+ summary: inventorySummary(inventory),
7793
8013
  expiresAt: Date.now() + 15 * 60 * 1e3,
7794
8014
  resources: inventory.entries.map((e) => e.resource).filter(
7795
8015
  (r) => (!type || r.type === type) && (!ownerId || r.owner?.type === ownerType && r.owner.id === ownerId)
@@ -7806,6 +8026,7 @@ function createCloudDataHandler(ctx) {
7806
8026
  }
7807
8027
  data = {
7808
8028
  resources: snapshot.resources.slice(offset, offset + limit),
8029
+ ...snapshot.summary ? { summary: snapshot.summary } : {},
7809
8030
  nextCursor: offset + limit < snapshot.resources.length ? `${snapshot.id}:${offset + limit}` : null,
7810
8031
  complete: snapshot.complete,
7811
8032
  gaps: snapshot.gaps
@@ -8150,7 +8371,7 @@ function createSingleUserServer(config) {
8150
8371
  }
8151
8372
 
8152
8373
  // src/server/lib/schema-version.js
8153
- var SCHEMA_VERSION = "2.6.0-cloud-data.1";
8374
+ var SCHEMA_VERSION = "2.6.0-cloud-data.2";
8154
8375
  function requireIntrospection(db) {
8155
8376
  if (!db || typeof db.describeSchema !== "function") {
8156
8377
  throw new Error(
@@ -8178,6 +8399,12 @@ async function getSchemaVersion(db) {
8178
8399
  for (const index of SQLITE_REQUIRED_SCHEMA.indexes) {
8179
8400
  if (!liveIndexes.has(index)) missing.push(`index:${index}`);
8180
8401
  }
8402
+ if (Array.isArray(live?.triggers)) {
8403
+ const liveTriggers = new Set(live.triggers);
8404
+ for (const name of SQLITE_REQUIRED_SCHEMA.triggers) {
8405
+ if (!liveTriggers.has(name)) missing.push(`trigger:${name}`);
8406
+ }
8407
+ }
8181
8408
  const ok = missing.length === 0;
8182
8409
  return { current: ok ? SCHEMA_VERSION : null, required: SCHEMA_VERSION, ok, missing };
8183
8410
  }
@@ -8434,12 +8661,13 @@ function createSingleUserCloudflareWorker(buildConfig, options = {}) {
8434
8661
  await reportError({ stage: "config", error, cause, path: null });
8435
8662
  return { ok: false, cause };
8436
8663
  }
8664
+ const cleanup = {};
8437
8665
  try {
8438
8666
  await resumeCloudDataCleanups(buildTickContext(cfg));
8439
8667
  } catch (error) {
8440
- const cause = summarizeErrorCause(error, "cloud-cleanup");
8441
- await reportError({ stage: "cloud-cleanup", error, cause, path: null });
8442
- return { ok: false, cause };
8668
+ console.error("[amsg single-user] scheduled(): cloud cleanup failed:", error && error.message);
8669
+ cleanup.cloudCleanupCause = summarizeErrorCause(error, "cloud-cleanup");
8670
+ await reportError({ stage: "cloud-cleanup", error, cause: cleanup.cloudCleanupCause, path: null });
8443
8671
  }
8444
8672
  if (!pushConfigured(cfg)) {
8445
8673
  console.error("[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick");
@@ -8448,16 +8676,16 @@ function createSingleUserCloudflareWorker(buildConfig, options = {}) {
8448
8676
  "config"
8449
8677
  );
8450
8678
  await reportError({ stage: "config", error: null, cause, path: null });
8451
- return { ok: false, cause };
8679
+ return { ok: false, cause, ...cleanup };
8452
8680
  }
8453
8681
  try {
8454
8682
  const summary = await runScheduledTick(buildTickContext(cfg));
8455
- return { ok: true, summary };
8683
+ return { ok: true, summary, ...cleanup };
8456
8684
  } catch (error) {
8457
8685
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
8458
8686
  const cause = summarizeErrorCause(error, "tick");
8459
8687
  await reportError({ stage: "tick", error, cause, path: null });
8460
- return { ok: false, cause };
8688
+ return { ok: false, cause, ...cleanup };
8461
8689
  }
8462
8690
  }
8463
8691
  async function runTask2(uuid, env) {