@rei-standard/amsg-sw 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -53,6 +53,10 @@ const REI_SW_MULTIPART_DONE_STORE = 'multipart-done';
53
53
  const REI_SW_MULTIPART_CHUNK_STORE = 'multipart-chunk';
54
54
  const REI_SW_DB_VERSION = 3;
55
55
  let cachedDB = null;
56
+ const REI_AMSG_DEDUPE_DB_NAME = 'rei_amsg_sw_dedupe_v1';
57
+ const REI_AMSG_DEDUPE_STORE = 'delivery-dedupe';
58
+ const DEFAULT_DEDUPE_TTL_MS = 10 * 60_000;
59
+ const DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS = 60_000;
56
60
  const REI_SW_SYNC_TAG = 'rei-sw-flush-request-outbox';
57
61
  const MULTIPART_MESSAGE_KIND = '_multipart';
58
62
  const MULTIPART_ENCODING = 'json-utf8-base64url';
@@ -67,6 +71,7 @@ const memoryMultipartPending = new Map();
67
71
  const memoryMultipartDone = new Map();
68
72
  const memoryMultipartChunks = new Map();
69
73
  const multipartLocks = new Map();
74
+ const dedupeDbCache = new Map();
70
75
 
71
76
  /**
72
77
  * Wire-level message type for SW → client postMessage envelopes.
@@ -96,10 +101,13 @@ const REI_SW_EVENT = Object.freeze({
96
101
 
97
102
  const REI_SW_MESSAGE_TYPE = Object.freeze({
98
103
  ENQUEUE_REQUEST: 'REI_ENQUEUE_REQUEST',
104
+ DELIVER: 'REI_AMSG_DELIVER',
99
105
  FLUSH_QUEUE: 'REI_FLUSH_QUEUE',
100
106
  QUEUE_RESULT: 'REI_QUEUE_RESULT'
101
107
  });
102
108
 
109
+ const REI_AMSG_DELIVER_MESSAGE_TYPE = REI_SW_MESSAGE_TYPE.DELIVER;
110
+
103
111
  /**
104
112
  * @typedef {Object} ReiSWOptions
105
113
  * @property {string} [defaultIcon] - Fallback notification icon URL.
@@ -110,7 +118,14 @@ const REI_SW_MESSAGE_TYPE = Object.freeze({
110
118
  * @property {number} [multipart.maxTotalBytes=256000]
111
119
  * @property {number} [multipart.maxChunks=128]
112
120
  * @property {number} [multipart.cleanupIntervalMs=900000]
121
+ * @property {Object} [dedupe]
122
+ * @property {boolean} [dedupe.enabled=true]
123
+ * @property {number} [dedupe.ttlMs=600000]
124
+ * @property {number} [dedupe.cleanupIntervalMs=60000]
125
+ * @property {(payload: any) => string | undefined} [dedupe.key]
126
+ * @property {string} [dedupe.dbName='rei_amsg_sw_dedupe_v1'] - 隔离去重数据用。每个 dbName 对应一个独立的 IndexedDB instance,互不影响。`dedupe.storeName` 不再可配(传了会抛错);本包不维护跨 dbName 的迁移逻辑。
113
127
  * @property {(payload: any) => void | Promise<void>} [onBusinessPayload]
128
+ * @property {(info: { key: string, source: string, messageKind?: string, firstSeenAt?: number, existingSource?: string, existingMessageKind?: string, existingNotificationShown?: boolean, duplicateNotificationShown?: boolean }) => void | Promise<void>} [onDuplicate]
114
129
  */
115
130
 
