@pylonsync/sync 0.3.193 → 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.193",
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
@@ -274,6 +274,15 @@ export class SyncEngine {
274
274
  private reactiveHandlers: Map<string, (msg: ReactiveMessage) => void> =
275
275
  new Map();
276
276
 
277
+ /**
278
+ * Listeners notified when the server signals a per-subscriber row
279
+ * revocation (`row-revoked` envelope). Used by `@pylonsync/loro`
280
+ * to evict the LoroDoc registry entry for a row whose policy was
281
+ * revoked mid-session. Plain Set so identity is the dedup key.
282
+ */
283
+ private rowEvictionListeners: Set<(entity: string, rowId: string) => void> =
284
+ new Set();
285
+
277
286
  /**
278
287
  * Register a binary-frame handler. Returns an unsubscribe fn that
279
288
  * pulls the handler back out — call on hook unmount / module
@@ -731,6 +740,28 @@ export class SyncEngine {
731
740
  return;
732
741
  }
733
742
 
743
+ // Server-driven revocation: a subscriber whose read policy
744
+ // was revoked mid-session for a specific row. Drop the row
745
+ // from the local replica at the current cursor seq so the
746
+ // tombstone supersedes any racing late-arriving WS update
747
+ // for the same row, and notify any LoroDoc subscriber
748
+ // (registered via `addRowEvictionListener`) so collaborative
749
+ // doc handles unmount cleanly.
750
+ //
751
+ // Distinct from a regular Delete change event because this
752
+ // envelope has no global seq — the row's underlying data
753
+ // hasn't been deleted, only the recipient's visibility of
754
+ // it. Other subscribers (with matching policy) keep their
755
+ // row intact.
756
+ if (
757
+ msg.type === "row-revoked" &&
758
+ typeof msg.entity === "string" &&
759
+ typeof msg.row_id === "string"
760
+ ) {
761
+ this.handleRowRevocation(msg.entity, msg.row_id);
762
+ return;
763
+ }
764
+
734
765
  // Session mutated server-side. Fires for select-org / clear-org
735
766
  // / session revoke — every tab connected as this user gets the
736
767
  // envelope (cross-machine too via the cluster bus). Trigger
@@ -1456,6 +1487,59 @@ export class SyncEngine {
1456
1487
  return this.refreshResolvedSession();
1457
1488
  }
1458
1489
 
1490
+ /**
1491
+ * Drop a row from the local replica because the server signaled
1492
+ * that the current subscriber's read policy was revoked for it.
1493
+ *
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
+ *
1501
+ * Also notifies row-eviction listeners so external row-bound
1502
+ * resources (LoroDoc registries, etc.) can unmount.
1503
+ */
1504
+ private handleRowRevocation(entity: string, rowId: string): void {
1505
+ const removed = this.store.revokeRow(
1506
+ entity,
1507
+ rowId,
1508
+ this.cursor.last_seq,
1509
+ );
1510
+ 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
+ // 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.
1516
+ if (this.persistence) {
1517
+ void this.persistence.deleteRow(entity, rowId).catch(() => {
1518
+ /* best-effort */
1519
+ });
1520
+ }
1521
+ this.store.notify();
1522
+ }
1523
+ for (const listener of this.rowEvictionListeners) {
1524
+ listener(entity, rowId);
1525
+ }
1526
+ }
1527
+
1528
+ /**
1529
+ * Register a listener invoked when the server signals a per-
1530
+ * subscriber row revocation. Used by `@pylonsync/loro` to evict
1531
+ * the LoroDoc registry entry for the row so collaborative doc
1532
+ * handles unmount cleanly. Returns an unsubscribe function.
1533
+ */
1534
+ addRowEvictionListener(
1535
+ listener: (entity: string, rowId: string) => void,
1536
+ ): () => void {
1537
+ this.rowEvictionListeners.add(listener);
1538
+ return () => {
1539
+ this.rowEvictionListeners.delete(listener);
1540
+ };
1541
+ }
1542
+
1459
1543
  /**
1460
1544
  * Switch the caller's active tenant (organization) and refresh the
1461
1545
  * resolved session in one shot. Membership is verified server-side
@@ -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);