@genn-inc/cluebase-frontend-sdk 0.0.1

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 (140) hide show
  1. package/README.md +222 -0
  2. package/dist/_generated/correlation-attribute-keys.d.ts +2 -0
  3. package/dist/_generated/correlation-attribute-keys.js +33 -0
  4. package/dist/_generated/identity-pii-policy.d.ts +38 -0
  5. package/dist/_generated/identity-pii-policy.js +103 -0
  6. package/dist/_generated/ingest-privacy-tables.d.ts +120 -0
  7. package/dist/_generated/ingest-privacy-tables.js +890 -0
  8. package/dist/_generated/otel-resource-keys.d.ts +21 -0
  9. package/dist/_generated/otel-resource-keys.js +72 -0
  10. package/dist/_generated/otel-span-conversion.d.ts +48 -0
  11. package/dist/_generated/otel-span-conversion.js +69 -0
  12. package/dist/_generated/parameter-snapshot-leaf.d.ts +11 -0
  13. package/dist/_generated/parameter-snapshot-leaf.js +37 -0
  14. package/dist/_generated/sdk-runtime-policy.d.ts +7 -0
  15. package/dist/_generated/sdk-runtime-policy.js +14 -0
  16. package/dist/_generated/usage-governor-tables.d.ts +15 -0
  17. package/dist/_generated/usage-governor-tables.js +44 -0
  18. package/dist/context/browser-storage.d.ts +15 -0
  19. package/dist/context/browser-storage.js +38 -0
  20. package/dist/context/environment.d.ts +37 -0
  21. package/dist/context/environment.js +229 -0
  22. package/dist/context/identity.d.ts +16 -0
  23. package/dist/context/identity.js +139 -0
  24. package/dist/context/organization.d.ts +16 -0
  25. package/dist/context/organization.js +60 -0
  26. package/dist/context/session.d.ts +28 -0
  27. package/dist/context/session.js +130 -0
  28. package/dist/context/tab.d.ts +22 -0
  29. package/dist/context/tab.js +117 -0
  30. package/dist/context/trace.d.ts +76 -0
  31. package/dist/context/trace.js +163 -0
  32. package/dist/exporter/batch.d.ts +94 -0
  33. package/dist/exporter/batch.js +485 -0
  34. package/dist/exporter/browser-outbox-records.d.ts +159 -0
  35. package/dist/exporter/browser-outbox-records.js +341 -0
  36. package/dist/exporter/browser-outbox.d.ts +112 -0
  37. package/dist/exporter/browser-outbox.js +876 -0
  38. package/dist/exporter/client.d.ts +92 -0
  39. package/dist/exporter/client.js +541 -0
  40. package/dist/exporter/delivery-response-contract.d.ts +103 -0
  41. package/dist/exporter/delivery-response-contract.js +147 -0
  42. package/dist/exporter/delivery-response.d.ts +37 -0
  43. package/dist/exporter/delivery-response.js +151 -0
  44. package/dist/exporter/diagnostics.d.ts +42 -0
  45. package/dist/exporter/diagnostics.js +148 -0
  46. package/dist/exporter/event-id.d.ts +10 -0
  47. package/dist/exporter/event-id.js +75 -0
  48. package/dist/exporter/frontend-resource.d.ts +20 -0
  49. package/dist/exporter/frontend-resource.js +52 -0
  50. package/dist/exporter/local-minimization.d.ts +29 -0
  51. package/dist/exporter/local-minimization.js +87 -0
  52. package/dist/exporter/observation-source-event-exporter.d.ts +69 -0
  53. package/dist/exporter/observation-source-event-exporter.js +154 -0
  54. package/dist/exporter/parameter-snapshot.d.ts +14 -0
  55. package/dist/exporter/parameter-snapshot.js +215 -0
  56. package/dist/exporter/path-template.d.ts +1 -0
  57. package/dist/exporter/path-template.js +67 -0
  58. package/dist/exporter/raw-event-to-source-signal.d.ts +12 -0
  59. package/dist/exporter/raw-event-to-source-signal.js +297 -0
  60. package/dist/exporter/retry.d.ts +8 -0
  61. package/dist/exporter/retry.js +8 -0
  62. package/dist/exporter/sanitize.d.ts +30 -0
  63. package/dist/exporter/sanitize.js +177 -0
  64. package/dist/exporter/schema-evidence.d.ts +20 -0
  65. package/dist/exporter/schema-evidence.js +232 -0
  66. package/dist/exporter/source-event.d.ts +99 -0
  67. package/dist/exporter/source-event.js +13 -0
  68. package/dist/exporter/span-to-source-signal.d.ts +8 -0
  69. package/dist/exporter/span-to-source-signal.js +174 -0
  70. package/dist/exporter/text-fingerprint.d.ts +9 -0
  71. package/dist/exporter/text-fingerprint.js +16 -0
  72. package/dist/index.d.ts +25 -0
  73. package/dist/index.js +158 -0
  74. package/dist/instrumentation/action.d.ts +2 -0
  75. package/dist/instrumentation/action.js +171 -0
  76. package/dist/instrumentation/cluebase-authored-attributes.d.ts +4 -0
  77. package/dist/instrumentation/cluebase-authored-attributes.js +32 -0
  78. package/dist/instrumentation/common.d.ts +73 -0
  79. package/dist/instrumentation/common.js +90 -0
  80. package/dist/instrumentation/dom.d.ts +63 -0
  81. package/dist/instrumentation/dom.js +640 -0
  82. package/dist/instrumentation/drag.d.ts +2 -0
  83. package/dist/instrumentation/drag.js +82 -0
  84. package/dist/instrumentation/error.d.ts +2 -0
  85. package/dist/instrumentation/error.js +318 -0
  86. package/dist/instrumentation/form.d.ts +2 -0
  87. package/dist/instrumentation/form.js +132 -0
  88. package/dist/instrumentation/input.d.ts +2 -0
  89. package/dist/instrumentation/input.js +180 -0
  90. package/dist/instrumentation/location.d.ts +11 -0
  91. package/dist/instrumentation/location.js +69 -0
  92. package/dist/instrumentation/naming-evidence.d.ts +20 -0
  93. package/dist/instrumentation/naming-evidence.js +11 -0
  94. package/dist/instrumentation/navigation.d.ts +2 -0
  95. package/dist/instrumentation/navigation.js +144 -0
  96. package/dist/instrumentation/network-request.d.ts +103 -0
  97. package/dist/instrumentation/network-request.js +285 -0
  98. package/dist/instrumentation/network-span-attributes.d.ts +25 -0
  99. package/dist/instrumentation/network-span-attributes.js +153 -0
  100. package/dist/instrumentation/network.d.ts +16 -0
  101. package/dist/instrumentation/network.js +502 -0
  102. package/dist/instrumentation/page.d.ts +2 -0
  103. package/dist/instrumentation/page.js +56 -0
  104. package/dist/instrumentation/performance.d.ts +2 -0
  105. package/dist/instrumentation/performance.js +365 -0
  106. package/dist/integrations/next-router.d.ts +16 -0
  107. package/dist/integrations/next-router.js +18 -0
  108. package/dist/integrations/react-router.d.ts +7 -0
  109. package/dist/integrations/react-router.js +61 -0
  110. package/dist/internal/internal-log.d.ts +23 -0
  111. package/dist/internal/internal-log.js +47 -0
  112. package/dist/internal/metrics.d.ts +10 -0
  113. package/dist/internal/metrics.js +50 -0
  114. package/dist/provider/_version.d.ts +1 -0
  115. package/dist/provider/_version.js +3 -0
  116. package/dist/provider/config.d.ts +12 -0
  117. package/dist/provider/config.js +177 -0
  118. package/dist/provider/constants.d.ts +62 -0
  119. package/dist/provider/constants.js +118 -0
  120. package/dist/provider/contracts.d.ts +51 -0
  121. package/dist/provider/contracts.js +46 -0
  122. package/dist/provider/privacy-policy-store.d.ts +20 -0
  123. package/dist/provider/privacy-policy-store.js +56 -0
  124. package/dist/provider/sampling.d.ts +2 -0
  125. package/dist/provider/sampling.js +17 -0
  126. package/dist/provider/sdk-event-pipeline.d.ts +60 -0
  127. package/dist/provider/sdk-event-pipeline.js +478 -0
  128. package/dist/provider/sdk-input-normalization.d.ts +7 -0
  129. package/dist/provider/sdk-input-normalization.js +113 -0
  130. package/dist/provider/sdk-otel-pipeline.d.ts +14 -0
  131. package/dist/provider/sdk-otel-pipeline.js +148 -0
  132. package/dist/provider/sdk-runtime-surfaces.d.ts +49 -0
  133. package/dist/provider/sdk-runtime-surfaces.js +216 -0
  134. package/dist/provider/sdk.d.ts +2 -0
  135. package/dist/provider/sdk.js +614 -0
  136. package/dist/provider/types.d.ts +323 -0
  137. package/dist/provider/types.js +1 -0
  138. package/dist/provider/usage-governor.d.ts +7 -0
  139. package/dist/provider/usage-governor.js +116 -0
  140. package/package.json +59 -0
