@pylonsync/sync 0.22.25 → 0.22.27

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.
@@ -35,7 +35,36 @@ export class LocalStore {
35
35
  * `optimistic_delete_releases_id_when_server_confirms`.
36
36
  */
37
37
  private optimisticTombstones: Map<string, Set<string>> = new Map();
38
- private listeners: Set<() => void> = new Set();
38
+ private listeners = new Map<(entity?: string) => void, string | undefined>();
39
+ private versions = new Map<string, number>();
40
+ private rowVersions = new Map<string, Map<string, number>>();
41
+ private revision = 0;
42
+ private resetVersion = 0;
43
+
44
+ /** Changes only when this entity changes or the replica is cleared. */
45
+ version(entity: string): number {
46
+ return this.versions.get(entity) ?? this.resetVersion;
47
+ }
48
+
49
+ /** Changes only when this row changes or the replica is cleared. */
50
+ rowVersion(entity: string, id: string): number {
51
+ return this.rowVersions.get(entity)?.get(id) ?? this.resetVersion;
52
+ }
53
+
54
+ private touch(entity: string, id: string): void {
55
+ const revision = ++this.revision;
56
+ this.versions.set(entity, revision);
57
+ if (this.tables.get(entity)?.has(id)) {
58
+ let versions = this.rowVersions.get(entity);
59
+ if (!versions) {
60
+ versions = new Map();
61
+ this.rowVersions.set(entity, versions);
62
+ }
63
+ versions.set(id, revision);
64
+ } else {
65
+ this.rowVersions.get(entity)?.delete(id);
66
+ }
67
+ }
39
68
 
40
69
  /** Get all rows for an entity. */
41
70
  list(entity: string): Row[] {
@@ -44,6 +73,11 @@ export class LocalStore {
44
73
  return Array.from(table.values());
45
74
  }
46
75
 
76
+ /** Iterate current rows without copying the table. Consume synchronously. */
77
+ rows(entity: string): IterableIterator<Row> {
78
+ return this.tables.get(entity)?.values() ?? [][Symbol.iterator]();
79
+ }
80
+
47
81
  /** Get a row by ID. */
48
82
  get(entity: string, id: string): Row | null {
49
83
  return this.tables.get(entity)?.get(id) ?? null;
@@ -84,6 +118,7 @@ export class LocalStore {
84
118
  const table = this.tables.get(entity);
85
119
  if (!table || !table.has(id)) return false;
86
120
  table.delete(id);
121
+ this.touch(entity, id);
87
122
  this.recordTombstone(entity, id, tombstoneSeq);
88
123
  return true;
89
124
  }
@@ -107,6 +142,7 @@ export class LocalStore {
107
142
  */
108
143
  revokeRow(entity: string, id: string, tombstoneSeq: number): boolean {
109
144
  const removed = this.tables.get(entity)?.delete(id) ?? false;
145
+ if (removed) this.touch(entity, id);
110
146
  this.recordTombstone(entity, id, tombstoneSeq);
111
147
  return removed;
112
148
  }
@@ -180,6 +216,7 @@ export class LocalStore {
180
216
  this.recordTombstone(change.entity, change.row_id, change.seq);
181
217
  break;
182
218
  }
219
+ this.touch(change.entity, change.row_id);
183
220
  }
184
221
 
185
222
  /** Apply multiple changes synchronously. Persistence runs fire-
@@ -190,7 +227,7 @@ export class LocalStore {
190
227
  for (const change of changes) {
191
228
  this.applyChange(change);
192
229
  }
193
- this.notify();
230
+ this.notifyEntities(new Set(changes.map((change) => change.entity)));
194
231
 
195
232
  if (this._persistFn) {
196
233
  for (const change of changes) {
@@ -210,7 +247,7 @@ export class LocalStore {
210
247
  for (const change of changes) {
211
248
  this.applyChange(change);
212
249
  }
213
- this.notify();
250
+ this.notifyEntities(new Set(changes.map((change) => change.entity)));
214
251
  return changes.map((c) => this.hydrateFromMemory(c));
215
252
  }
216
253
 
@@ -233,7 +270,7 @@ export class LocalStore {
233
270
  for (const change of changes) {
234
271
  this.applyChange(change);
235
272
  }
236
- this.notify();
273
+ this.notifyEntities(new Set(changes.map((change) => change.entity)));
237
274
  let allDurable = true;
238
275
  if (this._persistFn) {
239
276
  // Sequential await — concurrent IDB writes can resolve out of
@@ -272,15 +309,25 @@ export class LocalStore {
272
309
  * (treated as durable / fire-and-forget). */
273
310
  _persistFn: ((change: ChangeEvent) => void | Promise<boolean>) | null = null;
274
311
 
275
- /** Subscribe to store changes. Returns unsubscribe function. */
276
- subscribe(listener: () => void): () => void {
277
- this.listeners.add(listener);
278
- return () => this.listeners.delete(listener);
312
+ /** Subscribe globally or to one entity. Global status/reset signals reach both. */
313
+ subscribe(listener: (entity?: string) => void, entity?: string): () => void {
314
+ this.listeners.set(listener, entity);
315
+ return () => {
316
+ this.listeners.delete(listener);
317
+ };
318
+ }
319
+
320
+ /** No entity means a global status or reset signal. */
321
+ notify(entity?: string): void {
322
+ this.notifyEntities(entity === undefined ? undefined : new Set([entity]));
279
323
  }
280
324
 
281
- notify(): void {
282
- for (const listener of this.listeners) {
283
- listener();
325
+ private notifyEntities(entities?: ReadonlySet<string>): void {
326
+ if (entities?.size === 0) return;
327
+ const changedEntity =
328
+ entities?.size === 1 ? entities.values().next().value : undefined;
329
+ for (const [listener, entity] of this.listeners) {
330
+ if (!entities || entity === undefined || entities.has(entity)) listener(changedEntity);
284
331
  }
285
332
  }
286
333
 
@@ -291,7 +338,8 @@ export class LocalStore {
291
338
  this.tables.set(entity, new Map());
292
339
  }
293
340
  this.tables.get(entity)!.set(tempId, { id: tempId, ...data });
294
- this.notify();
341
+ this.touch(entity, tempId);
342
+ this.notify(entity);
295
343
  return tempId;
296
344
  }
297
345
 
@@ -312,7 +360,8 @@ export class LocalStore {
312
360
  this.tables.set(entity, new Map());
313
361
  }
314
362
  this.tables.get(entity)!.set(id, { ...data, id });
315
- this.notify();
363
+ this.touch(entity, id);
364
+ this.notify(entity);
316
365
  }
317
366
 
318
367
  /**
@@ -327,7 +376,10 @@ export class LocalStore {
327
376
  */
328
377
  rollbackOptimisticInsert(entity: string, id: string): void {
329
378
  const removed = this.tables.get(entity)?.delete(id);
330
- if (removed) this.notify();
379
+ if (removed) {
380
+ this.touch(entity, id);
381
+ this.notify(entity);
382
+ }
331
383
  }
332
384
 
333
385
  /** Apply an optimistic update. */
@@ -337,7 +389,8 @@ export class LocalStore {
337
389
  const existing = table.get(id);
338
390
  if (existing) {
339
391
  table.set(id, { ...existing, ...data });
340
- this.notify();
392
+ this.touch(entity, id);
393
+ this.notify(entity);
341
394
  }
342
395
  }
343
396
 
@@ -369,7 +422,8 @@ export class LocalStore {
369
422
  // Server authoritatively removed this row mid-flight — its
370
423
  // deletion outranks our local rollback.
371
424
  this.tables.get(entity)?.delete(id);
372
- this.notify();
425
+ this.touch(entity, id);
426
+ this.notify(entity);
373
427
  return;
374
428
  }
375
429
  if (prev) {
@@ -378,7 +432,8 @@ export class LocalStore {
378
432
  } else {
379
433
  this.tables.get(entity)?.delete(id);
380
434
  }
381
- this.notify();
435
+ this.touch(entity, id);
436
+ this.notify(entity);
382
437
  }
383
438
 
384
439
  /** Apply an optimistic delete. Block any incoming insert/update
@@ -389,7 +444,8 @@ export class LocalStore {
389
444
  this.optimisticTombstones.set(entity, new Set());
390
445
  }
391
446
  this.optimisticTombstones.get(entity)!.add(id);
392
- this.notify();
447
+ this.touch(entity, id);
448
+ this.notify(entity);
393
449
  }
394
450
 
395
451
  /**
@@ -409,12 +465,12 @@ export class LocalStore {
409
465
  * the engine's cursor stays pinned to the change-log position the
410
466
  * reconcile started from.
411
467
  */
412
- async applyReconcileBatch(
468
+ applyReconcileInMemory(
413
469
  entity: string,
414
470
  upserts: Row[],
415
471
  removalIds: string[],
416
472
  tombstoneSeq: number,
417
- ): Promise<void> {
473
+ ): ChangeEvent[] {
418
474
  if (!this.tables.has(entity)) this.tables.set(entity, new Map());
419
475
  const table = this.tables.get(entity)!;
420
476
  const applied: Array<{ id: string; row: Row }> = [];
@@ -427,6 +483,7 @@ export class LocalStore {
427
483
  if (this.isTombstoned(entity, id, tombstoneSeq + 1)) continue;
428
484
  const merged = { ...row, id };
429
485
  table.set(id, merged);
486
+ this.touch(entity, id);
430
487
  applied.push({ id, row: merged });
431
488
  }
432
489
  const removed: string[] = [];
@@ -435,43 +492,51 @@ export class LocalStore {
435
492
  removed.push(id);
436
493
  }
437
494
  }
438
- if (applied.length > 0 || removed.length > 0) this.notify();
439
- // Persist sequentially so disk order matches memory order — same
440
- // discipline as applyChangesAsync.
495
+ if (applied.length > 0 || removed.length > 0) this.notify(entity);
496
+ return [
497
+ ...applied.map(({ id, row }): ChangeEvent => ({
498
+ seq: tombstoneSeq + 1,
499
+ entity,
500
+ row_id: id,
501
+ kind: "insert",
502
+ data: row,
503
+ timestamp: "",
504
+ })),
505
+ ...removed.map((id): ChangeEvent => ({
506
+ seq: tombstoneSeq,
507
+ entity,
508
+ row_id: id,
509
+ kind: "delete",
510
+ timestamp: "",
511
+ })),
512
+ ];
513
+ }
514
+
515
+ /** Apply and persist with the single-row fallback for standalone stores. */
516
+ async applyReconcileBatch(
517
+ entity: string,
518
+ upserts: Row[],
519
+ removalIds: string[],
520
+ tombstoneSeq: number,
521
+ ): Promise<void> {
522
+ const changes = this.applyReconcileInMemory(
523
+ entity, upserts, removalIds, tombstoneSeq,
524
+ );
441
525
  if (this._persistFn) {
442
- for (const { id, row } of applied) {
443
- const ev: ChangeEvent = {
444
- seq: tombstoneSeq + 1,
445
- entity,
446
- row_id: id,
447
- kind: "insert",
448
- data: row,
449
- timestamp: "",
450
- };
451
- const result = this._persistFn(ev);
452
- if (result instanceof Promise) await result;
453
- }
454
- for (const id of removed) {
455
- const ev: ChangeEvent = {
456
- seq: tombstoneSeq,
457
- entity,
458
- row_id: id,
459
- kind: "delete",
460
- data: undefined as unknown as Row,
461
- timestamp: "",
462
- };
463
- const result = this._persistFn(ev);
464
- if (result instanceof Promise) await result;
465
- }
526
+ for (const change of changes) await this._persistFn(change);
466
527
  }
467
528
  }
468
529
 
530
+
469
531
  /**
470
532
  * Drop every table + tombstone in-place, then notify. Used by the
471
533
  * sync engine's `resetReplica()` on identity flip (token or tenant
472
534
  * changed — the old replica reflects a different visible set).
473
535
  */
474
536
  clearAll(): void {
537
+ this.resetVersion = ++this.revision;
538
+ this.versions.clear();
539
+ this.rowVersions.clear();
475
540
  this.tables.clear();
476
541
  this.tombstones.clear();
477
542
  this.optimisticTombstones.clear();
@@ -92,9 +92,13 @@ describe("mutation owner and the bearer token", () => {
92
92
  }, 10_000);
93
93
 
94
94
  test("a session that expired across a reload keeps the user's queued writes", async () => {
95
+ let pushCalls = 0;
95
96
  globalThis.fetch = (async (input: RequestInfo | URL) => {
96
97
  const url = String(input);
97
- if (url.includes("/api/sync/push")) throw new Error("must not push");
98
+ if (url.includes("/api/sync/push")) {
99
+ pushCalls += 1;
100
+ throw new Error("must not push");
101
+ }
98
102
  const body = url.includes("/api/auth/me")
99
103
  ? { user_id: null }
100
104
  : { changes: [], cursor: { last_seq: 0 }, has_more: false };
@@ -124,6 +128,7 @@ describe("mutation owner and the bearer token", () => {
124
128
  });
125
129
  await engine.start();
126
130
  await engine.push();
131
+ expect(pushCalls).toBe(0);
127
132
  expect(engine.pendingCount()).toBe(1);
128
133
  engine.stop();
129
134
  reopened.connection?.close();
@@ -112,9 +112,13 @@ describe("mutation owner", () => {
112
112
  }, 10_000);
113
113
 
114
114
  test("after a reload while signed out, the previous user's queued writes are not pushed", async () => {
115
+ let pushCalls = 0;
115
116
  globalThis.fetch = (async (input: RequestInfo | URL) => {
116
117
  const url = String(input);
117
- if (url.includes("/api/sync/push")) throw new Error("must not push");
118
+ if (url.includes("/api/sync/push")) {
119
+ pushCalls += 1;
120
+ throw new Error("must not push");
121
+ }
118
122
  const body = url.includes("/api/sync/pull")
119
123
  ? { changes: [], cursor: { last_seq: 0 }, has_more: false }
120
124
  : url.includes("/api/auth/me")
@@ -150,6 +154,7 @@ describe("mutation owner", () => {
150
154
  });
151
155
  await engine.start();
152
156
  await engine.push();
157
+ expect(pushCalls).toBe(0);
153
158
  expect(engine.pendingCount()).toBe(1);
154
159
  expect(engine.mutations.pending()[0]!.owner).toBe("u1");
155
160
  engine.stop();
@@ -73,12 +73,14 @@ export interface PendingMutation {
73
73
 
74
74
  /**
75
75
  * Optional persistence backend. The default IndexedDB persistence
76
- * layer provides `savePending`/`loadPending`. Callers can supply a
77
- * custom backend for tests or alternative storage.
76
+ * layer supports incremental writes with `saveChanges`. Custom backends
77
+ * can keep the full-snapshot `saveAll` contract.
78
78
  */
79
79
  export interface MutationQueuePersistence {
80
80
  saveAll(mutations: PendingMutation[]): Promise<void>;
81
81
  loadAll(): Promise<PendingMutation[]>;
82
+ /** Optional ordered, atomic delta write. Unchanged entries stay on disk. */
83
+ saveChanges?(upserts: PendingMutation[], removedIds: string[]): Promise<void>;
82
84
  }
83
85
 
84
86
  interface OutcomeWaiter {
@@ -87,7 +89,7 @@ interface OutcomeWaiter {
87
89
  }
88
90
 
89
91
  export class MutationQueue {
90
- private queue: PendingMutation[] = [];
92
+ private queue = new Map<string, PendingMutation>();
91
93
  private persistence?: MutationQueuePersistence;
92
94
  /** Callers waiting for a mutation's outcome, by op id. Memory-only:
93
95
  * a promise cannot outlive the page that created it. */
@@ -116,11 +118,10 @@ export class MutationQueue {
116
118
  // hydrate was awaiting `loadAll()` will already have flushed a
117
119
  // snapshot that didn't include the loaded rows — re-flush
118
120
  // after merge so disk matches memory again.
119
- const existingIds = new Set(this.queue.map((m) => m.id));
120
121
  let mergedAny = false;
121
122
  for (const m of loaded) {
122
- if (!existingIds.has(m.id)) {
123
- this.queue.push(m);
123
+ if (!this.queue.has(m.id)) {
124
+ this.queue.set(m.id, m);
124
125
  mergedAny = true;
125
126
  }
126
127
  }
@@ -150,25 +151,29 @@ export class MutationQueue {
150
151
  typeof change.op_id === "string" && change.op_id.length > 0
151
152
  ? change.op_id
152
153
  : `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
153
- if (this.queue.some((m) => m.id === id)) return id;
154
+ if (this.queue.has(id)) return id;
154
155
  const changeWithOp: ClientChange = { ...change, op_id: id };
155
156
  const entry: PendingMutation = { id, change: changeWithOp, status: "pending", prevRow };
156
157
  if (owner !== undefined) entry.owner = owner;
157
158
  if (ownerPending) entry.ownerPending = true;
158
- this.queue.push(entry);
159
- this.flush();
159
+ this.queue.set(id, entry);
160
+ this.flush([entry]);
160
161
  return id;
161
162
  }
162
163
 
163
164
  pending(): PendingMutation[] {
164
- return this.queue.filter((m) => m.status === "pending");
165
+ const pending: PendingMutation[] = [];
166
+ for (const mutation of this.queue.values()) {
167
+ if (mutation.status === "pending") pending.push(mutation);
168
+ }
169
+ return pending;
165
170
  }
166
171
 
167
172
  /** Look up a queued mutation by op id (any status). Used by the
168
173
  * follower's mutations-failed handler to reach the captured
169
174
  * `prevRow` for rollback. */
170
175
  get(id: string): PendingMutation | undefined {
171
- return this.queue.find((m) => m.id === id);
176
+ return this.queue.get(id);
172
177
  }
173
178
 
174
179
  /**
@@ -186,7 +191,7 @@ export class MutationQueue {
186
191
  */
187
192
  pendingRowKeys(): Set<string> {
188
193
  const out = new Set<string>();
189
- for (const m of this.queue) {
194
+ for (const m of this.queue.values()) {
190
195
  if (m.status === "pending" || m.status === "failed") {
191
196
  out.add(`${m.change.entity}/${m.change.row_id}`);
192
197
  }
@@ -219,20 +224,31 @@ export class MutationQueue {
219
224
  }
220
225
 
221
226
  markApplied(id: string): void {
222
- const m = this.queue.find((m) => m.id === id);
223
- if (m) m.status = "applied";
224
- this.flush();
225
- this.resolveWaiters(id);
227
+ this.markAppliedMany([id]);
228
+ }
229
+
230
+ /** Persist one response batch once, then settle each caller. */
231
+ markAppliedMany(ids: string[]): void {
232
+ const changed: PendingMutation[] = [];
233
+ for (const id of new Set(ids)) {
234
+ const m = this.queue.get(id);
235
+ if (m) {
236
+ m.status = "applied";
237
+ changed.push(m);
238
+ }
239
+ }
240
+ if (changed.length > 0) this.flush(changed);
241
+ for (const id of ids) this.resolveWaiters(id);
226
242
  }
227
243
 
228
244
  markFailed(id: string, error: string, errorCode?: string): void {
229
- const m = this.queue.find((m) => m.id === id);
245
+ const m = this.queue.get(id);
230
246
  if (m) {
231
247
  m.status = "failed";
232
248
  m.error = error;
233
249
  if (errorCode) m.errorCode = errorCode;
234
250
  }
235
- this.flush();
251
+ if (m) this.flush([m]);
236
252
  const waiters = this.waiters.get(id);
237
253
  if (!waiters) return;
238
254
  this.waiters.delete(id);
@@ -260,24 +276,28 @@ export class MutationQueue {
260
276
  * surface them to the user and so retries are possible.
261
277
  */
262
278
  clear(): void {
263
- this.queue = this.queue.filter(
264
- (m) => m.status === "pending" || m.status === "failed",
265
- );
266
- this.flush();
279
+ const removedIds: string[] = [];
280
+ for (const [id, m] of this.queue) {
281
+ if (m.status === "applied") {
282
+ this.queue.delete(id);
283
+ removedIds.push(id);
284
+ }
285
+ }
286
+ if (removedIds.length > 0) this.flush([], removedIds);
267
287
  }
268
288
 
269
289
  /** Stamp every write whose owner was pending with the user the first
270
290
  * resolved session names. */
271
291
  stampPendingOwner(owner: string | null): void {
272
- let changed = false;
273
- for (const m of this.queue) {
292
+ const changed: PendingMutation[] = [];
293
+ for (const m of this.queue.values()) {
274
294
  if (m.ownerPending) {
275
295
  m.owner = owner;
276
296
  delete m.ownerPending;
277
- changed = true;
297
+ changed.push(m);
278
298
  }
279
299
  }
280
- if (changed) this.flush();
300
+ if (changed.length > 0) this.flush(changed);
281
301
  }
282
302
 
283
303
  /** Drop one mutation that must never be pushed (it belongs to another
@@ -285,8 +305,8 @@ export class MutationQueue {
285
305
  * touch the local store. */
286
306
  discard(id: string): void {
287
307
  const m = this.get(id);
288
- this.queue = this.queue.filter((q) => q.id !== id);
289
- this.flush();
308
+ this.queue.delete(id);
309
+ this.flush([], [id]);
290
310
  const waiters = this.waiters.get(id);
291
311
  if (!waiters) return;
292
312
  this.waiters.delete(id);
@@ -306,8 +326,8 @@ export class MutationQueue {
306
326
  /** Remove a specific mutation by id. Used by the UI after user
307
327
  * ack of failures. */
308
328
  remove(id: string): void {
309
- this.queue = this.queue.filter((m) => m.id !== id);
310
- this.flush();
329
+ this.queue.delete(id);
330
+ this.flush([], [id]);
311
331
  }
312
332
 
313
333
  /**
@@ -323,11 +343,11 @@ export class MutationQueue {
323
343
  */
324
344
  clearAll(): void {
325
345
  const dropped = this.queue;
326
- this.queue = [];
346
+ this.queue = new Map();
327
347
  this.flush();
328
348
  // Nobody will push these writes any more. Reject their waiters so a
329
349
  // caller awaiting the outcome does not hang.
330
- for (const m of dropped) {
350
+ for (const m of dropped.values()) {
331
351
  const waiters = this.waiters.get(m.id);
332
352
  if (!waiters) continue;
333
353
  this.waiters.delete(m.id);
@@ -346,10 +366,13 @@ export class MutationQueue {
346
366
  }
347
367
 
348
368
  /** Fire-and-forget persistence write. */
349
- private flush(): void {
369
+ private flush(upserts?: PendingMutation[], removedIds: string[] = []): void {
350
370
  if (!this.persistence) return;
351
- const snapshot = this.queue.slice();
352
- this.persistence.saveAll(snapshot).catch((err) => {
371
+ // Copy mutable status/owner fields before handing data to an async backend.
372
+ const write = upserts && this.persistence.saveChanges
373
+ ? this.persistence.saveChanges(upserts.map((m) => ({ ...m })), removedIds)
374
+ : this.persistence.saveAll(Array.from(this.queue.values(), (m) => ({ ...m })));
375
+ write.catch((err) => {
353
376
  console.warn("[sync] mutation-queue persist failed:", err);
354
377
  });
355
378
  }
@@ -417,10 +417,8 @@ export async function persistChange(
417
417
  * `MutationQueue` into the same database as the entity mirror so everything
418
418
  * the app needs to resume a session lives in one place.
419
419
  *
420
- * `saveAll` writes the entire queue on every change. That's O(n) per write,
421
- * but `n` is bounded by "how many mutations the user queued while offline",
422
- * which is tiny in practice. If that ever becomes a bottleneck, switch to
423
- * per-id `put`/`delete` — the schema (`keyPath: "id"`) already supports it.
420
+ * Incremental writes update only changed IDs. Full snapshots support initial
421
+ * hydration and identity reset, and remain available to custom backends.
424
422
  */
425
423
  export class IndexedDBMutationPersistence implements MutationQueuePersistence {
426
424
  private db: IDBDatabase | null = null;
@@ -449,6 +447,20 @@ export class IndexedDBMutationPersistence implements MutationQueuePersistence {
449
447
  });
450
448
  }
451
449
 
450
+ async saveChanges(upserts: PendingMutation[], removedIds: string[]): Promise<void> {
451
+ const db = this.handle();
452
+ if (!db) return;
453
+ const tx = db.transaction(MUTATIONS_STORE, "readwrite");
454
+ const store = tx.objectStore(MUTATIONS_STORE);
455
+ for (const mutation of upserts) store.put(mutation);
456
+ for (const id of removedIds) store.delete(id);
457
+ return new Promise((resolve, reject) => {
458
+ tx.oncomplete = () => resolve();
459
+ tx.onerror = () => reject(tx.error ?? new Error("mutation queue update failed"));
460
+ tx.onabort = () => reject(tx.error ?? new Error("mutation queue update aborted"));
461
+ });
462
+ }
463
+
452
464
  async loadAll(): Promise<PendingMutation[]> {
453
465
  const db = this.handle();
454
466
  if (!db) return [];