@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 +1 -1
- package/src/index.ts +128 -0
- package/src/reconcile.test.ts +57 -0
package/package.json
CHANGED
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
|
|
package/src/reconcile.test.ts
CHANGED
|
@@ -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
|
+
});
|