@pylonsync/sync 0.22.25 → 0.22.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -491,19 +491,6 @@ export class SyncEngine {
491
491
  */
492
492
  private binaryHandlers: Set<(bytes: Uint8Array) => void> = new Set();
493
493
 
494
- /**
495
- * Latest server CRDT SNAPSHOT frame per `entity|rowId`, kept so a
496
- * follower tab that registers interest in an ALREADY-subscribed row
497
- * gets state immediately. The server sends its catch-up snapshot only
498
- * when a fresh `crdt-subscribe` goes over the WS — when the leader
499
- * (or another follower) already holds the subscription, no wire
500
- * traffic happens and, pre-cache, the new tab's LoroDoc stayed empty
501
- * until the next live edit. Server broadcasts are always FULL merged
502
- * snapshots (CRDT_FRAME_SNAPSHOT), so one frame per row is complete
503
- * state. Bounded FIFO — see LAST_CRDT_FRAMES_MAX.
504
- */
505
- private lastCrdtFrames: Map<string, Uint8Array> = new Map();
506
-
507
494
  /**
508
495
  * Server-side ephemeral subscriptions (CRDT row subs, reactive query
509
496
  * subs, future kinds). Owns the WS replay bookkeeping — each kind
@@ -631,14 +618,6 @@ export class SyncEngine {
631
618
  this.subscriptions = new SubscriptionCoordinator(this.serverSubs, {
632
619
  isLeader: () => this.isMultiTabLeader,
633
620
  broadcastToTabs: (payload) => this.broadcastToTabs(payload),
634
- // Follower registered interest in a row whose WS subscription is
635
- // already alive — no server catch-up will come, so replay the
636
- // cached snapshot over the tab channel. Loro imports are
637
- // idempotent, so tabs that already have the state are unaffected.
638
- replayCrdtFrame: (entity, rowId) => {
639
- const cached = this.lastCrdtFrames.get(`${entity}|${rowId}`);
640
- if (cached) this.broadcastToTabs({ type: "binary", bytes: cached });
641
- },
642
621
  });
643
622
  this.rooms = new RoomSubscriptions((msg) => {
644
623
  // Leader: send over the WS. Followers don't open a transport;
@@ -1098,10 +1077,10 @@ export class SyncEngine {
1098
1077
  // restoring it is correct on both tabs.
1099
1078
  this.mutations.add(op.change, op.prevRow, op.owner, op.ownerPending);
1100
1079
  }
1101
- void this.push();
1080
+ void this.pushNew(ops.map((op) => op.id));
1102
1081
  },
1103
1082
  onMutationsAcked: (opIds: string[]) => {
1104
- for (const id of opIds) this.mutations.markApplied(id);
1083
+ this.mutations.markAppliedMany(opIds);
1105
1084
  this.mutations.clear();
1106
1085
  },
1107
1086
  onMutationsQueued: (opIds: string[]) => {
@@ -1624,13 +1603,33 @@ export class SyncEngine {
1624
1603
  tombstoneSeq: number,
1625
1604
  opts: { fromBroadcast?: boolean } = {},
1626
1605
  ): Promise<void> {
1606
+ const epoch = this.replicaEpoch;
1627
1607
  return this.chainApply(async () => {
1628
- await this.store.applyReconcileBatch(
1629
- entity,
1630
- upserts,
1631
- removalIds,
1632
- tombstoneSeq,
1608
+ if (epoch !== this.replicaEpoch) return;
1609
+ const changes = this.store.applyReconcileInMemory(
1610
+ entity, upserts, removalIds, tombstoneSeq,
1633
1611
  );
1612
+ if (epoch !== this.replicaEpoch) return;
1613
+ if (changes.length > 0 && this.store._persistFn) {
1614
+ try {
1615
+ if (this.persistence?.saveBatch) {
1616
+ const durable = await this.persistence.saveBatch(changes, null);
1617
+ if (epoch !== this.replicaEpoch) return;
1618
+ if (!durable) this.persistDegraded = true;
1619
+ } else {
1620
+ for (const change of changes) {
1621
+ if (epoch !== this.replicaEpoch) break;
1622
+ const durable = await this.store._persistFn(change);
1623
+ if (epoch !== this.replicaEpoch) return;
1624
+ if (durable === false) this.persistDegraded = true;
1625
+ }
1626
+ }
1627
+ } catch {
1628
+ if (epoch !== this.replicaEpoch) return;
1629
+ this.persistDegraded = true;
1630
+ }
1631
+ }
1632
+ if (epoch !== this.replicaEpoch) return;
1634
1633
  // Leader fans the reconcile batch out so each follower can
1635
1634
  // converge without its own fetch. Suppress when we ourselves
1636
1635
  // received this batch via the channel (promotion mid-flight
@@ -1656,6 +1655,7 @@ export class SyncEngine {
1656
1655
  }
1657
1656
  for (const timer of this.retryTimers) clearTimeout(timer);
1658
1657
  this.retryTimers.clear();
1658
+ this.pushRetryTimer = null;
1659
1659
  if (this.transport) {
1660
1660
  this.transport.stop();
1661
1661
  this.transport = null;
@@ -1797,6 +1797,9 @@ export class SyncEngine {
1797
1797
  // re-latches the flag.)
1798
1798
  this.persistDegraded = false;
1799
1799
  if (wipeMutations) {
1800
+ this.cancelPushRetry();
1801
+ this.pushFailureCount = 0;
1802
+ this.newPushIds.clear();
1800
1803
  // Identity flip: discard the outgoing identity's pending offline
1801
1804
  // writes (and persist the empty queue to disk via the mutation
1802
1805
  // backend). persistence.clear() deliberately leaves MUTATIONS_STORE
@@ -2400,6 +2403,7 @@ export class SyncEngine {
2400
2403
  // under the new context.
2401
2404
  const cursorBeforeFetch = this.cursor.last_seq;
2402
2405
  const sessionBeforeFetch = this.session.signature();
2406
+ const epochBeforeFetch = this.replicaEpoch;
2403
2407
  let serverRows: Row[];
2404
2408
  let fetchTruncated: boolean;
2405
2409
  try {
@@ -2410,6 +2414,9 @@ export class SyncEngine {
2410
2414
  // Network errors are expected (offline, transient 5xx). Skip
2411
2415
  // this entity; the next reconcile trigger will retry.
2412
2416
  const status = (err as { status?: number })?.status;
2417
+ if (this.replicaEpoch !== epochBeforeFetch || this.session.signature() !== sessionBeforeFetch) {
2418
+ return;
2419
+ }
2413
2420
  if (status === 404) {
2414
2421
  // Entity removed from the manifest — definitive. Drop its rows.
2415
2422
  this.reconcile403Streak.delete(entity);
@@ -2576,23 +2583,13 @@ export class SyncEngine {
2576
2583
  entity: string,
2577
2584
  tombstoneSeq: number,
2578
2585
  ): Promise<void> {
2579
- const locals = this.store.list(entity);
2580
- let removed = false;
2581
- for (const local of locals) {
2582
- const id = (local as { id?: unknown }).id;
2583
- if (typeof id !== "string") continue;
2584
- if (this.store.reconcileRemove(entity, id, tombstoneSeq)) {
2585
- removed = true;
2586
- if (this.persistence) {
2587
- try {
2588
- await this.persistence.deleteRow(entity, id);
2589
- } catch {
2590
- /* best-effort */
2591
- }
2592
- }
2593
- }
2586
+ const removalIds: string[] = [];
2587
+ for (const row of this.store.list(entity)) {
2588
+ if (typeof row.id === "string") removalIds.push(row.id);
2589
+ }
2590
+ if (removalIds.length > 0) {
2591
+ await this.enqueueReconcile(entity, [], removalIds, tombstoneSeq);
2594
2592
  }
