@pylonsync/sync 0.20.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/dist/index.d.ts +108 -7
- package/dist/local-store.d.ts +7 -0
- package/dist/multi-tab-orchestrator.d.ts +14 -0
- package/dist/mutation-queue.d.ts +57 -2
- package/dist/persistence.d.ts +12 -0
- package/dist/room-subscriptions.d.ts +5 -0
- package/dist/test-harness/server.d.ts +7 -0
- package/package.json +1 -1
- package/src/bootstrap-speedup.test.ts +9 -6
- package/src/idb-warm-load.test.ts +5 -11
- package/src/identity-reset-safety.test.ts +174 -0
- package/src/identity-resync.test.ts +120 -0
- package/src/index.ts +701 -152
- package/src/live-batch-persist.test.ts +180 -0
- package/src/local-store.ts +14 -0
- package/src/multi-tab-orchestrator.test.ts +3 -0
- package/src/multi-tab-orchestrator.ts +39 -3
- package/src/mutation-outcome.test.ts +203 -0
- package/src/mutation-owner-token.test.ts +131 -0
- package/src/mutation-owner.test.ts +217 -0
- package/src/mutation-queue.ts +177 -3
- package/src/persistence.ts +31 -0
- package/src/reconcile.test.ts +6 -3
- package/src/room-subscriptions.test.ts +32 -0
- package/src/room-subscriptions.ts +11 -0
- package/src/scenarios.test.ts +9 -3
- package/src/synced-flag.test.ts +117 -0
- package/src/test-harness/server.ts +15 -0
- package/src/test-harness/transport.ts +1 -0
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
|
-
|
|
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 (
|
|
1075
|
-
|
|
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
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
this.cursor
|
|
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
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
this
|
|
1431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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(
|
|
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
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
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(
|
|
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<
|
|
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
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
//
|
|
2493
|
-
//
|
|
2494
|
-
//
|
|
2495
|
-
|
|
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.
|
|
2651
|
-
*
|
|
2652
|
-
*
|
|
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
|
-
|
|
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
|
|
2751
|
-
|
|
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:
|
|
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
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
3312
|
+
this.later(250, () => {
|
|
2882
3313
|
void this.push();
|
|
2883
|
-
}
|
|
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
|
-
|
|
3373
|
+
this.later(delayMs, () => {
|
|
2932
3374
|
void this.push();
|
|
2933
|
-
}
|
|
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(
|
|
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
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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>(
|
|
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
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
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. */
|