@rei-standard/amsg-sw 2.1.0-next.2 → 2.1.0-next.4

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
@@ -39,6 +39,55 @@ navigator.serviceWorker.addEventListener('message', (e) => {
39
39
  });
40
40
  ```
41
41
 
42
+ ### 通知显示策略 (Notification Rendering)
43
+
44
+ 默认情况下:
45
+ - `content` 和老式 payload:自动弹系统通知。
46
+ - `reasoning` / `tool_request` / `error`:不弹通知,只触发 client 事件。
47
+
48
+ 通过 `payload.notification.show`,你可以显式覆盖这个默认行为。此字段由服务端或产生 payload 时指定:
49
+
50
+ - `"auto"` 或不传:保持默认行为。
51
+ - `"always"`:强制弹系统通知,无视 `messageKind`。
52
+ - `"when-hidden"`:仅当没有 `visibilityState === "visible"` 的客户端时才弹系统通知。如果应用在前台,则静默。
53
+ - `false`:强制不弹通知,即使是 `content`。适合完全交给应用自行接管或自绘弹窗的场景。
54
+
55
+ 当设置了弹通知时,通知文案完全由 `payload.notification` 决定(支持 `title`, `body`, `icon`, `badge`, `tag`, `data` 等字段)。如果缺省,会后备到 payload 根级属性。
56
+
57
+ #### 场景示例
58
+
59
+ **1. tool_request 需要用户处理**
60
+ 某些 Agent loop 跑到 `tool_request` 时需要用户在界面上确认或执行。由于默认 `tool_request` 不弹通知,用户如果在后台可能会漏掉:
61
+
62
+ ```json
63
+ {
64
+ "messageKind": "tool_request",
65
+ "sessionId": "...",
66
+ "toolCalls": [],
67
+ "notification": {
68
+ "show": "when-hidden",
69
+ "title": "需要继续处理",
70
+ "body": "点开应用继续完成工具调用"
71
+ }
72
+ }
73
+ ```
74
+
75
+ **2. Content 消息完全由前端接管**
76
+ 应用层想在页面前台做非常定制的 Toast,不想弹系统级别通知:
77
+
78
+ ```json
79
+ {
80
+ "messageKind": "content",
81
+ "message": "...",
82
+ "notification": {
83
+ "show": false
84
+ }
85
+ }
86
+ ```
87
+
88
+ > **注意:对于 multipart 传输**
89
+ > 当 payload 通过 `_multipart` 分片时,未收齐前不仅不派发业务事件,也**绝不**弹系统通知。收齐并还原为原始 payload 后,再按原始 payload 的 `notification.show` 策略执行判定。
90
+
42
91
  ### Blob envelope
43
92
 
44
93
  当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。
@@ -78,6 +127,11 @@ installReiSW(self, {
78
127
  maxTotalBytes: 256_000,
79
128
  maxChunks: 128,
80
129
  cleanupIntervalMs: 15 * 60_000
130
+ },
131
+ // (新增于 2.1.0-next.3)离线持久化等业务拦截钩子:
132
+ onBusinessPayload: async (payload) => {
133
+ // 收到完整 payload 时触发,由于内置在 event.waitUntil 中,能够确保离线写库完毕再允许 SW 休眠
134
+ // await db.saveIncomingMessage(payload);
81
135
  }
82
136
  });
83
137
  ```
@@ -96,7 +150,7 @@ TTL 到期仍未收齐时,SW 会清理 pending 并广播:
96
150
 
97
151
  ### 升级注意事项
98
152
 
99
- - 想给 `reasoning` / `tool_request` / `error` 也弹通知的业务:必须自行在 app 内监听上面的 postMessage 事件、调 `Notification` `registration.showNotification`。SW 默认不再为它们弹通知。
153
+ - 想给 `reasoning` / `tool_request` / `error` 弹通知的业务:SW 默认不再为它们弹通知,但可以通过设置 `payload.notification.show = "always"` `"when-hidden"` 来让 SW 在包层直接弹通知。无需再强求在 app 内自绘。
100
154
  - 应用级 SW 可以删除旧 reasoning `chunkIndex` / `totalChunks` 拼接逻辑;next 版本只会把完整还原后的 reasoning payload 发给 client。