116
131
  /**
@@ -123,20 +138,28 @@ function installReiSW(sw, opts = {}) {
123
138
  const defaultIcon = opts.defaultIcon || '/icon-192x192.png';
124
139
  const defaultBadge = opts.defaultBadge || '/badge-72x72.png';
125
140
  const multipart = normalizeMultipartOptions(opts.multipart);
141
+ const dedupe = normalizeDedupeOptions(opts.dedupe);
126
142
  let lastMultipartCleanupAt = 0;
143
+ let lastDedupeCleanupAt = 0;
144
+ const makeDeliveryContext = (source) => ({
145
+ defaultBadge,
146
+ defaultIcon,
147
+ dedupe,
148
+ multipart,
149
+ onDuplicate: opts.onDuplicate,
150
+ onBusinessPayload: opts.onBusinessPayload,
151
+ source,
152
+ getLastDedupeCleanupAt: () => lastDedupeCleanupAt,
153
+ setLastDedupeCleanupAt: (value) => { lastDedupeCleanupAt = value; },
154
+ getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
155
+ setLastMultipartCleanupAt: (value) => { lastMultipartCleanupAt = value; },
156
+ });
127
157
 
128
158
  sw.addEventListener('push', (event) => {
129
159
  const payload = readPushPayload(event);
130
160
  if (!payload) return;
131
161
 
132
- event.waitUntil(handlePushPayload(sw, payload, {
133
- defaultBadge,
134
- defaultIcon,
135
- multipart,
136
- onBusinessPayload: opts.onBusinessPayload,
137
- getLastMultipartCleanupAt: () => lastMultipartCleanupAt,
138
- setLastMultipartCleanupAt: (value) => { lastMultipartCleanupAt = value; },
139
- }));
162
+ event.waitUntil(handlePushPayload(sw, payload, makeDeliveryContext('webpush')));
140
163
  });
141
164
 
142
165
  sw.addEventListener('message', (event) => {
@@ -150,6 +173,11 @@ function installReiSW(sw, opts = {}) {
150
173
  return;
151
174
  }
152
175
 
176
+ if (message.type === REI_SW_MESSAGE_TYPE.DELIVER) {
177
+ event.waitUntil(handleDeliverMessage(sw, event, message, makeDeliveryContext()));
178
+ return;
179
+ }
180
+
153
181
  if (message.type === REI_SW_MESSAGE_TYPE.FLUSH_QUEUE) {
154
182
  event.waitUntil(flushQueuedRequests(sw));
155
183
  }
@@ -168,18 +196,58 @@ async function handlePushPayload(sw, payload, ctx) {
168
196
  if (!ctx.multipart.enabled) return;
169
197
  const restoredPayload = await acceptMultipartChunk(sw, payload, ctx.multipart);
170
198
  if (!restoredPayload) return;
171
- await handlePushPayload(sw, restoredPayload, ctx);
172
- return;
199
+ return handlePushPayload(sw, restoredPayload, ctx);
200
+ }
201
+
202
+ const claim = await claimDedupe(payload, ctx);
203
+ if (claim.duplicate) {
204
+ const duplicateNotification = await maybeShowDuplicateNotification(sw, payload, claim, ctx);
205
+ claim.duplicateNotification = duplicateNotification;
206
+ await notifyDuplicate(payload, claim, ctx);
207
+ return { ...claim, duplicateNotification };
173
208
  }
174
209
 
175
210
  await dispatchBusinessPayload(sw, payload, {
176
211
  defaultIcon: ctx.defaultIcon,
177
212
  defaultBadge: ctx.defaultBadge,
178
213
  onBusinessPayload: ctx.onBusinessPayload,
214
+ }, async (intermediateResult) => {
215
+ // Settle the dedupe pending flag as soon as the notification policy
216
+ // is decided (dispatch + showNotification done) — do NOT wait for
217
+ // onBusinessPayload. A backup arriving mid-business would otherwise
218
+ // hit `notificationStatePending` and skip the repair path.
219
+ await updateDedupeNotificationState(claim, ctx, intermediateResult);
179
220
  });
221
+ return claim;
222
+ }
223
+
224
+ async function handleDeliverMessage(sw, event, message, ctx) {
225
+ let result = {};
226
+ try {
227
+ if (!Object.prototype.hasOwnProperty.call(message, 'payload')) {
228
+ throw new Error('[rei-standard-amsg-sw] REI_AMSG_DELIVER requires payload');
229
+ }
230
+ const source = typeof message.source === 'string' && message.source
231
+ ? message.source
232
+ : 'message';
233
+ result = await handlePushPayload(sw, message.payload, { ...ctx, source }) || {};
234
+ respondToSender(event, {
235
+ ok: true,
236
+ duplicate: Boolean(result.duplicate),
237
+ key: result.key,
238
+ requestId: message.requestId,
239
+ });
240
+ } catch (error) {
241
+ respondToSender(event, {
242
+ ok: false,
243
+ error: error instanceof Error ? error.message : 'Failed to deliver payload',
244
+ key: result && result.key,
245
+ requestId: message.requestId,
246
+ });
247
+ }
180
248
  }
181
249
 
182
- async function dispatchBusinessPayload(sw, payload, defaults) {
250
+ async function dispatchBusinessPayload(sw, payload, defaults, onNotificationSettled) {
183
251
  const eventName = resolveEventName(payload);
184
252
 
185
253
  let clientList = [];
@@ -191,47 +259,56 @@ async function dispatchBusinessPayload(sw, payload, defaults) {
191
259
  } catch (_matchError) {
192
260
  // Ignored
193
261
  }
194
-
195
- let shouldRenderNotification = false;
196
- const showOpt = payload && payload.notification ? payload.notification.show : undefined;
197
262
 
198
- if (showOpt === 'always') {
199
- shouldRenderNotification = true;
200
- } else if (showOpt === 'when-hidden') {
201
- const hasVisibleClient = clientList.some(client => client.visibilityState === 'visible');
202
- shouldRenderNotification = !hasVisibleClient;
203
- } else if (showOpt === false) {
204
- shouldRenderNotification = false;
205
- } else {
206
- shouldRenderNotification = isNotificationKind(payload);
207
- }
263
+ const notificationState = {
264
+ shouldRender: shouldRenderNotification(payload, clientList),
265
+ shown: false,
266
+ };
208
267
 
209
268
  /** @type {Array<Promise<unknown>>} */
