@pylonsync/sync 0.22.47 → 0.22.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -16,8 +16,25 @@ import {
16
16
  type TransportConfig,
17
17
  } from "./transport";
18
18
  import { LocalStore } from "./local-store";
19
- import { MutationQueue, type PendingMutation } from "./mutation-queue";
19
+ import {
20
+ byCreatedAt,
21
+ MutationQueue,
22
+ MutationRejectedError,
23
+ toOutboxItem,
24
+ type Outbox,
25
+ type OutboxItem,
26
+ type PendingMutation,
27
+ } from "./mutation-queue";
20
28
  import { MultiTabOrchestrator } from "./multi-tab-orchestrator";
29
+ import {
30
+ IndexedDBUploadStore,
31
+ UploadManager,
32
+ uploadOutboxItem,
33
+ type QueuedUpload,
34
+ type QueuedUploadHandle,
35
+ type UploadAttachTarget,
36
+ } from "./upload-queue";
37
+ import type { FileVisibility } from "./upload";
21
38
  import { OpQueue } from "./op-queue";
22
39
  import {
23
40
  RoomSubscriptions,
@@ -64,9 +81,34 @@ export {
64
81
  MutationQueue,
65
82
  MutationRejectedError,
66
83
  type MutationQueuePersistence,
84
+ type Outbox,
85
+ type OutboxItem,
67
86
  type PendingMutation,
68
87
  } from "./mutation-queue";
88
+
89
+ /** The store channel the outbox notifies on. `store.subscribe(cb,
90
+ * OUTBOX_CHANNEL)` hears outbox changes and no entity changes. */
91
+ export const OUTBOX_CHANNEL = "$outbox";
69
92
  export { generateId } from "./ids";
93
+ export {
94
+ uploadBytes,
95
+ type FileVisibility,
96
+ type UploadBytesOptions,
97
+ type UploadedFile,
98
+ } from "./upload";
99
+ export {
100
+ DEFAULT_MAX_QUEUED_UPLOAD_BYTES,
101
+ IndexedDBUploadStore,
102
+ LOCAL_FILE_PREFIX,
103
+ localUploadId,
104
+ MemoryUploadStore,
105
+ UploadQueueFullError,
106
+ UploadRejectedError,
107
+ type QueuedUpload,
108
+ type QueuedUploadHandle,
109
+ type UploadAttachTarget,
110
+ type UploadStore,
111
+ } from "./upload-queue";
70
112
  export type {
71
113
  ChangeEvent,
72
114
  ClientChange,
@@ -204,6 +246,9 @@ export interface SyncEngineConfig {
204
246
  * its own leader (the pre-multi-tab semantics).
205
247
  */
206
248
  multiTab?: boolean;
249
+ /** Cap on the bytes `queueUpload` keeps on the device. Default 200 MB.
250
+ * Past it `queueUpload` throws `UploadQueueFullError`. */
251
+ maxQueuedUploadBytes?: number;
207
252
  }
208
253
 
209
254
  /**
@@ -383,6 +428,8 @@ export class SyncEngine {
383
428
 
384
429
  readonly store: LocalStore;
385
430
  readonly mutations: MutationQueue;
431
+ /** Files queued with `queueUpload`, sent when there is a network. */
432
+ readonly uploads: UploadManager;
386
433
 
387
434
  /**
388
435
  * Stable per-client identifier. Minted on first construction, not
@@ -604,6 +651,168 @@ export class SyncEngine {
604
651
  return this.mutations.pending().length;
605
652
  }
606
653
 
654
+ private outboxCache: { version: number; uploadVersion: number; value: Outbox } | null =
655
+ null;
656
+
657
+ /**
658
+ * Writes made here that the server has not applied yet (`pending`:
659
+ * offline, or waiting for a sign-in), and writes the server rejected
660
+ * (`failed`, with its error). Oldest first. The same object is
661
+ * returned until the queue changes, so it can back
662
+ * `useSyncExternalStore`. Changes notify store subscribers on
663
+ * `OUTBOX_CHANNEL`.
664
+ */
665
+ outbox(): Outbox {
666
+ const version = this.mutations.version();
667
+ const uploadVersion = this.uploads.version();
668
+ if (
669
+ this.outboxCache &&
670
+ this.outboxCache.version === version &&
671
+ this.outboxCache.uploadVersion === uploadVersion
672
+ ) {
673
+ return this.outboxCache.value;
674
+ }
675
+ const uploads = this.uploads.list();
676
+ const byTime = (a: OutboxItem, b: OutboxItem) => a.createdAt - b.createdAt;
677
+ const value: Outbox = {
678
+ pending: [
679
+ ...byCreatedAt(this.mutations.pending().slice()).map(toOutboxItem),
680
+ ...uploads.filter((u) => u.status === "pending").map(uploadOutboxItem),
681
+ ].sort(byTime),
682
+ failed: [
683
+ ...this.mutations.failed().map(toOutboxItem),
684
+ ...uploads.filter((u) => u.status === "failed").map(uploadOutboxItem),
685
+ ].sort(byTime),
686
+ };
687
+ this.outboxCache = { version, uploadVersion, value };
688
+ return value;
689
+ }
690
+
691
+ /**
692
+ * Send a failed write again, with `data` (when given) merged over the
693
+ * fields it set. The write is applied locally again at once (the
694
+ * rejection rolled it back) and queued under a new op id. Settles like
695
+ * `insert`: resolves with the new op id once the server applies it or
696
+ * it is queued offline, and rejects with a `MutationRejectedError` when
697
+ * the server rejects it again (it is then failed again, with the new
698
+ * error).
699
+ *
700
+ * A write is only ever sent as the user who made it. Throws
701
+ * `MutationRejectedError` with code `OWNER_MISMATCH` when someone else
702
+ * is signed in, and `OWNER_UNKNOWN` when it is not yet known who is
703
+ * signed in (or the write predates owner tracking). Dismiss those.
704
+ */
705
+ async retryWrite(id: string, data?: Row): Promise<string> {
706
+ if (this.uploads.get(id)) {
707
+ // An upload is sent as it was queued; `data` does not apply.
708
+ await this.uploads.retry(id);
709
+ return id;
710
+ }
711
+ const m = this.mutations.get(id);
712
+ if (!m || m.status !== "failed") {
713
+ throw new Error(`retryWrite: no failed write with id ${id}`);
714
+ }
715
+ const reject = (code: string, message: string) =>
716
+ new MutationRejectedError(message, {
717
+ code,
718
+ opId: id,
719
+ entity: m.change.entity,
720
+ rowId: m.change.row_id,
721
+ kind: m.change.kind,
722
+ });
723
+ const current = this.currentOwner();
724
+ if (current === undefined || m.owner === undefined || m.ownerPending) {
725
+ throw reject(
726
+ "OWNER_UNKNOWN",
727
+ "This write cannot be sent again: it is not known who made it or who is signed in.",
728
+ );
729
+ }
730
+ if (m.owner !== current) {
731
+ throw reject(
732
+ "OWNER_MISMATCH",
733
+ "This write was made by another user and cannot be sent as the signed-in user.",
734
+ );
735
+ }
736
+ const { entity, row_id: rowId, kind } = m.change;
737
+ const before = this.store.get(entity, rowId);
738
+ const prev = before ? { ...before } : null;
739
+ const newId = this.mutations.retry(id, data, kind === "insert" ? undefined : prev);
740
+ const change = this.mutations.get(newId)!.change;
741
+ if (kind === "insert") {
742
+ this.store.optimisticInsertWithId(entity, rowId, change.data ?? { id: rowId });
743
+ } else if (kind === "update") {
744
+ this.store.optimisticUpdate(entity, rowId, change.data ?? {});
745
+ } else {
746
+ this.store.optimisticDelete(entity, rowId);
747
+ }
748
+ this.broadcastToTabs({ type: "mutations-removed", opIds: [id] });
749
+ await this.sendAndAwait(newId);
750
+ return newId;
751
+ }
752
+
753
+ /** Drop a failed write after the user has seen it. Its local change
754
+ * was already rolled back. Returns false when there is no failed write
755
+ * with that id; a pending write cannot be dismissed. */
756
+ dismissWrite(id: string): boolean {
757
+ if (this.uploads.get(id)) return this.uploads.dismiss(id);
758
+ if (!this.mutations.dismiss(id)) return false;
759
+ this.broadcastToTabs({ type: "mutations-removed", opIds: [id] });
760
+ return true;
761
+ }
762
+
763
+ /**
764
+ * Keep a file on the device and upload it when there is a network, then
765
+ * set it on the record it belongs to. Returns at once.
766
+ *
767
+ * At once, `attach.entity`/`attach.id` gets `attach.field` (and
768
+ * `attach.urlField`, when given) = `local:<localId>` as a queued write,
769
+ * so the record shows the file from the device (`uploadLocalUrl`). When
770
+ * the upload finishes, a second queued write sets the file's id (and
771
+ * URL). Both are ordinary writes: policies and owner rules apply.
772
+ *
773
+ * The file is sent only as the user who queued it. Throws
774
+ * `UploadQueueFullError` past `maxQueuedUploadBytes`. Pending and failed
775
+ * uploads show in `outbox()` with kind `"upload"`; retry and dismiss them
776
+ * with `retryWrite` / `dismissWrite`.
777
+ */
778
+ queueUpload(
779
+ file: Blob,
780
+ opts: {
781
+ attach: UploadAttachTarget;
782
+ filename?: string;
783
+ contentType?: string;
784
+ visibility?: FileVisibility;
785
+ },
786
+ ): Promise<QueuedUploadHandle> {
787
+ return this.uploads.enqueue(file, opts);
788
+ }
789
+
790
+ /** An object URL for a queued file (`local:<localId>` without the
791
+ * prefix), or null when no queued upload has that id. */
792
+ uploadLocalUrl(localId: string): string | null {
793
+ return this.uploads.localUrl(localId);
794
+ }
795
+
796
+ /** Apply the queued writes to the replica again after a reload. An
797
+ * optimistic change lives only in memory, so without this a row
798
+ * written offline vanished from the screen on reload (it was still
799
+ * queued and still reached the server later). Oldest first, so an
800
+ * update of a row inserted offline finds the row. */
801
+ private reapplyPendingWrites(): void {
802
+ for (const m of byCreatedAt(this.mutations.pending().slice())) {
803
+ const { entity, row_id: rowId, kind, data } = m.change;
804
+ if (kind === "insert") {
805
+ if (!this.store.get(entity, rowId)) {
806
+ this.store.optimisticInsertWithId(entity, rowId, data ?? { id: rowId });
807
+ }
808
+ } else if (kind === "update") {
809
+ this.store.optimisticUpdate(entity, rowId, data ?? {});
810
+ } else {
811
+ this.store.optimisticDelete(entity, rowId);
812
+ }
813
+ }
814
+ }
815
+
607
816
  /**
608
817
  * Mutate connection status + notify subscribers. Idempotent — same-
609
818
  * status calls are a no-op so the WS onopen → connected transition
@@ -613,6 +822,8 @@ export class SyncEngine {
613
822
  if (this._connectionStatus === next) return;
614
823
  this._connectionStatus = next;
615
824
  this.store.notify();
825
+ // Back online: send the files queued while offline.
826
+ if (next === "connected") void this.uploads.process();
616
827
  }
617
828
 
618
829
  /** Sync key-value adapter for hot-path state (token, client_id). */
@@ -622,6 +833,30 @@ export class SyncEngine {
622
833
  this.config = config;
623
834
  this.store = new LocalStore();
624
835
  this.mutations = new MutationQueue();
836
+ this.mutations.setChangeListener(() => this.store.notify(OUTBOX_CHANNEL));
837
+ this.uploads = new UploadManager(
838
+ {
839
+ transport: () => ({
840
+ baseUrl: this.config.baseUrl,
841
+ getToken: () => this.currentToken() ?? undefined,
842
+ }),
843
+ currentOwner: () =>
844
+ this.identityPending || !this.session.hasObserved()
845
+ ? undefined
846
+ : this.session.resolved().userId,
847
+ ownerForNewUpload: () => this.currentOwner(),
848
+ isLeader: () => this.isMultiTabLeader,
849
+ isRunning: () => this.running,
850
+ update: (entity, id, patch) => {
851
+ // An ordinary queued write: its outcome shows in the outbox.
852
+ this.update(entity, id, patch).catch(() => {});
853
+ },
854
+ changed: () => this.store.notify(OUTBOX_CHANNEL),
855
+ broadcast: () => this.broadcastToTabs({ type: "uploads-changed" }),
856
+ later: (ms, fn) => this.later(ms, fn),
857
+ },
858
+ config.maxQueuedUploadBytes,
859
+ );
625
860
  this.storage = config.storage ?? defaultStorage();
626
861
  this.clientId = generateClientId(this.storage);
627
862
  // ServerSubscriptions defers sending until the WS is open (the
@@ -822,6 +1057,9 @@ export class SyncEngine {
822
1057
  const mqPersistence = new IndexedDBMutationPersistence(persistence);
823
1058
  this.mutations.attachPersistence(mqPersistence);
824
1059
  await this.mutations.hydrate();
1060
+ this.reapplyPendingWrites();
1061
+ // Queued files live in their own database (see upload-queue.ts).
1062
+ await this.uploads.attachStore(new IndexedDBUploadStore(this.config.appName));
825
1063
  }
826
1064
  // The hydrated offline writes are drained in the leader path
827
1065
  // below (after `initMultiTab` settles), NOT here. We're still
@@ -954,6 +1192,9 @@ export class SyncEngine {
954
1192
  // drops here on the normal fast path and the start() fallback is cancelled.
955
1193
  await this.pull();
956
1194
 
1195
+ // Files queued in an earlier run (or by a tab that closed).
1196
+ void this.uploads.process();
1197
+
957
1198
  // Save cursor after pull. Fire-and-forget on bootstrap — the
958
1199
  // enqueueApply path already persists per-batch as pull lands rows,
959
1200
  // so this final save is belt-and-braces. Awaiting it adds 5-30ms
@@ -1088,7 +1329,7 @@ export class SyncEngine {
1088
1329
  // DELETE the leader's still-valid row. The follower's prevRow
1089
1330
  // (its pre-edit value) equals the leader's canonical row, so
1090
1331
  // restoring it is correct on both tabs.
1091
- this.mutations.add(op.change, op.prevRow, op.owner, op.ownerPending);
1332
+ this.mutations.add(op.change, op.prevRow, op.owner, op.ownerPending, op.createdAt);
1092
1333
  }
1093
1334
  void this.pushNew(ops.map((op) => op.id));
1094
1335
  },
@@ -1101,6 +1342,19 @@ export class SyncEngine {
1101
1342
  // and the leader retries them. Release this tab's callers.
1102
1343
  for (const id of opIds) this.mutations.settleQueued(id);
1103
1344
  },
1345
+ onUploadsChanged: () => {
1346
+ void this.uploads.reload().then(() => {
1347
+ if (this.isMultiTabLeader) return this.uploads.process();
1348
+ });
1349
+ },
1350
+ onMutationsRemoved: (opIds: string[]) => {
1351
+ // Another tab retried (under a new op id) or dismissed these
1352
+ // failed writes. Drop this tab's copy so neither tab shows or
1353
+ // resends the old one.
1354
+ for (const id of opIds) {
1355
+ if (this.mutations.get(id)?.status === "failed") this.mutations.remove(id);
1356
+ }
1357
+ },
1104
1358
  onMutationsFailed: (
1105
1359
  ops: { opId: string; error: string; code?: string }[],
1106
1360
  ) => {
@@ -2974,6 +3228,15 @@ export class SyncEngine {
2974
3228
  if ((wasPending || userFlipped) && this.mutations.pending().length > 0) {
2975
3229
  void this.push();
2976
3230
  }
3231
+ // Uploads follow the same owner rule: stamp the ones made before
3232
+ // the session was known, then send (or, for another user's,
3233
+ // discard) what is queued.
3234
+ if (wasPending || userFlipped) {
3235
+ const stamp = firstResolution
3236
+ ? this.uploads.stampPendingOwner(next.userId)
3237
+ : Promise.resolve();
3238
+ void stamp.then(() => this.uploads.process());
3239
+ }
2977
3240
  }
2978
3241
  if (verdict.identityChanged || firstResolution) {
2979
3242
  // The first answer flips `sessionResolved()` even when the
@@ -36,6 +36,8 @@ function makeRig(opts: { isLeader?: boolean } = {}) {
36
36
  onMutationsAcked: record("mutationsAcked"),
37
37
  onMutationsFailed: record("mutationsFailed"),
38
38
  onMutationsQueued: record("mutationsQueued"),
39
+ onMutationsRemoved: record("mutationsRemoved"),
40
+ onUploadsChanged: record("uploadsChanged"),
39
41
  onSyncedReceived: record("synced"),
40
42
  onSyncStatusRequested: record("syncStatusRequested"),
41
43
  onBinaryReceived: record("binary"),
@@ -95,6 +95,10 @@ export interface MultiTabOrchestratorHooks {
95
95
  /** Leader → followers: the leader's push failed transiently and
96
96
  * these op ids stay queued for retry. */
97
97
  onMutationsQueued(opIds: string[]): void;
98
+ /** Another tab retried or dismissed these failed writes. */
99
+ onMutationsRemoved(opIds: string[]): void;
100
+ /** Another tab changed the upload queue in storage. */
101
+ onUploadsChanged(): void;
98
102
  /** Leader → follower: a binary frame from the WS. Engine routes
99
103
  * to its local binary handlers. */
100
104
  onBinaryReceived(bytes: Uint8Array): void;
@@ -404,6 +408,15 @@ export class MultiTabOrchestrator {
404
408
  if (Array.isArray(opIds)) this.hooks.onMutationsQueued(opIds);
405
409
  break;
406
410
  }
411
+ case "uploads-changed": {
412
+ this.hooks.onUploadsChanged();
413
+ break;
414
+ }
415
+ case "mutations-removed": {
416
+ const opIds = msg.opIds as string[] | undefined;
417
+ if (Array.isArray(opIds)) this.hooks.onMutationsRemoved(opIds);
418
+ break;
419
+ }
407
420
  case "sub-register": {
408
421
  // Follower → leader. Leader-only — no work to do on a follower
409
422
  // because it can't act on a peer's request.
@@ -55,6 +55,9 @@ export interface PendingMutation {
55
55
  error?: string;
56
56
  /** Server error code for a failed mutation, when the server sent one. */
57
57
  errorCode?: string;
58
+ /** When the write was made, in ms since the epoch. Missing on writes
59
+ * queued by an older client; those sort first. */
60
+ createdAt?: number;
58
61
  /** User id the write was made as (`null` = anonymous). Persisted with
59
62
  * the queue so a write is only ever pushed as the user who made it.
60
63
  * `undefined` = unknown (queued by an older client). */
@@ -71,6 +74,49 @@ export interface PendingMutation {
71
74
  prevRow?: Row | null;
72
75
  }
73
76
 
77
+ /** One write as the outbox shows it. */
78
+ export interface OutboxItem {
79
+ /** Op id. Pass it to `retryWrite` / `dismissWrite`. */
80
+ id: string;
81
+ entity: string;
82
+ rowId: string;
83
+ /** `"upload"` is a file from `queueUpload`; `entity`/`rowId` is the
84
+ * record it attaches to. */
85
+ kind: "insert" | "update" | "delete" | "upload";
86
+ /** The fields the write sets. Empty for a delete. For an upload:
87
+ * `field`, `urlField`, `filename`, `contentType`, `size`. */
88
+ data: Row;
89
+ /** When the write was made, in ms since the epoch (0 when unknown). */
90
+ createdAt: number;
91
+ /** The server's message, for a failed write. */
92
+ error?: string;
93
+ /** The server's error code (`POLICY_DENIED`, `VALIDATION_FAILED`, ...),
94
+ * for a failed write. */
95
+ errorCode?: string;
96
+ }
97
+
98
+ /** Writes not yet on the server, and writes the server rejected. */
99
+ export interface Outbox {
100
+ pending: OutboxItem[];
101
+ failed: OutboxItem[];
102
+ }
103
+
104
+ export function toOutboxItem(m: PendingMutation): OutboxItem {
105
+ const item: OutboxItem = {
106
+ id: m.id,
107
+ entity: m.change.entity,
108
+ rowId: m.change.row_id,
109
+ kind: m.change.kind,
110
+ data: { ...(m.change.data ?? {}) },
111
+ createdAt: m.createdAt ?? 0,
112
+ };
113
+ if (m.status === "failed") {
114
+ if (m.error !== undefined) item.error = m.error;
115
+ if (m.errorCode !== undefined) item.errorCode = m.errorCode;
116
+ }
117
+ return item;
118
+ }
119
+
74
120
  /**
75
121
  * Optional persistence backend. The default IndexedDB persistence
76
122
  * layer supports incremental writes with `saveChanges`. Custom backends
@@ -94,11 +140,32 @@ export class MutationQueue {
94
140
  /** Callers waiting for a mutation's outcome, by op id. Memory-only:
95
141
  * a promise cannot outlive the page that created it. */
96
142
  private waiters = new Map<string, OutcomeWaiter[]>();
143
+ /** Called after every change to what the queue holds or to a write's
144
+ * status. The engine turns it into a store notification so the outbox
145
+ * re-renders. */
146
+ private changeListener: (() => void) | null = null;
147
+ /** Bumped on every change; lets readers cache a snapshot. */
148
+ private _version = 0;
97
149
 
98
150
  constructor(persistence?: MutationQueuePersistence) {
99
151
  this.persistence = persistence;
100
152
  }
101
153
 
154
+ /** Set the one listener called after each queue change. */
155
+ setChangeListener(listener: (() => void) | null): void {
156
+ this.changeListener = listener;
157
+ }
158
+
159
+ /** Changes so far. Equal versions mean the same contents. */
160
+ version(): number {
161
+ return this._version;
162
+ }
163
+
164
+ private changed(): void {
165
+ this._version += 1;
166
+ this.changeListener?.();
167
+ }
168
+
102
169
  /**
103
170
  * Attach a persistence backend after construction. The SyncEngine
104
171
  * swaps in IndexedDB-backed persistence once the DB has opened.
@@ -125,7 +192,10 @@ export class MutationQueue {
125
192
  mergedAny = true;
126
193
  }
127
194
  }
128
- if (mergedAny) this.flush();
195
+ if (mergedAny) {
196
+ this.flush();
197
+ this.changed();
198
+ }
129
199
  } catch (err) {
130
200
  // Broken storage shouldn't prevent the app from running — warn
131
201
  // and degrade to memory-only mode.
@@ -146,18 +216,26 @@ export class MutationQueue {
146
216
  prevRow?: Row | null,
147
217
  owner?: string | null,
148
218
  ownerPending?: boolean,
219
+ createdAt?: number,
149
220
  ): string {
150
221
  const id =
151
222
  typeof change.op_id === "string" && change.op_id.length > 0
152
223
  ? change.op_id
153
- : `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
224
+ : newOpId();
154
225
  if (this.queue.has(id)) return id;
155
226
  const changeWithOp: ClientChange = { ...change, op_id: id };
156
- const entry: PendingMutation = { id, change: changeWithOp, status: "pending", prevRow };
227
+ const entry: PendingMutation = {
228
+ id,
229
+ change: changeWithOp,
230
+ status: "pending",
231
+ prevRow,
232
+ createdAt: createdAt ?? Date.now(),
233
+ };
157
234
  if (owner !== undefined) entry.owner = owner;
158
235
  if (ownerPending) entry.ownerPending = true;
159
236
  this.queue.set(id, entry);
160
237
  this.flush([entry]);
238
+ this.changed();
161
239
  return id;
162
240
  }
163
241
 
@@ -169,6 +247,61 @@ export class MutationQueue {
169
247
  return pending;
170
248
  }
171
249
 
250
+ /** Writes the server rejected, oldest first. They stay until the user
251
+ * retries or dismisses them. */
252
+ failed(): PendingMutation[] {
253
+ return byCreatedAt(Array.from(this.queue.values()).filter((m) => m.status === "failed"));
254
+ }
255
+
256
+ /**
257
+ * Queue a failed write again under a new op id, with `data` (when
258
+ * given) merged over the original change's fields. The failed entry is
259
+ * removed in the same persistence write, so a reload never sees both.
260
+ * The new write keeps the original's owner and `createdAt`. `prevRow`
261
+ * is the row before the caller re-applied the write locally, for the
262
+ * rollback if the server rejects it again. Returns the new op id.
263
+ *
264
+ * The server forgets a rejected op id (it never applied it), so the new
265
+ * id cannot be applied alongside the old one.
266
+ */
267
+ retry(id: string, data: Row | undefined, prevRow: Row | null | undefined): string {
268
+ const m = this.queue.get(id);
269
+ if (!m || m.status !== "failed") {
270
+ throw new Error(`no failed write with id ${id}`);
271
+ }
272
+ const newId = newOpId();
273
+ const change: ClientChange = { ...m.change, op_id: newId };
274
+ if (data !== undefined && m.change.kind !== "delete") {
275
+ change.data = { ...(m.change.data ?? {}), ...data };
276
+ if (m.change.kind === "insert") change.data.id = m.change.row_id;
277
+ }
278
+ const entry: PendingMutation = {
279
+ id: newId,
280
+ change,
281
+ status: "pending",
282
+ prevRow,
283
+ createdAt: m.createdAt,
284
+ };
285
+ if (m.owner !== undefined) entry.owner = m.owner;
286
+ this.queue.delete(id);
287
+ this.queue.set(newId, entry);
288
+ this.flush([entry], [id]);
289
+ this.changed();
290
+ return newId;
291
+ }
292
+
293
+ /** Drop a failed write after the user has seen it. Returns false when
294
+ * there is no failed write with that id (pending writes cannot be
295
+ * dismissed: they may already be on the server). */
296
+ dismiss(id: string): boolean {
297
+ const m = this.queue.get(id);
298
+ if (!m || m.status !== "failed") return false;
299
+ this.queue.delete(id);
300
+ this.flush([], [id]);
301
+ this.changed();
302
+ return true;
303
+ }
304
+
172
305
  /** Look up a queued mutation by op id (any status). Used by the
173
306
  * follower's mutations-failed handler to reach the captured
174
307
  * `prevRow` for rollback. */
@@ -237,7 +370,10 @@ export class MutationQueue {
237
370
  changed.push(m);
238
371
  }
239
372
  }
240
- if (changed.length > 0) this.flush(changed);
373
+ if (changed.length > 0) {
374
+ this.flush(changed);
375
+ this.changed();
376
+ }
241
377
  for (const id of ids) this.resolveWaiters(id);
242
378
  }
243
379
 
@@ -248,7 +384,10 @@ export class MutationQueue {
248
384
  m.error = error;
249
385
  if (errorCode) m.errorCode = errorCode;
250
386
  }
251
- if (m) this.flush([m]);
387
+ if (m) {
388
+ this.flush([m]);
389
+ this.changed();
390
+ }
252
391
  const waiters = this.waiters.get(id);
253
392
  if (!waiters) return;
254
393
  this.waiters.delete(id);
@@ -283,7 +422,10 @@ export class MutationQueue {
283
422
  removedIds.push(id);
284
423
  }
285
424
  }
286
- if (removedIds.length > 0) this.flush([], removedIds);
425
+ if (removedIds.length > 0) {
426
+ this.flush([], removedIds);
427
+ this.changed();
428
+ }
287
429
  }
288
430
 
289
431
  /** Stamp every write whose owner was pending with the user the first
@@ -307,6 +449,7 @@ export class MutationQueue {
307
449
  const m = this.get(id);
308
450
  this.queue.delete(id);
309
451
  this.flush([], [id]);
452
+ if (m) this.changed();
310
453
  const waiters = this.waiters.get(id);
311
454
  if (!waiters) return;
312
455
  this.waiters.delete(id);
@@ -326,8 +469,9 @@ export class MutationQueue {
326
469
  /** Remove a specific mutation by id. Used by the UI after user
327
470
  * ack of failures. */
328
471
  remove(id: string): void {
329
- this.queue.delete(id);
472
+ if (!this.queue.delete(id)) return;
330
473
  this.flush([], [id]);
474
+ this.changed();
331
475
  }
332
476
 
333
477
  /**
@@ -345,6 +489,7 @@ export class MutationQueue {
345
489
  const dropped = this.queue;
346
490
  this.queue = new Map();
347
491
  this.flush();
492
+ if (dropped.size > 0) this.changed();
348
493
  // Nobody will push these writes any more. Reject their waiters so a
349
494
  // caller awaiting the outcome does not hang.
350
495
  for (const m of dropped.values()) {
@@ -378,6 +523,16 @@ export class MutationQueue {
378
523
  }
379
524
  }
380
525
 
526
+ /** A new op id. The server deduplicates pushes by it. */
527
+ function newOpId(): string {
528
+ return `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
529
+ }
530
+
531
+ /** Oldest first; writes without `createdAt` (older clients) first of all. */
532
+ export function byCreatedAt(list: PendingMutation[]): PendingMutation[] {
533
+ return list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
534
+ }
535
+
381
536
  function rejectionFor(m: PendingMutation): MutationRejectedError {
382
537
  return new MutationRejectedError(m.error ?? "The server rejected the write.", {
383
538
  code: m.errorCode,