@fairfox/polly 0.60.0 → 0.62.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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Tiny internal helpers around the raw IndexedDB API.
3
+ *
4
+ * Wraps the patterns that `storage-adapter.ts` and `blob-cache.ts` both
5
+ * re-implement: timed open with retry-on-failure caching, request→promise
6
+ * wrapping, transaction-to-completion, and cursor iteration. Not exported
7
+ * from the public package — internal to `src/shared/lib`.
8
+ */
9
+ /** Polly#107 post-v0.60: hard timeout on `indexedDB.open()`. Healthy
10
+ * opens land in microseconds; the v0.60.0 fingerprint surfaced a zombie
11
+ * cross-tab connection that left an open request firing no events at all.
12
+ * 5s is two orders of magnitude beyond the normal upper bound and short
13
+ * enough that the operator gets a named failure instead of a hung page
14
+ * when the storage layer wedges. */
15
+ export declare const IDB_OPEN_TIMEOUT_MS = 5000;
16
+ export interface OpenIDBOptions {
17
+ name: string;
18
+ version: number;
19
+ /** Invoked inside `onupgradeneeded`. Create object stores here. */
20
+ upgrade: (db: IDBDatabase, event: IDBVersionChangeEvent) => void;
21
+ }
22
+ /** Open an IndexedDB database with the Polly#107 timeout guard. */
23
+ export declare function openIDB(options: OpenIDBOptions): Promise<IDBDatabase>;
24
+ /** One-shot open caching with retry-on-failure. Caller owns a `{ promise }`
25
+ * cell; first call opens, subsequent calls return the cached promise, and
26
+ * a rejected open clears the cache so the next call can retry instead of
27
+ * being poisoned by one transient failure. */
28
+ export declare function cachedOpen(ref: {
29
+ promise: Promise<IDBDatabase> | null;
30
+ }, options: OpenIDBOptions): Promise<IDBDatabase>;
31
+ /** Promise-wrap a single `IDBRequest` (`get`, `put`, `count`, `delete`…). */
32
+ export declare function runRequest<T>(request: IDBRequest<T>): Promise<T>;
33
+ /** Run a transaction to completion. Resolves on `tx.oncomplete` (write
34
+ * durability), not on individual request success. Throws inside `fn`
35
+ * abort the transaction and reject. */
36
+ export declare function runTx(db: IDBDatabase, storeName: string, mode: IDBTransactionMode, fn: (store: IDBObjectStore) => void): Promise<void>;
37
+ /** Walk every record in a store. */
38
+ export declare function iterateCursor<V>(db: IDBDatabase, storeName: string, visit: (key: IDBValidKey, value: V) => void): Promise<void>;
@@ -23,7 +23,7 @@ import { Repo, type StorageAdapterInterface } from "@automerge/automerge-repo/sl
23
23
  import type { KeyringStorage } from "./keyring-storage";
24
24
  import { type MeshKeyring, MeshNetworkAdapter } from "./mesh-network-adapter";
25
25
  import { MeshSignalingClient, type MeshSignalingClientOptions } from "./mesh-signaling-client";
26
- import { type MeshStateLazyWrapperRecord, type MeshStateLoadedRejectionBreadcrumb } from "./mesh-state";
26
+ import { type MeshStateLazyWrapperRecord, type MeshStateLoadedRejectionBreadcrumb, type MeshStateStorageOpenError } from "./mesh-state";
27
27
  import { MeshWebRTCAdapter, type MeshWebRTCAdapterOptions } from "./mesh-webrtc-adapter";
28
28
  /** Options for {@link createMeshClient}. */