@@ -0,0 +1,876 @@
1
+ import { DiagnosticStore } from "./diagnostics.js";
2
+ import { isBrowserSourceSignalEvent } from "./source-event.js";
3
+ import { canonicalize as canonicalJsonStringify } from "json-canonicalize";
4
+ import { addStorageListener, browserEventsByteLength, createConsentChannel, createConsentRecord, createDraft, createGeneration, createLegacyTombstone, createPending, consentRecordKey, draftRecordKey, hasLegacyTombstone, largestFittingPrefix, nowIso, outboxEventCount, parseRecord, readLocks, readStorage, recordBatchId, sha256Hex, toHeadBatch, splitPendingRecord, pendingRecordKey, tombstoneRecordKey, UNANCHORED_COLLECTION_GENERATION, verifyHash, withChecksum, withoutChecksum, LEGACY_TOMBSTONE_LIMIT, } from "./browser-outbox-records.js";
5
+ export class BrowserOutbox {
6
+ constructor(options) {
7
+ this.drafts = [];
8
+ this.pending = [];
9
+ this.tombstones = [];
10
+ this.nextOrdinal = 1;
11
+ this.nextPermitOrdinal = 1;
12
+ this.legacyImportBlocked = false;
13
+ this.consentAnchorEstablished = false;
14
+ this.memoryOnly = false;
15
+ this.durableWarningRecorded = false;
16
+ this.deliveryLockHeld = false;
17
+ this.purgePending = false;
18
+ this.consentStateUnavailable = false;
19
+ this.projectHash = sha256Hex(options.projectKey);
20
+ this.prefix = `cluebase:source-event-outbox:v2:${this.projectHash}:`;
21
+ this.consentKey = consentRecordKey(this.prefix);
22
+ this.legacyKey = `cluebase:source_event_outbox:${options.projectKey}`;
23
+ this.lockName = `cluebase:source-event-outbox:v2:${this.projectHash}`;
24
+ this.storage = "storage" in options ? options.storage ?? null : readStorage();
25
+ this.durableOutboxSupported = readLocks() !== null;
26
+ this.maxBytes = Math.max(1024, options.maxBytes);
27
+ this.maxEvents = Math.max(1, options.maxEvents);
28
+ this.metrics = options.metrics ?? null;
29
+ this.diagnostics = options.diagnostics ?? new DiagnosticStore();
30
+ this.onConsentRevoked = options.onConsentRevoked ?? (() => undefined);
31
+ this.consent = options.initialConsent;
32
+ this.generation = createGeneration();
33
+ this.initialDenialPending = options.initialConsent === "denied";
34
+ if (this.initialDenialPending)
35
+ this.legacyImportBlocked = true;
36
+ // Consent is the only state that must be available before the first lock
37
+ // acquisition. All outbox scans and mutations happen under the project
38
+ // Web Lock in `withDeliveryLock`.
39
+ if (!this.initialDenialPending)
40
+ this.restore(false);
41
+ if (!this.durableOutboxSupported) {
42
+ this.memoryOnly = true;
43
+ this.warnDurableUnavailable();
44
+ }
45
+ this.consentChannel = createConsentChannel(this.projectHash, (generation) => {
46
+ this.applyRemoteDenial(generation);
47
+ });
48
+ this.removeConsentStorageListener = addStorageListener(this.consentKey, () => this.restoreConsentFromStorage());
49
+ this.removeLegacyStorageListener = addStorageListener(this.legacyKey, () => {
50
+ this.diagnostics.record({ code: "legacy_writer_detected" });
51
+ });
52
+ }
53
+ currentConsent() {
54
+ return this.consent;
55
+ }
56
+ capturePermit() {
57
+ return Object.freeze({
58
+ generation: this.consentAnchorEstablished
59
+ ? this.generation
60
+ : UNANCHORED_COLLECTION_GENERATION,
61
+ permittedAtStart: this.consent !== "denied",
62
+ captureOrdinal: this.nextPermitOrdinal++,
63
+ });
64
+ }
65
+ permits(permit) {
66
+ return (permit.permittedAtStart &&
67
+ this.consent !== "denied" &&
68
+ permit.generation === this.generation);
69
+ }
70
+ setConsent(next) {
71
+ const recoveryExpectedRaw = this.consentStateUnavailable
72
+ ? this.peekConsentRaw()
73
+ : undefined;
74
+ const expectedGeneration = this.consentAnchorEstablished && !this.consentStateUnavailable
75
+ ? this.generation
76
+ : null;
77
+ const wasDenied = this.consent === "denied";
78
+ if (next === "denied" && !wasDenied) {
79
+ const purged = this.pendingEventCount();
80
+ this.consent = "denied";
81
+ this.generation = createGeneration();
82
+ this.legacyImportBlocked = true;
83
+ this.diagnostics.rotateForConsentPurge(purged);
84
+ this.consentChannel?.postMessage({ type: "denied", generation: this.generation });
85
+ this.clearMemory();
86
+ return Object.freeze({
87
+ state: this.consent,
88
+ generation: this.generation,
89
+ expectedGeneration,
90
+ recoveryExpectedRaw,
91
+ legacyImportBlocked: this.legacyImportBlocked,
92
+ purge: true,
93
+ });
94
+ }
95
+ if (this.purgePending) {
96
+ return Object.freeze({
97
+ state: next,
98
+ generation: this.generation,
99
+ expectedGeneration,
100
+ recoveryExpectedRaw,
101
+ legacyImportBlocked: this.legacyImportBlocked,
102
+ purge: false,
103
+ });
104
+ }
105
+ this.consent = next;
106
+ return Object.freeze({
107
+ state: this.consent,
108
+ generation: this.generation,
109
+ expectedGeneration,
110
+ recoveryExpectedRaw,
111
+ legacyImportBlocked: this.legacyImportBlocked,
112
+ purge: false,
113
+ });
114
+ }
115
+ commitConsentTransition(transition) {
116
+ if (transition.generation !== this.generation)
117
+ return;
118
+ if (transition.state !== "denied" && this.purgePending) {
119
+ this.consent = "denied";
120
+ this.legacyImportBlocked = true;
121
+ this.clearMemory();
122
+ this.onConsentRevoked();
123
+ return;
124
+ }
125
+ if (transition.state !== "denied" &&
126
+ transition.recoveryExpectedRaw !== undefined) {
127
+ const currentRaw = this.peekConsentRaw();
128
+ if (currentRaw === false ||
129
+ currentRaw !== transition.recoveryExpectedRaw) {
130
+ const persisted = this.readConsent();
131
+ if (persisted?.state === "denied")
132
+ this.applyRemoteDenial(persisted.collectionGeneration);
133
+ else if (persisted)
134
+ this.reconcileRemotePermittedConsent(persisted);
135
+ else
136
+ this.failClosedConsentState();
137
+ return;
138
+ }
139
+ }
140
+ else if (transition.state !== "denied") {
141
+ const persisted = this.readConsent();
142
+ if (persisted?.state === "denied" &&
143
+ transition.expectedGeneration !== persisted.collectionGeneration) {
144
+ this.applyRemoteDenial(persisted.collectionGeneration);
145
+ return;
146
+ }
147
+ if (persisted &&
148
+ persisted.collectionGeneration !== transition.expectedGeneration) {
149
+ this.reconcileRemotePermittedConsent(persisted);
150
+ return;
151
+ }
152
+ if (!persisted && transition.expectedGeneration !== null) {
153
+ this.failClosedConsentState();
154
+ return;
155
+ }
156
+ }
157
+ if (this.initialDenialPending && transition.state !== "denied") {
158
+ if (!this.persistCommittedConsent({
159
+ state: "denied",
160
+ generation: transition.generation,
161
+ expectedGeneration: null,
162
+ legacyImportBlocked: true,
163
+ purge: true,
164
+ })) {
165
+ return;
166
+ }
167
+ }
168
+ this.initialDenialPending = false;
169
+ this.persistCommittedConsent(transition);
170
+ }
171
+ persistCommittedConsent(transition) {
172
+ this.consent = transition.state;
173
+ this.generation = transition.generation;
174
+ this.legacyImportBlocked = transition.legacyImportBlocked;
175
+ const persisted = this.persistConsent();
176
+ if (!persisted) {
177
+ this.diagnostics.record({ code: "consent_persistence_failed" });
178
+ this.failClosedConsentState();
179
+ }
180
+ if (persisted) {
181
+ this.consentStateUnavailable = false;
182
+ this.consentAnchorEstablished = true;
183
+ }
184
+ const purged = transition.purge ? this.purgeAll() : true;
185
+ return persisted && purged;
186
+ }
187
+ addDraft(event, permit = this.capturePermit()) {
188
+ const resolvedPermit = this.resolvePermit(permit);
189
+ if (!resolvedPermit)
190
+ return false;
191
+ const draft = createDraft({
192
+ event,
193
+ permit: resolvedPermit,
194
+ projectHash: this.projectHash,
195
+ createdAt: nowIso(),
196
+ });
197
+ if (!draft)
198
+ return false;
199
+ const key = draftRecordKey(this.prefix, draft.eventKeyHash);
200
+ const existing = this.readRecord(key);
201
+ if (existing) {
202
+ if (existing.kind === "draft" &&
203
+ existing.recordVerifierSha256 === draft.recordVerifierSha256) {
204
+ return true;
205
+ }
206
+ this.diagnostics.record({ code: "outbox_key_collision" });
207
+ return false;
208
+ }
209
+ this.drafts.push(draft);
210
+ this.enforceCapacity();
211
+ if (!this.writeRecord(key, draft)) {
212
+ this.memoryOnly = true;
213
+ this.warnDurableUnavailable();
214
+ }
215
+ return this.drafts.some((item) => item.eventId === draft.eventId);
216
+ }
217
+ estimatedDraftBytes(measure, metadata) {
218
+ return measure(this.drafts.map((draft) => draft.event), metadata);
219
+ }
220
+ prepareFence(input) {
221
+ const claimedEventKeyHashes = new Set(this.pending.flatMap((record) => record.eventKeyHashes));
222
+ const candidates = this.drafts.filter((draft) => !claimedEventKeyHashes.has(draft.eventKeyHash) &&
223
+ this.permits({
224
+ generation: draft.collectionGeneration,
225
+ permittedAtStart: draft.permittedAtStart,
226
+ captureOrdinal: 0,
227
+ }) &&
228
+ (draft.origin !== "legacy_v1" ||
229
+ hasLegacyTombstone(draft, this.tombstones)));
230
+ let oversized = 0;
231
+ let cursor = 0;
232
+ let targetOrdinal = this.pending[this.pending.length - 1]?.ordinal ?? 0;
233
+ while (cursor < candidates.length) {
234
+ const remaining = candidates.slice(cursor);
235
+ const metadata = input.createMetadata();
236
+ const count = largestFittingPrefix(remaining, input.maxPayloadBytes, metadata, input.measure);
237
+ if (count === 0) {
238
+ const draft = remaining[0];
239
+ if (draft) {
240
+ this.removeDraft(draft);
241
+ this.metrics?.increment("oversized_event_count");
242
+ this.diagnostics.record({
243
+ code: "event_oversize",
244
+ event_fingerprint: this.diagnostics.eventFingerprint(canonicalJsonStringify(draft.event)),
245
+ });
246
+ }
247
+ oversized += 1;
248
+ cursor += 1;
249
+ continue;
250
+ }
251
+ const selected = remaining.slice(0, count);
252
+ const record = createPending({
253
+ projectHash: this.projectHash,
254
+ metadata,
255
+ drafts: selected,
256
+ ordinal: this.nextOrdinal,
257
+ });
258
+ const key = pendingRecordKey(this.prefix, record.batchId);
259
+ if (!this.writeRecord(key, record)) {
260
+ this.memoryOnly = true;
261
+ this.warnDurableUnavailable();
262
+ break;
263
+ }
264
+ this.pending.push(record);
265
+ // The pending record now owns these events. Drop the drafts from the
266
+ // in-memory list unconditionally so a failed storage removal cannot
267
+ // leave them as live candidates for a duplicate send; the pending
268
+ // record carries the responsibility to delete any surviving draft
269
+ // record when it is finalized (`removeClaimedDraftRecords`).
270
+ for (const draft of selected) {
271
+ this.removeRecord(draftRecordKey(this.prefix, draft.eventKeyHash));
272
+ }
273
+ this.drafts = this.drafts.filter((draft) => !selected.includes(draft));
274
+ targetOrdinal = record.ordinal;
275
+ this.nextOrdinal += 1;
276
+ cursor += count;
277
+ }
278
+ return { targetOrdinal, oversized };
279
+ }
280
+ headThrough(targetOrdinal) {
281
+ const record = this.pending
282
+ .sort((left, right) => left.ordinal - right.ordinal)
283
+ .find((item) => item.ordinal <= targetOrdinal);
284
+ if (!record)
285
+ return null;
286
+ return toHeadBatch(record, pendingRecordKey(this.prefix, record.batchId));
287
+ }
288
+ removeHead(expected) {
289
+ const record = this.pending.find((item) => item.ordinal === expected.ordinal &&
290
+ item.batchId === recordBatchId(expected));
291
+ if (!record || !this.permitsBatch(expected))
292
+ return false;
293
+ const key = pendingRecordKey(this.prefix, record.batchId);
294
+ if (record.kind === "pending" ||
295
+ record.nextOrdinal === record.children.length - 1) {
296
+ // Delete any draft record still claimed by this batch before dropping
297
+ // the pending record. If a claimed draft cannot be removed, keep the
298
+ // pending record so the draft stays covered and cannot resurrect as a
299
+ // fresh event on the next restore; the batch is retried idempotently
300
+ // instead.
301
+ if (!this.removeClaimedDraftRecords(record))
302
+ return false;
303
+ if (!this.removeRecord(key))
304
+ return false;
305
+ this.pending = this.pending.filter((item) => item !== record);
306
+ return true;
307
+ }
308
+ const progressed = withChecksum({
309
+ ...withoutChecksum(record),
310
+ nextOrdinal: record.nextOrdinal + 1,
311
+ });
312
+ if (!this.writeRecord(key, progressed))
313
+ return false;
314
+ this.pending = this.pending.map((item) => item === record ? progressed : item);
315
+ return true;
316
+ }
317
+ retainHead(expected) {
318
+ return (this.permitsBatch(expected) &&
319
+ this.headThrough(expected.ordinal)?.recordKey === expected.recordKey);
320
+ }
321
+ splitHead(expected, createMetadata) {
322
+ const record = this.pending.find((item) => item.ordinal === expected.ordinal &&
323
+ item.batchId === recordBatchId(expected));
324
+ if (!record)
325
+ return false;
326
+ const plan = splitPendingRecord(record, createMetadata);
327
+ if (!plan)
328
+ return false;
329
+ const key = pendingRecordKey(this.prefix, record.batchId);
330
+ if (!this.writeRecord(key, plan))
331
+ return false;
332
+ this.pending = this.pending.map((item) => (item === record ? plan : item));
333
+ this.diagnostics.record({
334
+ code: "batch_split",
335
+ batch_fingerprint: expected.diagnosticCanonicalBytes
336
+ ? this.diagnostics.batchFingerprint(expected.diagnosticCanonicalBytes)
337
+ : undefined,
338
+ });
339
+ return true;
340
+ }
341
+ clear() {
342
+ let removed = true;
343
+ for (const draft of this.drafts) {
344
+ removed =
345
+ this.removeRecord(draftRecordKey(this.prefix, draft.eventKeyHash)) &&
346
+ removed;
347
+ }
348
+ for (const pending of this.pending) {
349
+ // Also remove any draft record the batch still claims so a draft that
350
+ // survived its commit cannot outlive the clear and resurrect on restore.
351
+ removed = this.removeClaimedDraftRecords(pending) && removed;
352
+ removed =
353
+ this.removeRecord(pendingRecordKey(this.prefix, pending.batchId)) &&
354
+ removed;
355
+ }
356
+ for (const tombstone of this.tombstones) {
357
+ removed =
358
+ this.removeRecord(tombstoneRecordKey(this.prefix, tombstone.eventKeyHash)) && removed;
359
+ }
360
+ this.drafts = [];
361
+ this.pending = [];
362
+ this.tombstones = [];
363
+ return removed;
364
+ }
365
+ close() {
366
+ this.consentChannel?.close();
367
+ this.removeConsentStorageListener();
368
+ this.removeLegacyStorageListener();
369
+ }
370
+ pendingEventCount() {
371
+ return outboxEventCount(this.drafts, this.pending);
372
+ }
373
+ pendingBytes() {
374
+ return browserEventsByteLength([
375
+ ...this.drafts.map((item) => item.event),
376
+ ...this.pending.flatMap((item) => item.events.map((event) => event.event)),
377
+ ]);
378
+ }
379
+ async withDeliveryLock(operation, options = {}) {
380
+ const locks = readLocks();
381
+ if (!locks) {
382
+ this.memoryOnly = true;
383
+ this.warnDurableUnavailable();
384
+ if (this.purgePending)
385
+ this.purgeAll();
386
+ this.initializeConsentForDelivery();
387
+ return await operation();
388
+ }
389
+ return await locks.request(this.lockName, { mode: "exclusive" }, async () => {
390
+ this.deliveryLockHeld = true;
391
+ try {
392
+ if (this.purgePending)
393
+ this.purgeAll();
394
+ if (options.restore !== false) {
395
+ this.initializeConsentForDelivery();
396
+ this.importLegacy();
397
+ }
398
+ return await operation();
399
+ }
400
+ finally {
401
+ this.deliveryLockHeld = false;
402
+ }
403
+ });
404
+ }
405
+ permitsBatch(batch) {
406
+ return batch.events.every((event) => this.permits(event.permit));
407
+ }
408
+ resolvePermit(permit) {
409
+ if (!permit.permittedAtStart || this.consent === "denied")
410
+ return null;
411
+ if (permit.generation === UNANCHORED_COLLECTION_GENERATION) {
412
+ if (!this.consentAnchorEstablished)
413
+ return null;
414
+ return Object.freeze({
415
+ generation: this.generation,
416
+ permittedAtStart: true,
417
+ captureOrdinal: permit.captureOrdinal,
418
+ });
419
+ }
420
+ return this.permits(permit) ? permit : null;
421
+ }
422
+ initializeConsentForDelivery() {
423
+ if (this.consentStateUnavailable) {
424
+ this.consent = "denied";
425
+ this.consentAnchorEstablished = false;
426
+ this.clearMemory();
427
+ return;
428
+ }
429
+ if (this.initialDenialPending) {
430
+ this.commitConsentTransition({
431
+ state: "denied",
432
+ generation: this.generation,
433
+ expectedGeneration: null,
434
+ legacyImportBlocked: true,
435
+ purge: true,
436
+ });
437
+ return;
438
+ }
439
+ // A synchronous revocation must fence mutations that were queued before
440
+ // its durable commit. Never restore an older permitted record over it.
441
+ if (this.consent === "denied")
442
+ return;
443
+ const restored = this.restore(false);
444
+ if (!restored) {
445
+ if (!this.persistConsent()) {
446
+ this.diagnostics.record({ code: "consent_persistence_failed" });
447
+ this.failClosedConsentState();
448
+ return;
449
+ }
450
+ this.consentAnchorEstablished = true;
451
+ }
452
+ this.restoreOutbox();
453
+ }
454
+ enforceCapacity() {
455
+ while (this.pendingEventCount() > this.maxEvents ||
456
+ this.pendingBytes() > this.maxBytes) {
457
+ const dropped = this.drafts.shift();
458
+ if (!dropped)
459
+ return;
460
+ this.removeRecord(draftRecordKey(this.prefix, dropped.eventKeyHash));
461
+ this.metrics?.increment("dropped_count");
462
+ this.diagnostics.record({
463
+ code: "queue_overflow",
464
+ event_fingerprint: this.diagnostics.eventFingerprint(canonicalJsonStringify(dropped.event)),
465
+ });
466
+ }
467
+ }
468
+ purgeAll() {
469
+ const count = this.pendingEventCount();
470
+ let removed = this.clear();
471
+ const keys = this.rawStorageKeys();
472
+ if (!keys) {
473
+ removed = false;
474
+ }
475
+ else {
476
+ for (const key of keys) {
477
+ if (key.startsWith(this.prefix) && key !== this.consentKey) {
478
+ removed = this.removeRawRecord(key) && removed;
479
+ }
480
+ }
481
+ }
482
+ if (removed) {
483
+ this.purgePending = false;
484
+ if (count > 0)
485
+ this.metrics?.set("pending_batch_count", 0);
486
+ }
487
+ else {
488
+ this.purgePending = true;
489
+ this.diagnostics.record({ code: "consent_purge_failed" });
490
+ }
491
+ return removed;
492
+ }
493
+ clearMemory() {
494
+ this.drafts = [];
495
+ this.pending = [];
496
+ this.tombstones = [];
497
+ }
498
+ restore(includeOutbox = true) {
499
+ const consent = this.readConsent();
500
+ if (consent) {
501
+ this.consent = consent.state;
502
+ this.generation = consent.collectionGeneration;
503
+ this.legacyImportBlocked = consent.legacyImportBlocked;
504
+ this.consentAnchorEstablished = true;
505
+ }
506
+ if (includeOutbox)
507
+ this.restoreOutbox();
508
+ return consent !== null;
509
+ }
510
+ restoreOutbox() {
511
+ if (this.consent === "denied" || this.memoryOnly)
512
+ return;
513
+ const drafts = [];
514
+ const pending = [];
515
+ const tombstones = [];
516
+ for (const key of this.storageKeys()) {
517
+ if (key === this.consentKey)
518
+ continue;
519
+ const record = this.readRecord(key);
520
+ if (!record)
521
+ continue;
522
+ if (record.kind === "draft" &&
523
+ record.collectionGeneration === this.generation) {
524
+ drafts.push(record);
525
+ }
526
+ if ((record.kind === "pending" || record.kind === "split_plan") &&
527
+ record.collectionGeneration === this.generation) {
528
+ pending.push(record);
529
+ }
530
+ if (record.kind === "legacy_tombstone")
531
+ tombstones.push(record);
532
+ }
533
+ const pendingHashes = new Set(pending.flatMap((record) => record.eventKeyHashes));
534
+ this.drafts = drafts
535
+ .filter((draft) => !pendingHashes.has(draft.eventKeyHash))
536
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
537
+ this.pending = pending.sort((left, right) => left.ordinal - right.ordinal);
538
+ this.tombstones = tombstones;
539
+ this.nextOrdinal = Math.max(1, ...pending.map((record) => record.ordinal + 1));
540
+ }
541
+ readConsent() {
542
+ const raw = this.peekConsentRaw();
543
+ if (raw === false) {
544
+ this.failClosedConsentState();
545
+ return null;
546
+ }
547
+ if (raw === null)
548
+ return null;
549
+ const record = parseRecord(raw, this.projectHash);
550
+ if (record?.kind === "consent")
551
+ return record;
552
+ this.diagnostics.record({ code: "outbox_record_corrupt" });
553
+ this.failClosedConsentState();
554
+ return null;
555
+ }
556
+ peekConsentRaw() {
557
+ if (!this.storage)
558
+ return false;
559
+ try {
560
+ return this.storage.getItem(this.consentKey);
561
+ }
562
+ catch {
563
+ return false;
564
+ }
565
+ }
566
+ restoreConsentFromStorage() {
567
+ if (this.initialDenialPending || this.consentStateUnavailable)
568
+ return;
569
+ const record = this.readConsent();
570
+ if (!record) {
571
+ this.failClosedConsentState();
572
+ return;
573
+ }
574
+ if (record?.state === "denied") {
575
+ this.applyRemoteDenial(record.collectionGeneration);
576
+ return;
577
+ }
578
+ this.reconcileRemotePermittedConsent(record);
579
+ }
580
+ reconcileRemotePermittedConsent(record) {
581
+ if (this.legacyImportBlocked && !record.legacyImportBlocked) {
582
+ this.repairDeniedConsentState();
583
+ return;
584
+ }
585
+ if (!this.consentAnchorEstablished) {
586
+ this.adoptConsentRecord(record);
587
+ return;
588
+ }
589
+ if (record.collectionGeneration === this.generation) {
590
+ this.adoptConsentRecord(record);
591
+ return;
592
+ }
593
+ if (this.consent === "denied" || !record.legacyImportBlocked) {
594
+ this.repairDeniedConsentState();
595
+ return;
596
+ }
597
+ const purged = this.pendingEventCount();
598
+ this.diagnostics.rotateForConsentPurge(purged);
599
+ this.clearMemory();
600
+ this.onConsentRevoked();
601
+ this.adoptConsentRecord(record);
602
+ void this.withDeliveryLock(async () => {
603
+ this.purgeAll();
604
+ }, { restore: false });
605
+ }
606
+ adoptConsentRecord(record) {
607
+ this.consent = record.state;
608
+ this.generation = record.collectionGeneration;
609
+ this.legacyImportBlocked = record.legacyImportBlocked;
610
+ this.consentAnchorEstablished = true;
611
+ }
612
+ repairDeniedConsentState() {
613
+ if (this.consent !== "denied") {
614
+ const purged = this.pendingEventCount();
615
+ this.consent = "denied";
616
+ this.generation = createGeneration();
617
+ this.legacyImportBlocked = true;
618
+ this.diagnostics.rotateForConsentPurge(purged);
619
+ this.clearMemory();
620
+ this.onConsentRevoked();
621
+ }
622
+ const generation = this.generation;
623
+ void this.withDeliveryLock(async () => {
624
+ this.commitConsentTransition({
625
+ state: "denied",
626
+ generation,
627
+ expectedGeneration: null,
628
+ legacyImportBlocked: true,
629
+ purge: true,
630
+ });
631
+ }, { restore: false });
632
+ }
633
+ importLegacy() {
634
+ if (!this.storage)
635
+ return;
636
+ if (this.legacyImportBlocked || this.consent === "denied") {
637
+ if (this.readLegacyEvents() !== null) {
638
+ this.diagnostics.record({
639
+ code: "legacy_pre_revocation_event_blocked",
640
+ });
641
+ }
642
+ return;
643
+ }
644
+ const events = this.readLegacyEvents();
645
+ if (!events)
646
+ return;
647
+ for (const event of events) {
648
+ const eventId = event.event_id;
649
+ if (typeof eventId !== "string" || eventId.length === 0) {
650
+ this.diagnostics.record({ code: "outbox_record_corrupt" });
651
+ continue;
652
+ }
653
+ const verifier = verifyHash(eventId);
654
+ if (this.pending.some((record) => record.events.some((stored) => stored.recordVerifierSha256 === verifier)) ||
655
+ this.tombstones.some((record) => record.recordVerifierSha256 === verifier)) {
656
+ continue;
657
+ }
658
+ if (this.tombstones.length >= LEGACY_TOMBSTONE_LIMIT) {
659
+ this.diagnostics.record({ code: "legacy_bridge_capacity_reached" });
660
+ return;
661
+ }
662
+ const existingDraft = this.drafts.find((record) => record.recordVerifierSha256 === verifier);
663
+ if (existingDraft && existingDraft.origin !== "legacy_v1") {
664
+ continue;
665
+ }
666
+ const draft = existingDraft ??
667
+ createDraft({
668
+ event,
669
+ permit: this.capturePermit(),
670
+ projectHash: this.projectHash,
671
+ createdAt: nowIso(),
672
+ origin: "legacy_v1",
673
+ });
674
+ if (!draft) {
675
+ this.diagnostics.record({ code: "outbox_record_corrupt" });
676
+ continue;
677
+ }
678
+ if (!existingDraft) {
679
+ if (!this.writeRecord(draftRecordKey(this.prefix, draft.eventKeyHash), draft))
680
+ return;
681
+ this.drafts.push(draft);
682
+ }
683
+ const tombstone = createLegacyTombstone({
684
+ projectHash: this.projectHash,
685
+ eventKeyHash: draft.eventKeyHash,
686
+ verifier,
687
+ });
688
+ if (!this.writeRecord(tombstoneRecordKey(this.prefix, draft.eventKeyHash), tombstone))
689
+ return;
690
+ this.tombstones.push(tombstone);
691
+ }
692
+ }
693
+ readLegacyEvents() {
694
+ try {
695
+ const raw = this.storage?.getItem(this.legacyKey);
696
+ if (!raw)
697
+ return null;
698
+ const value = JSON.parse(raw);
699
+ if (!Array.isArray(value)) {
700
+ this.diagnostics.record({ code: "outbox_record_corrupt" });
701
+ return null;
702
+ }
703
+ return value.filter(isBrowserSourceSignalEvent);
704
+ }
705
+ catch {
706
+ this.diagnostics.record({ code: "outbox_record_corrupt" });
707
+ return null;
708
+ }
709
+ }
710
+ applyRemoteDenial(generation) {
711
+ if (this.consent === "denied" && this.generation === generation)
712
+ return;
713
+ const purged = this.pendingEventCount();
714
+ this.consent = "denied";
715
+ this.generation = generation;
716
+ this.legacyImportBlocked = true;
717
+ this.diagnostics.rotateForConsentPurge(purged);
718
+ this.clearMemory();
719
+ this.onConsentRevoked();
720
+ void this.withDeliveryLock(async () => {
721
+ this.commitConsentTransition({
722
+ state: "denied",
723
+ generation,
724
+ expectedGeneration: null,
725
+ legacyImportBlocked: true,
726
+ purge: true,
727
+ });
728
+ }, { restore: false });
729
+ }
730
+ persistConsent() {
731
+ if (!this.storage)
732
+ return false;
733
+ const record = createConsentRecord({
734
+ projectHash: this.projectHash,
735
+ state: this.consent,
736
+ collectionGeneration: this.generation,
737
+ legacyImportBlocked: this.legacyImportBlocked,
738
+ });
739
+ try {
740
+ this.storage.setItem(this.consentKey, JSON.stringify(record));
741
+ const persisted = parseRecord(this.storage.getItem(this.consentKey) ?? "", this.projectHash);
742
+ return (persisted?.kind === "consent" &&
743
+ persisted.state === record.state &&
744
+ persisted.collectionGeneration === record.collectionGeneration &&
745
+ persisted.legacyImportBlocked === record.legacyImportBlocked);
746
+ }
747
+ catch {
748
+ return false;
749
+ }
750
+ }
751
+ writeRecord(key, record) {
752
+ if (!this.storage || this.memoryOnly)
753
+ return true;
754
+ if (this.durableOutboxSupported && !this.deliveryLockHeld) {
755
+ this.memoryOnly = true;
756
+ this.warnDurableUnavailable();
757
+ return true;
758
+ }
759
+ const existing = this.readRecord(key);
760
+ if (existing &&
761
+ existing.recordVerifierSha256 !== record.recordVerifierSha256) {
762
+ this.diagnostics.record({ code: "outbox_key_collision" });
763
+ return false;
764
+ }
765
+ try {
766
+ this.storage.setItem(key, JSON.stringify(record));
767
+ return true;
768
+ }
769
+ catch {
770
+ return false;
771
+ }
772
+ }
773
+ removeRecord(key) {
774
+ if (!this.storage || this.memoryOnly)
775
+ return true;
776
+ if (this.durableOutboxSupported && !this.deliveryLockHeld) {
777
+ this.memoryOnly = true;
778
+ this.warnDurableUnavailable();
779
+ return true;
780
+ }
781
+ try {
782
+ this.storage.removeItem(key);
783
+ return true;
784
+ }
785
+ catch {
786
+ this.warnDurableUnavailable();
787
+ return false;
788
+ }
789
+ }
790
+ removeDraft(draft) {
791
+ if (!this.removeRecord(draftRecordKey(this.prefix, draft.eventKeyHash)))
792
+ return false;
793
+ this.drafts = this.drafts.filter((item) => item !== draft);
794
+ return true;
795
+ }
796
+ removeClaimedDraftRecords(record) {
797
+ let removed = true;
798
+ for (const eventKeyHash of record.eventKeyHashes) {
799
+ removed =
800
+ this.removeRecord(draftRecordKey(this.prefix, eventKeyHash)) && removed;
801
+ }
802
+ return removed;
803
+ }
804
+ readRecord(key) {
805
+ if (!this.storage || this.memoryOnly)
806
+ return null;
807
+ let raw;
808
+ try {
809
+ raw = this.storage.getItem(key);
810
+ }
811
+ catch {
812
+ this.memoryOnly = true;
813
+ this.warnDurableUnavailable();
814
+ return null;
815
+ }
816
+ if (!raw)
817
+ return null;
818
+ const record = parseRecord(raw, this.projectHash);
819
+ if (!record) {
820
+ this.diagnostics.record({ code: "outbox_record_corrupt" });
821
+ }
822
+ return record;
823
+ }
824
+ failClosedConsentState() {
825
+ const wasUnavailable = this.consentStateUnavailable;
826
+ const purged = this.pendingEventCount();
827
+ this.consentStateUnavailable = true;
828
+ this.consent = "denied";
829
+ this.generation = createGeneration();
830
+ this.legacyImportBlocked = true;
831
+ this.consentAnchorEstablished = false;
832
+ this.purgePending = true;
833
+ if (!wasUnavailable)
834
+ this.diagnostics.rotateForConsentPurge(purged);
835
+ this.clearMemory();
836
+ this.onConsentRevoked();
837
+ if (!wasUnavailable) {
838
+ this.diagnostics.record({ code: "consent_state_unavailable" });
839
+ }
840
+ }
841
+ storageKeys() {
842
+ if (!this.storage || this.memoryOnly)
843
+ return [];
844
+ return this.rawStorageKeys() ?? [];
845
+ }
846
+ rawStorageKeys() {
847
+ if (!this.storage)
848
+ return null;
849
+ try {
850
+ return Array.from({ length: this.storage.length }, (_, index) => this.storage?.key(index)).filter((key) => Boolean(key?.startsWith(this.prefix)));
851
+ }
852
+ catch {
853
+ this.memoryOnly = true;
854
+ this.warnDurableUnavailable();
855
+ return null;
856
+ }
857
+ }
858
+ removeRawRecord(key) {
859
+ if (!this.storage)
860
+ return false;
861
+ try {
862
+ this.storage.removeItem(key);
863
+ return true;
864
+ }
865
+ catch {
866
+ this.warnDurableUnavailable();
867
+ return false;
868
+ }
869
+ }
870
+ warnDurableUnavailable() {
871
+ if (this.durableWarningRecorded)
872
+ return;
873
+ this.durableWarningRecorded = true;
874
+ this.diagnostics.record({ code: "durable_outbox_unavailable" });
875
+ }
876
+ }