101
155
  - 客户端代码继续兼容只有 `installReiSW` + `REI_SW_MESSAGE_TYPE`(队列)的 2.0.x 写法——新增导出不破坏既有 API。
102
156
  - 想拿到 push 类型相关的 TS 类型:从 `@rei-standard/amsg-shared` 引 `AmsgPush` 等类型(本包通过 JSDoc 引用同一份类型)。
@@ -125,7 +179,10 @@ import { installReiSW } from '@rei-standard/amsg-sw';
125
179
  installReiSW(self, {
126
180
  defaultIcon: '/icon-192x192.png',
127
181
  defaultBadge: '/badge-72x72.png',
128
- multipart: { enabled: true }
182
+ multipart: { enabled: true },
183
+ onBusinessPayload: async (payload) => {
184
+ // 这里可安全地进行应用级别的离线数据库存储
185
+ }
129
186
  });
130
187
 
131
188
  // 业务侧自行实现点击跳转
package/dist/index.cjs CHANGED
@@ -25,11 +25,14 @@ __export(src_exports, {
25
25
  installReiSW: () => installReiSW
26
26
  });
27
27
  module.exports = __toCommonJS(src_exports);
28
+ var import_amsg_shared = require("@rei-standard/amsg-shared");
28
29
  var REI_SW_DB_NAME = "rei-sw";
29
30
  var REI_SW_DB_STORE = "request-outbox";
30
31
  var REI_SW_MULTIPART_STORE = "multipart-pending";
31
32
  var REI_SW_MULTIPART_DONE_STORE = "multipart-done";
32
- var REI_SW_DB_VERSION = 2;
33
+ var REI_SW_MULTIPART_CHUNK_STORE = "multipart-chunk";
34
+ var REI_SW_DB_VERSION = 3;
35
+ var cachedDB = null;
33
36
  var REI_SW_SYNC_TAG = "rei-sw-flush-request-outbox";
34
37
  var MULTIPART_MESSAGE_KIND = "_multipart";
35
38
  var MULTIPART_ENCODING = "json-utf8-base64url";
@@ -42,6 +45,7 @@ var DEFAULT_MULTIPART_OPTIONS = Object.freeze({
42
45
  });
43
46
  var memoryMultipartPending = /* @__PURE__ */ new Map();
44
47
  var memoryMultipartDone = /* @__PURE__ */ new Map();
48
+ var memoryMultipartChunks = /* @__PURE__ */ new Map();
45
49
  var REI_AMSG_POSTMESSAGE_TYPE = "REI_AMSG_PUSH";
