@pylonsync/sync 0.3.131 → 0.3.134

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.131",
6
+ "version": "0.3.134",
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));
@@ -1884,9 +2004,17 @@ export class SyncEngine {
1884
2004
  undefined;
1885
2005
  if (token) headers["Authorization"] = `Bearer ${token}`;
1886
2006
 
2007
+ // credentials: "include" so cookie-auth apps (Yapless and any other
2008
+ // app relying on the `<app>_session` cookie pylon sets at login)
2009
+ // actually authenticate on /api/sync/pull + /api/entities/<E>/cursor.
2010
+ // Without it the pull goes anonymous, every policy default-denies,
2011
+ // and the response is `{changes: []}` even when the same browser
2012
+ // session can read every row via the entity API. Reported in
2013
+ // Repro C against v0.3.131; closed in v0.3.134.
1887
2014
  const res = await fetch(`${this.config.baseUrl}${path}`, {
1888
2015
  method,
1889
2016
  headers,
2017
+ credentials: "include",
1890
2018
  body: body ? JSON.stringify(body) : undefined,
1891
2019
  });
1892
2020
 
@@ -294,3 +294,60 @@ describe("LocalStore.entityNames", () => {
294
294
  expect(names).toEqual(["A", "B"]);
295
295
  });
296
296
  });
297
+
298
+ describe("SyncEngine cookie auth", () => {
299
+ // Regression test for Repro C (v0.3.131): the SyncEngine's `request`
300
+ // method must send `credentials: "include"` on every HTTP call so
301
+ // cookie-authenticated browser sessions reach the server with their
302
+ // session cookie. Without it, /api/sync/pull and the entity
303
+ // reconcile endpoint go anonymous, the default-deny policy returns
304
+ // nothing, and the local replica stays empty forever — even when
305
+ // the same browser session can read every row via the entity API.
306
+ test("pull request sends cookies (credentials: include)", async () => {
307
+ let capturedInit: RequestInit | undefined;
308
+ const restore = installFetch(async (url, init) => {
309
+ capturedInit = init;
310
+ if (url.includes("/api/sync/pull")) {
311
+ return {
312
+ status: 200,
313
+ body: { changes: [], cursor: { last_seq: 0 }, has_more: false },
314
+ };
315
+ }
316
+ return { status: 404, body: {} };
317
+ });
318
+ try {
319
+ const engine = makeEngine();
320
+ await engine.pull();
321
+ expect(capturedInit).toBeDefined();
322
+ expect(capturedInit!.credentials).toBe("include");
323
+ } finally {
324
+ restore();
325
+ }
326
+ });
327
+
328
+ test("reconcile entity fetch sends cookies (credentials: include)", async () => {
329
+ let capturedInit: RequestInit | undefined;
330
+ const restore = installFetch(async (url, init) => {
331
+ if (url.includes("/api/entities/")) {
332
+ capturedInit = init;
333
+ }
334
+ return {
335
+ status: 200,
336
+ body: {
337
+ data: [{ id: "r1", title: "alive" }],
338
+ next_cursor: null,
339
+ has_more: false,
340
+ },
341
+ };
342
+ });
343
+ try {
344
+ const engine = makeEngine();
345
+ seedStore(engine, "Recording", [{ id: "r1", title: "alive" }]);
346
+ await engine.reconcile(["Recording"]);
347
+ expect(capturedInit).toBeDefined();
348
+ expect(capturedInit!.credentials).toBe("include");
349
+ } finally {
350
+ restore();
351
+ }
352
+ });
353
+ });