@pylonsync/sync 0.22.47 → 0.22.58

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.
@@ -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,
@@ -0,0 +1,110 @@
1
+ // What happens today when two clients edit the same row while offline and
2
+ // then reconnect (Plan 013 step 5). This records the behavior; it does not
3
+ // change it.
4
+ //
5
+ // Result: the server applies each update as a field-level patch in the order
6
+ // the pushes arrive. Different fields both survive. On the same field the
7
+ // last push wins. Both pushes come back "applied", so neither client is told
8
+ // that another edit replaced its value: the earlier writer's edit is
9
+ // overwritten in silence, and its screen changes when the other edit syncs.
10
+
11
+ import { afterEach, describe, expect, test } from "bun:test";
12
+
13
+ import { SyncEngine, type Row } from "./index";
14
+ import type { Storage } from "./storage";
15
+ import { createTestEnv, type TestEnv } from "./test-harness";
16
+
17
+ class MemoryStorage implements Storage {
18
+ private map = new Map<string, string>();
19
+ get(key: string) {
20
+ return this.map.get(key) ?? null;
21
+ }
22
+ set(key: string, value: string) {
23
+ this.map.set(key, value);
24
+ }
25
+ remove(key: string) {
26
+ this.map.delete(key);
27
+ }
28
+ }
29
+
30
+ describe("two clients edit one row offline", () => {
31
+ let env: TestEnv | null = null;
32
+ let second: SyncEngine | null = null;
33
+ afterEach(async () => {
34
+ second?.stop();
35
+ second = null;
36
+ if (env) await env.dispose();
37
+ env = null;
38
+ });
39
+
40
+ async function twoClients() {
41
+ // Client A is the harness engine; client B is a second engine against
42
+ // the same server, signed in as another rep.
43
+ env = createTestEnv({ transport: "poll", appName: "rep-a" });
44
+ env.signIn({ userId: "rep-a" });
45
+ env.server.seed("Lead", [{ id: "l1", status: "new", note: "", phone: "" }]);
46
+ await env.start();
47
+ const storage = new MemoryStorage();
48
+ storage.set("pylon:rep-b:token", env.server.signIn({ userId: "rep-b" }));
49
+ second = new SyncEngine({
50
+ baseUrl: "http://test.invalid",
51
+ appName: "rep-b",
52
+ persist: false,
53
+ storage,
54
+ transport: "poll",
55
+ multiTab: false,
56
+ });
57
+ await second.start();
58
+ await env.flush();
59
+ return { a: env.engine, b: second, server: env.server };
60
+ }
61
+
62
+ /** Let each push's catch-up pull finish, then pull the other's edit
63
+ * (on the poll transport nothing pushes it). */
64
+ async function syncBoth(a: SyncEngine, b: SyncEngine) {
65
+ await env!.flush(20);
66
+ await a.pull();
67
+ await b.pull();
68
+ }
69
+
70
+ async function offlineUpdate(engine: SyncEngine, patch: Row) {
71
+ env!.server.primeNextPushOutcome({ kind: "network" });
72
+ await engine.update("Lead", "l1", patch);
73
+ expect(engine.pendingCount()).toBe(1);
74
+ }
75
+
76
+ test("different fields: both edits survive", async () => {
77
+ const { a, b, server } = await twoClients();
78
+ await offlineUpdate(a, { status: "sold" });
79
+ await offlineUpdate(b, { note: "dog in yard" });
80
+
81
+ await a.push();
82
+ await b.push();
83
+ await syncBoth(a, b);
84
+
85
+ const [row] = await server.listEntityRows("Lead", env!.token);
86
+ expect(row).toMatchObject({ status: "sold", note: "dog in yard" });
87
+ expect(a.store.get("Lead", "l1")).toMatchObject({ status: "sold", note: "dog in yard" });
88
+ expect(b.store.get("Lead", "l1")).toMatchObject({ status: "sold", note: "dog in yard" });
89
+ });
90
+
91
+ test("same field: the last push wins and neither client is told", async () => {
92
+ const { a, b, server } = await twoClients();
93
+ await offlineUpdate(a, { status: "sold" });
94
+ await offlineUpdate(b, { status: "not interested" });
95
+
96
+ // A reconnects first, then B.
97
+ await a.push();
98
+ await b.push();
99
+ await syncBoth(a, b);
100
+
101
+ const [row] = await server.listEntityRows("Lead", env!.token);
102
+ expect(row).toMatchObject({ status: "not interested" });
103
+ // A's edit was replaced: A now shows B's value...
104
+ expect(a.store.get("Lead", "l1")).toMatchObject({ status: "not interested" });
105
+ // ...and nothing reports it. Both writes were applied, so neither
106
+ // outbox has a failed write.
107
+ expect(a.outbox()).toEqual({ pending: [], failed: [] });
108
+ expect(b.outbox()).toEqual({ pending: [], failed: [] });
109
+ });
110
+ });