210
- const work = [dispatchPushToClients(sw, eventName, payload, clientList)];
269
+ const notificationWork = [dispatchPushToClients(sw, eventName, payload, clientList)];
211
270
 
212
- if (shouldRenderNotification) {
271
+ if (notificationState.shouldRender) {
213
272
  const notification = createNotificationFromPayload(payload, defaults);
214
273
  if (notification) {
215
- work.push(
274
+ notificationWork.push(
216
275
  sw.registration.showNotification(notification.title, notification.options)
276
+ .then(() => {
277
+ notificationState.shown = true;
278
+ })
217
279
  );
218
280
  }
219
281
  }
220
282
 
283
+ // Kick the user's business callback off in parallel with notification
284
+ // work, but do NOT block notification-state settlement on it. A slow
285
+ // onBusinessPayload would otherwise keep `notificationStatePending`
286
+ // set, and a Web Push backup arriving in that window would be swallowed
287
+ // as 'first-delivery-pending' with no chance to repair a missed
288
+ // notification. The overall waitUntil chain still awaits the business
289
+ // callback below so the SW does not get killed mid-flight.
290
+ let businessWork = null;
221
291
  if (typeof defaults.onBusinessPayload === 'function') {
222
292
  try {
223
293
  const result = defaults.onBusinessPayload(payload);
224
294
  if (result && typeof result.then === 'function') {
225
- work.push(Promise.resolve(result).catch(error => {
295
+ businessWork = Promise.resolve(result).catch(error => {
226
296
  console.error('[rei-standard-amsg-sw] onBusinessPayload promise rejected:', error);
227
- }));
297
+ });
228
298
  }
229
299
  } catch (error) {
230
300
  console.error('[rei-standard-amsg-sw] onBusinessPayload error:', error);
231
301
  }
232
302
  }
233
303
 
234
- await Promise.all(work);
304
+ await Promise.all(notificationWork);
305
+ const settledResult = { eventName, notification: notificationState };
306
+ if (typeof onNotificationSettled === 'function') {
307
+ await onNotificationSettled(settledResult);
308
+ }
309
+ if (businessWork) await businessWork;
310
+
311
+ return settledResult;
235
312
  }
236
313
 
237
314
  /**
@@ -263,7 +340,6 @@ function resolveEventName(payload) {
263
340
  * `messageKind: 'content'` renders a notification; everything else
264
341
  * (`reasoning`, `tool_request`, `error`) is dispatched silently so
265
342
  * apps can render them in-app.
266
- *
267
343
  * Legacy payloads with no `messageKind` field still render a
268
344
  * notification — that's the 2.0.x back-compat path.
269
345
  *
@@ -277,6 +353,21 @@ function isNotificationKind(payload) {
277
353
  return kind === MESSAGE_KIND.CONTENT;
278
354
  }
279
355
 
356
+ function shouldRenderNotification(payload, clientList) {
357
+ const showOpt = payload && payload.notification ? payload.notification.show : undefined;
358
+
359
+ if (showOpt === 'always') {
360
+ return true;
361
+ }
362
+ if (showOpt === 'when-hidden') {
363
+ return !clientList.some(client => client.visibilityState === 'visible');
364
+ }
365
+ if (showOpt === false) {
366
+ return false;
367
+ }
368
+ return isNotificationKind(payload);
369
+ }
370
+
280
371
  /**
281
372
  * Broadcast a parsed push payload to every controlled client. Failures
282
373
  * on individual `postMessage` calls are swallowed — one offline tab
@@ -367,7 +458,8 @@ function createNotificationFromPayload(payload, defaults) {
367
458
  renotify: Boolean(pushNotification.renotify ?? payload.renotify ?? false),
368
459
  requireInteraction: Boolean(
369
460
  pushNotification.requireInteraction ?? payload.requireInteraction ?? false
370
- )
461
+ ),
462
+ silent: Boolean(pushNotification.silent ?? payload.silent ?? false)
371
463
  }
372
464
  };
373
465
  }
@@ -391,10 +483,240 @@ function normalizeMultipartOptions(input) {
391
483
  };
392
484
  }
393
485
 
486
+ function normalizeDedupeOptions(input) {
487
+ const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
488
+
489
+ // storeName 不再可配。同 dbName 下 storeName 一变就要做 IDB 版本升级,
490
+ // 暴露这个配置点的收益(一个内部 store 名字)远小于让用户踩 IDB upgrade
491
+ // 坑的代价。隔离用 dbName —— 每个 dbName 是独立 IndexedDB instance。
492
+ if (Object.prototype.hasOwnProperty.call(source, 'storeName')) {
493
+ throw new Error(
494
+ '[rei-standard-amsg-sw] dedupe.storeName 不再可配置。改 storeName 会触发 IndexedDB 版本升级,'
495
+ + '本包不维护 migration 逻辑。需要隔离去重数据请改用 dedupe.dbName(每个 dbName 是独立 IDB 实例)。'
496
+ );
497
+ }
498
+
499
+ return {
500
+ enabled: source.enabled !== false,
501
+ ttlMs: positiveIntegerOrDefault(source.ttlMs, DEFAULT_DEDUPE_TTL_MS),
502
+ cleanupIntervalMs: source.cleanupIntervalMs === 0
503
+ ? 0
504
+ : positiveIntegerOrDefault(
505
+ source.cleanupIntervalMs,
506
+ DEFAULT_DEDUPE_CLEANUP_INTERVAL_MS
507
+ ),
508
+ key: typeof source.key === 'function' ? source.key : null,
509
+ dbName: typeof source.dbName === 'string' && source.dbName.trim()
510
+ ? source.dbName.trim()
511
+ : REI_AMSG_DEDUPE_DB_NAME,
512
+ storeName: REI_AMSG_DEDUPE_STORE,
513
+ _memoryStore: new Map(),
514
+ };
515
+ }
516
+
394
517
  function positiveIntegerOrDefault(value, fallback) {
395
518
  return Number.isInteger(value) && value > 0 ? value : fallback;
396
519
  }
397
520
 
521
+ async function claimDedupe(payload, ctx) {
522
+ if (!ctx.dedupe || ctx.dedupe.enabled === false) {
523
+ return { duplicate: false, key: undefined };
524
+ }
525
+
526
+ const key = resolveDedupeKey(payload, ctx.dedupe);
527
+ if (!key) return { duplicate: false, key: undefined };
528
+
529
+ await maybeCleanupDedupe(ctx);
530
+
531
+ const now = Date.now();
532
+ const record = {
533
+ key,
534
+ firstSeenAt: now,
535
+ expiresAt: now + ctx.dedupe.ttlMs,
536
+ source: ctx.source || 'unknown',
537
+ messageKind: getPayloadMessageKind(payload),
538
+ notificationShown: false,
539
+ notificationStatePending: true,
540
+ };
541
+
542
+ if (await addDedupeRecord(ctx.dedupe, record)) {
543
+ return { duplicate: false, key, record };
544
+ }
545
+
546
+ const existing = await readDedupeRecord(ctx.dedupe, key);
547
+ if (existing && existing.expiresAt <= now) {
548
+ await deleteDedupeRecord(ctx.dedupe, key);
549
+ if (await addDedupeRecord(ctx.dedupe, record)) {
550
+ return { duplicate: false, key, record };
551
+ }
552
+ }
553
+
554
+ return {
555
+ duplicate: true,
556
+ key,
557
+ record,
558
+ existing: existing || null,
559
+ };
560
+ }
561
+
562
+ async function updateDedupeNotificationState(claim, ctx, dispatchResult) {
563
+ if (!claim || claim.duplicate || !claim.key || !ctx.dedupe || ctx.dedupe.enabled === false) return;
564
+ if (!dispatchResult || !dispatchResult.notification) return;
565
+
566
+ const notification = dispatchResult.notification;
567
+ const next = {
568
+ ...claim.record,
569
+ notificationShown: notification.shown === true,
570
+ notificationStatePending: false,
571
+ };
572
+
573
+ try {
574
+ await putDedupeRecord(ctx.dedupe, next);
575
+ claim.record = next;
576
+ } catch (error) {
577
+ console.error('[rei-standard-amsg-sw] dedupe notification state update failed:', error);
578
+ }
579
+ }
580
+
581
+ async function maybeShowDuplicateNotification(sw, payload, claim, ctx) {
582
+ const existing = claim && claim.existing ? claim.existing : null;
583
+ if (!existing || existing.notificationShown === true) {
584
+ return { shown: false, reason: existing ? 'already-shown' : 'no-existing-record' };
585
+ }
586
+ if (existing.notificationStatePending === true) {
587
+ return { shown: false, reason: 'first-delivery-pending' };
588
+ }
589
+
590
+ let clientList = [];
591
+ try {
592
+ clientList = await sw.clients.matchAll({
593
+ type: 'window',
594
+ includeUncontrolled: true
595
+ });
596
+ } catch (_matchError) {
597
+ // Ignored
598
+ }
599
+
600
+ if (!shouldRenderNotification(payload, clientList)) {
601
+ return { shown: false, reason: 'policy-suppressed' };
602
+ }
603
+
604
+ const notification = createNotificationFromPayload(payload, {
605
+ defaultIcon: ctx.defaultIcon,
606
+ defaultBadge: ctx.defaultBadge,
607
+ });
608
+ if (!notification) {
609
+ return { shown: false, reason: 'no-notification' };
610
+ }
611
+
612
+ await sw.registration.showNotification(notification.title, notification.options);
613
+
614
+ const next = {
615
+ ...existing,
616
+ notificationShown: true,
617
+ notificationStatePending: false,
618
+ };
619
+ await putDedupeRecord(ctx.dedupe, next);
620
+
621
+ return { shown: true, reason: 'shown-from-duplicate' };
622
+ }
623
+
624
+ function resolveDedupeKey(payload, dedupe) {
625
+ if (typeof dedupe.key === 'function') {
626
+ try {
627
+ const custom = dedupe.key(payload);
628
+ return typeof custom === 'string' && custom.trim() ? custom.trim() : undefined;
629
+ } catch (error) {
630
+ console.error('[rei-standard-amsg-sw] dedupe.key error:', error);
631
+ return undefined;
632
+ }
633
+ }
634
+
635
+ if (!payload || typeof payload !== 'object') return undefined;
636
+ for (const field of ['messageId', 'id', 'dedupeKey']) {
637
+ const value = payload[field];
638
+ if (typeof value === 'string' && value.trim()) return value.trim();
639
+ }
640
+ return undefined;
641
+ }
642
+
643
+ function getPayloadMessageKind(payload) {
644
+ return payload && typeof payload === 'object' && typeof payload.messageKind === 'string'
645
+ ? payload.messageKind
646
+ : undefined;
647
+ }
648
+
649
+ async function notifyDuplicate(payload, claim, ctx) {
650
+ if (typeof ctx.onDuplicate !== 'function') return;
651
+ const existing = claim.existing || {};
652
+ const info = {
653
+ key: claim.key,
654
+ source: ctx.source || 'unknown',
655
+ messageKind: getPayloadMessageKind(payload),
656
+ firstSeenAt: existing.firstSeenAt,
657
+ existingSource: existing.source,
658
+ existingMessageKind: existing.messageKind,
659
+ existingNotificationShown: existing.notificationShown === true,
660
+ duplicateNotificationShown: claim.duplicateNotification && claim.duplicateNotification.shown === true,
661
+ };
662
+ try {
663
+ await ctx.onDuplicate(info);
664
+ } catch (error) {
665
+ console.error('[rei-standard-amsg-sw] onDuplicate error:', error);
666
+ }
667
+ }
668
+
669
+ async function maybeCleanupDedupe(ctx) {
670
+ if (!ctx.dedupe || ctx.dedupe.enabled === false || ctx.dedupe.cleanupIntervalMs === 0) return;
671
+ const now = Date.now();
672
+ const last = ctx.getLastDedupeCleanupAt ? ctx.getLastDedupeCleanupAt() : 0;
673
+ if (last && now - last < ctx.dedupe.cleanupIntervalMs) return;
674
+ if (ctx.setLastDedupeCleanupAt) ctx.setLastDedupeCleanupAt(now);
675
+ try {
676
+ await cleanupDedupeStore(ctx.dedupe, now);
677
+ } catch (error) {
678
+ console.error('[rei-standard-amsg-sw] dedupe cleanup failed:', error);
679
+ }
680
+ }
681
+
682
+ async function cleanupDedupeStore(dedupe, now) {
683
+ if (!hasIndexedDB()) {
684
+ const store = memoryDedupeStoreFor(dedupe);
685
+ for (const [key, record] of store.entries()) {
686
+ if (record.expiresAt <= now) store.delete(key);
687
+ }
688
+ return;
689
+ }
690
+
691
+ await withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => {
692
+ const index = store.index('expiresAt');
693
+ const range = IDBKeyRange.upperBound(now);
694
+ let failed = false;
695
+ const request = index.openCursor(range);
696
+ request.onsuccess = () => {
697
+ if (failed) return;
698
+ const cursor = request.result;
699
+ if (!cursor) {
700
+ resolve(undefined);
701
+ return;
702
+ }
703
+
704
+ const deleteRequest = cursor.delete();
705
+ deleteRequest.onsuccess = () => {
706
+ if (failed) return;
707
+ cursor.continue();
708
+ };
709
+ deleteRequest.onerror = () => {
710
+ if (!failed) {
711
+ failed = true;
712
+ reject(deleteRequest.error || new Error('Failed to delete expired dedupe record'));
713
+ }
714
+ };
715
+ };
716
+ request.onerror = () => reject(request.error || new Error('Failed to scan expired dedupe records'));
717
+ });
718
+ }
719
+
398
720
  function isMultipartPush(payload) {
399
721
  return !!payload &&
400
722
  typeof payload === 'object' &&
@@ -793,6 +1115,78 @@ function respondToSender(event, message) {
793
1115
  }
794
1116
  }
795
1117
 
1118
+ async function addDedupeRecord(dedupe, record) {
1119
+ if (!hasIndexedDB()) {
1120
+ const store = memoryDedupeStoreFor(dedupe);
1121
+ if (store.has(record.key)) return false;
1122
+ store.set(record.key, cloneRecord(record));
1123
+ return true;
1124
+ }
1125
+
1126
+ return withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => {
1127
+ let settled = false;
1128
+ const request = store.add(record);
1129
+ request.onsuccess = () => {
1130
+ settled = true;
1131
+ resolve(true);
1132
+ };
1133
+ request.onerror = (event) => {
1134
+ settled = true;
1135
+ if (request.error && request.error.name === 'ConstraintError') {
1136
+ if (event && typeof event.preventDefault === 'function') event.preventDefault();
1137
+ resolve(false);
1138
+ return;
1139
+ }
1140
+ reject(request.error || new Error('Failed to add dedupe record'));
1141
+ };
1142
+ store.transaction.onerror = () => {
1143
+ if (!settled) reject(store.transaction.error || new Error('Dedupe transaction failed'));
1144
+ };
1145
+ });
1146
+ }
1147
+
1148
+ function readDedupeRecord(dedupe, key) {
1149
+ if (!hasIndexedDB()) {
1150
+ return Promise.resolve(cloneRecord(memoryDedupeStoreFor(dedupe).get(key) || null));
1151
+ }
1152
+
1153
+ return withDedupeStore(dedupe, 'readonly', (store, resolve, reject) => {
1154
+ const request = store.get(key);
1155
+ request.onsuccess = () => resolve(request.result || null);
1156
+ request.onerror = () => reject(request.error || new Error('Failed to read dedupe record'));
1157
+ });
1158
+ }
1159
+
1160
+ function putDedupeRecord(dedupe, record) {
1161
+ if (!record || typeof record.key !== 'string' || !record.key) {
1162
+ return Promise.resolve();
1163
+ }
1164
+
1165
+ if (!hasIndexedDB()) {
1166
+ memoryDedupeStoreFor(dedupe).set(record.key, cloneRecord(record));
1167
+ return Promise.resolve();
1168
+ }
1169
+
1170
+ return withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => {
1171
+ const request = store.put(record);
1172
+ request.onsuccess = () => resolve(undefined);
1173
+ request.onerror = () => reject(request.error || new Error('Failed to put dedupe record'));
1174
+ });
1175
+ }
1176
+
1177
+ function deleteDedupeRecord(dedupe, key) {
1178
+ if (!hasIndexedDB()) {
1179
+ memoryDedupeStoreFor(dedupe).delete(key);
1180
+ return Promise.resolve();
1181
+ }
1182
+
1183
+ return withDedupeStore(dedupe, 'readwrite', (store, resolve, reject) => {
1184
+ const request = store.delete(key);
1185
+ request.onsuccess = () => resolve(undefined);
1186
+ request.onerror = () => reject(request.error || new Error('Failed to delete dedupe record'));
1187
+ });
1188
+ }
1189
+
796
1190
  function readMultipartPending(id) {
797
1191
  return readStoreRecord(REI_SW_MULTIPART_STORE, id);
798
1192
  }
@@ -918,12 +1312,27 @@ async function withDatabaseStore(storeName, mode, handler) {
918
1312
  });
919
1313
  }
920
1314
 
1315
+ async function withDedupeStore(dedupe, mode, handler) {
1316
+ const db = await openDedupeDatabase(dedupe);
1317
+ return new Promise((resolve, reject) => {
1318
+ const transaction = db.transaction(dedupe.storeName, mode);
1319
+ const store = transaction.objectStore(dedupe.storeName);
1320
+ transaction.onerror = () => reject(transaction.error || new Error('Dedupe transaction failed'));
1321
+ Promise.resolve(handler(store, resolve, reject)).catch(reject);
1322
+ });
1323
+ }
1324
+
921
1325
  function hasIndexedDB() {
922
1326
  return typeof indexedDB !== 'undefined' &&
923
1327
  indexedDB &&
924
1328
  typeof indexedDB.open === 'function';
925
1329
  }
926
1330
 
1331
+ function memoryDedupeStoreFor(dedupe) {
1332
+ if (!dedupe._memoryStore) dedupe._memoryStore = new Map();
1333
+ return dedupe._memoryStore;
1334
+ }
1335
+
927
1336
  function memoryStoreFor(storeName) {
928
1337
  if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone;
929
1338
  if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending;
@@ -936,6 +1345,37 @@ function cloneRecord(record) {
936
1345
  return JSON.parse(JSON.stringify(record));
937
1346
  }
938
1347
 
1348
+ function openDedupeDatabase(dedupe) {
1349
+ const cacheKey = `${dedupe.dbName}:${dedupe.storeName}`;
1350
+ const cached = dedupeDbCache.get(cacheKey);
1351
+ if (cached) return Promise.resolve(cached);
1352
+
1353
+ return new Promise((resolve, reject) => {
1354
+ const request = indexedDB.open(dedupe.dbName, 1);
1355
+
1356
+ request.onupgradeneeded = () => {
1357
+ const db = request.result;
1358
+ const store = db.objectStoreNames.contains(dedupe.storeName)
1359
+ ? request.transaction.objectStore(dedupe.storeName)
1360
+ : db.createObjectStore(dedupe.storeName, { keyPath: 'key' });
1361
+ if (store && !store.indexNames.contains('expiresAt')) {
1362
+ store.createIndex('expiresAt', 'expiresAt', { unique: false });
1363
+ }
1364
+ };
1365
+
1366
+ request.onsuccess = () => {
1367
+ const db = request.result;
1368
+ dedupeDbCache.set(cacheKey, db);
1369
+ db.onversionchange = () => {
1370
+ db.close();
1371
+ dedupeDbCache.delete(cacheKey);
1372
+ };
1373
+ resolve(db);
1374
+ };
1375
+ request.onerror = () => reject(request.error || new Error('Failed to open dedupe database'));
1376
+ });
1377
+ }
1378
+
939
1379
  function openQueueDatabase() {
940
1380
  if (cachedDB) return Promise.resolve(cachedDB);
941
1381
 
@@ -1016,4 +1456,4 @@ async function removeQueuedRequest(id) {
1016
1456
  });
1017
1457
  }
1018
1458
 
1019
- export { REI_AMSG_POSTMESSAGE_TYPE, REI_SW_EVENT, REI_SW_MESSAGE_TYPE, installReiSW };
1459
+ export { REI_AMSG_DELIVER_MESSAGE_TYPE, REI_AMSG_POSTMESSAGE_TYPE, REI_SW_EVENT, REI_SW_MESSAGE_TYPE, installReiSW };