@joryio/web-sdk 0.0.0-stage → 1.3.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 (76) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +54 -0
  3. package/README.md +497 -2
  4. package/dist/core/config.d.ts +101 -0
  5. package/dist/core/config.d.ts.map +1 -0
  6. package/dist/core/device.d.ts +14 -0
  7. package/dist/core/device.d.ts.map +1 -0
  8. package/dist/core/ecommerce.d.ts +67 -0
  9. package/dist/core/ecommerce.d.ts.map +1 -0
  10. package/dist/core/identity.d.ts +36 -0
  11. package/dist/core/identity.d.ts.map +1 -0
  12. package/dist/core/inapp.d.ts +169 -0
  13. package/dist/core/inapp.d.ts.map +1 -0
  14. package/dist/core/network.d.ts +158 -0
  15. package/dist/core/network.d.ts.map +1 -0
  16. package/dist/core/outbox.d.ts +79 -0
  17. package/dist/core/outbox.d.ts.map +1 -0
  18. package/dist/core/pending-attributes.d.ts +43 -0
  19. package/dist/core/pending-attributes.d.ts.map +1 -0
  20. package/dist/core/push.d.ts +59 -0
  21. package/dist/core/push.d.ts.map +1 -0
  22. package/dist/core/queue.d.ts +107 -0
  23. package/dist/core/queue.d.ts.map +1 -0
  24. package/dist/core/sdk.d.ts +169 -0
  25. package/dist/core/sdk.d.ts.map +1 -0
  26. package/dist/core/session.d.ts +36 -0
  27. package/dist/core/session.d.ts.map +1 -0
  28. package/dist/core/storage.d.ts +17 -0
  29. package/dist/core/storage.d.ts.map +1 -0
  30. package/dist/core/user.d.ts +28 -0
  31. package/dist/core/user.d.ts.map +1 -0
  32. package/dist/core/utm.d.ts +39 -0
  33. package/dist/core/utm.d.ts.map +1 -0
  34. package/dist/core/values.d.ts +3 -0
  35. package/dist/core/values.d.ts.map +1 -0
  36. package/dist/core/write-retry.d.ts +66 -0
  37. package/dist/core/write-retry.d.ts.map +1 -0
  38. package/dist/index.d.mts +711 -0
  39. package/dist/index.d.ts +21 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/joryio-sw.js +6 -0
  42. package/dist/joryio.cjs.js +7265 -0
  43. package/dist/joryio.min.js +7 -0
  44. package/dist/joryio.min.js.map +1 -0
  45. package/dist/joryio.mjs +7254 -0
  46. package/dist/joryio.umd.js +7271 -0
  47. package/dist/utils/bucket-hash.d.ts +2 -0
  48. package/dist/utils/bucket-hash.d.ts.map +1 -0
  49. package/dist/utils/logger.d.ts +14 -0
  50. package/dist/utils/logger.d.ts.map +1 -0
  51. package/dist/utils/uuid.d.ts +3 -0
  52. package/dist/utils/uuid.d.ts.map +1 -0
  53. package/package.json +90 -4
  54. package/src/core/config.ts +362 -0
  55. package/src/core/device.ts +50 -0
  56. package/src/core/ecommerce.ts +482 -0
  57. package/src/core/identity.ts +251 -0
  58. package/src/core/inapp.ts +2419 -0
  59. package/src/core/network.ts +1261 -0
  60. package/src/core/outbox.ts +707 -0
  61. package/src/core/pending-attributes.ts +350 -0
  62. package/src/core/push.ts +667 -0
  63. package/src/core/queue.ts +894 -0
  64. package/src/core/sdk.ts +3108 -0
  65. package/src/core/session.ts +311 -0
  66. package/src/core/storage.ts +157 -0
  67. package/src/core/user.ts +203 -0
  68. package/src/core/utm.ts +304 -0
  69. package/src/core/values.ts +85 -0
  70. package/src/core/write-retry.ts +136 -0
  71. package/src/index.ts +305 -0
  72. package/src/snippet/loader.js +134 -0
  73. package/src/utils/bucket-hash.ts +44 -0
  74. package/src/utils/logger.ts +58 -0
  75. package/src/utils/uuid.ts +29 -0
  76. package/src/worker/joryio-sw.js +218 -0
