@pylonsync/sync 0.3.195 → 0.3.197

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.195",
6
+ "version": "0.3.197",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -758,7 +758,15 @@ export class SyncEngine {
758
758
  typeof msg.entity === "string" &&
759
759
  typeof msg.row_id === "string"
760
760
  ) {
761
- this.handleRowRevocation(msg.entity, msg.row_id);
761
+ // Server includes its current high-water seq when known —
762
+ // use it as the tombstone seq so an in-flight stale frame
763
+ // with `seq <= server_seq` is filtered locally. A
764
+ // legitimate re-grant at a higher seq still lands.
765
+ const revokeSeq =
766
+ typeof msg.seq === "number" && msg.seq > 0
767
+ ? msg.seq
768
+ : this.cursor.last_seq;
769
+ this.handleRowRevocation(msg.entity, msg.row_id, revokeSeq);
762
770
  return;
763
771
  }
764
772
 
@@ -1491,25 +1499,28 @@ export class SyncEngine {
1491
1499
  * Drop a row from the local replica because the server signaled
1492
1500
  * that the current subscriber's read policy was revoked for it.
1493
1501
  *
1494
- * Calls `LocalStore.reconcileRemove(entity, id, cursor.last_seq)`
1495
- * to record a tombstone at the current cursor. A future server
1496
- * issuing seqs strictly greater than the cursor can re-create
1497
- * the row if policy is re-granted; replays older than the cursor
1498
- * are filtered by the tombstone (closing the "WS update lands
1499
- * between revoke and tombstone" race).
1502
+ * `tombstoneSeq` is the server's high-water seq at the time of
1503
+ * revocation (from the envelope). Stale in-flight WS frames with
1504
+ * `seq <= tombstoneSeq` are filtered locally; legitimate
1505
+ * re-grant + re-insert at higher seqs still land. Also fires a
1506
+ * catch-up pull on revocation so any frame with `seq >
1507
+ * tombstoneSeq` that arrives before the next legitimate event
1508
+ * gets reconciled against server truth.
1509
+ *
1510
+ * Uses `LocalStore.revokeRow` (not `reconcileRemove`) so the
1511
+ * tombstone is recorded even for CRDT-only consumers whose row
1512
+ * was never materialized into `tables`.
1500
1513
  *
1501
1514
  * Also notifies row-eviction listeners so external row-bound
1502
1515
  * resources (LoroDoc registries, etc.) can unmount.
1503
1516
  */
1504
- private handleRowRevocation(entity: string, rowId: string): void {
1505
- const removed = this.store.reconcileRemove(
1506
- entity,
1507
- rowId,
1508
- this.cursor.last_seq,
1509
- );
1517
+ private handleRowRevocation(
1518
+ entity: string,
1519
+ rowId: string,
1520
+ tombstoneSeq: number,
1521
+ ): void {
1522
+ const removed = this.store.revokeRow(entity, rowId, tombstoneSeq);
1510
1523
  if (removed) {
1511
- // Persist the deletion through the same pipe as a real Delete
1512
- // event so on-disk replica + in-memory replica stay aligned.
1513
1524
  if (this.persistence) {
1514
1525
  void this.persistence.deleteRow(entity, rowId).catch(() => {
1515
1526
  /* best-effort */
@@ -1520,6 +1531,11 @@ export class SyncEngine {
1520
1531
  for (const listener of this.rowEvictionListeners) {
1521
1532
  listener(entity, rowId);
1522
1533
  }
1534
+ // Fire a catch-up pull so any in-flight frame with seq above
1535
+ // the revocation tombstone is resolved against server truth.
1536
+ // Fire-and-forget — pull is internally serialized so concurrent
1537
+ // triggers coalesce.
1538
+ void this.pull();
1523
1539
  }
1524
1540
 
1525
1541
  /**
@@ -80,6 +80,29 @@ export class LocalStore {
80
80
  return true;
81
81
  }
82
82
 
83
+ /**
84
+ * Per-subscriber row revocation: the server signaled that the
85
+ * current client lost read access to a specific row. ALWAYS
86
+ * records the tombstone (even when the row isn't in memory), so
87
+ * a stale insert/update event that arrives after the revocation
88
+ * can't resurrect the row.
89
+ *
90
+ * `reconcileRemove` returns early when the row is absent — that's
91
+ * the right behavior for "did anything change?" reconcile passes,
92
+ * but wrong here: a CRDT-only consumer using `useLoroDoc` without
93
+ * a JSON row never had the row in `tables` to begin with, and
94
+ * without the tombstone any future server-issued insert
95
+ * (legitimate re-grant or a slow stale frame) would land.
96
+ *
97
+ * Returns true if the row was present + removed; the tombstone
98
+ * is recorded regardless.
99
+ */
100
+ revokeRow(entity: string, id: string, tombstoneSeq: number): boolean {
101
+ const removed = this.tables.get(entity)?.delete(id) ?? false;
102
+ this.recordTombstone(entity, id, tombstoneSeq);
103
+ return removed;
104
+ }
105
+
83
106
  private isTombstoned(entity: string, id: string, at_seq?: number): boolean {
84
107
  if (this.optimisticTombstones.get(entity)?.has(id)) return true;
85
108
  const tombSeq = this.tombstones.get(entity)?.get(id);