@pylonsync/sync 0.21.0 → 0.22.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.
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export type { PylonRequestInit, TransportConfig } from "./transport";
55
55
  export { LocalStore } from "./local-store";
56
56
  export {
57
57
  MutationQueue,
58
+ MutationRejectedError,
58
59
  type MutationQueuePersistence,
59
60
  type PendingMutation,
60
61
  } from "./mutation-queue";
@@ -272,6 +273,28 @@ export class SyncEngine {
272
273
 
273
274
  private _initialSyncFallback: ReturnType<typeof setTimeout> | null = null;
274
275
 
276
+ /**
277
+ * True once a pull completed against the server in this run since the
278
+ * last replica reset. Unlike `isInitialSyncSettled()`, a warm cache does
279
+ * not set it and there is no fallback deadline: it stays false while the
280
+ * replica holds only what IndexedDB had (possibly stale or partial) and
281
+ * while the server is unreachable. A replica reset (identity or org
282
+ * switch, 410 resync) clears it until the re-pull lands. Follower tabs
283
+ * take it from the leader.
284
+ */
285
+ private _synced = false;
286
+ isSynced(): boolean {
287
+ return this._synced;
288
+ }
289
+
290
+ /** Flip `_synced` true (idempotent), notify, and tell follower tabs. */
291
+ private markSynced(): void {
292
+ if (this._synced) return;
293
+ this._synced = true;
294
+ this.store.notify();
295
+ if (this.isMultiTabLeader) this.broadcastToTabs({ type: "synced" });
296
+ }
297
+
275
298
  /** Flip `_initialSyncSettled` true (idempotent) + notify so `useQuery`
276
299
  * re-reads and drops its loading state. */
277
300
  private markInitialSyncSettled(): void {
@@ -876,6 +899,8 @@ export class SyncEngine {
876
899
  if (pendingOps.length > 0) {
877
900
  this.broadcastToTabs({ type: "mutations", ops: pendingOps });
878
901
  }
902
+ // Ask the leader whether it has synced; it answers with `synced`.
903
+ this.broadcastToTabs({ type: "sync-status-request" });
879
904
  return;
880
905
  }
881
906
 
@@ -897,7 +922,9 @@ export class SyncEngine {
897
922
  // we don't get stuck without a session.
898
923
  const bootstrapSession = await sessionPromise;
899
924
  if (bootstrapSession !== null) {
900
- await this.applySessionTransition(bootstrapSession, /* broadcast */ true);
925
+ await this.applySessionTransition(bootstrapSession.session, /* broadcast */ true, {
926
+ token: bootstrapSession.token,
927
+ });
901
928
  } else {
902
929
  await this.refreshResolvedSession();
903
930
  }
@@ -1037,6 +1064,16 @@ export class SyncEngine {
1037
1064
  onResetReceived: (wipeMutations: boolean) => {
1038
1065
  void this.resetReplicaInner({ wipeMutations });
1039
1066
  },
1067
+ onSyncedReceived: () => {
1068
+ if (this.isMultiTabLeader) return;
1069
+ // Flip after the leader's earlier `applied` batches land here.
1070
+ void this.applyQueue.then(() => this.markSynced());
1071
+ },
1072
+ onSyncStatusRequested: () => {
1073
+ if (this.isMultiTabLeader && this._synced) {
1074
+ this.broadcastToTabs({ type: "synced" });
1075
+ }
1076
+ },
1040
1077
  onSessionReceived: (resolved: ResolvedSession) => {
1041
1078
  // Funnel through the shared session chain so concurrent triggers
1042
1079
  // (broadcast + local notifySessionChanged) commit in arrival
@@ -1054,7 +1091,7 @@ export class SyncEngine {
1054
1091
  // DELETE the leader's still-valid row. The follower's prevRow
1055
1092
  // (its pre-edit value) equals the leader's canonical row, so
1056
1093
  // restoring it is correct on both tabs.
1057
- this.mutations.add(op.change, op.prevRow);
1094
+ this.mutations.add(op.change, op.prevRow, op.owner, op.ownerPending);
1058
1095
  }
1059
1096
  void this.push();
1060
1097
  },
@@ -1062,7 +1099,14 @@ export class SyncEngine {
1062
1099
  for (const id of opIds) this.mutations.markApplied(id);
1063
1100
  this.mutations.clear();
1064
1101
  },
1065
- onMutationsFailed: (ops: { opId: string; error: string }[]) => {
1102
+ onMutationsQueued: (opIds: string[]) => {
1103
+ // The leader's push failed transiently; the writes stay queued
1104
+ // and the leader retries them. Release this tab's callers.
1105
+ for (const id of opIds) this.mutations.settleQueued(id);
1106
+ },
1107
+ onMutationsFailed: (
1108
+ ops: { opId: string; error: string; code?: string }[],
1109
+ ) => {
1066
1110
  // The leader pushed this follower's forwarded mutation and the
1067
1111
  // server rejected it. Roll back the follower's OWN optimistic
1068
1112
  // ghost (the leader already rolled back its copy) — calling
@@ -1071,10 +1115,14 @@ export class SyncEngine {
1071
1115
  // update/delete and removes the insert ghost, then marks failed.
1072
1116
  for (const op of ops) {
1073
1117
  const m = this.mutations.get(op.opId);
1074
- if (m) {
1075
- this.failPushedMutation(m, op.error);
1118
+ if (op.code === "DISCARDED") {
1119
+ // Another user is signed in: drop it without restoring rows
1120
+ // of the old identity's replica.
1121
+ this.mutations.discard(op.opId);
1122
+ } else if (m) {
1123
+ this.failPushedMutation(m, op.error, op.code);
1076
1124
  } else {
1077
- this.mutations.markFailed(op.opId, op.error);
1125
+ this.mutations.markFailed(op.opId, op.error, op.code);
1078
1126
  }
1079
1127
  }
1080
1128
  },
@@ -1145,6 +1193,10 @@ export class SyncEngine {
1145
1193
  release();
1146
1194
  }
1147
1195
  },
1196
+ onRoomSubResubscribe: (roomId: string) => {
1197
+ if (!this.isMultiTabLeader) return;
1198
+ this.rooms.resubscribe(roomId);
1199
+ },
1148
1200
  onRoomFanoutSnapshot: (roomId: string, members: unknown) => {
1149
1201
  if (Array.isArray(members)) {
1150
1202
  this.rooms.applySnapshot(roomId, members as RoomMember[]);
@@ -1372,63 +1424,184 @@ export class SyncEngine {
1372
1424
  this.pullHold.push(...changes);
1373
1425
  return Promise.resolve();
1374
1426
  }
1375
- const prev = this.applyQueue;
1376
- const next = prev.then(async () => {
1377
- // Per-event monotonic filter: re-applies of an already-seen seq
1378
- // are skipped before touching the store. Without that, a
1379
- // retransmit (WS + pull window overlap) would have us run
1380
- // applyChange twice against the local store.
1381
- const filtered = changes.filter(
1382
- (c) => typeof c.seq === "number" && c.seq > this.cursor.last_seq,
1383
- );
1384
- if (filtered.length > 0) {
1385
- const durable = await this.store.applyChangesAsync(filtered);
1427
+ // Reset fence (see `resetDepth`): live frames from the socket being
1428
+ // replaced. The leader's broadcasts to a follower are NOT fenced:
1429
+ // after its `reset` they already describe the new replica.
1430
+ if (this.resetDepth > 0 && !opts.isPull && !opts.fromBroadcast) {
1431
+ return Promise.resolve();
1432
+ }
1433
+ const epoch = this.replicaEpoch;
1434
+ // Group commit for live frames (WS events, tab broadcasts without a
1435
+ // pull cursor). While an earlier batch is still applying and writing
1436
+ // to IndexedDB, frames that arrive join the next queued batch instead
1437
+ // of queueing one apply + two IDB transactions each. The first frame
1438
+ // after an idle period starts its batch at once, so batching adds no
1439
+ // latency; under load each batch is one store notify and one IDB
1440
+ // transaction (rows + cursor).
1441
+ if (!opts.isPull && targetCursor === undefined) {
1442
+ const fromBroadcast = opts.fromBroadcast === true;
1443
+ const open = this.liveBatch;
1444
+ if (open && open.fromBroadcast === fromBroadcast && open.epoch === epoch) {
1445
+ open.changes.push(...changes);
1446
+ return open.promise;
1447
+ }
1448
+ const batch: LiveBatch = {
1449
+ changes: [...changes],
1450
+ fromBroadcast,
1451
+ epoch,
1452
+ promise: Promise.resolve(),
1453
+ };
1454
+ batch.promise = this.chainApply(() => {
1455
+ // Close the batch when it starts: frames from here on go into
1456
+ // the next one, so each frame is applied exactly once, in order.
1457
+ if (this.liveBatch === batch) this.liveBatch = null;
1458
+ return this.applyBatch(batch.changes, undefined, { fromBroadcast }, epoch);
1459
+ });
1460
+ this.liveBatch = batch;
1461
+ return batch.promise;
1462
+ }
1463
+ return this.chainApply(() => this.applyBatch(changes, targetCursor, opts, epoch));
1464
+ }
1465
+
1466
+ /** Live frames waiting for their apply to start. See `enqueueApply`. */
1467
+ private liveBatch: LiveBatch | null = null;
1468
+
1469
+ /** Bumped by every replica reset. An apply step records the epoch it
1470
+ * was queued in and drops its work when a reset happened since: its
1471
+ * changes belong to the replica that was wiped. */
1472
+ private replicaEpoch = 0;
1473
+ /** > 0 while `resetReplicaInner` runs. Live frames that arrive then
1474
+ * come from the socket of the replica being wiped; the pull that
1475
+ * follows every reset delivers anything current. */
1476
+ private resetDepth = 0;
1477
+
1478
+ /**
1479
+ * True from a token change until a session refresh says who the new
1480
+ * token belongs to. While true, owned writes are not pushed (the
1481
+ * owner can't be checked) and a push retries the refresh with backoff.
1482
+ */
1483
+ private identityPending = false;
1484
+ /** Bearer token the committed session was fetched with (leader). A
1485
+ * push whose current token differs holds owned writes: the token may
1486
+ * belong to someone else. `undefined` until a session is committed. */
1487
+ private sessionToken: string | null | undefined = undefined;
1488
+ private identityRetryTimer: ReturnType<typeof setTimeout> | null = null;
1489
+ /** Delayed push / pull retries scheduled through `later()`. `stop()`
1490
+ * clears them, so a stopped engine makes no further requests. */
1491
+ private retryTimers = new Set<ReturnType<typeof setTimeout>>();
1492
+ private identityRetryAttempts = 0;
1493
+
1494
+ /** Chain one apply step behind every queued one. Anything chained
1495
+ * here closes the open live batch first, so later live frames can
1496
+ * never be applied ahead of it. Errors stay scoped to the step. */
1497
+ private chainApply(step: () => Promise<void>): Promise<void> {
1498
+ if (this.liveBatch) this.liveBatch = null;
1499
+ const next = this.applyQueue.then(step);
1500
+ this.applyQueue = next.catch(() => {});
1501
+ return next;
1502
+ }
1503
+
1504
+ /** Apply one batch: seq-filter, apply to memory, persist rows and the
1505
+ * advanced cursor, and fan out to follower tabs. Runs on the apply
1506
+ * queue only. */
1507
+ private async applyBatch(
1508
+ changes: ChangeEvent[],
1509
+ targetCursor: SyncCursor | undefined,
1510
+ opts: { fromBroadcast?: boolean; isPull?: boolean },
1511
+ epoch: number,
1512
+ ): Promise<void> {
1513
+ // Queued before a replica reset: these changes belong to the wiped
1514
+ // replica (another identity, or a cursor the server rejected).
1515
+ if (epoch !== this.replicaEpoch) return;
1516
+ // Per-event monotonic filter: re-applies of an already-seen seq
1517
+ // are skipped before touching the store. Without that, a
1518
+ // retransmit (WS + pull window overlap) would have us run
1519
+ // applyChange twice against the local store. A live batch holds
1520
+ // frames that used to apply one at a time, so it filters against a
1521
+ // running high mark: a frame at or below an earlier frame's seq is
1522
+ // dropped exactly as it was when that frame had already advanced the
1523
+ // cursor. A pull page keeps every change above the cursor.
1524
+ const live = targetCursor === undefined && !opts.isPull;
1525
+ let high = this.cursor.last_seq;
1526
+ const filtered: ChangeEvent[] = [];
1527
+ for (const c of changes) {
1528
+ if (typeof c.seq !== "number") continue;
1529
+ if (live ? c.seq > high : c.seq > this.cursor.last_seq) {
1530
+ filtered.push(c);
1531
+ if (c.seq > high) high = c.seq;
1532
+ }
1533
+ }
1534
+ // Pick the cursor target. Explicit `targetCursor` (from pull) wins
1535
+ // — pull's response carries the server's authoritative current_seq
1536
+ // even when no changes landed in this window. Otherwise derive
1537
+ // from the last applied seq.
1538
+ const candidate =
1539
+ targetCursor ??
1540
+ (filtered.length > 0
1541
+ ? { last_seq: filtered[filtered.length - 1].seq }
1542
+ : null);
1543
+ const advance =
1544
+ candidate && candidate.last_seq > this.cursor.last_seq ? candidate : null;
1545
+ let cursorOnDisk = false;
1546
+ if (filtered.length > 0) {
1547
+ const persistence = this.persistence;
1548
+ if (persistence?.saveBatch && this.store._persistFn) {
1549
+ // One transaction for the rows and the advanced cursor: they
1550
+ // commit or abort together. Once persistence degraded, the
1551
+ // cursor stays frozen on disk (see `persistDegraded`).
1552
+ const rows = this.store.applyInMemory(filtered);
1553
+ const cursorToSave = advance && !this.persistDegraded ? advance : null;
1554
+ const durable = await persistence.saveBatch(rows, cursorToSave);
1555
+ if (!durable) this.persistDegraded = true;
1556
+ cursorOnDisk = durable && cursorToSave !== null;
1557
+ } else if (this.store._persistFn) {
1558
+ // Row by row, in order. Stop writing once a reset happened: the
1559
+ // reset cleared disk and later rows would land after it.
1560
+ const rows = this.store.applyInMemory(filtered);
1561
+ let durable = true;
1562
+ for (const row of rows) {
1563
+ if (epoch !== this.replicaEpoch) break;
1564
+ const result = this.store._persistFn(row);
1565
+ if (result instanceof Promise && (await result) === false) durable = false;
1566
+ }
1386
1567
  // A row in this batch didn't reach disk (quota / abort). Latch
1387
1568
  // the degraded flag so we never persist a cursor ahead of the
1388
1569
  // durable replica — the next cold start must re-pull this gap.
1389
1570
  if (!durable) this.persistDegraded = true;
1571
+ } else {
1572
+ this.store.applyInMemory(filtered);
1390
1573
  }
1391
- // Pick the cursor target. Explicit `targetCursor` (from pull) wins
1392
- // — pull's response carries the server's authoritative current_seq
1393
- // even when no changes landed in this window. Otherwise derive
1394
- // from the last applied seq.
1395
- const candidate =
1396
- targetCursor ??
1397
- (filtered.length > 0
1398
- ? { last_seq: filtered[filtered.length - 1].seq }
1399
- : null);
1400
- if (candidate && candidate.last_seq > this.cursor.last_seq) {
1401
- // In-memory cursor ALWAYS advances — live sync stays correct.
1402
- this.cursor = candidate;
1403
- // The on-disk cursor only advances while persistence is healthy.
1404
- // Once degraded, freezing it keeps disk self-consistent (cursor
1405
- // never exceeds the rows actually written) so restart re-pulls.
1406
- if (this.persistence && !this.persistDegraded) {
1407
- await this.persistence.saveCursor(this.cursor);
1408
- }
1409
- }
1410
- // Multi-tab: leader fans the batch out so follower replicas
1411
- // converge without their own WS. Skip when we ourselves
1412
- // RECEIVED this batch from another tab — otherwise a tab that
1413
- // was promoted between receiving and applying would re-broadcast
1414
- // its own copy, and even though the seq filter dedupes on
1415
- // arrival the round-trip is wasted bandwidth.
1416
- if (
1417
- this.isMultiTabLeader &&
1418
- !opts.fromBroadcast &&
1419
- filtered.length > 0
1420
- ) {
1421
- this.broadcastToTabs({
1422
- type: "applied",
1423
- changes: filtered,
1424
- targetCursor: candidate ?? undefined,
1425
- });
1574
+ }
1575
+ // A reset ran while this batch was writing: it wiped memory and
1576
+ // disk, so the cursor must not move back up to this batch's seqs.
1577
+ if (epoch !== this.replicaEpoch) return;
1578
+ if (advance) {
1579
+ // In-memory cursor ALWAYS advances — live sync stays correct.
1580
+ this.cursor = advance;
1581
+ // The on-disk cursor only advances while persistence is healthy.
1582
+ // Once degraded, freezing it keeps disk self-consistent (cursor
1583
+ // never exceeds the rows actually written) so restart re-pulls.
1584
+ if (this.persistence && !this.persistDegraded && !cursorOnDisk) {
1585
+ await this.persistence.saveCursor(this.cursor);
1426
1586
  }
1427
- });
1428
- // Errors stay scoped to this batch — don't poison the chain for
1429
- // future applies.
1430
- this.applyQueue = next.catch(() => {});
1431
- return next;
1587
+ }
1588
+ // Multi-tab: leader fans the batch out so follower replicas
1589
+ // converge without their own WS. Skip when we ourselves
1590
+ // RECEIVED this batch from another tab — otherwise a tab that
1591
+ // was promoted between receiving and applying would re-broadcast
1592
+ // its own copy, and even though the seq filter dedupes on
1593
+ // arrival the round-trip is wasted bandwidth.
1594
+ if (
1595
+ this.isMultiTabLeader &&
1596
+ !opts.fromBroadcast &&
1597
+ filtered.length > 0
1598
+ ) {
1599
+ this.broadcastToTabs({
1600
+ type: "applied",
1601
+ changes: filtered,
1602
+ targetCursor: candidate ?? undefined,
1603
+ });
1604
+ }
1432
1605
  }
1433
1606
 
1434
1607
  /**
@@ -1446,8 +1619,7 @@ export class SyncEngine {
1446
1619
  tombstoneSeq: number,
1447
1620
  opts: { fromBroadcast?: boolean } = {},
1448
1621
  ): Promise<void> {
1449
- const prev = this.applyQueue;
1450
- const next = prev.then(async () => {
1622
+ return this.chainApply(async () => {
1451
1623
  await this.store.applyReconcileBatch(
1452
1624
  entity,
1453
1625
  upserts,
@@ -1468,13 +1640,17 @@ export class SyncEngine {
1468
1640
  });
1469
1641
  }
1470
1642
  });
1471
- this.applyQueue = next.catch(() => {});
1472
- return next;
1473
1643
  }
1474
1644
 
1475
1645
  /** Stop the sync engine. */
1476
1646
  stop(): void {
1477
1647
  this.running = false;
1648
+ if (this.identityRetryTimer !== null) {
1649
+ clearTimeout(this.identityRetryTimer);
1650
+ this.identityRetryTimer = null;
1651
+ }
1652
+ for (const timer of this.retryTimers) clearTimeout(timer);
1653
+ this.retryTimers.clear();
1478
1654
  if (this.transport) {
1479
1655
  this.transport.stop();
1480
1656
  this.transport = null;
@@ -1543,12 +1719,9 @@ export class SyncEngine {
1543
1719
  // queued writes should re-push under the user). For any other flip
1544
1720
  // (user A → user B, user → guest) discard them so one identity's
1545
1721
  // unsynced writes never push under another.
1546
- const isGuestToUser =
1547
- typeof prev === "string" &&
1548
- prev.startsWith("guest_") &&
1549
- typeof now === "string" &&
1550
- !now.startsWith("guest_");
1551
- await this.resetReplica({ wipeMutations: !isGuestToUser });
1722
+ // Queued writes stay: each carries its owner, and push holds the ones
1723
+ // of a signed-out user and discards the ones of another user.
1724
+ await this.resetReplica({ wipeMutations: false });
1552
1725
  this.persistReplicaIdentity();
1553
1726
  }
1554
1727
 
@@ -1559,6 +1732,9 @@ export class SyncEngine {
1559
1732
  * the guard treats conservatively (no wipe, re-tag next run).
1560
1733
  */
1561
1734
  private persistReplicaIdentity(): void {
1735
+ // Before /api/auth/me answered, the session is a placeholder: it
1736
+ // says nothing about who owns the replica. Keep the old tag.
1737
+ if (!this.session.hasObserved()) return;
1562
1738
  const id = this.session.resolved().userId;
1563
1739
  this._replicaIdentity = id;
1564
1740
  if (this.persistence && !this.persistDegraded) {
@@ -1578,6 +1754,19 @@ export class SyncEngine {
1578
1754
  */
1579
1755
  private async resetReplicaInner(
1580
1756
  opts: { wipeMutations?: boolean } = {},
1757
+ ): Promise<void> {
1758
+ this.replicaEpoch += 1;
1759
+ this.liveBatch = null;
1760
+ this.resetDepth += 1;
1761
+ try {
1762
+ await this.resetReplicaSteps(opts);
1763
+ } finally {
1764
+ this.resetDepth -= 1;
1765
+ }
1766
+ }
1767
+
1768
+ private async resetReplicaSteps(
1769
+ opts: { wipeMutations?: boolean },
1581
1770
  ): Promise<void> {
1582
1771
  const wipeMutations = opts.wipeMutations === true;
1583
1772
  this.cursor = { last_seq: 0 };
@@ -1594,6 +1783,7 @@ export class SyncEngine {
1594
1783
  // the next line reached nobody. The documented protection above silently
1595
1784
  // did nothing: an org switch dropped straight to the new org's empty list.
1596
1785
  this._initialSyncSettled = false;
1786
+ this._synced = false;
1597
1787
  this.armInitialSyncFallback();
1598
1788
  this.store.clearAll();
1599
1789
  // Disk is about to be wiped + re-pulled from 0, so any prior
@@ -1619,14 +1809,18 @@ export class SyncEngine {
1619
1809
  if (this.persistence) {
1620
1810
  try {
1621
1811
  await this.persistence.clear();
1622
- await this.persistence.saveCursor(this.cursor);
1812
+ await this.persistence.saveCursor({ last_seq: 0 });
1623
1813
  // clear() wiped the cursor store (identity tag included). The
1624
1814
  // replica is about to be re-pulled for the CURRENT identity, so
1625
1815
  // re-tag it to match — otherwise the on-disk tag reads "unknown"
1626
1816
  // until the next cold-start pull re-records it.
1627
- const id = this.session.resolved().userId;
1628
- this._replicaIdentity = id;
1629
- await this.persistence.saveIdentity(id);
1817
+ if (this.session.hasObserved()) {
1818
+ const id = this.session.resolved().userId;
1819
+ this._replicaIdentity = id;
1820
+ await this.persistence.saveIdentity(id);
1821
+ } else if (this._replicaIdentity !== undefined) {
1822
+ await this.persistence.saveIdentity(this._replicaIdentity);
1823
+ }
1630
1824
  } catch {
1631
1825
  /* best-effort */
1632
1826
  }
@@ -1765,20 +1959,27 @@ export class SyncEngine {
1765
1959
  // the replica from seq=0 under the new identity.
1766
1960
  const { tokenChanged } = this.session.observeToken(this.currentToken());
1767
1961
  if (tokenChanged) {
1768
- // We're holding the "pull" slot in the op queue — bypass the
1769
- // queue's reset path to avoid self-deadlock. Identity flipped, so
1770
- // wipe the old identity's pending offline writes.
1771
- await this.resetReplicaInner({ wipeMutations: true });
1772
- // Token flipped → the cached tenant is for the previous user. Pull
1773
- // the fresh session in parallel with the cursor catch-up below.
1774
- void this.refreshResolvedSession();
1775
1962
  // The live socket was opened with the OLD token (the bearer rides
1776
1963
  // the WS subprotocol at connect time), so the server keeps
1777
1964
  // fanning out the previous identity's events to it. Cycle the
1778
- // transport: stop() closes the socket, start() reconnects and
1965
+ // transport BEFORE the reset so no old-identity frame lands in the
1966
+ // new replica: stop() closes the socket, start() reconnects and
1779
1967
  // reads the token fresh. The new socket's onConnected pull sees
1780
1968
  // the token as already observed, so this does not recurse.
1781
1969
  this.cycleTransport();
1970
+ // Until the session refresh below says who the new token belongs
1971
+ // to, owned writes wait (see `pushable`).
1972
+ this.identityPending = true;
1973
+ // We're holding the "pull" slot in the op queue — bypass the
1974
+ // queue's reset path to avoid self-deadlock. Queued writes stay:
1975
+ // each carries its owner, and push drops the ones that belong to
1976
+ // someone else.
1977
+ await this.resetReplicaInner({ wipeMutations: false });
1978
+ // Token flipped → the cached tenant is for the previous user. Pull
1979
+ // the fresh session in parallel with the cursor catch-up below.
1980
+ // This pull already reset the replica and cycled the transport,
1981
+ // so the session refresh must not do either a second time.
1982
+ void this.refreshResolvedSession({ replicaAlreadyReset: true });
1782
1983
  }
1783
1984
 
1784
1985
  // Capture whether this pull started from cursor=0 BEFORE the
@@ -1894,6 +2095,7 @@ export class SyncEngine {
1894
2095
  this.pullHold = null;
1895
2096
  if (held.length > 0) await this.enqueueApply(held);
1896
2097
  }
2098
+ this.markSynced();
1897
2099
  } catch (err) {
1898
2100
  // Settle any in-flight page apply before acting on the error —
1899
2101
  // the 410 path below wipes the replica, and an apply landing
@@ -1951,11 +2153,11 @@ export class SyncEngine {
1951
2153
  console.warn(
1952
2154
  `[pylon] persistent 410 RESYNC_REQUIRED (attempt ${attempt + 1}); backing off ${delayMs}ms`,
1953
2155
  );
1954
- setTimeout(() => {
2156
+ this.later(delayMs, () => {
1955
2157
  // Retry after the delay; a delta success resets the counter,
1956
2158
  // a repeat 410 extends the backoff (no snapshot).
1957
2159
  void this.pull();
1958
- }, delayMs);
2160
+ });
1959
2161
  }
1960
2162
  }
1961
2163
  } finally {
@@ -2095,19 +2297,55 @@ export class SyncEngine {
2095
2297
  if (entities === undefined && now - this.lastReconcileAt < minIntervalMs) {
2096
2298
  return;
2097
2299
  }
2098
- // Coalesce concurrent reconciles to a single op via the queue's
2099
- // keyed dedupe — multiple callers in the same tick share one fetch.
2100
2300
  // Reconcile waits behind any in-flight pull / refresh / push so it
2101
2301
  // can't apply rows captured under a stale session.
2102
- return this.opQueue.enqueue("reconcile", async () => {
2103
- try {
2104
- await this.reconcileInner(entities);
2105
- } finally {
2106
- this.lastReconcileAt = Date.now();
2107
- }
2108
- });
2302
+ if (entities === undefined) {
2303
+ // Full sweeps coalesce via the queue's keyed dedupe — callers in
2304
+ // the same window share one fetch per entity.
2305
+ return this.opQueue.enqueue("reconcile", async () => {
2306
+ try {
2307
+ await this.reconcileInner();
2308
+ } finally {
2309
+ this.lastReconcileAt = Date.now();
2310
+ }
2311
+ });
2312
+ }
2313
+ // Scoped reconciles batch by entity: every call made before the
2314
+ // batch starts running adds its entities to the batch, and all of
2315
+ // them are fetched in parallel. A call made after the batch started
2316
+ // opens a new batch, so no requested entity is dropped.
2317
+ const open = this.scopedReconcileBatch;
2318
+ if (open) {
2319
+ for (const e of entities) open.entities.add(e);
2320
+ return open.promise;
2321
+ }
2322
+ const batch: { entities: Set<string>; promise: Promise<void> } = {
2323
+ entities: new Set(entities),
2324
+ promise: Promise.resolve(),
2325
+ };
2326
+ this.scopedReconcileBatch = batch;
2327
+ this.scopedReconcileSeq += 1;
2328
+ batch.promise = this.opQueue.enqueue(
2329
+ `reconcile-scoped:${this.scopedReconcileSeq}`,
2330
+ async () => {
2331
+ if (this.scopedReconcileBatch === batch) this.scopedReconcileBatch = null;
2332
+ try {
2333
+ await this.reconcileInner([...batch.entities]);
2334
+ } finally {
2335
+ this.lastReconcileAt = Date.now();
2336
+ }
2337
+ },
2338
+ );
2339
+ return batch.promise;
2109
2340
  }
2110
2341
 
2342
+ /** Scoped reconcile batch that has not started running yet. */
2343
+ private scopedReconcileBatch: {
2344
+ entities: Set<string>;
2345
+ promise: Promise<void>;
2346
+ } | null = null;
2347
+ private scopedReconcileSeq = 0;
2348
+
2111
2349
  private async reconcileInner(entities?: string[]): Promise<void> {
2112
2350
  // Same reasoning as pullInner: the leader reconciles, broadcasts
2113
2351
  // results, and follower replicas converge via the channel.
@@ -2365,14 +2603,19 @@ export class SyncEngine {
2365
2603
  * On tenant flip this also resets the replica — same logic as the
2366
2604
  * token-flip path, for the same reason (visible set changed).
2367
2605
  */
2368
- async refreshResolvedSession(): Promise<void> {
2606
+ async refreshResolvedSession(
2607
+ opts: { replicaAlreadyReset?: boolean } = {},
2608
+ ): Promise<void> {
2369
2609
  // Followers don't fetch /api/auth/me — the leader does and
2370
2610
  // broadcasts the result, which `handleMultiTabMessage` routes
2371
2611
  // into the resolver.
2372
2612
  if (!this.isMultiTabLeader) return;
2373
2613
  const next = await this.fetchSessionBootstrap();
2374
2614
  if (next === null) return;
2375
- await this.applySessionTransition(next, /* broadcast */ true);
2615
+ await this.applySessionTransition(next.session, /* broadcast */ true, {
2616
+ ...opts,
2617
+ token: next.token,
2618
+ });
2376
2619
  }
2377
2620
 
2378
2621
  /**
@@ -2389,8 +2632,11 @@ export class SyncEngine {
2389
2632
  * caller's next pull cycle (or the WS `session-changed` envelope)
2390
2633
  * will retry. Errors must not abort bootstrap.
2391
2634
  */
2392
- private async fetchSessionBootstrap(): Promise<ResolvedSession | null> {
2635
+ private async fetchSessionBootstrap(): Promise<
2636
+ { session: ResolvedSession; token: string | null } | null
2637
+ > {
2393
2638
  try {
2639
+ const token = this.currentToken();
2394
2640
  const res = await this.rawFetch("/api/auth/me");
2395
2641
  if (!res.ok) return null;
2396
2642
  const raw = (await res.json()) as {
@@ -2402,11 +2648,14 @@ export class SyncEngine {
2402
2648
  };
2403
2649
  if ((raw.user_id ?? null) === null) this.warnForeignToken();
2404
2650
  return {
2405
- userId: raw.user_id ?? null,
2406
- tenantId: raw.tenant_id ?? null,
2407
- isAdmin: raw.is_admin ?? false,
2408
- roles: raw.roles ?? [],
2409
- avatarUrl: raw.avatar_url ?? null,
2651
+ token,
2652
+ session: {
2653
+ userId: raw.user_id ?? null,
2654
+ tenantId: raw.tenant_id ?? null,
2655
+ isAdmin: raw.is_admin ?? false,
2656
+ roles: raw.roles ?? [],
2657
+ avatarUrl: raw.avatar_url ?? null,
2658
+ },
2410
2659
  };
2411
2660
  } catch {
2412
2661
  // Swallow — /api/auth/me errors are transient and the next pull
@@ -2463,6 +2712,7 @@ export class SyncEngine {
2463
2712
  private applySessionTransition(
2464
2713
  next: ResolvedSession,
2465
2714
  broadcast: boolean,
2715
+ opts: { replicaAlreadyReset?: boolean; token?: string | null } = {},
2466
2716
  ): Promise<void> {
2467
2717
  const prev = this.sessionChain;
2468
2718
  // Swallow errors when storing back to the chain so a single
@@ -2476,7 +2726,38 @@ export class SyncEngine {
2476
2726
  // closes the brief window where useSession would report the
2477
2727
  // new tenant while useQuery still has the old tenant's rows.
2478
2728
  const verdict = this.session.inspectSession(next);
2479
- if (verdict.tenantChanged) {
2729
+ const prevUserId = this.session.resolved().userId;
2730
+ // USER flip (sign-in, sign-out, account switch) observed on a
2731
+ // session refresh. The rows, the cursor, and the live socket all
2732
+ // belong to the previous identity, and a tenant verdict alone
2733
+ // misses the common case where both sides have no tenant
2734
+ // (anonymous → signed in). Wipe, reconnect as the new identity,
2735
+ // and pull every entity from zero. Always wipes, whatever
2736
+ // `resetOnTenantFlip` says.
2737
+ //
2738
+ // Leader only. A follower hears about the flip from the leader's
2739
+ // `session` message, which arrives AFTER the leader's `reset` and
2740
+ // the re-pulled rows; acting on it again would wipe those rows and
2741
+ // the IndexedDB the tabs share.
2742
+ const userFlipped =
2743
+ this.session.hasObserved() && prevUserId !== next.userId;
2744
+ if (userFlipped && this.isMultiTabLeader) {
2745
+ // Owned writes wait until the new identity is committed below.
2746
+ this.identityPending = true;
2747
+ if (!opts.replicaAlreadyReset) {
2748
+ // Mark the current token as seen so the pull below does not
2749
+ // detect the same flip and reset a second time.
2750
+ this.session.observeToken(this.currentToken());
2751
+ // Close the old identity's socket before the wipe so none of
2752
+ // its frames land in the new replica.
2753
+ this.cycleTransport();
2754
+ // Queued writes stay: each carries its owner, and push drops
2755
+ // the ones that belong to someone else.
2756
+ await this.resetAndPull({ wipeMutations: false });
2757
+ }
2758
+ } else if (userFlipped) {
2759
+ // Follower: the leader's reset already wiped this tab.
2760
+ } else if (verdict.tenantChanged && !opts.replicaAlreadyReset) {
2480
2761
  // `resetOnTenantFlip: false` — the app has declared its read
2481
2762
  // policies MEMBERSHIP-scoped, so the replica is already valid
2482
2763
  // for every org the user belongs to and the wipe would be pure
@@ -2484,21 +2765,20 @@ export class SyncEngine {
2484
2765
  // Only the TENANT verdict honors the flag; user flips (token
2485
2766
  // change, guardReplicaIdentity) always wipe.
2486
2767
  const skipReset = this.config.resetOnTenantFlip === false;
2487
- if (verdict.replicaInvalidated && !skipReset) {
2488
- // Route reset through the public (queued) method so the
2489
- // wipe serializes against in-flight pulls / WS-event
2490
- // applies / pushes. sessionChain serializes session
2491
- // transitions but NOT the apply queue — without queuing
2492
- // the reset, a concurrent applyChangesAsync could write
2493
- // rows AFTER we clear the store, leaving stale data under
2494
- // the new identity. Identity flipped → wipe the outgoing
2495
- // identity's pending offline writes too.
2496
- await this.resetReplica({ wipeMutations: true });
2768
+ // Leader only, like the user flip above: a follower's replica is
2769
+ // wiped by the leader's `reset` message.
2770
+ const reset =
2771
+ verdict.replicaInvalidated && !skipReset && this.isMultiTabLeader;
2772
+ if (reset) {
2773
+ // Reset and re-pull in one op-queue slot (see resetAndPull).
2774
+ // The tenant's view changed → wipe the outgoing tenant's
2775
+ // pending offline writes too.
2776
+ await this.resetAndPull({ wipeMutations: true });
2497
2777
  }
2498
2778
  if (this.isMultiTabLeader) {
2499
2779
  // Only the leader pulls — followers receive subsequent
2500
2780
  // applied broadcasts that close the catch-up window.
2501
- await this.pull();
2781
+ if (!reset) await this.pull();
2502
2782
  if (skipReset) {
2503
2783
  // No wipe happened, so rows of an org the user only just
2504
2784
  // JOINED aren't in the replica and no change event will
@@ -2510,6 +2790,22 @@ export class SyncEngine {
2510
2790
  }
2511
2791
  const firstResolution = !this.session.hasObserved();
2512
2792
  this.session.commitObservation(next);
2793
+ // The reset above tagged the replica with the outgoing user (the
2794
+ // session was not committed yet). Re-tag it with the new owner.
2795
+ if (userFlipped && this.isMultiTabLeader) this.persistReplicaIdentity();
2796
+ // The session now says who the token belongs to: push writes
2797
+ // that were waiting for it (their owner may be this user).
2798
+ if (this.isMultiTabLeader) {
2799
+ const wasPending = this.identityPending || firstResolution;
2800
+ if (opts.token !== undefined) this.sessionToken = opts.token;
2801
+ // Writes made before any session resolved belong to this one.
2802
+ if (firstResolution) this.mutations.stampPendingOwner(next.userId);
2803
+ this.identityPending = false;
2804
+ this.identityRetryAttempts = 0;
2805
+ if ((wasPending || userFlipped) && this.mutations.pending().length > 0) {
2806
+ void this.push();
2807
+ }
2808
+ }
2513
2809
  if (verdict.identityChanged || firstResolution) {
2514
2810
  // The first answer flips `sessionResolved()` even when the
2515
2811
  // session itself matches the placeholder (anonymous caller).
@@ -2522,6 +2818,76 @@ export class SyncEngine {
2522
2818
  return this.sessionChain;
2523
2819
  }
2524
2820
 
2821
+ /** Reset the replica and pull from zero in ONE op-queue slot, so no
2822
+ * live frame can move the cursor between the reset and the pull's
2823
+ * hold (the pull would then run as a delta and miss rows). */
2824
+ private resetAndPull(opts: { wipeMutations: boolean }): Promise<void> {
2825
+ return this.opQueue.enqueue("reset-pull", async () => {
2826
+ await this.resetReplicaInner(opts);
2827
+ await this.pullInner();
2828
+ }).then(() => {
2829
+ if (this.isMultiTabLeader) this.markInitialSyncSettled();
2830
+ });
2831
+ }
2832
+
2833
+ /** The user a write made now belongs to: the resolved session, or,
2834
+ * before `/api/auth/me` answered this run, the identity the replica
2835
+ * was tagged with on disk. `undefined` = unknown. */
2836
+ private currentOwner(): string | null | undefined {
2837
+ if (this.session.hasObserved()) return this.session.resolved().userId;
2838
+ return this._replicaIdentity;
2839
+ }
2840
+
2841
+ /**
2842
+ * Decide what push does with a queued write, by its owner:
2843
+ * - `send`: it belongs to the signed-in user (or its owner is unknown,
2844
+ * an older client's write), or a guest's write after that guest
2845
+ * signed in (the server merges the guest's rows into the account).
2846
+ * - `hold`: nobody is signed in, or a token change has not resolved
2847
+ * yet. The write waits for the next identity.
2848
+ * - `discard`: another user is signed in. The write is dropped so one
2849
+ * user's writes never push as another's.
2850
+ */
2851
+ private pushable(
2852
+ m: PendingMutation,
2853
+ token: string | null,
2854
+ ): "send" | "hold" | "discard" {
2855
+ if (m.ownerPending) return "hold";
2856
+ if (m.owner === undefined) return "send";
2857
+ if (this.identityPending || !this.session.hasObserved()) return "hold";
2858
+ // The token changed since the session was fetched: it may be someone
2859
+ // else's. Hold until a refresh says whose it is.
2860
+ if (this.sessionToken !== undefined && token !== this.sessionToken) {
2861
+ this.identityPending = true;
2862
+ return "hold";
2863
+ }
2864
+ const now = this.session.resolved().userId;
2865
+ if (m.owner === now) return "send";
2866
+ if (now === null) return "hold";
2867
+ if (typeof m.owner === "string" && m.owner.startsWith("guest_") && !now.startsWith("guest_")) {
2868
+ return "send";
2869
+ }
2870
+ return "discard";
2871
+ }
2872
+
2873
+ /** A push found writes waiting on an unresolved token change. Retry the
2874
+ * session refresh with backoff (1 s, 2 s, ... 30 s) until it answers;
2875
+ * the refresh then pushes them. */
2876
+ private scheduleIdentityRetry(): void {
2877
+ if (this.identityRetryTimer !== null || !this.running) return;
2878
+ const attempt = this.identityRetryAttempts;
2879
+ this.identityRetryAttempts += 1;
2880
+ const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5));
2881
+ this.identityRetryTimer = setTimeout(() => {
2882
+ this.identityRetryTimer = null;
2883
+ void this.refreshResolvedSession().then(() => {
2884
+ if (this.identityPending || !this.session.hasObserved()) {
2885
+ this.scheduleIdentityRetry();
2886
+ }
2887
+ });
2888
+ }, delay);
2889
+ }
2890
+
2525
2891
  private async rawFetch(path: string): Promise<Response> {
2526
2892
  const headers: Record<string, string> = {};
2527
2893
  const token = this.currentToken();
@@ -2647,9 +3013,9 @@ export class SyncEngine {
2647
3013
 
2648
3014
  /**
2649
3015
  * Revoke the current session server-side (DELETE /api/auth/session)
2650
- * and refresh — leaves the caller anonymous. Local sync stops on
2651
- * the next pull cycle; replica content stays in IndexedDB so a
2652
- * subsequent sign-in as the same user is instant.
3016
+ * and refresh — leaves the caller anonymous. The refresh sees the
3017
+ * user flip, wipes the replica (memory and IndexedDB), and re-pulls
3018
+ * as the anonymous identity.
2653
3019
  */
2654
3020
  async signOut(): Promise<void> {
2655
3021
  await this.authMutate("/api/auth/session", undefined, "DELETE");
@@ -2743,29 +3109,83 @@ export class SyncEngine {
2743
3109
  * serializes against pull / reconcile / resetReplica so a push can't
2744
3110
  * observe a half-reset cursor or a mid-reconcile replica. */
2745
3111
  async push(): Promise<void> {
2746
- return this.opQueue.enqueue("push", () => this.pushInner());
3112
+ await this.opQueue.enqueue("push", () => this.pushInner());
3113
+ // A call made while a push was already running got that push's
3114
+ // promise, but that push had snapshotted its batch before this
3115
+ // caller's mutation was queued. Send any mutation no push has
3116
+ // attempted yet. Mutations that failed transiently were attempted
3117
+ // and wait for their backoff retry instead.
3118
+ if (this.mutations.pending().some((m) => !this.attemptedOps.has(m.id))) {
3119
+ await this.push();
3120
+ }
2747
3121
  }
2748
3122
 
3123
+ /** Op ids of pending mutations that some push already sent (or
3124
+ * forwarded to the leader). */
3125
+ private attemptedOps = new Set<string>();
3126
+
2749
3127
  private async pushInner(): Promise<void> {
2750
- const pending = this.mutations.pending();
2751
- if (pending.length === 0) return;
3128
+ const queued = this.mutations.pending();
3129
+ // Keep the attempted set bounded to what is still pending.
3130
+ const pendingIds = new Set(queued.map((m) => m.id));
3131
+ for (const id of this.attemptedOps) {
3132
+ if (!pendingIds.has(id)) this.attemptedOps.delete(id);
3133
+ }
3134
+ for (const id of pendingIds) this.attemptedOps.add(id);
3135
+ if (queued.length === 0) return;
2752
3136
 
2753
3137
  // Multi-tab follower: we don't own the network. Forward the
2754
3138
  // pending batch to the leader and let it push. The leader
2755
3139
  // broadcasts `mutations-acked` when the server confirms; that
2756
3140
  // path clears our queue. Note we don't clear locally here — if
2757
3141
  // the leader dies before pushing, on promotion we still have
2758
- // the queue and can ship it ourselves.
3142
+ // the queue and can ship it ourselves. The leader checks each
3143
+ // write's owner.
2759
3144
  if (!this.isMultiTabLeader) {
2760
- this.broadcastToTabs({ type: "mutations", ops: pending });
3145
+ this.broadcastToTabs({ type: "mutations", ops: queued });
2761
3146
  return;
2762
3147
  }
2763
3148
 
3149
+ // Owner check (see `pushable`). Discarded writes leave the queue with
3150
+ // code DISCARDED, here and in the tab that made them; their rows were
3151
+ // wiped with the old identity's replica, so nothing is rolled back.
3152
+ // Held writes stay queued; their callers are released.
3153
+ const token = this.currentToken();
3154
+ const pending: PendingMutation[] = [];
3155
+ const discarded: { opId: string; error: string; code: string }[] = [];
3156
+ const held: string[] = [];
3157
+ for (const m of queued) {
3158
+ const verdict = this.pushable(m, token);
3159
+ if (verdict === "send") {
3160
+ pending.push(m);
3161
+ } else if (verdict === "discard") {
3162
+ this.mutations.discard(m.id);
3163
+ discarded.push({ opId: m.id, error: "discarded: another user is signed in", code: "DISCARDED" });
3164
+ } else {
3165
+ this.mutations.settleQueued(m.id);
3166
+ held.push(m.id);
3167
+ }
3168
+ }
3169
+ if (discarded.length > 0) this.broadcastToTabs({ type: "mutations-failed", ops: discarded });
3170
+ if (held.length > 0) {
3171
+ this.broadcastToTabs({ type: "mutations-queued", opIds: held });
3172
+ if (this.identityPending || !this.session.hasObserved()) {
3173
+ this.scheduleIdentityRetry();
3174
+ }
3175
+ }
3176
+ if (pending.length === 0) return;
3177
+
2764
3178
  try {
2765
- const resp = await this.request<PushResponse>("POST", "/api/sync/push", {
2766
- changes: pending.map((m) => m.change),
2767
- client_id: this.clientId,
2768
- });
3179
+ // Sent with the token the owner check ran against.
3180
+ const resp = await this.request<PushResponse>(
3181
+ "POST",
3182
+ "/api/sync/push",
3183
+ {
3184
+ changes: pending.map((m) => m.change),
3185
+ client_id: this.clientId,
3186
+ },
3187
+ token,
3188
+ );
2769
3189
  // The request reached the server and returned a response — clear
2770
3190
  // the transient-failure backoff counter (success or per-op
2771
3191
  // rejections both mean "we're online and the server answered").
@@ -2787,7 +3207,12 @@ export class SyncEngine {
2787
3207
  const r =
2788
3208
  (m.change.op_id ? byOpId.get(m.change.op_id) : undefined) ??
2789
3209
  resp.results[i];
2790
- if (!r) continue;
3210
+ if (!r) {
3211
+ // No verdict for this op: it stays queued for the next push.
3212
+ // Release its caller like a transient failure.
3213
+ this.mutations.settleQueued(m.id);
3214
+ continue;
3215
+ }
2791
3216
  // applied: first-time commit at r.seq.
2792
3217
  // replayed: same op_id arrived again after a confirmed apply;
2793
3218
  // r.seq is the original write's seq. Both are terminal-success
@@ -2810,7 +3235,11 @@ export class SyncEngine {
2810
3235
  typeof r.error === "string"
2811
3236
  ? r.error
2812
3237
  : r.error?.message ?? "unknown";
2813
- this.failPushedMutation(m, msg);
3238
+ const code =
3239
+ typeof r.error === "object" && r.error !== null
3240
+ ? r.error.code
3241
+ : undefined;
3242
+ this.failPushedMutation(m, msg, code);
2814
3243
  }
2815
3244
  }
2816
3245
  } else {
@@ -2827,6 +3256,8 @@ export class SyncEngine {
2827
3256
  this.mutations.markApplied(pending[i].id);
2828
3257
  } else if (errors[i - applied]) {
2829
3258
  this.failPushedMutation(pending[i], errors[i - applied]);
3259
+ } else {
3260
+ this.mutations.settleQueued(pending[i].id);
2830
3261
  }
2831
3262
  }
2832
3263
  }
@@ -2839,14 +3270,14 @@ export class SyncEngine {
2839
3270
  // dedupe) get neither — the leader will retry, and a later push
2840
3271
  // will broadcast a real ack.
2841
3272
  const ackedOpIds: string[] = [];
2842
- const failedOps: { opId: string; error: string }[] = [];
3273
+ const failedOps: { opId: string; error: string; code?: string }[] = [];
2843
3274
  for (const m of pending) {
2844
3275
  const opId = m.change.op_id;
2845
3276
  if (typeof opId !== "string") continue;
2846
3277
  if (m.status === "applied") {
2847
3278
  ackedOpIds.push(opId);
2848
3279
  } else if (m.status === "failed") {
2849
- failedOps.push({ opId, error: m.error ?? "unknown" });
3280
+ failedOps.push({ opId, error: m.error ?? "unknown", code: m.errorCode });
2850
3281
  }
2851
3282
  }
2852
3283
  if (ackedOpIds.length > 0) {
@@ -2878,9 +3309,9 @@ export class SyncEngine {
2878
3309
  // takes the Proceed slot). 250ms is short enough that user
2879
3310
  // perception doesn't notice, long enough to not hot-loop.
2880
3311
  if (hasInFlightDedupe) {
2881
- setTimeout(() => {
3312
+ this.later(250, () => {
2882
3313
  void this.push();
2883
- }, 250);
3314
+ });
2884
3315
  }
2885
3316
  } catch (err) {
2886
3317
  // Whole-request failure. CRITICAL distinction:
@@ -2900,13 +3331,14 @@ export class SyncEngine {
2900
3331
  // roll back the optimistic ghost + surface mutations-failed.
2901
3332
  const msg = err instanceof Error ? err.message : String(err);
2902
3333
  const status = (err as { status?: number })?.status;
3334
+ const code = (err as { code?: string })?.code;
2903
3335
  if (isPermanentPushError(status)) {
2904
- const failedOps: { opId: string; error: string }[] = [];
3336
+ const failedOps: { opId: string; error: string; code?: string }[] = [];
2905
3337
  for (const m of pending) {
2906
- this.failPushedMutation(m, msg);
3338
+ this.failPushedMutation(m, msg, code);
2907
3339
  const opId = m.change.op_id;
2908
3340
  if (typeof opId === "string") {
2909
- failedOps.push({ opId, error: msg });
3341
+ failedOps.push({ opId, error: msg, code });
2910
3342
  }
2911
3343
  }
2912
3344
  if (failedOps.length > 0) {
@@ -2921,6 +3353,16 @@ export class SyncEngine {
2921
3353
  // per-op rejection). A 429 also pushes the WS reconnect out so a
2922
3354
  // rate-limited push doesn't drive a tight loop.
2923
3355
  if (status === 429) this.transport?.bumpReconnect(3);
3356
+ // The writes stay queued and retry below. Release the callers
3357
+ // awaiting them (here and in follower tabs): the optimistic rows
3358
+ // are in place, and a later rejection shows up as a failed
3359
+ // mutation.
3360
+ const queuedOpIds: string[] = [];
3361
+ for (const m of pending) {
3362
+ this.mutations.settleQueued(m.id);
3363
+ queuedOpIds.push(m.id);
3364
+ }
3365
+ this.broadcastToTabs({ type: "mutations-queued", opIds: queuedOpIds });
2924
3366
  const attempt = this.pushFailureCount;
2925
3367
  this.pushFailureCount += 1;
2926
3368
  const delayMs = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5));
@@ -2928,13 +3370,23 @@ export class SyncEngine {
2928
3370
  console.warn(
2929
3371
  `[sync] /api/sync/push transient failure (status ${status ?? "offline"}); keeping ${pending.length} mutation(s) pending, retrying in ${delayMs}ms`,
2930
3372
  );
2931
- setTimeout(() => {
3373
+ this.later(delayMs, () => {
2932
3374
  void this.push();
2933
- }, delayMs);
3375
+ });
2934
3376
  }
2935
3377
  }
2936
3378
  }
2937
3379
 
3380
+ /** Run `fn` after `ms` unless the engine stops first. A retry from a
3381
+ * stopped engine would otherwise still reach the network. */
3382
+ private later(ms: number, fn: () => void): void {
3383
+ const timer = setTimeout(() => {
3384
+ this.retryTimers.delete(timer);
3385
+ if (this.running) fn();
3386
+ }, ms);
3387
+ this.retryTimers.add(timer);
3388
+ }
3389
+
2938
3390
  /**
2939
3391
  * Mark a pending mutation as failed AND undo its optimistic ghost
2940
3392
  * in the local replica. Without the rollback step, a server-
@@ -2953,7 +3405,11 @@ export class SyncEngine {
2953
3405
  * insert; collaborative-edit is an update with a separate CRDT
2954
3406
  * channel) so insert-only rollback is the right shape to ship now.
2955
3407
  */
2956
- private failPushedMutation(m: PendingMutation, error: string): void {
3408
+ private failPushedMutation(
3409
+ m: PendingMutation,
3410
+ error: string,
3411
+ code?: string,
3412
+ ): void {
2957
3413
  const { entity, row_id, kind } = m.change;
2958
3414
  if (kind === "insert") {
2959
3415
  // No tombstone — a future legitimate insert of this id must work.
@@ -2972,10 +3428,18 @@ export class SyncEngine {
2972
3428
  this.store.restoreRow(entity, row_id, m.prevRow);
2973
3429
  }
2974
3430
  }
2975
- this.mutations.markFailed(m.id, error);
3431
+ this.mutations.markFailed(m.id, error, code);
2976
3432
  }
2977
3433
 
2978
3434
  /** Insert a row with optimistic local update.
3435
+ *
3436
+ * The row is in the local store before this returns a promise. The
3437
+ * promise resolves with the row id once the server applies the write,
3438
+ * or once the write is queued because the server could not be reached
3439
+ * (offline, 5xx); a queued write that the server rejects later shows up
3440
+ * as a failed mutation. The promise rejects with a
3441
+ * `MutationRejectedError` when the server rejects the write (policy
3442
+ * denial, validation); the optimistic row is removed first.
2979
3443
  *
2980
3444
  * Invariant: the optimistic ghost and the canonical server row
2981
3445
  * share a single id. The client mints a Pylon-shaped id, threads
@@ -2986,17 +3450,50 @@ export class SyncEngine {
2986
3450
  const id = generateId();
2987
3451
  const dataWithId = { ...data, id };
2988
3452
  this.store.optimisticInsertWithId(entity, id, dataWithId);
2989
- this.mutations.add({
2990
- entity,
2991
- row_id: id,
2992
- kind: "insert",
2993
- data: dataWithId,
2994
- });
2995
- await this.push();
3453
+ const opId = this.mutations.add(
3454
+ {
3455
+ entity,
3456
+ row_id: id,
3457
+ kind: "insert",
3458
+ data: dataWithId,
3459
+ },
3460
+ undefined,
3461
+ this.currentOwner(),
3462
+ this.currentOwner() === undefined,
3463
+ );
3464
+ await this.sendAndAwait(opId);
2996
3465
  return id;
2997
3466
  }
2998
3467
 
2999
- /** Update a row with optimistic local update. */
3468
+ /** Push, then wait for the mutation's outcome. The wait starts before
3469
+ * the push so an outcome that lands during the push is not missed. */
3470
+ private async sendAndAwait(opId: string): Promise<void> {
3471
+ const outcome = this.mutations.waitForOutcome(opId);
3472
+ // Mark the promise handled: push() can settle it before we await it.
3473
+ outcome.catch(() => {});
3474
+ await this.push();
3475
+ if (!this.isMultiTabLeader) {
3476
+ // A follower forwards the write and waits for the leader's verdict.
3477
+ // A frozen or dying leader must not hang the caller: after the
3478
+ // timeout the write counts as queued (a later verdict still rolls it
3479
+ // back and marks it failed).
3480
+ const timer = setTimeout(
3481
+ () => this.mutations.settleQueued(opId),
3482
+ FOLLOWER_OUTCOME_TIMEOUT_MS,
3483
+ );
3484
+ try {
3485
+ await outcome;
3486
+ } finally {
3487
+ clearTimeout(timer);
3488
+ }
3489
+ return;
3490
+ }
3491
+ await outcome;
3492
+ }
3493
+
3494
+ /** Update a row with optimistic local update. Settles like `insert`:
3495
+ * rejects with a `MutationRejectedError` (after restoring the prior
3496
+ * value) when the server rejects the write. */
3000
3497
  async update(entity: string, id: string, data: Partial<Row>): Promise<void> {
3001
3498
  // Snapshot the pre-update row BEFORE applying the optimistic merge so
3002
3499
  // a rejected push can restore the exact prior value (see
@@ -3004,7 +3501,7 @@ export class SyncEngine {
3004
3501
  const before = this.store.get(entity, id);
3005
3502
  const prev = before ? { ...before } : null;
3006
3503
  this.store.optimisticUpdate(entity, id, data);
3007
- this.mutations.add(
3504
+ const opId = this.mutations.add(
3008
3505
  {
3009
3506
  entity,
3010
3507
  row_id: id,
@@ -3012,26 +3509,32 @@ export class SyncEngine {
3012
3509
  data: data as Row,
3013
3510
  },
3014
3511
  prev,
3512
+ this.currentOwner(),
3513
+ this.currentOwner() === undefined,
3015
3514
  );
3016
- await this.push();
3515
+ await this.sendAndAwait(opId);
3017
3516
  }
3018
3517
 
3019
- /** Delete a row with optimistic local update. */
3518
+ /** Delete a row with optimistic local update. Settles like `insert`:
3519
+ * rejects with a `MutationRejectedError` (after restoring the row)
3520
+ * when the server rejects the delete. */
3020
3521
  async delete(entity: string, id: string): Promise<void> {
3021
3522
  // Snapshot the row before removing it so a rejected delete can bring
3022
3523
  // it back (and clear the optimistic tombstone).
3023
3524
  const before = this.store.get(entity, id);
3024
3525
  const prev = before ? { ...before } : null;
3025
3526
  this.store.optimisticDelete(entity, id);
3026
- this.mutations.add(
3527
+ const opId = this.mutations.add(
3027
3528
  {
3028
3529
  entity,
3029
3530
  row_id: id,
3030
3531
  kind: "delete",
3031
3532
  },
3032
3533
  prev,
3534
+ this.currentOwner(),
3535
+ this.currentOwner() === undefined,
3033
3536
  );
3034
- await this.push();
3537
+ await this.sendAndAwait(opId);
3035
3538
  }
3036
3539
 
3037
3540
  // -----------------------------------------------------------------------
@@ -3360,6 +3863,18 @@ export class SyncEngine {
3360
3863
  }
3361
3864
  }
3362
3865
 
3866
+ /** Re-send `room-subscribe` for a room this tab is subscribed to and
3867
+ * clear its error. Call after rejoining the room over HTTP following a
3868
+ * `NOT_IN_ROOM` error; the server replies with a fresh snapshot. A
3869
+ * follower asks the leader, which owns the wire subscription. */
3870
+ resubscribeRoom(roomId: string): void {
3871
+ if (this.isMultiTabLeader) {
3872
+ this.rooms.resubscribe(roomId);
3873
+ return;
3874
+ }
3875
+ this.broadcastToTabs({ type: "room-sub-resubscribe", room: roomId });
3876
+ }
3877
+
3363
3878
  /** Read the current cached members snapshot for `roomId`. Returns
3364
3879
  * `null` when no snapshot has landed yet (distinct from `[]` for
3365
3880
  * an empty room). */
@@ -3671,7 +4186,12 @@ export class SyncEngine {
3671
4186
  this.lastCrdtFrames.delete(`${entity}|${rowId}`);
3672
4187
  }
3673
4188
 
3674
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
4189
+ private async request<T>(
4190
+ method: string,
4191
+ path: string,
4192
+ body?: unknown,
4193
+ tokenOverride?: string | null,
4194
+ ): Promise<T> {
3675
4195
  const headers: Record<string, string> = {};
3676
4196
  if (body) headers["Content-Type"] = "application/json";
3677
4197
  // Prefer the token explicitly configured on the engine; fall back to
@@ -3680,9 +4200,11 @@ export class SyncEngine {
3680
4200
  // anonymous caller and gets rate-limited into a 429 reconnect storm
3681
4201
  // once the anon bucket fills.
3682
4202
  const token =
3683
- this.config.token ??
3684
- this.storage.get(this.tokenStorageKey()) ??
3685
- undefined;
4203
+ tokenOverride !== undefined
4204
+ ? tokenOverride ?? undefined
4205
+ : this.config.token ??
4206
+ this.storage.get(this.tokenStorageKey()) ??
4207
+ undefined;
3686
4208
  if (token) headers["Authorization"] = `Bearer ${token}`;
3687
4209
 
3688
4210
  // credentials: "include" so cookie-auth apps (Yapless and any other
@@ -3705,8 +4227,22 @@ export class SyncEngine {
3705
4227
  // loop uses this to decide whether to back off.
3706
4228
  const err = new Error(`Sync request failed: ${res.status}`) as Error & {
3707
4229
  status?: number;
4230
+ code?: string;
3708
4231
  };
3709
4232
  err.status = res.status;
4233
+ // Carry the server's error envelope ({ error: { code, message } })
4234
+ // so a rejected push can tell the caller why.
4235
+ try {
4236
+ const body = (await res.json()) as {
4237
+ error?: { code?: string; message?: string };
4238
+ };
4239
+ if (typeof body?.error?.code === "string") err.code = body.error.code;
4240
+ if (typeof body?.error?.message === "string") {
4241
+ err.message = body.error.message;
4242
+ }
4243
+ } catch {
4244
+ // Not JSON (proxy error page, empty body): keep the status text.
4245
+ }
3710
4246
  throw err;
3711
4247
  }
3712
4248
 
@@ -3718,6 +4254,19 @@ export class SyncEngine {
3718
4254
  // SSR / Hydration types
3719
4255
  // ---------------------------------------------------------------------------
3720
4256
 
4257
+ /** How long a follower tab's insert/update/delete waits for the leader's
4258
+ * verdict before it resolves as queued. */
4259
+ const FOLLOWER_OUTCOME_TIMEOUT_MS = 5_000;
4260
+
4261
+ /** Live change frames queued for one apply step (see `enqueueApply`). */
4262
+ interface LiveBatch {
4263
+ changes: ChangeEvent[];
4264
+ fromBroadcast: boolean;
4265
+ /** `replicaEpoch` when the batch opened. */
4266
+ epoch: number;
4267
+ promise: Promise<void>;
4268
+ }
4269
+
3721
4270
  /** Data shape for hydrating the client from server-rendered content. */
3722
4271
  export interface HydrationData {
3723
4272
  /** Map of entity name -> rows fetched on the server. */