@@ -0,0 +1,707 @@
1
+ /**
2
+ * The web SDK's persisted, ordered outbox for profile writes (SDK 1.3.0
3
+ * contract items 28, 36, 37, 38, 40): subscription and subscription-group
4
+ * changes, aliases and the `tracking_opted_out` marker.
5
+ *
6
+ * Why it exists. These writes used to be fire-and-forget closures, each with
7
+ * its own retry loop:
8
+ * - two changes to the same channel were not ordered, so a retried older
9
+ * `subscribed` could land after a newer `unsubscribed` and invert it;
10
+ * - a retry loop kept sending after optOut(), a denied consent or wipeData();
11
+ * - once consent was granted they lived only in memory, so a navigation lost
12
+ * them (a newsletter sign-up followed by a redirect never arrived).
13
+ *
14
+ * Now every write is a DATA record in one list, kept in localStorage, sent one
15
+ * request at a time:
16
+ * - a newer write to the same subject (channel, group, the marker) of the same
17
+ * person supersedes an older one still waiting, and an older one already on
18
+ * the wire is never retried once a newer one is queued behind it - so no
19
+ * older write can land after a newer one;
20
+ * - a write is sent only while the SDK may send, re-checked before EVERY
21
+ * attempt; opt-out, denied consent and wipeData() discard every write that
22
+ * is not a WITHDRAWAL, and withdrawals (unsubscribes, the opt-out marker)
23
+ * are always delivered;
24
+ * - ids are resolved at SEND time: a write made while anonymous goes out with
25
+ * the user id once that anonymous id has been identified (otherwise SDK
26
+ * authentication refuses it with 403 SDK_ANONYMOUS_ID_IDENTIFIED);
27
+ * - retries follow the one rule of write-retry.ts (offline, 429 and 5xx never
28
+ * count toward the cap);
29
+ * - at page unload the ready writes are handed to the browser with fetch
30
+ * keepalive, and stay stored until acknowledged (a resend is harmless).
31
+ */
32
+
33
+ import type { Logger } from '../utils/logger';
34
+ import type { StorageManager } from './storage';
35
+ import type { NetworkResponse } from './network';
36
+ import { generateUUID } from '../utils/uuid';
37
+ import {
38
+ WriteRecord,
39
+ WriteOwner,
40
+ isWithdrawal,
41
+ classifyWriteResponse,
42
+ writeAttemptsExhausted,
43
+ writeRetryDelay,
44
+ } from './write-retry';
45
+
46
+ /** Where the outbox lives. */
47
+ export const OUTBOX_KEY = 'write_outbox';
48
+ /** Where an earlier build kept writes held while consent was pending. */
49
+ export const LEGACY_DEFERRED_WRITES_KEY = 'deferred_writes';
50
+ /**
51
+ * Ids of writes that left the shared list - settled, replaced by a newer write
52
+ * of the same subject, or discarded - and when (rule 77). Another tab still
53
+ * holding one of them in memory drops it instead of sending it: an older
54
+ * subscribe waiting out a backoff in tab A must not land after the newer
55
+ * unsubscribe tab B already delivered.
56
+ */
57
+ export const OUTBOX_REMOVED_KEY = 'write_outbox_removed';
58
+ /** Bound on stored writes; the oldest non-withdrawal goes first. */
59
+ export const MAX_OUTBOX_ENTRIES = 200;
60
+ /** Bound on remembered removals (newest kept), and how long one is remembered. */
61
+ const MAX_REMOVED_IDS = 500;
62
+ const REMOVED_ID_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
63
+ /** A write handed to keepalive is not handed off again within this window (one unload). */
64
+ const UNLOAD_HANDOFF_WINDOW_MS = 10_000;
65
+
66
+ export interface OutboxEntry {
67
+ id: string;
68
+ record: WriteRecord;
69
+ withdrawal: boolean;
70
+ /** Counted attempts (contract item 40), apart from 404s. */
71
+ attempts: number;
72
+ /** 404 "no profile yet" attempts. */
73
+ notFoundAttempts: number;
74
+ /** Every failed attempt, for the backoff. */
75
+ failures: number;
76
+ /** Not before this time (backoff, Retry-After). */
77
+ notBefore: number;
78
+ /** When the write was made (epoch ms): orders writes of several tabs in the shared store. */
79
+ createdAt: number;
80
+ }
81
+
82
+ export type OutboxGate = 'send' | 'hold' | 'drop';
83
+
84
+ export interface OutboxDeps {
85
+ storage: StorageManager;
86
+ logger: Logger;
87
+ /** May this write go now ('hold': later; 'drop': never - e.g. opted out). */
88
+ gate(entry: OutboxEntry): OutboxGate;
89
+ /** The ids this write is sent with (contract item 38). */
90
+ resolveOwner(owner: WriteOwner): WriteOwner;
91
+ /** Is this the signed-in user's write (for an SDK-authentication 401)? */
92
+ ownerIsCurrentUser(owner: WriteOwner): boolean;
93
+ /** The current identity, to tell whether two writes are about the same person. */
94
+ currentIdentity(): { anonymousId: string; userId: string | null };
95
+ /** Send one write. Must never reject (a NetworkResponse with ok:false instead). */
96
+ send(entry: OutboxEntry, owner: WriteOwner): Promise<NetworkResponse>;
97
+ /** Before an attempt: get queued events and any identify to the server. */
98
+ beforeSend(entry: OutboxEntry): Promise<void>;
99
+ isSdkAuthError(status: number, data: any): boolean;
100
+ /** A write left the outbox: stored ('done') or given up ('drop'). */
101
+ onSettled?(entry: OutboxEntry, outcome: 'done' | 'drop'): void;
102
+ /** Hand one write to the browser at unload; returns the bytes used, or null. */
103
+ keepalive?(entry: OutboxEntry, owner: WriteOwner): number | null;
104
+ }
105
+
106
+ export function ownerOf(record: WriteRecord): WriteOwner {
107
+ const p: any = record.payload;
108
+ return { anonymousId: p.anonymousId, ...(p.userId ? { userId: p.userId } : {}) };
109
+ }
110
+
111
+ /** The subject a newer write replaces an older one on; null = never superseded. */
112
+ export function subjectOf(record: WriteRecord): string | null {
113
+ switch (record.kind) {
114
+ case 'subscription':
115
+ return `subscription:${record.payload.channel}`;
116
+ case 'group':
117
+ return `group:${record.payload.groupId}:${record.payload.channel}`;
118
+ case 'marker':
119
+ return 'marker';
120
+ default:
121
+ return null;
122
+ }
123
+ }
124
+
125
+ export function labelOf(record: WriteRecord): string {
126
+ switch (record.kind) {
127
+ case 'alias':
128
+ return 'addAlias';
129
+ case 'subscription':
130
+ return `Update ${record.payload.channel} subscription`;
131
+ case 'group':
132
+ return `Subscription group ${record.payload.groupId} ${record.payload.action}`;
133
+ case 'marker':
134
+ return 'The tracking_opted_out marker';
135
+ }
136
+ }
137
+
138
+ function isOffline(): boolean {
139
+ return typeof navigator !== 'undefined' && (navigator as any).onLine === false;
140
+ }
141
+
142
+ function isRecord(raw: any): raw is WriteRecord {
143
+ return (
144
+ !!raw &&
145
+ typeof raw === 'object' &&
146
+ (raw.kind === 'alias' || raw.kind === 'subscription' || raw.kind === 'group' || raw.kind === 'marker') &&
147
+ !!raw.payload &&
148
+ typeof raw.payload === 'object' &&
149
+ typeof raw.payload.anonymousId === 'string'
150
+ );
151
+ }
152
+
153
+ /** One stored write, or null when it is malformed. */
154
+ function entryFromStored(raw: any): OutboxEntry | null {
155
+ if (!raw || !isRecord(raw.record)) return null;
156
+ const count = (n: any) => (Number.isInteger(n) && n >= 0 ? n : 0);
157
+ return {
158
+ id: typeof raw.id === 'string' ? raw.id : generateUUID(),
159
+ record: raw.record,
160
+ withdrawal: isWithdrawal(raw.record),
161
+ attempts: count(raw.attempts),
162
+ notFoundAttempts: count(raw.notFoundAttempts),
163
+ failures: count(raw.failures),
164
+ // A new page view tries again now.
165
+ notBefore: 0,
166
+ // Stored by an earlier build: older than anything made now.
167
+ createdAt: typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : 0,
168
+ };
169
+ }
170
+
171
+ export class WriteOutbox {
172
+ private entries: OutboxEntry[] = [];
173
+ private inFlight: OutboxEntry | null = null;
174
+ private timer: ReturnType<typeof setTimeout> | null = null;
175
+ /**
176
+ * The key was refused (a 401 that is not SDK authentication): nothing more
177
+ * this page view (kept for the next one). NOT a 402: a paused workspace
178
+ * still accepts withdrawals, so a 402 only holds the writes that are not
179
+ * (rule 50; the SDK's gate does that).
180
+ */
181
+ private stopped = false;
182
+ /**
183
+ * Writes handed to keepalive at this unload, and when. An unload runs
184
+ * `beforeunload` AND `pagehide`; the second must not hand the same writes
185
+ * off again (the browser's 64 KiB keepalive budget is shared).
186
+ */
187
+ private handedOffAt = new Map<string, number>();
188
+ private destroyed = false;
189
+ /**
190
+ * Ids of every write this tab loaded or made. The stored list is shared by
191
+ * every tab of the site (rule 62): an id in it that is NOT here is another
192
+ * tab's write, kept when this tab stores its own list; an id that is here
193
+ * but no longer in `entries` was settled or discarded by this tab.
194
+ */
195
+ private knownIds = new Set<string>();
196
+
197
+ constructor(private readonly deps: OutboxDeps) {
198
+ this.load();
199
+ }
200
+
201
+ /** Queue a write (after the caller checked it may be made at all). */
202
+ enqueue(record: WriteRecord): void {
203
+ if (this.destroyed) return;
204
+ const entry: OutboxEntry = {
205
+ id: generateUUID(),
206
+ record,
207
+ withdrawal: isWithdrawal(record),
208
+ attempts: 0,
209
+ notFoundAttempts: 0,
210
+ failures: 0,
211
+ notBefore: 0,
212
+ createdAt: Date.now(),
213
+ };
214
+ this.knownIds.add(entry.id);
215
+ const subject = subjectOf(record);
216
+ if (subject) {
217
+ // A newer write to the same subject of the same person: what was still
218
+ // waiting is moot. One already on the wire is answered first, and not
219
+ // retried (see run()).
220
+ this.entries = this.entries.filter(
221
+ (e) => e === this.inFlight || subjectOf(e.record) !== subject || !this.sameOwner(e.record, record),
222
+ );
223
+ }
224
+ this.entries.push(entry);
225
+ this.trim();
226
+ this.persist();
227
+ this.kick();
228
+ }
229
+
230
+ /**
231
+ * Opt-out, consent denied, wipeData(): forget every write that is not a
232
+ * withdrawal. One on the wire is forgotten too - its answer is ignored.
233
+ */
234
+ discardNonWithdrawals(): void {
235
+ this.entries = this.entries.filter((e) => e.withdrawal);
236
+ // Every tab's: the decision is the site's, and a tab that closed left its
237
+ // writes only in storage (contract item 21, scan 7 N6).
238
+ this.persist({ discardNonWithdrawals: true });
239
+ }
240
+
241
+ /** Store this tab's list again (another tab cleared localStorage). */
242
+ persistNow(): void {
243
+ this.persist();
244
+ }
245
+
246
+ /** Is a write matching this test still waiting (or on the wire)? */
247
+ find(test: (entry: OutboxEntry) => boolean): OutboxEntry | undefined {
248
+ return this.entries.find(test);
249
+ }
250
+
251
+ /** The newest waiting write matching this test. */
252
+ findLast(test: (entry: OutboxEntry) => boolean): OutboxEntry | undefined {
253
+ for (let i = this.entries.length - 1; i >= 0; i--) if (test(this.entries[i])) return this.entries[i];
254
+ return undefined;
255
+ }
256
+
257
+ /** Remove waiting writes matching this test (never the one on the wire). */
258
+ cancel(test: (entry: OutboxEntry) => boolean): void {
259
+ const before = this.entries.length;
260
+ this.entries = this.entries.filter((e) => e === this.inFlight || !test(e));
261
+ if (this.entries.length !== before) this.persist();
262
+ }
263
+
264
+ /**
265
+ * The identity is about to change: writes made while `anonymousId` was
266
+ * anonymous belong to `userId` from now on (see resolveOwner).
267
+ */
268
+ pinOwner(anonymousId: string, userId: string): void {
269
+ let changed = false;
270
+ for (const e of this.entries) {
271
+ const payload: any = e.record.payload;
272
+ if (payload.anonymousId === anonymousId && !payload.userId) {
273
+ e.record = { ...e.record, payload: { ...payload, userId } } as WriteRecord;
274
+ changed = true;
275
+ }
276
+ }
277
+ if (changed) this.persist();
278
+ }
279
+
280
+ isInFlight(entry: OutboxEntry): boolean {
281
+ return this.inFlight === entry;
282
+ }
283
+
284
+ size(): number {
285
+ return this.entries.length;
286
+ }
287
+
288
+ /** The waiting writes, oldest first (tests and diagnostics). */
289
+ records(): WriteRecord[] {
290
+ return this.entries.map((e) => e.record);
291
+ }
292
+
293
+ /** Send what may be sent now. Safe to call at any time. */
294
+ kick(): void {
295
+ if (this.destroyed || this.stopped || this.inFlight) return;
296
+ if (this.timer !== null) {
297
+ clearTimeout(this.timer);
298
+ this.timer = null;
299
+ }
300
+ // Offline: the SDK kicks again on the browser's `online` event.
301
+ if (isOffline()) return;
302
+ const now = Date.now();
303
+ const blocked = new Set<string>();
304
+ let wake = Number.POSITIVE_INFINITY;
305
+ let pick: OutboxEntry | null = null;
306
+ for (const entry of [...this.entries]) {
307
+ const key = this.blockKey(entry);
308
+ if (blocked.has(key)) continue;
309
+ const gate = this.safeGate(entry);
310
+ if (gate === 'drop') {
311
+ this.remove(entry); // not allowed any more (opt-out): discarded, not refused
312
+ continue;
313
+ }
314
+ if (gate === 'hold') {
315
+ blocked.add(key);
316
+ continue;
317
+ }
318
+ if (entry.notBefore > now) {
319
+ blocked.add(key);
320
+ wake = Math.min(wake, entry.notBefore);
321
+ continue;
322
+ }
323
+ pick = entry;
324
+ break;
325
+ }
326
+ if (pick) {
327
+ void this.run(pick);
328
+ return;
329
+ }
330
+ if (wake < Number.POSITIVE_INFINITY) {
331
+ this.timer = setTimeout(() => {
332
+ this.timer = null;
333
+ this.kick();
334
+ }, Math.max(0, wake - now));
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Page unload: hand the writes that may go now to the browser with fetch
340
+ * keepalive. They STAY stored until acknowledged on a later page view (a
341
+ * repeated subscription or alias write is harmless); only one write per
342
+ * subject is handed off, so two of one subject never race.
343
+ *
344
+ * The same rule-77 check as run() first (rule 85): a write another tab
345
+ * settled, or replaced with a newer one, is never handed off - a tab
346
+ * closing with an older subscribe in its backoff must not land it after
347
+ * the other tab's newer unsubscribe. persist() drops what the stored
348
+ * removal list names and what a newer stored write replaced;
349
+ * removedElsewhere() catches the rest.
350
+ */
351
+ handOffAtUnload(budgetBytes: number): number {
352
+ let used = 0;
353
+ if (!this.deps.keepalive || this.destroyed || this.stopped || isOffline()) return used;
354
+ this.persist();
355
+ const now = Date.now();
356
+ const blocked = new Set<string>();
357
+ if (this.inFlight) blocked.add(this.blockKey(this.inFlight));
358
+ for (const entry of [...this.entries]) {
359
+ const key = this.blockKey(entry);
360
+ if (blocked.has(key)) continue;
361
+ if (entry !== this.inFlight && this.removedElsewhere(entry)) {
362
+ this.deps.logger.debug(`${labelOf(entry.record)} not handed off: another tab settled or replaced it`);
363
+ const i = this.entries.indexOf(entry);
364
+ if (i >= 0) this.entries.splice(i, 1);
365
+ continue;
366
+ }
367
+ blocked.add(key);
368
+ if (entry === this.inFlight || this.safeGate(entry) !== 'send') continue;
369
+ const handed = this.handedOffAt.get(entry.id);
370
+ if (handed !== undefined && now - handed < UNLOAD_HANDOFF_WINDOW_MS) continue;
371
+ try {
372
+ const bytes = this.deps.keepalive(entry, this.deps.resolveOwner(ownerOf(entry.record)));
373
+ if (bytes === null) break;
374
+ this.handedOffAt.set(entry.id, now);
375
+ used += bytes;
376
+ if (used >= budgetBytes) break;
377
+ } catch {
378
+ break;
379
+ }
380
+ }
381
+ this.persist();
382
+ return used;
383
+ }
384
+
385
+ destroy(): void {
386
+ this.destroyed = true;
387
+ if (this.timer !== null) clearTimeout(this.timer);
388
+ this.timer = null;
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+
393
+ private async run(entry: OutboxEntry): Promise<void> {
394
+ this.inFlight = entry;
395
+ const label = labelOf(entry.record);
396
+ try {
397
+ await this.deps.beforeSend(entry).catch(() => undefined);
398
+ // Re-checked after the await: an opt-out, a denial or a wipe meanwhile.
399
+ if (!this.entries.includes(entry)) return;
400
+ const gate = this.safeGate(entry);
401
+ if (gate === 'drop') {
402
+ this.remove(entry);
403
+ return;
404
+ }
405
+ if (gate === 'hold') return;
406
+ // Another tab settled this write, or made a newer one of the same
407
+ // subject for the same person (rule 77): sending it now could land an
408
+ // older choice after a newer one, or send it twice.
409
+ if (this.removedElsewhere(entry)) {
410
+ this.deps.logger.debug(`${label} not sent: another tab settled or replaced it`);
411
+ this.remove(entry);
412
+ return;
413
+ }
414
+
415
+ const owner = this.deps.resolveOwner(ownerOf(entry.record));
416
+ const res = await this.deps.send(entry, owner);
417
+ // Discarded while on the wire (opt-out, wipe): its answer is moot.
418
+ if (!this.entries.includes(entry)) return;
419
+
420
+ const verdict = classifyWriteResponse(res, {
421
+ isSdkAuthError: this.deps.isSdkAuthError(res.status, res.data),
422
+ ownerIsCurrentUser: this.deps.ownerIsCurrentUser(owner),
423
+ });
424
+ switch (verdict.action) {
425
+ case 'done':
426
+ this.settle(entry, 'done');
427
+ return;
428
+ case 'drop':
429
+ this.deps.logger.error(`${label} dropped: ${verdict.reason}`);
430
+ this.settle(entry, 'drop');
431
+ return;
432
+ case 'hold':
433
+ this.deps.logger.error(`${label} not sent: ${verdict.reason}; kept for the next page view`);
434
+ this.stopped = true;
435
+ return;
436
+ case 'retry':
437
+ if (this.hasNewerOfSameSubject(entry)) {
438
+ // A newer write to this subject is queued: retrying this one
439
+ // could land it after the newer one.
440
+ this.remove(entry);
441
+ return;
442
+ }
443
+ entry.failures++;
444
+ if (verdict.counts) {
445
+ if (verdict.notFound) entry.notFoundAttempts++;
446
+ else entry.attempts++;
447
+ }
448
+ if (writeAttemptsExhausted(entry.attempts, entry.notFoundAttempts)) {
449
+ this.deps.logger.error(`${label} failed (HTTP ${res.status || 'no response'}) too many times; dropping it`);
450
+ this.settle(entry, 'drop');
451
+ return;
452
+ }
453
+ entry.notBefore = Date.now() + writeRetryDelay(entry.failures, verdict.retryAfterMs);
454
+ this.deps.logger.debug(`${label} failed with HTTP ${res.status || 'no response'}; retrying`);
455
+ return;
456
+ }
457
+ } catch (error) {
458
+ // A bug, not an answer: keep the write, back off, never throw.
459
+ this.deps.logger.error(`${label} failed:`, error);
460
+ entry.failures++;
461
+ entry.notBefore = Date.now() + writeRetryDelay(entry.failures);
462
+ } finally {
463
+ if (this.inFlight === entry) this.inFlight = null;
464
+ this.persist();
465
+ this.kick();
466
+ }
467
+ }
468
+
469
+ private safeGate(entry: OutboxEntry): OutboxGate {
470
+ try {
471
+ return this.deps.gate(entry);
472
+ } catch {
473
+ return 'hold';
474
+ }
475
+ }
476
+
477
+ private settle(entry: OutboxEntry, outcome: 'done' | 'drop'): void {
478
+ this.remove(entry);
479
+ try {
480
+ this.deps.onSettled?.(entry, outcome);
481
+ } catch (error) {
482
+ this.deps.logger.error('Outbox bookkeeping failed:', error);
483
+ }
484
+ }
485
+
486
+ private remove(entry: OutboxEntry): void {
487
+ const i = this.entries.indexOf(entry);
488
+ if (i >= 0) {
489
+ this.entries.splice(i, 1);
490
+ this.persist();
491
+ }
492
+ }
493
+
494
+ private hasNewerOfSameSubject(entry: OutboxEntry): boolean {
495
+ const subject = subjectOf(entry.record);
496
+ if (!subject) return false;
497
+ const i = this.entries.indexOf(entry);
498
+ return this.entries.slice(i + 1).some((e) => subjectOf(e.record) === subject && this.sameOwner(e.record, entry.record));
499
+ }
500
+
501
+ /** The user id a write will carry: its own, or the current one when its anonymous id is current. */
502
+ private effectiveUser(owner: WriteOwner): string | null {
503
+ if (owner.userId) return owner.userId;
504
+ const current = this.deps.currentIdentity();
505
+ return owner.anonymousId === current.anonymousId ? current.userId : null;
506
+ }
507
+
508
+ private sameOwner(a: WriteRecord, b: WriteRecord): boolean {
509
+ const oa = ownerOf(a);
510
+ const ob = ownerOf(b);
511
+ return oa.anonymousId === ob.anonymousId && this.effectiveUser(oa) === this.effectiveUser(ob);
512
+ }
513
+
514
+ /** Writes of one subject and person go in order; anything else may pass. */
515
+ private blockKey(entry: OutboxEntry): string {
516
+ const owner = ownerOf(entry.record);
517
+ return `${subjectOf(entry.record) ?? `${entry.record.kind}:${entry.id}`}|${owner.anonymousId}`;
518
+ }
519
+
520
+ /** Bound memory and storage: the oldest non-withdrawal waiting goes first. */
521
+ private trim(): void {
522
+ while (this.entries.length > MAX_OUTBOX_ENTRIES) {
523
+ let i = this.entries.findIndex((e) => !e.withdrawal && e !== this.inFlight);
524
+ if (i < 0) i = this.entries.findIndex((e) => e !== this.inFlight);
525
+ if (i < 0) break;
526
+ const [dropped] = this.entries.splice(i, 1);
527
+ this.deps.logger.warn(`Too many profile writes waiting; dropped the oldest (${labelOf(dropped.record)})`);
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Store this tab's list MERGED into the stored one (rule 62, scan 5 N8):
533
+ * every tab writes the same key, and a blind overwrite lost the writes of
534
+ * the others - an opt-out marker queued in tab A was gone once tab B stored
535
+ * its list, and with it the withdrawal if tab A closed before sending it.
536
+ * Another tab's writes are kept, in the order they were made; one of them
537
+ * replaced by a newer write of this tab for the same subject and person is
538
+ * dropped (and a newer one of theirs replaces a waiting one of ours).
539
+ *
540
+ * Every write that leaves the stored list is remembered under
541
+ * OUTBOX_REMOVED_KEY (rule 77), and a write another tab removed is dropped
542
+ * here too - never stored again by this tab, never sent (see run()).
543
+ */
544
+ private persist(options: { discardNonWithdrawals?: boolean } = {}): void {
545
+ try {
546
+ const stored = this.deps.storage.get<any[]>(OUTBOX_KEY);
547
+ const storedIds = Array.isArray(stored)
548
+ ? stored.filter((raw) => raw && typeof raw.id === 'string').map((raw) => raw.id as string)
549
+ : [];
550
+ const removed = this.readRemoved();
551
+ // Settled or replaced by another tab: moot here too. (One on the wire
552
+ // stays in memory until its answer; it is not stored again.)
553
+ this.entries = this.entries.filter((e) => e === this.inFlight || !removed.has(e.id));
554
+ let foreign = this.foreignStoredEntries(stored).filter((e) => !removed.has(e.id));
555
+ if (options.discardNonWithdrawals) foreign = foreign.filter((e) => e.withdrawal);
556
+ let kept: OutboxEntry[] = this.entries;
557
+ if (foreign.length) {
558
+ const merged = [...this.entries, ...foreign].sort((x, y) => x.createdAt - y.createdAt);
559
+ const superseded = (e: OutboxEntry, i: number) => {
560
+ const subject = subjectOf(e.record);
561
+ return (
562
+ !!subject &&
563
+ e !== this.inFlight &&
564
+ merged.slice(i + 1).some((n) => subjectOf(n.record) === subject && this.sameOwner(n.record, e.record))
565
+ );
566
+ };
567
+ kept = merged.filter((e, i) => !superseded(e, i));
568
+ // Bounded like one tab's list: the oldest non-withdrawal goes first,
569
+ // whichever tab made it - a tab that closed left its writes only
570
+ // here, so "theirs are still in their memory" does not hold (N3).
571
+ while (kept.length > MAX_OUTBOX_ENTRIES) {
572
+ let i = kept.findIndex((e) => !e.withdrawal && e !== this.inFlight);
573
+ if (i < 0) i = kept.findIndex((e) => e !== this.inFlight);
574
+ if (i < 0) break;
575
+ this.deps.logger.warn(`Too many profile writes waiting; dropped the oldest (${labelOf(kept[i].record)})`);
576
+ kept = kept.filter((_, j) => j !== i);
577
+ }
578
+ // A newer write of another tab replaced one of ours still waiting: ours is moot here too.
579
+ this.entries = this.entries.filter((e) => e === this.inFlight || kept.includes(e));
580
+ }
581
+ const list = kept.filter((e) => !removed.has(e.id));
582
+ this.write(list);
583
+ const written = new Set(list.map((e) => e.id));
584
+ const leaving = storedIds.filter((id) => !written.has(id) && !removed.has(id));
585
+ if (leaving.length) this.writeRemoved(removed, leaving);
586
+ } catch (error) {
587
+ this.deps.logger.error('Failed to store pending profile writes:', error);
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Did another tab settle this write, or store a newer write of the same
593
+ * subject for the same person (rule 77)?
594
+ */
595
+ private removedElsewhere(entry: OutboxEntry): boolean {
596
+ try {
597
+ if (this.readRemoved().has(entry.id)) return true;
598
+ const subject = subjectOf(entry.record);
599
+ if (!subject) return false;
600
+ const stored = this.deps.storage.get<any[]>(OUTBOX_KEY);
601
+ if (!Array.isArray(stored)) return false;
602
+ return stored.some(
603
+ (raw) =>
604
+ raw &&
605
+ raw.id !== entry.id &&
606
+ isRecord(raw.record) &&
607
+ typeof raw.createdAt === 'number' &&
608
+ raw.createdAt > entry.createdAt &&
609
+ subjectOf(raw.record) === subject &&
610
+ this.sameOwner(raw.record, entry.record),
611
+ );
612
+ } catch {
613
+ return false;
614
+ }
615
+ }
616
+
617
+ /** The remembered removals (id -> when), expired ones left out. */
618
+ private readRemoved(): Map<string, number> {
619
+ const out = new Map<string, number>();
620
+ const stored = this.deps.storage.get<unknown>(OUTBOX_REMOVED_KEY);
621
+ if (!Array.isArray(stored)) return out;
622
+ const now = Date.now();
623
+ for (const item of stored) {
624
+ if (!Array.isArray(item) || typeof item[0] !== 'string' || typeof item[1] !== 'number') continue;
625
+ if (now - item[1] > REMOVED_ID_MAX_AGE_MS) continue;
626
+ out.set(item[0], item[1]);
627
+ }
628
+ return out;
629
+ }
630
+
631
+ private writeRemoved(removed: Map<string, number>, leaving: string[]): void {
632
+ const now = Date.now();
633
+ const all: [string, number][] = [...removed.entries(), ...leaving.map((id): [string, number] => [id, now])];
634
+ this.deps.storage.set(OUTBOX_REMOVED_KEY, all.slice(-MAX_REMOVED_IDS));
635
+ }
636
+
637
+ private write(list: OutboxEntry[]): void {
638
+ if (list.length) {
639
+ this.deps.storage.set(
640
+ OUTBOX_KEY,
641
+ list.map((e) => ({
642
+ id: e.id,
643
+ record: e.record,
644
+ attempts: e.attempts,
645
+ notFoundAttempts: e.notFoundAttempts,
646
+ failures: e.failures,
647
+ createdAt: e.createdAt,
648
+ })),
649
+ );
650
+ } else {
651
+ this.deps.storage.remove(OUTBOX_KEY);
652
+ }
653
+ }
654
+
655
+ /** Stored writes this tab never saw: another tab's. */
656
+ private foreignStoredEntries(stored: any[] | null): OutboxEntry[] {
657
+ if (!Array.isArray(stored)) return [];
658
+ const out: OutboxEntry[] = [];
659
+ for (const raw of stored) {
660
+ if (!raw || typeof raw.id !== 'string' || this.knownIds.has(raw.id) || !isRecord(raw.record)) continue;
661
+ const entry = entryFromStored(raw);
662
+ if (entry) out.push(entry);
663
+ }
664
+ return out;
665
+ }
666
+
667
+ private load(): void {
668
+ const stored = this.deps.storage.get<any[]>(OUTBOX_KEY);
669
+ if (Array.isArray(stored)) {
670
+ // Every stored entry is read and THEN bounded by trim(), which drops the
671
+ // oldest non-withdrawal first. A plain slice(-MAX) here dropped the
672
+ // oldest entries whatever they were - an unsubscribe among them - when
673
+ // another tab had stored more than the bound (contract v5 51).
674
+ for (const raw of stored) {
675
+ const entry = entryFromStored(raw);
676
+ if (!entry) continue;
677
+ this.entries.push(entry);
678
+ this.knownIds.add(entry.id);
679
+ }
680
+ }
681
+ // Writes an earlier build held while consent was pending.
682
+ const legacy = this.deps.storage.get<any[]>(LEGACY_DEFERRED_WRITES_KEY);
683
+ if (Array.isArray(legacy)) {
684
+ for (const raw of legacy) {
685
+ if (!isRecord(raw)) continue;
686
+ const entry: OutboxEntry = {
687
+ id: generateUUID(),
688
+ record: raw,
689
+ withdrawal: isWithdrawal(raw),
690
+ attempts: 0,
691
+ notFoundAttempts: 0,
692
+ failures: 0,
693
+ notBefore: 0,
694
+ createdAt: 0,
695
+ };
696
+ this.entries.push(entry);
697
+ this.knownIds.add(entry.id);
698
+ }
699
+ this.deps.storage.remove(LEGACY_DEFERRED_WRITES_KEY);
700
+ this.trim();
701
+ this.persist();
702
+ } else if (this.entries.length > MAX_OUTBOX_ENTRIES) {
703
+ this.trim();
704
+ this.persist();
705
+ }
706
+ }
707
+ }