2595
- if (removed) this.store.notify();
2596
2593
  }
2597
2594
 
2598
2595
  /**
@@ -2990,7 +2987,6 @@ export class SyncEngine {
2990
2987
  }
2991
2988
  this.store.notify();
2992
2989
  }
2993
- this.evictCrdtFrame(entity, rowId);
2994
2990
  for (const listener of this.rowEvictionListeners) {
2995
2991
  listener(entity, rowId);
2996
2992
  }
@@ -3134,37 +3130,54 @@ export class SyncEngine {
3134
3130
  return parsed;
3135
3131
  }
3136
3132
 
3137
- /** Push pending mutations to the server. Coalesces concurrent callers
3138
- * via the op queue's keyed dedupe — a slow push can't be restarted
3139
- * by the poll timer or a user mutation, which would resend the same
3140
- * batch (the mutation `op_id` keeps that safe at the protocol level,
3141
- * but shipping the same batch twice is still wasted bandwidth). Also
3142
- * serializes against pull / reconcile / resetReplica so a push can't
3143
- * observe a half-reset cursor or a mid-reconcile replica. */
3144
- async push(): Promise<void> {
3145
- await this.opQueue.enqueue("push", () => this.pushInner());
3146
- // A call made while a push was already running got that push's
3147
- // promise, but that push had snapshotted its batch before this
3148
- // caller's mutation was queued. Send any mutation no push has
3149
- // attempted yet. Mutations that failed transiently were attempted
3150
- // and wait for their backoff retry instead.
3151
- if (this.mutations.pending().some((m) => !this.attemptedOps.has(m.id))) {
3152
- await this.push();
3153
- }
3133
+ private inFlightPush: Promise<void> | null = null;
3134
+ private newPushIds = new Set<string>();
3135
+ private pushReplayRequested = false;
3136
+ private pushRetryTimer: ReturnType<typeof setTimeout> | null = null;
3137
+
3138
+ /** Explicit recovery retries all pending writes. Concurrent callers share
3139
+ * the complete drain, including writes added during the request. */
3140
+ push(): Promise<void> {
3141
+ this.pushReplayRequested = true;
3142
+ return this.inFlightPush ?? this.drainPush();
3154
3143
  }
3155
3144
 
3156
- /** Op ids of pending mutations that some push already sent (or
3157
- * forwarded to the leader). */
3158
- private attemptedOps = new Set<string>();
3145
+ private pushNew(ids: string[]): Promise<void> {
3146
+ for (const id of ids) this.newPushIds.add(id);
3147
+ return this.inFlightPush ?? this.drainPush();
3148
+ }
3149
+
3150
+ private drainPush(): Promise<void> {
3151
+ this.inFlightPush = (async () => {
3152
+ try {
3153
+ do {
3154
+ await this.opQueue.enqueue("push", async () => {
3155
+ const replay = this.pushReplayRequested;
3156
+ this.pushReplayRequested = false;
3157
+ const ids = this.newPushIds;
3158
+ this.newPushIds = new Set();
3159
+ if (replay) this.cancelPushRetry();
3160
+ await this.pushInner(replay ? null : ids);
3161
+ });
3162
+ } while (this.pushReplayRequested || this.newPushIds.size > 0);
3163
+ } finally {
3164
+ this.inFlightPush = null;
3165
+ }
3166
+ })();
3167
+ return this.inFlightPush;
3168
+ }
3159
3169
 
3160
- private async pushInner(): Promise<void> {
3161
- const queued = this.mutations.pending();
3162
- // Keep the attempted set bounded to what is still pending.
3163
- const pendingIds = new Set(queued.map((m) => m.id));
3164
- for (const id of this.attemptedOps) {
3165
- if (!pendingIds.has(id)) this.attemptedOps.delete(id);
3170
+ private async pushInner(newIds: Set<string> | null): Promise<void> {
3171
+ // Followers forward new writes only. During backoff, new writes settle
3172
+ // locally without copying or sending the existing queue again.
3173
+ const delta = newIds !== null && (!this.isMultiTabLeader || this.pushRetryTimer !== null);
3174
+ const queued: PendingMutation[] = delta ? [] : this.mutations.pending();
3175
+ if (delta) {
3176
+ for (const id of newIds) {
3177
+ const mutation = this.mutations.get(id);
3178
+ if (mutation?.status === "pending") queued.push(mutation);
3179
+ }
3166
3180
  }
3167
- for (const id of pendingIds) this.attemptedOps.add(id);
3168
3181
  if (queued.length === 0) return;
3169
3182
 
3170
3183
  // Multi-tab follower: we don't own the network. Forward the
@@ -3208,6 +3221,12 @@ export class SyncEngine {
3208
3221
  }
3209
3222
  if (pending.length === 0) return;
3210
3223
 
3224
+ if (this.pushRetryTimer !== null) {
3225
+ for (const mutation of pending) this.mutations.settleQueued(mutation.id);
3226
+ this.broadcastToTabs({ type: "mutations-queued", opIds: pending.map((mutation) => mutation.id) });
3227
+ return;
3228
+ }
3229
+
3211
3230
  try {
3212
3231
  // Sent with the token the owner check ran against.
3213
3232
  const resp = await this.request<PushResponse>(
@@ -3228,6 +3247,7 @@ export class SyncEngine {
3228
3247
  // back to positional. Invariant: a partial-failure batch lands
3229
3248
  // the correct status on each mutation by id, never by position.
3230
3249
  // Test: `push_partial_failure_maps_results_by_op_id`.
3250
+ const appliedIds: string[] = [];
3231
3251
  let maxAppliedSeq = 0;
3232
3252
  let hasInFlightDedupe = false;
3233
3253
  if (Array.isArray(resp.results)) {
@@ -3252,7 +3272,7 @@ export class SyncEngine {
3252
3272
  // from the client's perspective.
3253
3273
  // deduped: legacy server response — treat as replayed.
3254
3274
  if (r.status === "applied" || r.status === "replayed" || r.status === "deduped") {
3255
- this.mutations.markApplied(m.id);
3275
+ appliedIds.push(m.id);
3256
3276
  if (typeof r.seq === "number" && r.seq > maxAppliedSeq) {
3257
3277
  maxAppliedSeq = r.seq;
3258
3278
  }
@@ -3286,7 +3306,7 @@ export class SyncEngine {
3286
3306
  const errors = Array.isArray(resp.errors) ? resp.errors : [];
3287
3307
  for (let i = 0; i < pending.length; i++) {
3288
3308
  if (i < applied) {
3289
- this.mutations.markApplied(pending[i].id);
3309
+ appliedIds.push(pending[i].id);
3290
3310
  } else if (errors[i - applied]) {
3291
3311
  this.failPushedMutation(pending[i], errors[i - applied]);
3292
3312
  } else {
@@ -3295,6 +3315,8 @@ export class SyncEngine {
3295
3315
  }
3296
3316
  }
3297
3317
 
3318
+ this.mutations.markAppliedMany(appliedIds);
3319
+
3298
3320
  // Broadcast per-op outcomes BEFORE clearing locally so followers
3299
3321
  // can update their queue status. Filter strictly by current
3300
3322
  // status — pending[i] is the LIVE PendingMutation that markApplied
@@ -3342,9 +3364,7 @@ export class SyncEngine {
3342
3364
  // takes the Proceed slot). 250ms is short enough that user
3343
3365
  // perception doesn't notice, long enough to not hot-loop.
3344
3366
  if (hasInFlightDedupe) {
3345
- this.later(250, () => {
3346
- void this.push();
3347
- });
3367
+ this.schedulePushRetry(250);
3348
3368
  }
3349
3369
  } catch (err) {
3350
3370
  // Whole-request failure. CRITICAL distinction:
@@ -3403,13 +3423,29 @@ export class SyncEngine {
3403
3423
  console.warn(
3404
3424
  `[sync] /api/sync/push transient failure (status ${status ?? "offline"}); keeping ${pending.length} mutation(s) pending, retrying in ${delayMs}ms`,
3405
3425
  );
3406
- this.later(delayMs, () => {
3407
- void this.push();
3408
- });
3426
+ this.schedulePushRetry(delayMs);
3409
3427
  }
3410
3428
  }
3411
3429
  }
3412
3430
 
3431
+ private cancelPushRetry(): void {
3432
+ if (this.pushRetryTimer === null) return;
3433
+ clearTimeout(this.pushRetryTimer);
3434
+ this.retryTimers.delete(this.pushRetryTimer);
3435
+ this.pushRetryTimer = null;
3436
+ }
3437
+
3438
+ private schedulePushRetry(ms: number): void {
3439
+ this.cancelPushRetry();
3440
+ const timer = setTimeout(() => {
3441
+ this.retryTimers.delete(timer);
3442
+ this.pushRetryTimer = null;
3443
+ if (this.running) void this.push();
3444
+ }, ms);
3445
+ this.pushRetryTimer = timer;
3446
+ this.retryTimers.add(timer);
3447
+ }
3448
+
3413
3449
  /** Run `fn` after `ms` unless the engine stops first. A retry from a
3414
3450
  * stopped engine would otherwise still reach the network. */
3415
3451
  private later(ms: number, fn: () => void): void {
@@ -3504,7 +3540,7 @@ export class SyncEngine {
3504
3540
  const outcome = this.mutations.waitForOutcome(opId);
3505
3541
  // Mark the promise handled: push() can settle it before we await it.
3506
3542
  outcome.catch(() => {});
3507
- await this.push();
3543
+ await this.pushNew([opId]);
3508
3544
  if (!this.isMultiTabLeader) {
3509
3545
  // A follower forwards the write and waits for the leader's verdict.
3510
3546
  // A frozen or dying leader must not hang the caller: after the
@@ -4011,7 +4047,8 @@ export class SyncEngine {
4011
4047
  },
4012
4048
  setStatus: (s) => this.setConnectionStatus(s),
4013
4049
  performPollTick: async () => {
4014
- await this.push().then(() => this.pull());
4050
+ if (this.pushRetryTimer === null) await this.push();
4051
+ await this.pull();
4015
4052
  },
4016
4053
  performReconnectPull: async () => {
4017
4054
  // Wrapped in try so a transient pull failure doesn't kill the
@@ -4177,19 +4214,7 @@ export class SyncEngine {
4177
4214
  * follower tab forwarded a CRDT sub, mirror over the multi-tab
4178
4215
  * channel so followers see Loro updates too. */
4179
4216
  private dispatchBinaryFrame(bytes: Uint8Array): void {
4180
- // Remember the latest snapshot per row for follower catch-up (see
4181
- // lastCrdtFrames). Header-only peek; the payload stays opaque.
4182
- const key = crdtFrameKey(bytes);
4183
- if (key !== null) {
4184
- if (!this.lastCrdtFrames.has(key) &&
4185
- this.lastCrdtFrames.size >= LAST_CRDT_FRAMES_MAX) {
4186
- const oldest = this.lastCrdtFrames.keys().next().value;
4187
- if (oldest !== undefined) this.lastCrdtFrames.delete(oldest);
4188
- }
4189
- // Re-insert to refresh FIFO position.
4190
- this.lastCrdtFrames.delete(key);
4191
- this.lastCrdtFrames.set(key, bytes);
4192
- }
4217
+ const route = crdtFrameRoute(bytes);
4193
4218
  for (const handler of this.binaryHandlers) {
4194
4219
  try {
4195
4220
  handler(bytes);
@@ -4197,28 +4222,15 @@ export class SyncEngine {
4197
4222
  console.warn("[sync] binary handler threw:", err);
4198
4223
  }
4199
4224
  }
4200
- // Forward to follower tabs ONLY when at least one follower is
4201
- // currently forwarded for a CRDT row. The engine is binary-
4202
- // agnostic — it can't peek inside the frame to route per-row —
4203
- // so this is a tab-level gate: no forwarders = no broadcast.
4204
- // Saves bandwidth in the common single-tab case.
4205
- //
4206
- // Trade-off: when ANY follower has forwarded a CRDT sub on ANY
4207
- // key, we broadcast EVERY binary frame regardless of which row
4208
- // it's for. Acceptable for now; the lever to pull if it shows
4209
- // up in profiling is a binaryRoutes map keyed by the Loro doc
4210
- // id parsed from the frame header.
4211
- if (this.subscriptions.hasCrdtForwarders()) {
4225
+ // Known CRDT frames need a follower subscribed to this row.
4226
+ // Other binary formats retain the general follower fallback.
4227
+ if (route
4228
+ ? this.subscriptions.hasCrdtForwardersFor(route.entity, route.rowId)
4229
+ : this.subscriptions.hasCrdtForwarders()) {
4212
4230
  this.broadcastToTabs({ type: "binary", bytes });
4213
4231
  }
4214
4232
  }
4215
4233
 
4216
- /** Drop the cached snapshot for a revoked row so it can't be
4217
- * replayed to a late-joining tab after policy said no. */
4218
- private evictCrdtFrame(entity: string, rowId: string): void {
4219
- this.lastCrdtFrames.delete(`${entity}|${rowId}`);
4220
- }
4221
-
4222
4234
  private async request<T>(
4223
4235
  method: string,
4224
4236
  path: string,
@@ -4355,16 +4367,7 @@ export async function getServerData(
4355
4367
  return { entities: entityData, cursor };
4356
4368
  }
4357
4369
 
4358
- /**
4359
- * Stable equality check for reconciler diffs. Keys are sorted so
4360
- * `{a:1,b:2}` and `{b:2,a:1}` compare equal — without that, every
4361
- * reconcile pass would think every row had changed (insertion order
4362
- * varies by mutation path on the server). Recursive on objects only;
4363
- * arrays and primitives use their natural shape.
4364
- */
4365
- /** Cap on cached per-row CRDT snapshots (see lastCrdtFrames). 64 rows of
4366
- * collaborative state is far beyond what one browser session edits at
4367
- * once; FIFO eviction keeps a long-lived leader tab bounded. */
4370
+ /** @deprecated The engine no longer caches binary frames. */
4368
4371
  export const LAST_CRDT_FRAMES_MAX = 64;
4369
4372
 
4370
4373
  /**
@@ -4381,22 +4384,33 @@ export const LAST_CRDT_FRAMES_MAX = 64;
4381
4384
  * this parser and the Swift one change with it.
4382
4385
  *
4383
4386
  * The engine still treats the PAYLOAD as opaque — this reads only the
4384
- * routing header so the follower-catch-up cache can be keyed per row.
4387
+ * routing header. A snapshot type does not prove the payload is complete.
4385
4388
  */
4386
4389
  export function crdtFrameKey(bytes: Uint8Array): string | null {
4387
- const SNAPSHOT_TYPE = 0x10;
4388
- if (bytes.length < 5 || bytes[0] !== SNAPSHOT_TYPE) return null;
4390
+ const route = crdtFrameRoute(bytes);
4391
+ return route?.snapshot ? `${route.entity}|${route.rowId}` : null;
4392
+ }
4393
+
4394
+ const crdtHeaderDecoder = new TextDecoder("utf-8", { fatal: true });
4395
+
4396
+ function crdtFrameRoute(bytes: Uint8Array): { entity: string; rowId: string; snapshot: boolean } | null {
4397
+ if (bytes.length < 5 || (bytes[0] !== 0x10 && bytes[0] !== 0x11)) return null;
4389
4398
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
4390
4399
  const entityLen = view.getUint16(1, false);
4391
4400
  const entityEnd = 3 + entityLen;
4392
- if (entityEnd + 2 > bytes.length) return null;
4401
+ if (entityLen === 0 || entityEnd + 2 > bytes.length) return null;
4393
4402
  const rowIdLen = view.getUint16(entityEnd, false);
4394
4403
  const rowIdEnd = entityEnd + 2 + rowIdLen;
4395
- if (rowIdEnd > bytes.length) return null;
4396
- const decoder = new TextDecoder();
4397
- const entity = decoder.decode(bytes.subarray(3, entityEnd));
4398
- const rowId = decoder.decode(bytes.subarray(entityEnd + 2, rowIdEnd));
4399
- return `${entity}|${rowId}`;
4404
+ if (rowIdLen === 0 || rowIdEnd > bytes.length) return null;
4405
+ try {
4406
+ return {
4407
+ entity: crdtHeaderDecoder.decode(bytes.subarray(3, entityEnd)),
4408
+ rowId: crdtHeaderDecoder.decode(bytes.subarray(entityEnd + 2, rowIdEnd)),
4409
+ snapshot: bytes[0] === 0x10,
4410
+ };
4411
+ } catch {
4412
+ return null;
4413
+ }
4400
4414
  }
4401
4415
 
4402
4416
  function rowsDiffer(a: Row, b: Row): boolean {