@pylonsync/sync 0.3.195 → 0.3.196

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.196",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -1491,18 +1491,18 @@ export class SyncEngine {
1491
1491
  * Drop a row from the local replica because the server signaled
1492
1492
  * that the current subscriber's read policy was revoked for it.
1493
1493
  *
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).
1494
+ * Calls `LocalStore.revokeRow(entity, id, cursor.last_seq)`
1495
+ * NOT `reconcileRemove` because the latter early-returns when
1496
+ * the row isn't in memory (common for CRDT-only consumers using
1497
+ * `useLoroDoc` without a JSON row). `revokeRow` always records
1498
+ * the tombstone so a stale insert/update arriving after the
1499
+ * revocation can't resurrect the row.
1500
1500
  *
1501
1501
  * Also notifies row-eviction listeners so external row-bound
1502
1502
  * resources (LoroDoc registries, etc.) can unmount.
1503
1503
  */
1504
1504
  private handleRowRevocation(entity: string, rowId: string): void {
1505
- const removed = this.store.reconcileRemove(
1505
+ const removed = this.store.revokeRow(
1506
1506
  entity,
1507
1507
  rowId,
1508
1508
  this.cursor.last_seq,
@@ -1510,6 +1510,9 @@ export class SyncEngine {
1510
1510
  if (removed) {
1511
1511
  // Persist the deletion through the same pipe as a real Delete
1512
1512
  // event so on-disk replica + in-memory replica stay aligned.
1513
+ // For CRDT-only consumers `removed` is false (the row was
1514
+ // never materialized into `tables`), but the tombstone is
1515
+ // still recorded above so future replays are filtered.
1513
1516
  if (this.persistence) {
1514
1517
  void this.persistence.deleteRow(entity, rowId).catch(() => {
1515
1518
  /* best-effort */
@@ -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);