46
50
  var REI_SW_EVENT = Object.freeze({
47
51
  CONTENT_RECEIVED: "rei-amsg-content-received",
@@ -68,6 +72,7 @@ function installReiSW(sw, opts = {}) {
68
72
  defaultBadge,
69
73
  defaultIcon,
70
74
  multipart,
75
+ onBusinessPayload: opts.onBusinessPayload,
71
76
  getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
72
77
  setLastMultipartCleanupAt: (value) => {
73
78
  lastMultipartCleanupAt = value;
@@ -103,13 +108,33 @@ async function handlePushPayload(sw, payload, ctx) {
103
108
  }
104
109
  await dispatchBusinessPayload(sw, payload, {
105
110
  defaultIcon: ctx.defaultIcon,
106
- defaultBadge: ctx.defaultBadge
111
+ defaultBadge: ctx.defaultBadge,
112
+ onBusinessPayload: ctx.onBusinessPayload
107
113
  });
108
114
  }
109
115
  async function dispatchBusinessPayload(sw, payload, defaults) {
110
116
  const eventName = resolveEventName(payload);
111
- const shouldRenderNotification = isNotificationKind(payload);
112
- const work = [dispatchPushToClients(sw, eventName, payload)];
117
+ let clientList = [];
118
+ try {
119
+ clientList = await sw.clients.matchAll({
120
+ type: "window",
121
+ includeUncontrolled: true
122
+ });
123
+ } catch (_matchError) {
124
+ }
125
+ let shouldRenderNotification = false;
126
+ const showOpt = payload && payload.notification ? payload.notification.show : void 0;
127
+ if (showOpt === "always") {
128
+ shouldRenderNotification = true;
129
+ } else if (showOpt === "when-hidden") {
130
+ const hasVisibleClient = clientList.some((client) => client.visibilityState === "visible");
131
+ shouldRenderNotification = !hasVisibleClient;
132
+ } else if (showOpt === false) {
133
+ shouldRenderNotification = false;
134
+ } else {
135
+ shouldRenderNotification = isNotificationKind(payload);
136
+ }
137
+ const work = [dispatchPushToClients(sw, eventName, payload, clientList)];
113
138
  if (shouldRenderNotification) {
114
139
  const notification = createNotificationFromPayload(payload, defaults);
115
140
  if (notification) {
@@ -118,18 +143,30 @@ async function dispatchBusinessPayload(sw, payload, defaults) {
118
143
  );
119
144
  }
120
145
  }
146
+ if (typeof defaults.onBusinessPayload === "function") {
147
+ try {
148
+ const result = defaults.onBusinessPayload(payload);
149
+ if (result instanceof Promise) {
150
+ work.push(result.catch((error) => {
151
+ console.error("[rei-standard-amsg-sw] onBusinessPayload promise rejected:", error);
152
+ }));
153
+ }
154
+ } catch (error) {
155
+ console.error("[rei-standard-amsg-sw] onBusinessPayload error:", error);
156
+ }
157
+ }
121
158
  await Promise.all(work);
122
159
  }
123
160
  function resolveEventName(payload) {
124
161
  const kind = payload && typeof payload === "object" ? payload.messageKind : void 0;
125
162
  switch (kind) {
126
- case "content":
163
+ case import_amsg_shared.MESSAGE_KIND.CONTENT:
127
164
  return REI_SW_EVENT.CONTENT_RECEIVED;
128
- case "reasoning":
165
+ case import_amsg_shared.MESSAGE_KIND.REASONING:
129
166
  return REI_SW_EVENT.REASONING_RECEIVED;
130
- case "tool_request":
167
+ case import_amsg_shared.MESSAGE_KIND.TOOL_REQUEST:
131
168
  return REI_SW_EVENT.TOOL_REQUEST_RECEIVED;
132
- case "error":
169
+ case import_amsg_shared.MESSAGE_KIND.ERROR:
133
170
  return REI_SW_EVENT.ERROR_RECEIVED;
134
171
  default:
135
172
  return REI_SW_EVENT.UNKNOWN_RECEIVED;
@@ -139,11 +176,11 @@ function isNotificationKind(payload) {
139
176
  if (!payload || typeof payload !== "object") return false;
140
177
  const kind = payload.messageKind;
141
178
  if (kind === void 0 || kind === null) return true;
142
- return kind === "content";
179
+ return kind === import_amsg_shared.MESSAGE_KIND.CONTENT;
143
180
  }
144
- async function dispatchPushToClients(sw, eventName, payload) {
181
+ async function dispatchPushToClients(sw, eventName, payload, preFetchedClientList = null) {
145
182
  try {
146
- const clientList = await sw.clients.matchAll({
183
+ const clientList = preFetchedClientList || await sw.clients.matchAll({
147
184
  type: "window",
148
185
  includeUncontrolled: true
149
186
  });
@@ -185,9 +222,9 @@ function createNotificationFromPayload(payload, defaults) {
185
222
  };
186
223
  }
187
224
  const pushNotification = payload.notification && typeof payload.notification === "object" ? payload.notification : {};
188
- const title = pushNotification.title || payload.title || payload.contactName && `\u6765\u81EA ${payload.contactName}` || "New notification";
225
+ const title = pushNotification.title || payload.title || payload.contactName || "New notification";
189
226
  const body = pushNotification.body || payload.body || payload.message || "";
190
- const data = payload.data && typeof payload.data === "object" ? { ...payload.data } : {};
227
+ const data = pushNotification.data && typeof pushNotification.data === "object" ? { ...pushNotification.data } : payload.data && typeof payload.data === "object" ? { ...payload.data } : {};
191
228
  if (data.payload == null) data.payload = payload;
192
229
  return {
193
230
  title,
@@ -255,34 +292,43 @@ async function acceptMultipartChunk(sw, payload, options) {
255
292
  total: normalized.total,
256
293
  originalMessageKind: normalized.originalMessageKind,
257
294
  encoding: normalized.encoding,
258
- chunks: {},
295
+ receivedCount: 0,
259
296
  receivedBytes: 0
260
297
  };
261
298
  if (base.total !== normalized.total || base.encoding !== normalized.encoding) {
262
299
  await deleteMultipartPending(normalized.id);
300
+ await deleteMultipartChunks(normalized.id, base.total);
263
301
  return null;
264
302
  }
265
- if (base.chunks[String(normalized.index)] !== void 0) {
266
- return null;
267
- }
268
- base.chunks[String(normalized.index)] = normalized.chunk;
303
+ const chunkId = `${normalized.id}_${normalized.index}`;
304
+ const chunkExists = await hasMultipartChunk(chunkId);
305
+ if (chunkExists) return null;
306
+ base.receivedCount++;
269
307
  base.receivedBytes = positiveIntegerOrDefault(base.receivedBytes, 0) + normalized.chunkBytes.byteLength;
270
308
  if (base.receivedBytes > options.maxTotalBytes) {
271
309
  await deleteMultipartPending(normalized.id);
310
+ await deleteMultipartChunks(normalized.id, base.total);
272
311
  return null;
273
312
  }
274
- const received = Object.keys(base.chunks).length;
275
- if (received < base.total) {
313
+ await writeMultipartChunk({
314
+ id_index: chunkId,
315
+ id: normalized.id,
316
+ index: normalized.index,
317
+ chunk: normalized.chunk
318
+ });
319
+ if (base.receivedCount < base.total) {
276
320
  await writeMultipartPending(base);
277
321
  return null;
278
322
  }
279
323
  await deleteMultipartPending(base.id);
280
324
  let restored;
281
325
  try {
282
- restored = restoreMultipartPayload(base, options);
326
+ restored = await restoreMultipartPayload(base, options);
283
327
  } catch (_error) {
328
+ await deleteMultipartChunks(base.id, base.total);
284
329
  return null;
285
330
  }
331
+ await deleteMultipartChunks(base.id, base.total);
286
332
  const doneTtlMs = Math.max(base.ttlMs * 2, base.ttlMs + 1);
287
333
  await writeMultipartDone({
288
334
  id: base.id,
@@ -300,8 +346,9 @@ function normalizeMultipartChunk(payload, options) {
300
346
  if (meta.index <= 0 || meta.index > meta.total) return null;
301
347
  let chunkBytes;
302
348
  try {
303
- chunkBytes = base64UrlToBytes(payload.chunk);
349
+ chunkBytes = (0, import_amsg_shared.base64UrlToBytes)(payload.chunk);
304
350
  } catch (_error) {
351
+ console.error("RESTORE ERROR", _error);
305
352
  return null;
306
353
  }
307
354
  const now = Date.now();
@@ -324,22 +371,22 @@ function normalizeMultipartChunk(payload, options) {
324
371
  chunkBytes
325
372
  };
326
373
  }
327
- function restoreMultipartPayload(record, options) {
374
+ async function restoreMultipartPayload(record, options) {
328
375
  const chunks = [];
329
376
  let totalBytes = 0;
330
377
  for (let index = 1; index <= record.total; index++) {
331
- const chunk = record.chunks[String(index)];
332
- if (typeof chunk !== "string") {
378
+ const chunkRecord = await readMultipartChunk(record.id, index);
379
+ if (!chunkRecord || typeof chunkRecord.chunk !== "string") {
333
380
  throw new Error("[rei-standard-amsg-sw] multipart missing chunk");
334
381
  }
335
- const bytes = base64UrlToBytes(chunk);
382
+ const bytes = (0, import_amsg_shared.base64UrlToBytes)(chunkRecord.chunk);
336
383
  totalBytes += bytes.byteLength;
337
384
  if (totalBytes > options.maxTotalBytes) {
338
385
  throw new Error("[rei-standard-amsg-sw] multipart payload exceeds maxTotalBytes");
339
386
  }
340
387
  chunks.push(bytes);
341
388
  }
342
- const json = new TextDecoder("utf-8", { fatal: false }).decode(concatBytes(chunks));
389
+ const json = new TextDecoder("utf-8", { fatal: false }).decode((0, import_amsg_shared.concatBytes)(...chunks));
343
390
  return JSON.parse(json);
344
391
  }
345
392
  async function maybeCleanupMultipart(sw, ctx) {
@@ -351,50 +398,62 @@ async function maybeCleanupMultipart(sw, ctx) {
351
398
  try {
352
399
  await cleanupMultipartStores(sw, now);
353
400
  } catch (_error) {
401
+ console.error("RESTORE ERROR", _error);
354
402
  }
355
403
  }
356
404
  async function cleanupMultipartStores(sw, now) {
357
- const pending = await listMultipartPending();
358
- for (const record of pending) {
359
- if (record.expiresAt > now) continue;
360
- await deleteMultipartPending(record.id);
405
+ if (!hasIndexedDB()) {
406
+ for (const [id, record] of memoryMultipartPending.entries()) {
407
+ if (record.expiresAt <= now) {
408
+ memoryMultipartPending.delete(id);
409
+ await deleteMultipartChunks(id, record.total);
410
+ await dispatchMultipartExpired(sw, record);
411
+ }
412
+ }
413
+ for (const [id, record] of memoryMultipartDone.entries()) {
414
+ if (record.expiresAt <= now) {
415
+ memoryMultipartDone.delete(id);
416
+ }
417
+ }
418
+ return;
419
+ }
420
+ const pendingExpired = await withDatabaseStore(REI_SW_MULTIPART_STORE, "readonly", (store, resolve, reject) => {
421
+ const index = store.index("expiresAt");
422
+ const range = IDBKeyRange.upperBound(now);
423
+ const req = index.getAll(range);
424
+ req.onsuccess = () => resolve(req.result || []);
425
+ req.onerror = () => reject(req.error);
426
+ });
427
+ for (const record of pendingExpired) {
428
+ await deleteStoreRecord(REI_SW_MULTIPART_STORE, record.id);
429
+ await deleteMultipartChunks(record.id, record.total);
361
430
  await dispatchMultipartExpired(sw, record);
362
431
  }
363
- const done = await listMultipartDone();
364
- for (const record of done) {
365
- if (record.expiresAt <= now) {
366
- await deleteMultipartDone(record.id);
432
+ const doneExpiredKeys = await withDatabaseStore(REI_SW_MULTIPART_DONE_STORE, "readonly", (store, resolve, reject) => {
433
+ const index = store.index("expiresAt");
434
+ const range = IDBKeyRange.upperBound(now);
435
+ if (index.getAllKeys) {
436
+ const req = index.getAllKeys(range);
437
+ req.onsuccess = () => resolve(req.result || []);
438
+ req.onerror = () => reject(req.error);
439
+ } else {
440
+ const req = index.getAll(range);
441
+ req.onsuccess = () => resolve((req.result || []).map((r) => r.id));
442
+ req.onerror = () => reject(req.error);
367
443
  }
444
+ });
445
+ for (const id of doneExpiredKeys) {
446
+ await deleteStoreRecord(REI_SW_MULTIPART_DONE_STORE, id);
368
447
  }
369
448
  }
370
449
  async function dispatchMultipartExpired(sw, record) {
371
450
  await dispatchPushToClients(sw, REI_SW_EVENT.MULTIPART_EXPIRED, {
372
451
  id: record.id,
373
- received: record.chunks && typeof record.chunks === "object" ? Object.keys(record.chunks).length : 0,
452
+ received: typeof record.receivedCount === "number" ? record.receivedCount : 0,
374
453
  total: record.total,
375
454
  originalMessageKind: record.originalMessageKind
376
455
  });
377
456
  }
378
- function base64UrlToBytes(input) {
379
- const s = String(input).replace(/-/g, "+").replace(/_/g, "/");
380
- const pad = (4 - s.length % 4) % 4;
381
- const padded = s + "=".repeat(pad);
382
- const bin = typeof atob === "function" ? atob(padded) : Buffer.from(padded, "base64").toString("binary");
383
- const out = new Uint8Array(bin.length);
384
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
385
- return out;
386
- }
387
- function concatBytes(chunks) {
388
- let total = 0;
389
- for (const chunk of chunks) total += chunk.byteLength;
390
- const out = new Uint8Array(total);
391
- let offset = 0;
392
- for (const chunk of chunks) {
393
- out.set(chunk, offset);
394
- offset += chunk.byteLength;
395
- }
396
- return out;
397
- }
398
457
  async function enqueueAndFlush(sw, event, requestPayload) {
399
458
  try {
400
459
  const request = normalizeQueuedRequest(requestPayload);
@@ -454,6 +513,7 @@ function normalizeRequestBody(bodyInput) {
454
513
  try {
455
514
  return JSON.stringify(bodyInput);
456
515
  } catch (_error) {
516
+ console.error("RESTORE ERROR", _error);
457
517
  throw new Error("[rei-standard-amsg-sw] request body is not serializable");
458
518
  }
459
519
  }
@@ -480,6 +540,7 @@ async function trySendQueuedRequest(queuedRequest) {
480
540
  }
481
541
  return false;
482
542
  } catch (_error) {
543
+ console.error("RESTORE ERROR", _error);
483
544
  return false;
484
545
  }
485
546
  }
@@ -489,6 +550,7 @@ async function registerFlushSync(sw) {
489
550
  try {
490
551
  await syncManager.register(REI_SW_SYNC_TAG);
491
552
  } catch (_error) {
553
+ console.error("RESTORE ERROR", _error);
492
554
  }
493
555
  }
494
556
  function respondToSender(event, message) {
@@ -511,9 +573,6 @@ function writeMultipartPending(record) {
511
573
  function deleteMultipartPending(id) {
512
574
  return deleteStoreRecord(REI_SW_MULTIPART_STORE, id);
513
575
  }
514
- function listMultipartPending() {
515
- return listStoreRecords(REI_SW_MULTIPART_STORE);
516
- }
517
576
  function readMultipartDone(id) {
518
577
  return readStoreRecord(REI_SW_MULTIPART_DONE_STORE, id);
519
578
  }
@@ -523,8 +582,54 @@ function writeMultipartDone(record) {
523
582
  function deleteMultipartDone(id) {
524
583
  return deleteStoreRecord(REI_SW_MULTIPART_DONE_STORE, id);
525
584
  }
526
- function listMultipartDone() {
527
- return listStoreRecords(REI_SW_MULTIPART_DONE_STORE);
585
+ async function hasMultipartChunk(id_index) {
586
+ if (!hasIndexedDB()) return memoryMultipartChunks.has(id_index);
587
+ return withDatabaseStore(REI_SW_MULTIPART_CHUNK_STORE, "readonly", (store, resolve, reject) => {
588
+ const request = store.count(id_index);
589
+ request.onsuccess = () => resolve(request.result > 0);
590
+ request.onerror = () => reject(request.error);
591
+ });
592
+ }
593
+ function writeMultipartChunk(record) {
594
+ if (!hasIndexedDB()) {
595
+ memoryMultipartChunks.set(record.id_index, cloneRecord(record));
596
+ return Promise.resolve();
597
+ }
598
+ return putStoreRecord(REI_SW_MULTIPART_CHUNK_STORE, record);
599
+ }
600
+ function readMultipartChunk(id, index) {
601
+ const id_index = `${id}_${index}`;
602
+ if (!hasIndexedDB()) {
603
+ return Promise.resolve(cloneRecord(memoryMultipartChunks.get(id_index) || null));
604
+ }
605
+ return readStoreRecord(REI_SW_MULTIPART_CHUNK_STORE, id_index);
606
+ }
607
+ async function deleteMultipartChunks(id, total) {
608
+ if (!hasIndexedDB()) {
609
+ for (let index = 1; index <= total; index++) {
610
+ memoryMultipartChunks.delete(`${id}_${index}`);
611
+ }
612
+ return;
613
+ }
614
+ return withDatabaseStore(REI_SW_MULTIPART_CHUNK_STORE, "readwrite", (store, resolve, reject) => {
615
+ let pending = total;
616
+ let failed = false;
617
+ for (let index = 1; index <= total; index++) {
618
+ const request = store.delete(`${id}_${index}`);
619
+ request.onsuccess = () => {
620
+ if (failed) return;
621
+ pending--;
622
+ if (pending === 0) resolve(void 0);
623
+ };
624
+ request.onerror = () => {
625
+ if (!failed) {
626
+ failed = true;
627
+ reject(request.error);
628
+ }
629
+ };
630
+ }
631
+ if (total === 0) resolve(void 0);
632
+ });
528
633
  }
529
634
  async function readStoreRecord(storeName, id) {
530
635
  if (!hasIndexedDB()) {
@@ -558,28 +663,14 @@ async function deleteStoreRecord(storeName, id) {
558
663
  request.onerror = () => reject(request.error || new Error(`Failed to delete ${storeName}`));
559
664
  });
560
665
  }
561
- async function listStoreRecords(storeName) {
562
- if (!hasIndexedDB()) {
563
- return Array.from(memoryStoreFor(storeName).values()).map(cloneRecord);
564
- }
565
- return withDatabaseStore(storeName, "readonly", (store, resolve, reject) => {
566
- const request = store.getAll();
567
- request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
568
- request.onerror = () => reject(request.error || new Error(`Failed to list ${storeName}`));
569
- });
570
- }
571
666
  async function withDatabaseStore(storeName, mode, handler) {
572
667
  const db = await openQueueDatabase();
573
- try {
574
- return await new Promise((resolve, reject) => {
575
- const transaction = db.transaction(storeName, mode);
576
- const store = transaction.objectStore(storeName);
577
- transaction.onerror = () => reject(transaction.error || new Error("Database transaction failed"));
578
- Promise.resolve(handler(store, resolve, reject)).catch(reject);
579
- });
580
- } finally {
581
- db.close();
582
- }
668
+ return new Promise((resolve, reject) => {
669
+ const transaction = db.transaction(storeName, mode);
670
+ const store = transaction.objectStore(storeName);
671
+ transaction.onerror = () => reject(transaction.error || new Error("Database transaction failed"));
672
+ Promise.resolve(handler(store, resolve, reject)).catch(reject);
673
+ });
583
674
  }
584
675
  function hasIndexedDB() {
585
676
  return typeof indexedDB !== "undefined" && indexedDB && typeof indexedDB.open === "function";
@@ -587,6 +678,7 @@ function hasIndexedDB() {
587
678
  function memoryStoreFor(storeName) {
588
679
  if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone;
589
680
  if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending;
681
+ if (storeName === REI_SW_MULTIPART_CHUNK_STORE) return memoryMultipartChunks;
590
682
  throw new Error(`[rei-standard-amsg-sw] unknown memory store: ${storeName}`);
591
683
  }
592
684
  function cloneRecord(record) {
@@ -594,35 +686,47 @@ function cloneRecord(record) {
594
686
  return JSON.parse(JSON.stringify(record));
595
687
  }
596
688
  function openQueueDatabase() {
689
+ if (cachedDB) return Promise.resolve(cachedDB);
597
690
  return new Promise((resolve, reject) => {
598
691
  const request = indexedDB.open(REI_SW_DB_NAME, REI_SW_DB_VERSION);
599
692
  request.onupgradeneeded = () => {
600
693
  const db = request.result;
601
- createObjectStoreIfMissing(db, REI_SW_DB_STORE, { keyPath: "id", autoIncrement: true });
602
- createObjectStoreIfMissing(db, REI_SW_MULTIPART_STORE, { keyPath: "id" });
603
- createObjectStoreIfMissing(db, REI_SW_MULTIPART_DONE_STORE, { keyPath: "id" });
694
+ const tx = request.transaction;
695
+ createObjectStoreIfMissing(db, tx, REI_SW_DB_STORE, { keyPath: "id", autoIncrement: true });
696
+ const mpStore = createObjectStoreIfMissing(db, tx, REI_SW_MULTIPART_STORE, { keyPath: "id" });
697
+ const mpDoneStore = createObjectStoreIfMissing(db, tx, REI_SW_MULTIPART_DONE_STORE, { keyPath: "id" });
698
+ createObjectStoreIfMissing(db, tx, REI_SW_MULTIPART_CHUNK_STORE, { keyPath: "id_index" });
699
+ if (mpStore && !mpStore.indexNames.contains("expiresAt")) {
700
+ mpStore.createIndex("expiresAt", "expiresAt", { unique: false });
701
+ }
702
+ if (mpDoneStore && !mpDoneStore.indexNames.contains("expiresAt")) {
703
+ mpDoneStore.createIndex("expiresAt", "expiresAt", { unique: false });
704
+ }
705
+ };
706
+ request.onsuccess = () => {
707
+ cachedDB = request.result;
708
+ cachedDB.onversionchange = () => {
709
+ cachedDB.close();
710
+ cachedDB = null;
711
+ };
712
+ resolve(cachedDB);
604
713
  };
605
- request.onsuccess = () => resolve(request.result);
606
714
  request.onerror = () => reject(request.error || new Error("Failed to open queue database"));
607
715
  });
608
716
  }
609
- function createObjectStoreIfMissing(db, name, options) {
610
- if (db.objectStoreNames.contains(name)) return;
611
- db.createObjectStore(name, options);
717
+ function createObjectStoreIfMissing(db, tx, name, options) {
718
+ if (db.objectStoreNames.contains(name)) return tx.objectStore(name);
719
+ return db.createObjectStore(name, options);
612
720
  }
613
721
  async function withQueueStore(mode, handler) {
614
722
  const db = await openQueueDatabase();
615
- try {
616
- return await new Promise((resolve, reject) => {
617
- const transaction = db.transaction(REI_SW_DB_STORE, mode);
618
- const store = transaction.objectStore(REI_SW_DB_STORE);
619
- transaction.oncomplete = () => resolve(void 0);
620
- transaction.onerror = () => reject(transaction.error || new Error("Queue transaction failed"));
621
- Promise.resolve(handler(store, resolve, reject)).catch(reject);
622
- });
623
- } finally {
624
- db.close();
625
- }
723
+ return new Promise((resolve, reject) => {
724
+ const transaction = db.transaction(REI_SW_DB_STORE, mode);
725
+ const store = transaction.objectStore(REI_SW_DB_STORE);
726
+ transaction.oncomplete = () => resolve(void 0);
727
+ transaction.onerror = () => reject(transaction.error || new Error("Queue transaction failed"));
728
+ Promise.resolve(handler(store, resolve, reject)).catch(reject);
729
+ });
626
730
  }
627
731
  async function addQueuedRequest(request) {
628
732
  return withQueueStore("readwrite", (store, resolve, reject) => {