@pylonsync/react 0.4.5 → 0.4.7
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/dist/engine-registry.d.ts +8 -0
- package/package.json +3 -3
- package/src/callFn.test.ts +82 -0
- package/src/db.ts +5 -0
- package/src/engine-registry.ts +30 -0
- package/src/index.ts +20 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SyncEngine } from "@pylonsync/sync";
|
|
2
|
+
/** Publish the engine `init()` built. Called only from `db.ts`. */
|
|
3
|
+
export declare function setActiveEngine(engine: SyncEngine | null): void;
|
|
4
|
+
/**
|
|
5
|
+
* The engine, if one exists. Never constructs one — a `null` return
|
|
6
|
+
* means "this app isn't running sync", not "not yet initialized".
|
|
7
|
+
*/
|
|
8
|
+
export declare function peekActiveEngine(): SyncEngine | null;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.4.
|
|
6
|
+
"version": "0.4.7",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"prepack": "bun run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pylonsync/sdk": "0.4.
|
|
18
|
-
"@pylonsync/sync": "0.4.
|
|
17
|
+
"@pylonsync/sdk": "0.4.7",
|
|
18
|
+
"@pylonsync/sync": "0.4.7"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"react": ">=19.0.0"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `callFn` routing. The bug this locks down: the exported helper did a bare
|
|
2
|
+
// fetch and dropped the `X-Pylon-Change-Seq` response header, so a caller's
|
|
3
|
+
// own mutation didn't land in their own replica — add a row, and it isn't
|
|
4
|
+
// there until something else refreshes. `db.fn` and `useMutation` both went
|
|
5
|
+
// through `SyncEngine.fn`, which observes the header, so the same operation
|
|
6
|
+
// behaved differently depending on which import you reached for.
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { callFn } from "./index";
|
|
10
|
+
import { peekActiveEngine, setActiveEngine } from "./engine-registry";
|
|
11
|
+
|
|
12
|
+
describe("callFn", () => {
|
|
13
|
+
const realFetch = globalThis.fetch;
|
|
14
|
+
let requests: Array<{ url: string; headers: Record<string, string> }>;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
requests = [];
|
|
18
|
+
setActiveEngine(null);
|
|
19
|
+
globalThis.fetch = (async (input: any, init: any = {}) => {
|
|
20
|
+
requests.push({
|
|
21
|
+
url: String(input?.url ?? input),
|
|
22
|
+
headers: (init.headers ?? {}) as Record<string, string>,
|
|
23
|
+
});
|
|
24
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
25
|
+
status: 200,
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
});
|
|
28
|
+
}) as typeof fetch;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
globalThis.fetch = realFetch;
|
|
33
|
+
setActiveEngine(null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Minimal engine stand-in that records `fn` calls. */
|
|
37
|
+
function fakeEngine() {
|
|
38
|
+
const calls: Array<{ name: string; args: unknown }> = [];
|
|
39
|
+
const engine = {
|
|
40
|
+
async fn<T>(name: string, args: unknown): Promise<T> {
|
|
41
|
+
calls.push({ name, args });
|
|
42
|
+
return { viaEngine: true } as unknown as T;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
setActiveEngine(engine as any);
|
|
46
|
+
return calls;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("routes through the engine when the app has one", async () => {
|
|
50
|
+
const calls = fakeEngine();
|
|
51
|
+
const result = await callFn("addRoom", { name: "Main" });
|
|
52
|
+
expect(calls).toEqual([{ name: "addRoom", args: { name: "Main" } }]);
|
|
53
|
+
expect(result).toEqual({ viaEngine: true } as any);
|
|
54
|
+
// The engine owns the request, so nothing should have gone out
|
|
55
|
+
// through the bare transport.
|
|
56
|
+
expect(requests).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("falls back to a plain fetch when no engine exists", async () => {
|
|
60
|
+
// Apps that never call init() must keep working — and must NOT have
|
|
61
|
+
// an engine constructed (and a WebSocket opened) by a single POST.
|
|
62
|
+
await callFn("addRoom", { name: "Main" });
|
|
63
|
+
expect(requests).toHaveLength(1);
|
|
64
|
+
expect(requests[0]!.url).toContain("/api/fn/addRoom");
|
|
65
|
+
expect(peekActiveEngine()).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("an explicit token bypasses the engine", async () => {
|
|
69
|
+
// That's a call as some other identity. Pulling its results into
|
|
70
|
+
// this replica would mix two users' data.
|
|
71
|
+
const calls = fakeEngine();
|
|
72
|
+
await callFn("addRoom", { name: "Main" }, { token: "someone-elses" });
|
|
73
|
+
expect(calls).toHaveLength(0);
|
|
74
|
+
expect(requests).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("peekActiveEngine never constructs an engine", async () => {
|
|
78
|
+
// The distinction from db.ts's getSync(), which deliberately does.
|
|
79
|
+
expect(peekActiveEngine()).toBeNull();
|
|
80
|
+
expect(peekActiveEngine()).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/db.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { SyncEngine, createSyncEngine, type Row, type SyncEngineConfig } from "@pylonsync/sync";
|
|
4
|
+
import { setActiveEngine } from "./engine-registry";
|
|
4
5
|
import {
|
|
5
6
|
useQuery as useQueryHook,
|
|
6
7
|
useQueryOne as useQueryOneHook,
|
|
@@ -57,6 +58,9 @@ let _started = false;
|
|
|
57
58
|
export function init(config?: Partial<SyncEngineConfig> & { baseUrl?: string }) {
|
|
58
59
|
_sync = createSyncEngine(config?.baseUrl, config);
|
|
59
60
|
_started = false;
|
|
61
|
+
// Publish it so the free helpers in index.ts (callFn) can route
|
|
62
|
+
// through the engine and observe X-Pylon-Change-Seq.
|
|
63
|
+
setActiveEngine(_sync);
|
|
60
64
|
// Keep the React-side helpers in sync — a single init() should fully
|
|
61
65
|
// namespace this app's storage without a separate configureClient call.
|
|
62
66
|
// Match createSyncEngine's resolution: when init({ appName }) omits
|
|
@@ -124,6 +128,7 @@ export function getSync(): SyncEngine {
|
|
|
124
128
|
// app's SyncProvider effect lands would leak `localhost:4321`
|
|
125
129
|
// requests in production.
|
|
126
130
|
_sync = createSyncEngine();
|
|
131
|
+
setActiveEngine(_sync);
|
|
127
132
|
}
|
|
128
133
|
if (!_started) {
|
|
129
134
|
_started = true;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The active SyncEngine, readable without creating one.
|
|
2
|
+
//
|
|
3
|
+
// `db.ts` owns the engine's lifecycle, and `getSync()` there deliberately
|
|
4
|
+
// creates and starts an engine on first access. That is right for `db.*`
|
|
5
|
+
// — a call to `db.useQuery` means the app wants sync — but wrong for the
|
|
6
|
+
// free helpers in `index.ts`, which must work in apps that never call
|
|
7
|
+
// `init()`. `callFn` reaching for `getSync()` would open a WebSocket and
|
|
8
|
+
// run an initial pull as a side effect of a single POST.
|
|
9
|
+
//
|
|
10
|
+
// Hence a separate module: `db.ts` publishes the engine here, `index.ts`
|
|
11
|
+
// peeks at it, and neither imports the other. They already form a cycle
|
|
12
|
+
// (`db.ts` imports `callFn` from `index.ts`), so the registry has to live
|
|
13
|
+
// outside both.
|
|
14
|
+
|
|
15
|
+
import type { SyncEngine } from "@pylonsync/sync";
|
|
16
|
+
|
|
17
|
+
let _engine: SyncEngine | null = null;
|
|
18
|
+
|
|
19
|
+
/** Publish the engine `init()` built. Called only from `db.ts`. */
|
|
20
|
+
export function setActiveEngine(engine: SyncEngine | null): void {
|
|
21
|
+
_engine = engine;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The engine, if one exists. Never constructs one — a `null` return
|
|
26
|
+
* means "this app isn't running sync", not "not yet initialized".
|
|
27
|
+
*/
|
|
28
|
+
export function peekActiveEngine(): SyncEngine | null {
|
|
29
|
+
return _engine;
|
|
30
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
pylonFetchRaw,
|
|
65
65
|
type Storage as PylonStorage,
|
|
66
66
|
} from "@pylonsync/sync";
|
|
67
|
+
import { peekActiveEngine } from "./engine-registry";
|
|
67
68
|
|
|
68
69
|
// React hooks — high-level ergonomic shape
|
|
69
70
|
export {
|
|
@@ -485,6 +486,25 @@ export async function callFn<T = unknown>(
|
|
|
485
486
|
args: Record<string, unknown> = {},
|
|
486
487
|
options: { token?: string } = {}
|
|
487
488
|
): Promise<T> {
|
|
489
|
+
// Route through the engine when the app has one. `SyncEngine.fn`
|
|
490
|
+
// observes the `X-Pylon-Change-Seq` response header and fires a
|
|
491
|
+
// one-shot pull when the call produced events the replica hasn't
|
|
492
|
+
// seen — without it the caller's own write doesn't appear until
|
|
493
|
+
// something else refreshes, which reads as "I added it and nothing
|
|
494
|
+
// happened". `db.fn` and `useMutation` have always done this; the
|
|
495
|
+
// free helper silently didn't, so the same operation behaved
|
|
496
|
+
// differently depending on which import you reached for.
|
|
497
|
+
//
|
|
498
|
+
// Two cases deliberately keep the bare fetch:
|
|
499
|
+
// - no engine: apps that never call `init()` must not have one
|
|
500
|
+
// constructed (and a WebSocket opened) by a plain POST.
|
|
501
|
+
// - an explicit `options.token`: that's a call as some other
|
|
502
|
+
// identity, and pulling its results into this replica would mix
|
|
503
|
+
// two users' data.
|
|
504
|
+
const engine = options.token ? null : peekActiveEngine();
|
|
505
|
+
if (engine) {
|
|
506
|
+
return engine.fn<T>(name, args);
|
|
507
|
+
}
|
|
488
508
|
return pylonFetch<T>(
|
|
489
509
|
{
|
|
490
510
|
baseUrl: getBaseUrl(),
|