29
29
  export interface CreateMeshClientOptions {
@@ -164,6 +164,30 @@ export interface CreateMeshClientOptions {
164
164
  * production call site is inside {@link createMeshClient}.
165
165
  */
166
166
  export declare function resolveIceServers(rtc: CreateMeshClientOptions["rtc"]): Promise<RTCIceServer[] | undefined>;
167
+ /** Polly#107 post-v0.60 instrumentation. Walks the lazy-wrapper log
168
+ * and groups records by `docId`; emits one entry per `docId` that
169
+ * appears in more than one record. Surfaces the "17 wrappers / 16
170
+ * `repo.handles`" off-by-one shape that the v0.60.0 single-tab
171
+ * fingerprint flagged — typically two distinct `$mesh*` consumer
172
+ * call sites whose logical keys hash to the same Automerge
173
+ * `DocumentId`, or the same logical key invoked from two consumers
174
+ * during pre-warm before either factory's `repo.import` committed
175
+ * the handle. The snapshot can paste this verbatim; an empty array
176
+ * means the wrapper-to-handle accounting is one-to-one. */
177
+ export interface MeshStateLazyWrapperDocIdDuplicate {
178
+ /** The `DocumentId` that more than one factory invocation
179
+ * resolved to. */
180
+ docId: string;
181
+ /** Distinct logical keys that derived to this same `DocumentId`.
182
+ * Length 1 means the same key was registered twice; length > 1
183
+ * means two different keys hashed to the same id (a SHA-512-prefix
184
+ * collision in {@link deriveDocumentId}, vanishingly unlikely but
185
+ * detected for completeness). */
186
+ keys: string[];
187
+ /** Total number of records in the lazy-wrapper log that resolved
188
+ * to this `DocumentId`. Typically 2 for a same-key double-call. */
189
+ recordCount: number;
190
+ }
167
191
  /** Per-peer per-handle enrichment polly#107 adds on top of the
168
192
  * {@link MeshWebRTCAdapter} snapshot. `state` and `announcedToPeer`
169
193
  * come from the Repo side (which the adapter cannot see); the wire
@@ -255,6 +279,17 @@ export interface MeshStateModuleDiagnostics {
255
279
  * silently. `undefined` means no rejection has escaped any
256
280
  * wrapper on THIS module instance since module load. */
257
281
  lastLoadedRejection: MeshStateLoadedRejectionBreadcrumb | undefined;
282
+ /** Polly#107 post-v0.60 instrumentation. Populated when a storage
283
+ * read inside `buildHandleFactory` exceeds the internal 5s
284
+ * timeout — i.e. `cached.whenReady(...)` or
285
+ * `repo.storageSubsystem.loadDoc(...)` hung. Names the operation,
286
+ * the document id under attempt, the elapsed time, and a
287
+ * pre-formatted message ready to paste into a ticket. The v0.60.0
288
+ * fingerprint diagnosed "factories hung mid-await, not throwing"
289
+ * indirectly; this field surfaces the same shape within seconds
290
+ * and in one read. `undefined` means no storage timeout has
291
+ * occurred since module load. */
292
+ storageOpenError: MeshStateStorageOpenError | undefined;
258
293
  /** Polly#107 post-v0.59 instrumentation. Per-factory-invocation
259
294
  * structured log — one record per `$mesh*` wrapper's lazy handle
260
295
  * factory call, ring-buffered at 64 entries. Each row names the
@@ -269,6 +304,15 @@ export interface MeshStateModuleDiagnostics {
269
304
  * `exitReason: "seeded-and-imported"` and
270
305
  * `handleRegistered: false`. */
271
306
  lazyWrappers: MeshStateLazyWrapperRecord[];
307
+ /** Polly#107 post-v0.60 instrumentation. One entry per
308
+ * `DocumentId` that appears in more than one
309
+ * {@link lazyWrappers} record — the shape behind the v0.60.0
310
+ * single-tab fingerprint's "17 wrappers / 16 `repo.handles`"
311
+ * off-by-one. Empty when wrapper-to-handle accounting is
312
+ * one-to-one, populated when two factory invocations resolved to
313
+ * the same `DocumentId` (most commonly the same logical key
314
+ * registered from two consumer call sites during pre-warm). */
315
+ lazyWrapperDuplicateDocIds: MeshStateLazyWrapperDocIdDuplicate[];
272
316
  }
273
317
  /** The mesh client's enriched per-peer state snapshot. Mirrors the
274
318
  * underlying {@link MeshWebRTCAdapter.getPeerStateSnapshot} shape but
@@ -140,6 +140,43 @@ export interface MeshStateLoadedRejectionBreadcrumb {
140
140
  /** Returns the most recent rejection escaping a `$meshState` factory
141
141
  * invocation since module load. See {@link lastLoadedRejection}. */
142
142
  export declare function getLastLoadedRejection(): MeshStateLoadedRejectionBreadcrumb | undefined;
143
+ /** Polly#107 post-v0.60 instrumentation. Captured when a storage
144
+ * read from inside a `$meshState`-family factory exceeds
145
+ * {@link STORAGE_OP_TIMEOUT_MS}. Names which op timed out
146
+ * (`whenReady`, `loadDoc`), the document id under attempt, the
147
+ * elapsed time, and a single human-readable message that the
148
+ * snapshot can paste verbatim into a ticket. The breadcrumb also
149
+ * flows into {@link lastLoadedRejection} so the existing
150
+ * "did anything reject" channel surfaces it alongside.
151
+ *
152
+ * The v0.60.0 fingerprint diagnosed "factories hung mid-await,
153
+ * not throwing" indirectly — empty {@link lazyWrappers} beside
154
+ * non-zero {@link lazyInvocations} / {@link lazyReachedRepo}. With
155
+ * this field a future polly#107-shaped session reads
156
+ * `storageOpenError.message: "Polly $meshState: storage operation
157
+ * 'loadDoc' on document '<id>' timed out after 5000ms"` and jumps
158
+ * directly to "the storage layer is hung; clear site data,"
159
+ * skipping every other rung in the ladder. */
160
+ export interface MeshStateStorageOpenError {
161
+ /** Which await in `buildHandleFactory` exceeded the timeout. */
162
+ operation: "whenReady" | "loadDoc";
163
+ /** Stringified Automerge `DocumentId` that was under attempt. */
164
+ documentId: string;
165
+ /** Time the operation was given before the timeout fired (ms). */
166
+ timeoutMs: number;
167
+ /** Wall-clock elapsed when the timeout fired (ms). Always
168
+ * `>= timeoutMs`; a small overshoot is normal. */
169
+ elapsedMs: number;
170
+ /** Pre-formatted human-readable message. Identical to what
171
+ * flows into {@link lastLoadedRejection}.message. */
172
+ message: string;
173
+ /** `Date.now()` at the moment the timeout fired. */
174
+ at: number;
175
+ }
176
+ /** Returns the most recent storage-operation timeout escaping a
177
+ * `$meshState` factory invocation since module load. See
178
+ * {@link lastStorageOpenError}. */
179
+ export declare function getStorageOpenError(): MeshStateStorageOpenError | undefined;
143
180
  /** Polly#107 post-v0.59 instrumentation. Categorises the exit path
144
181
  * a `$mesh*` lazy factory invocation took. The v0.59.0 fingerprint
145
182
  * showed `lazyInvocations === lazyReachedRepo === 17` and no
@@ -69,6 +69,90 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
69
69
  throw Error('Dynamic require of "' + x + '" is not supported');
70
70
  });
71
71
 
72
+ // src/shared/lib/idb-helpers.ts
73
+ function openIDB(options) {
74
+ return new Promise((resolve, reject) => {
75
+ const start = Date.now();
76
+ const request = indexedDB.open(options.name, options.version);
77
+ let settled = false;
78
+ const timer = setTimeout(() => {
79
+ if (settled)
80
+ return;
81
+ settled = true;
82
+ const elapsedMs = Date.now() - start;
83
+ reject(new Error(`Polly IndexedDB open of '${options.name}' timed out after ${elapsedMs}ms (cross-tab lock or zombie connection?)`));
84
+ }, IDB_OPEN_TIMEOUT_MS);
85
+ request.onerror = () => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ reject(request.error);
91
+ };
92
+ request.onsuccess = () => {
93
+ if (settled)
94
+ return;
95
+ settled = true;
96
+ clearTimeout(timer);
97
+ resolve(request.result);
98
+ };
99
+ request.onupgradeneeded = (event) => {
100
+ const db = event.target.result;
101
+ options.upgrade(db, event);
102
+ };
103
+ });
104
+ }
105
+ function cachedOpen(ref, options) {
106
+ if (ref.promise)
107
+ return ref.promise;
108
+ const pending = openIDB(options);
109
+ pending.catch(() => {
110
+ if (ref.promise === pending)
111
+ ref.promise = null;
112
+ });
113
+ ref.promise = pending;
114
+ return pending;
115
+ }
116
+ function runRequest(request) {
117
+ return new Promise((resolve, reject) => {
118
+ request.onsuccess = () => resolve(request.result);
119
+ request.onerror = () => reject(request.error);
120
+ });
121
+ }
122
+ function runTx(db, storeName, mode, fn) {
123
+ return new Promise((resolve, reject) => {
124
+ const tx = db.transaction(storeName, mode);
125
+ const store = tx.objectStore(storeName);
126
+ tx.oncomplete = () => resolve();
127
+ tx.onerror = () => reject(tx.error);
128
+ tx.onabort = () => reject(tx.error);
129
+ try {
130
+ fn(store);
131
+ } catch (err) {
132
+ try {
133
+ tx.abort();
134
+ } catch {}
135
+ reject(err);
136
+ }
137
+ });
138
+ }
139
+ function iterateCursor(db, storeName, visit) {
140
+ return new Promise((resolve, reject) => {
141
+ const tx = db.transaction(storeName, "readonly");
142
+ const store = tx.objectStore(storeName);
143
+ const request = store.openCursor();
144
+ request.onsuccess = () => {
145
+ const cursor = request.result;
146
+ if (!cursor)
147
+ return resolve();
148
+ visit(cursor.key, cursor.value);
149
+ cursor.continue();
150
+ };
151
+ request.onerror = () => reject(request.error);
152
+ });
153
+ }
154
+ var IDB_OPEN_TIMEOUT_MS = 5000;
155
+
72
156
  // src/shared/lib/storage-adapter.ts
73
157
  var exports_storage_adapter = {};
74
158
  __export(exports_storage_adapter, {
@@ -81,42 +165,32 @@ __export(exports_storage_adapter, {
81
165
  class IndexedDBAdapter {
82
166
  dbName;
83
167
  storeName = "state";
84
- dbPromise = null;
168
+ dbRef = { promise: null };
85
169
  constructor(dbName = "polly-state") {
86
170
  this.dbName = dbName;
87
171
  }
88
172
  getDB() {
89
- if (this.dbPromise)
90
- return this.dbPromise;
91
- this.dbPromise = new Promise((resolve, reject) => {
92
- const request = indexedDB.open(this.dbName, 1);
93
- request.onerror = () => reject(request.error);
94
- request.onsuccess = () => resolve(request.result);
95
- request.onupgradeneeded = (event) => {
96
- const db = event.target.result;
173
+ return cachedOpen(this.dbRef, {
174
+ name: this.dbName,
175
+ version: 1,
176
+ upgrade: (db) => {
97
177
  if (!db.objectStoreNames.contains(this.storeName)) {
98
178
  db.createObjectStore(this.storeName);
99
179
  }
100
- };
180
+ }
101
181
  });
102
- return this.dbPromise;
103
182
  }
104
183
  async get(keys) {
105
184
  try {
106
185
  const db = await this.getDB();
186
+ const tx = db.transaction(this.storeName, "readonly");
187
+ const store = tx.objectStore(this.storeName);
188
+ const pairs = await Promise.all(keys.map(async (key) => [key, await runRequest(store.get(key))]));
107
189
  const result = {};
108
- await Promise.all(keys.map((key) => new Promise((resolve, reject) => {
109
- const transaction = db.transaction([this.storeName], "readonly");
110
- const store = transaction.objectStore(this.storeName);
111
- const request = store.get(key);
112
- request.onerror = () => reject(request.error);
113
- request.onsuccess = () => {
114
- if (request.result !== undefined) {
115
- result[key] = request.result;
116
- }
117
- resolve();
118
- };
119
- })));
190
+ for (const [key, value] of pairs) {
191
+ if (value !== undefined)
192
+ result[key] = value;
193
+ }
120
194
  return result;
121
195
  } catch (error) {
122
196
  console.warn("[Polly] IndexedDB get failed:", error);
@@ -126,13 +200,11 @@ class IndexedDBAdapter {
126
200
  async set(items) {
127
201
  try {
128
202
  const db = await this.getDB();
129
- await Promise.all(Object.entries(items).map(([key, value]) => new Promise((resolve, reject) => {
130
- const transaction = db.transaction([this.storeName], "readwrite");
131
- const store = transaction.objectStore(this.storeName);
132
- const request = store.put(value, key);
133
- request.onerror = () => reject(request.error);
134
- request.onsuccess = () => resolve();
135
- })));
203
+ await runTx(db, this.storeName, "readwrite", (store) => {
204
+ for (const [key, value] of Object.entries(items)) {
205
+ store.put(value, key);
206
+ }
207
+ });
136
208
  } catch (error) {
137
209
  console.warn("[Polly] IndexedDB set failed:", error);
138
210
  }
@@ -140,13 +212,10 @@ class IndexedDBAdapter {
140
212
  async remove(keys) {
141
213
  try {
142
214
  const db = await this.getDB();
143
- await Promise.all(keys.map((key) => new Promise((resolve, reject) => {
144
- const transaction = db.transaction([this.storeName], "readwrite");
145
- const store = transaction.objectStore(this.storeName);
146
- const request = store.delete(key);
147
- request.onerror = () => reject(request.error);
148
- request.onsuccess = () => resolve();
149
- })));
215
+ await runTx(db, this.storeName, "readwrite", (store) => {
216
+ for (const key of keys)
217
+ store.delete(key);
218
+ });
150
219
  } catch (error) {
151
220
  console.warn("[Polly] IndexedDB remove failed:", error);
152
221
  }
@@ -219,6 +288,7 @@ function createStorageAdapter() {
219
288
  }
220
289
  return new MemoryStorageAdapter;
221
290
  }
291
+ var init_storage_adapter = () => {};
222
292
 
223
293
  // src/shared/lib/sync-adapter.ts
224
294
  var exports_sync_adapter = {};
@@ -722,6 +792,7 @@ class MessageLoggerAdapter {
722
792
  }
723
793
 
724
794
  // src/shared/lib/adapter-factory.ts
795
+ init_storage_adapter();
725
796
  function createStateAdapters(options = {}) {
726
797
  if (options.storage && options.sync) {
727
798
  return {
@@ -742,7 +813,7 @@ function createStateAdapters(options = {}) {
742
813
  return { storage, sync };
743
814
  }
744
815
  function createChromeAdapters() {
745
- const { ChromeStorageAdapter: ChromeStorageAdapter3 } = __toCommonJS(exports_storage_adapter);
816
+ const { ChromeStorageAdapter: ChromeStorageAdapter3 } = (init_storage_adapter(), __toCommonJS(exports_storage_adapter));
746
817
  const { ChromeRuntimeSyncAdapter: ChromeRuntimeSyncAdapter2 } = __toCommonJS(exports_sync_adapter);
747
818
  return {
748
819
  storage: new ChromeStorageAdapter3,
@@ -750,14 +821,14 @@ function createChromeAdapters() {
750
821
  };
751
822
  }
752
823
  function createWebAdapters(options = {}) {
753
- const { IndexedDBAdapter: IndexedDBAdapter2 } = __toCommonJS(exports_storage_adapter);
824
+ const { IndexedDBAdapter: IndexedDBAdapter2 } = (init_storage_adapter(), __toCommonJS(exports_storage_adapter));
754
825
  const { BroadcastChannelSyncAdapter: BroadcastChannelSyncAdapter2, NoOpSyncAdapter: NoOpSyncAdapter2 } = __toCommonJS(exports_sync_adapter);
755
826
  const storage = new IndexedDBAdapter2(options.dbName);
756
827
  const sync = options.singleTab ? new NoOpSyncAdapter2 : new BroadcastChannelSyncAdapter2(options.channelName);
757
828
  return { storage, sync };
758
829
  }
759
830
  function createNodeAdapters() {
760
- const { MemoryStorageAdapter: MemoryStorageAdapter2 } = __toCommonJS(exports_storage_adapter);
831
+ const { MemoryStorageAdapter: MemoryStorageAdapter2 } = (init_storage_adapter(), __toCommonJS(exports_storage_adapter));
761
832
  const { NoOpSyncAdapter: NoOpSyncAdapter2 } = __toCommonJS(exports_sync_adapter);
762
833
  return {
763
834
  storage: new MemoryStorageAdapter2,
@@ -765,7 +836,7 @@ function createNodeAdapters() {
765
836
  };
766
837
  }
767
838
  function createMockAdapters() {
768
- const { MemoryStorageAdapter: MemoryStorageAdapter2 } = __toCommonJS(exports_storage_adapter);
839
+ const { MemoryStorageAdapter: MemoryStorageAdapter2 } = (init_storage_adapter(), __toCommonJS(exports_storage_adapter));
769
840
  const { NoOpSyncAdapter: NoOpSyncAdapter2 } = __toCommonJS(exports_sync_adapter);
770
841
  return {
771
842
  storage: new MemoryStorageAdapter2,
@@ -774,6 +845,7 @@ function createMockAdapters() {
774
845
  }
775
846
 
776
847
  // src/shared/adapters/index.ts
848
+ init_storage_adapter();
777
849
  function createChromeAdapters2(context, options) {
778
850
  const runtime2 = new ChromeRuntimeAdapter;
779
851
  return {
@@ -1563,4 +1635,4 @@ export {
1563
1635
  MessageBus
1564
1636
  };
1565
1637
 
1566
- //# debugId=93688ED3AE3A9AC864756E2164756E21
1638
+ //# debugId=3223EA30A6C6201E64756E2164756E21