@pylonsync/sync 0.3.131 → 0.3.133

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +120 -0
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.131",
6
+ "version": "0.3.133",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -70,6 +70,28 @@ export interface ClientChange {
70
70
  op_id?: string;
71
71
  }
72
72
 
73
+ /**
74
+ * Reactive subscription spec — what the server needs to replay a
75
+ * subscription if the client reconnects. Cached client-side so the
76
+ * `ws.onopen` reconnect sweep can re-register every active sub
77
+ * without the React hooks having to know about reconnect lifecycle.
78
+ */
79
+ export interface ReactiveSpec {
80
+ fn_name: string;
81
+ args: unknown;
82
+ }
83
+
84
+ /**
85
+ * Push message routed to a reactive subscription handler. `result`
86
+ * fires on initial run + every time the server's re-run produces a
87
+ * value whose hash differs from the last push. `error` fires when
88
+ * the server can't execute the handler (function not registered,
89
+ * reactive runtime unavailable, runtime error in user code).
90
+ */
91
+ export type ReactiveMessage =
92
+ | { kind: "result"; result: unknown }
93
+ | { kind: "error"; code: string; message: string };
94
+
73
95
  // ---------------------------------------------------------------------------
74
96
  // Local store — in-memory replica of server state
75
97
  // ---------------------------------------------------------------------------
@@ -721,6 +743,22 @@ export class SyncEngine {
721
743
  private crdtSubscriptions: Set<string> = new Set();
722
744
  private crdtSubscribers: Map<string, number> = new Map();
723
745
 
746
+ /**
747
+ * Reactive query subscriptions registered via `subscribeReactive`.
748
+ * Two maps:
749
+ * - `reactiveSpecs`: sub_id → {fn_name, args} for re-registration
750
+ * on WS reconnect. Server-side state evaporates on disconnect.
751
+ * - `reactiveHandlers`: sub_id → handler that receives result + error
752
+ * pushes. The React hook owns these handlers and unsubscribes on
753
+ * unmount.
754
+ *
755
+ * Both maps are keyed by the same client-minted `sub_id` so they
756
+ * stay in sync. Cleared together by `unsubscribeReactive`.
757
+ */
758
+ private reactiveSpecs: Map<string, ReactiveSpec> = new Map();
759
+ private reactiveHandlers: Map<string, (msg: ReactiveMessage) => void> =
760
+ new Map();
761
+
724
762
  /**
725
763
  * Register a binary-frame handler. Returns an unsubscribe fn that
726
764
  * pulls the handler back out — call on hook unmount / module
@@ -1003,6 +1041,18 @@ export class SyncEngine {
1003
1041
  const [entity, rowId] = key.split("\x00");
1004
1042
  this.sendWs({ type: "crdt-subscribe", entity, rowId });
1005
1043
  }
1044
+ // Re-register every reactive subscription on the fresh socket.
1045
+ // The server's ReactiveRegistry tears down on disconnect (via
1046
+ // `disconnect_client`) so without this resync the handlers
1047
+ // would silently stop receiving result pushes.
1048
+ for (const [sub_id, spec] of this.reactiveSpecs) {
1049
+ this.sendWs({
1050
+ type: "reactive-subscribe",
1051
+ sub_id,
1052
+ fn_name: spec.fn_name,
1053
+ args: spec.args,
1054
+ });
1055
+ }
1006
1056
  // Pull-on-open catches every event broadcast in the gap between
1007
1057
  // the prior `pull()` returning and this socket actually opening.
1008
1058
  // The WS has no replay-on-connect (it's just a fanout), so events
@@ -1062,6 +1112,29 @@ export class SyncEngine {
1062
1112
  this.store.notify();
1063
1113
  return;
1064
1114
  }
1115
+
1116
+ // Reactive query push: the server-side ReactiveRegistry re-ran
1117
+ // a subscribed handler and the result hash changed. Route to
1118
+ // the handler registered by `subscribeReactive` so the React
1119
+ // hook re-renders.
1120
+ if (msg.type === "reactive-result" && typeof msg.sub_id === "string") {
1121
+ const handler = this.reactiveHandlers.get(msg.sub_id);
1122
+ if (handler) {
1123
+ handler({ kind: "result", result: msg.result });
1124
+ }
1125
+ return;
1126
+ }
1127
+ if (msg.type === "reactive-error" && typeof msg.sub_id === "string") {
1128
+ const handler = this.reactiveHandlers.get(msg.sub_id);
1129
+ if (handler) {
1130
+ handler({
1131
+ kind: "error",
1132
+ code: typeof msg.code === "string" ? msg.code : "REACTIVE_ERROR",
1133
+ message: typeof msg.message === "string" ? msg.message : "",
1134
+ });
1135
+ }
1136
+ return;
1137
+ }
1065
1138
  } catch {
1066
1139
  // Ignore malformed messages.
1067
1140
  }
@@ -1864,6 +1937,53 @@ export class SyncEngine {
1864
1937
  }
1865
1938
  }
1866
1939
 
1940
+ // -----------------------------------------------------------------------
1941
+ // Reactive query subscriptions
1942
+ //
1943
+ // Convex-shaped: the client mounts `useReactiveQuery(fnName, args)`,
1944
+ // the server runs the handler with dep tracking, registers the sub,
1945
+ // and pushes the initial result. Whenever a future mutation touches
1946
+ // anything in the dep set, the server re-runs the handler and pushes
1947
+ // the new result. The handler always re-runs under the subscriber's
1948
+ // original auth context — not the mutating user's — so policy gates
1949
+ // applied at first run apply on every re-run.
1950
+ // -----------------------------------------------------------------------
1951
+
1952
+ /**
1953
+ * Register a reactive query subscription. The caller-minted `sub_id`
1954
+ * is used by the React hook to dispatch result/error pushes to the
1955
+ * right component. Returns nothing — push handling is async via the
1956
+ * registered handler.
1957
+ *
1958
+ * Idempotent: re-calling with the same `sub_id` replaces the prior
1959
+ * handler + spec. Useful when args change and the hook re-registers.
1960
+ *
1961
+ * The actual subscribe message goes over the WS — works only when
1962
+ * the socket is open. When called before the WS opens (initial
1963
+ * mount during start()), the spec is still recorded and gets sent
1964
+ * on `ws.onopen`'s re-registration sweep.
1965
+ */
1966
+ subscribeReactive(
1967
+ sub_id: string,
1968
+ fn_name: string,
1969
+ args: unknown,
1970
+ handler: (msg: ReactiveMessage) => void,
1971
+ ): void {
1972
+ this.reactiveSpecs.set(sub_id, { fn_name, args });
1973
+ this.reactiveHandlers.set(sub_id, handler);
1974
+ this.sendWs({ type: "reactive-subscribe", sub_id, fn_name, args });
1975
+ }
1976
+
1977
+ /** Tear down a reactive subscription. Sends the unsubscribe to the
1978
+ * server and clears local state. No-op for unknown sub_ids — React
1979
+ * StrictMode double-unmount won't error. */
1980
+ unsubscribeReactive(sub_id: string): void {
1981
+ if (!this.reactiveSpecs.has(sub_id)) return;
1982
+ this.reactiveSpecs.delete(sub_id);
1983
+ this.reactiveHandlers.delete(sub_id);
1984
+ this.sendWs({ type: "reactive-unsubscribe", sub_id });
1985
+ }
1986
+
1867
1987
  private sendWs(msg: unknown): void {
1868
1988
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1869
1989
  this.ws.send(JSON.stringify(msg));