rwsdk 1.0.0-beta.27-test.20251112194755 → 1.0.0-beta.27-test.20251115092208
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/runtime/client/client.d.ts +1 -0
- package/dist/runtime/imports/__mocks__/use-client-lookup.d.ts +6 -0
- package/dist/runtime/imports/__mocks__/use-client-lookup.js +6 -0
- package/dist/runtime/lib/db/typeInference/builders/alterTable.d.ts +3 -13
- package/dist/runtime/lib/db/typeInference/builders/columnDefinition.d.ts +20 -34
- package/dist/runtime/lib/db/typeInference/builders/createTable.d.ts +2 -9
- package/dist/runtime/lib/db/typeInference/database.d.ts +2 -16
- package/dist/runtime/lib/db/typeInference/typetests/alterTable.typetest.js +5 -80
- package/dist/runtime/lib/db/typeInference/typetests/createTable.typetest.js +1 -102
- package/dist/runtime/lib/db/typeInference/typetests/testUtils.d.ts +0 -1
- package/dist/runtime/lib/db/typeInference/utils.d.ts +9 -59
- package/dist/use-synced-state/SyncStateServer.d.mts +20 -0
- package/dist/use-synced-state/SyncStateServer.mjs +124 -0
- package/dist/use-synced-state/__tests__/SyncStateServer.test.d.mts +1 -0
- package/dist/use-synced-state/__tests__/SyncStateServer.test.mjs +109 -0
- package/dist/use-synced-state/__tests__/useSyncState.test.d.ts +1 -0
- package/dist/use-synced-state/__tests__/useSyncState.test.js +115 -0
- package/dist/use-synced-state/__tests__/useSyncedState.test.d.ts +1 -0
- package/dist/use-synced-state/__tests__/useSyncedState.test.js +115 -0
- package/dist/use-synced-state/__tests__/worker.test.d.mts +1 -0
- package/dist/use-synced-state/__tests__/worker.test.mjs +69 -0
- package/dist/use-synced-state/client.d.ts +28 -0
- package/dist/use-synced-state/client.js +39 -0
- package/dist/use-synced-state/constants.d.mts +1 -0
- package/dist/use-synced-state/constants.mjs +1 -0
- package/dist/use-synced-state/useSyncState.d.ts +20 -0
- package/dist/use-synced-state/useSyncState.js +58 -0
- package/dist/use-synced-state/useSyncedState.d.ts +20 -0
- package/dist/use-synced-state/useSyncedState.js +58 -0
- package/dist/use-synced-state/worker.d.mts +14 -0
- package/dist/use-synced-state/worker.mjs +73 -0
- package/package.json +12 -3
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
2
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
3
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
4
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
5
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
6
|
+
};
|
|
7
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
8
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
9
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
10
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
|
+
};
|
|
12
|
+
var _a, _SyncStateServer_keyHandler, _SyncStateServer_setStateHandler, _SyncStateServer_getStateHandler, _SyncStateServer_stateStore, _SyncStateServer_subscriptions, _SyncStateServer_subscriptionRefs, _CoordinatorApi_coordinator;
|
|
13
|
+
import { RpcTarget } from "capnweb";
|
|
14
|
+
import { DurableObject } from "cloudflare:workers";
|
|
15
|
+
/**
|
|
16
|
+
* Durable Object that keeps shared state for multiple clients and notifies subscribers.
|
|
17
|
+
*/
|
|
18
|
+
export class SyncStateServer extends DurableObject {
|
|
19
|
+
constructor() {
|
|
20
|
+
super(...arguments);
|
|
21
|
+
_SyncStateServer_stateStore.set(this, new Map());
|
|
22
|
+
_SyncStateServer_subscriptions.set(this, new Map());
|
|
23
|
+
_SyncStateServer_subscriptionRefs.set(this, new Map());
|
|
24
|
+
}
|
|
25
|
+
static registerKeyHandler(handler) {
|
|
26
|
+
__classPrivateFieldSet(_a, _a, handler, "f", _SyncStateServer_keyHandler);
|
|
27
|
+
}
|
|
28
|
+
static getKeyHandler() {
|
|
29
|
+
return __classPrivateFieldGet(_a, _a, "f", _SyncStateServer_keyHandler);
|
|
30
|
+
}
|
|
31
|
+
static registerSetStateHandler(handler) {
|
|
32
|
+
__classPrivateFieldSet(_a, _a, handler, "f", _SyncStateServer_setStateHandler);
|
|
33
|
+
}
|
|
34
|
+
static registerGetStateHandler(handler) {
|
|
35
|
+
__classPrivateFieldSet(_a, _a, handler, "f", _SyncStateServer_getStateHandler);
|
|
36
|
+
}
|
|
37
|
+
getState(key) {
|
|
38
|
+
const value = __classPrivateFieldGet(this, _SyncStateServer_stateStore, "f").get(key);
|
|
39
|
+
if (__classPrivateFieldGet(_a, _a, "f", _SyncStateServer_getStateHandler)) {
|
|
40
|
+
__classPrivateFieldGet(_a, _a, "f", _SyncStateServer_getStateHandler).call(_a, key, value);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
setState(value, key) {
|
|
45
|
+
__classPrivateFieldGet(this, _SyncStateServer_stateStore, "f").set(key, value);
|
|
46
|
+
if (__classPrivateFieldGet(_a, _a, "f", _SyncStateServer_setStateHandler)) {
|
|
47
|
+
__classPrivateFieldGet(_a, _a, "f", _SyncStateServer_setStateHandler).call(_a, key, value);
|
|
48
|
+
}
|
|
49
|
+
const subscribers = __classPrivateFieldGet(this, _SyncStateServer_subscriptions, "f").get(key);
|
|
50
|
+
if (!subscribers) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const subscriber of subscribers) {
|
|
54
|
+
subscriber(value).catch(() => {
|
|
55
|
+
subscribers.delete(subscriber);
|
|
56
|
+
const refs = __classPrivateFieldGet(this, _SyncStateServer_subscriptionRefs, "f").get(key);
|
|
57
|
+
if (refs) {
|
|
58
|
+
for (const [original, duplicate] of refs) {
|
|
59
|
+
if (duplicate === subscriber) {
|
|
60
|
+
refs.delete(original);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (refs.size === 0) {
|
|
65
|
+
__classPrivateFieldGet(this, _SyncStateServer_subscriptionRefs, "f").delete(key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (subscribers.size === 0) {
|
|
71
|
+
__classPrivateFieldGet(this, _SyncStateServer_subscriptions, "f").delete(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
subscribe(key, client) {
|
|
75
|
+
if (!__classPrivateFieldGet(this, _SyncStateServer_subscriptions, "f").has(key)) {
|
|
76
|
+
__classPrivateFieldGet(this, _SyncStateServer_subscriptions, "f").set(key, new Set());
|
|
77
|
+
}
|
|
78
|
+
if (!__classPrivateFieldGet(this, _SyncStateServer_subscriptionRefs, "f").has(key)) {
|
|
79
|
+
__classPrivateFieldGet(this, _SyncStateServer_subscriptionRefs, "f").set(key, new Map());
|
|
80
|
+
}
|
|
81
|
+
const duplicate = client.dup();
|
|
82
|
+
__classPrivateFieldGet(this, _SyncStateServer_subscriptions, "f").get(key).add(duplicate);
|
|
83
|
+
__classPrivateFieldGet(this, _SyncStateServer_subscriptionRefs, "f").get(key).set(client, duplicate);
|
|
84
|
+
}
|
|
85
|
+
unsubscribe(key, client) {
|
|
86
|
+
const duplicates = __classPrivateFieldGet(this, _SyncStateServer_subscriptionRefs, "f").get(key);
|
|
87
|
+
const duplicate = duplicates?.get(client);
|
|
88
|
+
const subscribers = __classPrivateFieldGet(this, _SyncStateServer_subscriptions, "f").get(key);
|
|
89
|
+
if (duplicate && subscribers) {
|
|
90
|
+
subscribers.delete(duplicate);
|
|
91
|
+
duplicates.delete(client);
|
|
92
|
+
if (subscribers.size === 0) {
|
|
93
|
+
__classPrivateFieldGet(this, _SyncStateServer_subscriptions, "f").delete(key);
|
|
94
|
+
}
|
|
95
|
+
if (duplicates.size === 0) {
|
|
96
|
+
__classPrivateFieldGet(this, _SyncStateServer_subscriptionRefs, "f").delete(key);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
_a = SyncStateServer, _SyncStateServer_stateStore = new WeakMap(), _SyncStateServer_subscriptions = new WeakMap(), _SyncStateServer_subscriptionRefs = new WeakMap();
|
|
102
|
+
_SyncStateServer_keyHandler = { value: null };
|
|
103
|
+
_SyncStateServer_setStateHandler = { value: null };
|
|
104
|
+
_SyncStateServer_getStateHandler = { value: null };
|
|
105
|
+
class CoordinatorApi extends RpcTarget {
|
|
106
|
+
constructor(coordinator) {
|
|
107
|
+
super();
|
|
108
|
+
_CoordinatorApi_coordinator.set(this, void 0);
|
|
109
|
+
__classPrivateFieldSet(this, _CoordinatorApi_coordinator, coordinator, "f");
|
|
110
|
+
}
|
|
111
|
+
getState(key) {
|
|
112
|
+
return __classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").getState(key);
|
|
113
|
+
}
|
|
114
|
+
setState(value, key) {
|
|
115
|
+
__classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").setState(value, key);
|
|
116
|
+
}
|
|
117
|
+
subscribe(key, client) {
|
|
118
|
+
__classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").subscribe(key, client);
|
|
119
|
+
}
|
|
120
|
+
unsubscribe(key, client) {
|
|
121
|
+
__classPrivateFieldGet(this, _CoordinatorApi_coordinator, "f").unsubscribe(key, client);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
_CoordinatorApi_coordinator = new WeakMap();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("cloudflare:workers", () => {
|
|
3
|
+
class DurableObject {
|
|
4
|
+
}
|
|
5
|
+
return { DurableObject };
|
|
6
|
+
});
|
|
7
|
+
import { SyncStateServer } from "../SyncStateServer.mjs";
|
|
8
|
+
const createStub = (onInvoke) => {
|
|
9
|
+
const fn = Object.assign(async (value) => {
|
|
10
|
+
await onInvoke(value);
|
|
11
|
+
}, {
|
|
12
|
+
dup: () => fn,
|
|
13
|
+
});
|
|
14
|
+
return fn;
|
|
15
|
+
};
|
|
16
|
+
describe("SyncStateServer", () => {
|
|
17
|
+
it("notifies subscribers when state changes", async () => {
|
|
18
|
+
const coordinator = new SyncStateServer({}, {});
|
|
19
|
+
const received = [];
|
|
20
|
+
const stub = createStub((value) => {
|
|
21
|
+
received.push(value);
|
|
22
|
+
});
|
|
23
|
+
coordinator.subscribe("counter", stub);
|
|
24
|
+
coordinator.setState(5, "counter");
|
|
25
|
+
expect(coordinator.getState("counter")).toBe(5);
|
|
26
|
+
expect(received).toEqual([5]);
|
|
27
|
+
});
|
|
28
|
+
it("removes subscriptions on unsubscribe", () => {
|
|
29
|
+
const coordinator = new SyncStateServer({}, {});
|
|
30
|
+
const stub = createStub(() => { });
|
|
31
|
+
coordinator.subscribe("counter", stub);
|
|
32
|
+
coordinator.unsubscribe("counter", stub);
|
|
33
|
+
coordinator.setState(1, "counter");
|
|
34
|
+
expect(coordinator.getState("counter")).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
it("drops failing subscribers", async () => {
|
|
37
|
+
const coordinator = new SyncStateServer({}, {});
|
|
38
|
+
const stub = createStub(async () => {
|
|
39
|
+
throw new Error("fail");
|
|
40
|
+
});
|
|
41
|
+
coordinator.subscribe("counter", stub);
|
|
42
|
+
coordinator.setState(3, "counter");
|
|
43
|
+
await Promise.resolve();
|
|
44
|
+
coordinator.setState(4, "counter");
|
|
45
|
+
expect(coordinator.getState("counter")).toBe(4);
|
|
46
|
+
});
|
|
47
|
+
it("invokes registered onSet handler", () => {
|
|
48
|
+
const coordinator = new SyncStateServer({}, {});
|
|
49
|
+
const calls = [];
|
|
50
|
+
SyncStateServer.registerSetStateHandler((key, value) => {
|
|
51
|
+
calls.push({ key, value });
|
|
52
|
+
});
|
|
53
|
+
coordinator.setState(2, "counter");
|
|
54
|
+
expect(calls).toEqual([{ key: "counter", value: 2 }]);
|
|
55
|
+
SyncStateServer.registerSetStateHandler(null);
|
|
56
|
+
});
|
|
57
|
+
it("invokes registered onGet handler", () => {
|
|
58
|
+
const coordinator = new SyncStateServer({}, {});
|
|
59
|
+
const calls = [];
|
|
60
|
+
SyncStateServer.registerGetStateHandler((key, value) => {
|
|
61
|
+
calls.push({ key, value });
|
|
62
|
+
});
|
|
63
|
+
coordinator.setState(4, "counter");
|
|
64
|
+
expect(coordinator.getState("counter")).toBe(4);
|
|
65
|
+
expect(calls).toEqual([{ key: "counter", value: 4 }]);
|
|
66
|
+
SyncStateServer.registerGetStateHandler(null);
|
|
67
|
+
});
|
|
68
|
+
describe("registerKeyHandler", () => {
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
SyncStateServer.registerKeyHandler(async (key) => key);
|
|
71
|
+
});
|
|
72
|
+
it("stores and retrieves the registered handler", async () => {
|
|
73
|
+
const handler = async (key) => `transformed:${key}`;
|
|
74
|
+
SyncStateServer.registerKeyHandler(handler);
|
|
75
|
+
const retrievedHandler = SyncStateServer.getKeyHandler();
|
|
76
|
+
expect(retrievedHandler).toBe(handler);
|
|
77
|
+
});
|
|
78
|
+
it("transforms keys using the registered handler", async () => {
|
|
79
|
+
const handler = async (key) => `user:123:${key}`;
|
|
80
|
+
SyncStateServer.registerKeyHandler(handler);
|
|
81
|
+
const result = await handler("counter");
|
|
82
|
+
expect(result).toBe("user:123:counter");
|
|
83
|
+
});
|
|
84
|
+
it("returns null when no handler is registered", () => {
|
|
85
|
+
SyncStateServer.registerKeyHandler(async (key) => key);
|
|
86
|
+
const handler = SyncStateServer.getKeyHandler();
|
|
87
|
+
expect(handler).not.toBeNull();
|
|
88
|
+
});
|
|
89
|
+
it("allows handler to be async", async () => {
|
|
90
|
+
const handler = async (key) => {
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
92
|
+
return `async:${key}`;
|
|
93
|
+
};
|
|
94
|
+
SyncStateServer.registerKeyHandler(handler);
|
|
95
|
+
const result = await handler("test");
|
|
96
|
+
expect(result).toBe("async:test");
|
|
97
|
+
});
|
|
98
|
+
it("handler receives the correct key parameter", async () => {
|
|
99
|
+
let receivedKey = "";
|
|
100
|
+
const handler = async (key) => {
|
|
101
|
+
receivedKey = key;
|
|
102
|
+
return key;
|
|
103
|
+
};
|
|
104
|
+
SyncStateServer.registerKeyHandler(handler);
|
|
105
|
+
await handler("myKey");
|
|
106
|
+
expect(receivedKey).toBe("myKey");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { setSyncStateClientForTesting } from "../client";
|
|
3
|
+
import { createSyncStateHook, } from "../useSyncedState";
|
|
4
|
+
const createStateHarness = () => {
|
|
5
|
+
let currentState;
|
|
6
|
+
const cleanups = [];
|
|
7
|
+
const useStateImpl = ((initialValue) => {
|
|
8
|
+
const resolved = typeof initialValue === "function"
|
|
9
|
+
? initialValue()
|
|
10
|
+
: initialValue;
|
|
11
|
+
currentState = resolved;
|
|
12
|
+
const setState = (next) => {
|
|
13
|
+
currentState =
|
|
14
|
+
typeof next === "function"
|
|
15
|
+
? next(currentState)
|
|
16
|
+
: next;
|
|
17
|
+
};
|
|
18
|
+
return [currentState, setState];
|
|
19
|
+
});
|
|
20
|
+
const useEffectImpl = (callback) => {
|
|
21
|
+
const cleanup = callback();
|
|
22
|
+
if (typeof cleanup === "function") {
|
|
23
|
+
cleanups.push(cleanup);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const useRefImpl = ((value) => ({
|
|
27
|
+
current: value,
|
|
28
|
+
}));
|
|
29
|
+
const useCallbackImpl = (fn) => fn;
|
|
30
|
+
const deps = {
|
|
31
|
+
useState: useStateImpl,
|
|
32
|
+
useEffect: useEffectImpl,
|
|
33
|
+
useRef: useRefImpl,
|
|
34
|
+
useCallback: useCallbackImpl,
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
deps,
|
|
38
|
+
getState: () => currentState,
|
|
39
|
+
runCleanups: () => cleanups.forEach((fn) => fn()),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
describe("createSyncStateHook", () => {
|
|
43
|
+
const subscribeHandlers = new Map();
|
|
44
|
+
const client = {
|
|
45
|
+
async getState() {
|
|
46
|
+
return 5;
|
|
47
|
+
},
|
|
48
|
+
async setState(_value, _key) { },
|
|
49
|
+
async subscribe(key, handler) {
|
|
50
|
+
subscribeHandlers.set(key, handler);
|
|
51
|
+
},
|
|
52
|
+
async unsubscribe(key) {
|
|
53
|
+
subscribeHandlers.delete(key);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const resetClient = () => {
|
|
57
|
+
client.getState = async () => 5;
|
|
58
|
+
client.setState = async (_value, _key) => { };
|
|
59
|
+
client.subscribe = async (key, handler) => {
|
|
60
|
+
subscribeHandlers.set(key, handler);
|
|
61
|
+
};
|
|
62
|
+
client.unsubscribe = async (key) => {
|
|
63
|
+
subscribeHandlers.delete(key);
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
resetClient();
|
|
68
|
+
setSyncStateClientForTesting(client);
|
|
69
|
+
subscribeHandlers.clear();
|
|
70
|
+
});
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
setSyncStateClientForTesting(null);
|
|
73
|
+
});
|
|
74
|
+
it("loads remote state and updates local value", async () => {
|
|
75
|
+
const harness = createStateHarness();
|
|
76
|
+
const useSyncedState = createSyncStateHook({ hooks: harness.deps });
|
|
77
|
+
const [value] = useSyncedState(0, "counter");
|
|
78
|
+
expect(value).toBe(0);
|
|
79
|
+
await Promise.resolve();
|
|
80
|
+
expect(harness.getState()).toBe(5);
|
|
81
|
+
});
|
|
82
|
+
it("sends updates through the client and applies optimistic value", async () => {
|
|
83
|
+
const harness = createStateHarness();
|
|
84
|
+
const setCalls = [];
|
|
85
|
+
client.setState = async (value, key) => {
|
|
86
|
+
setCalls.push({ key, value });
|
|
87
|
+
};
|
|
88
|
+
const useSyncedState = createSyncStateHook({ hooks: harness.deps });
|
|
89
|
+
const [, setSyncValue] = useSyncedState(0, "counter");
|
|
90
|
+
setSyncValue(9);
|
|
91
|
+
expect(harness.getState()).toBe(9);
|
|
92
|
+
expect(setCalls).toEqual([{ key: "counter", value: 9 }]);
|
|
93
|
+
});
|
|
94
|
+
it("applies remote updates from the subscription handler", async () => {
|
|
95
|
+
const harness = createStateHarness();
|
|
96
|
+
const useSyncedState = createSyncStateHook({ hooks: harness.deps });
|
|
97
|
+
useSyncedState(0, "counter");
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
const handler = subscribeHandlers.get("counter");
|
|
100
|
+
handler?.(7);
|
|
101
|
+
expect(harness.getState()).toBe(7);
|
|
102
|
+
});
|
|
103
|
+
it("unsubscribes during cleanup", () => {
|
|
104
|
+
const harness = createStateHarness();
|
|
105
|
+
const unsubscribed = [];
|
|
106
|
+
client.unsubscribe = async (key) => {
|
|
107
|
+
unsubscribed.push({ key });
|
|
108
|
+
subscribeHandlers.delete(key);
|
|
109
|
+
};
|
|
110
|
+
const useSyncedState = createSyncStateHook({ hooks: harness.deps });
|
|
111
|
+
useSyncedState(0, "counter");
|
|
112
|
+
harness.runCleanups();
|
|
113
|
+
expect(unsubscribed).toEqual([{ key: "counter" }]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { setSyncStateClientForTesting, } from "../client";
|
|
3
|
+
import { createSyncStateHook, } from "../useSyncedState";
|
|
4
|
+
const createStateHarness = () => {
|
|
5
|
+
let currentState;
|
|
6
|
+
const cleanups = [];
|
|
7
|
+
const useStateImpl = ((initialValue) => {
|
|
8
|
+
const resolved = typeof initialValue === "function"
|
|
9
|
+
? initialValue()
|
|
10
|
+
: initialValue;
|
|
11
|
+
currentState = resolved;
|
|
12
|
+
const setState = (next) => {
|
|
13
|
+
currentState =
|
|
14
|
+
typeof next === "function"
|
|
15
|
+
? next(currentState)
|
|
16
|
+
: next;
|
|
17
|
+
};
|
|
18
|
+
return [currentState, setState];
|
|
19
|
+
});
|
|
20
|
+
const useEffectImpl = (callback) => {
|
|
21
|
+
const cleanup = callback();
|
|
22
|
+
if (typeof cleanup === "function") {
|
|
23
|
+
cleanups.push(cleanup);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const useRefImpl = ((value) => ({
|
|
27
|
+
current: value,
|
|
28
|
+
}));
|
|
29
|
+
const useCallbackImpl = (fn) => fn;
|
|
30
|
+
const deps = {
|
|
31
|
+
useState: useStateImpl,
|
|
32
|
+
useEffect: useEffectImpl,
|
|
33
|
+
useRef: useRefImpl,
|
|
34
|
+
useCallback: useCallbackImpl,
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
deps,
|
|
38
|
+
getState: () => currentState,
|
|
39
|
+
runCleanups: () => cleanups.forEach((fn) => fn()),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
describe("createSyncStateHook", () => {
|
|
43
|
+
const subscribeHandlers = new Map();
|
|
44
|
+
const client = {
|
|
45
|
+
async getState() {
|
|
46
|
+
return 5;
|
|
47
|
+
},
|
|
48
|
+
async setState(_value, _key) { },
|
|
49
|
+
async subscribe(key, handler) {
|
|
50
|
+
subscribeHandlers.set(key, handler);
|
|
51
|
+
},
|
|
52
|
+
async unsubscribe(key) {
|
|
53
|
+
subscribeHandlers.delete(key);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const resetClient = () => {
|
|
57
|
+
client.getState = async () => 5;
|
|
58
|
+
client.setState = async (_value, _key) => { };
|
|
59
|
+
client.subscribe = async (key, handler) => {
|
|
60
|
+
subscribeHandlers.set(key, handler);
|
|
61
|
+
};
|
|
62
|
+
client.unsubscribe = async (key) => {
|
|
63
|
+
subscribeHandlers.delete(key);
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
resetClient();
|
|
68
|
+
setSyncStateClientForTesting(client);
|
|
69
|
+
subscribeHandlers.clear();
|
|
70
|
+
});
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
setSyncStateClientForTesting(null);
|
|
73
|
+
});
|
|
74
|
+
it("loads remote state and updates local value", async () => {
|
|
75
|
+
const harness = createStateHarness();
|
|
76
|
+
const useSyncedState = createSyncStateHook({ hooks: harness.deps });
|
|
77
|
+
const [value] = useSyncedState(0, "counter");
|
|
78
|
+
expect(value).toBe(0);
|
|
79
|
+
await Promise.resolve();
|
|
80
|
+
expect(harness.getState()).toBe(5);
|
|
81
|
+
});
|
|
82
|
+
it("sends updates through the client and applies optimistic value", async () => {
|
|
83
|
+
const harness = createStateHarness();
|
|
84
|
+
const setCalls = [];
|
|
85
|
+
client.setState = async (value, key) => {
|
|
86
|
+
setCalls.push({ key, value });
|
|
87
|
+
};
|
|
88
|
+
const useSyncedState = createSyncStateHook({ hooks: harness.deps });
|
|
89
|
+
const [, setSyncValue] = useSyncedState(0, "counter");
|
|
90
|
+
setSyncValue(9);
|
|
91
|
+
expect(harness.getState()).toBe(9);
|
|
92
|
+
expect(setCalls).toEqual([{ key: "counter", value: 9 }]);
|
|
93
|
+
});
|
|
94
|
+
it("applies remote updates from the subscription handler", async () => {
|
|
95
|
+
const harness = createStateHarness();
|
|
96
|
+
const useSyncedState = createSyncStateHook({ hooks: harness.deps });
|
|
97
|
+
useSyncedState(0, "counter");
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
const handler = subscribeHandlers.get("counter");
|
|
100
|
+
handler?.(7);
|
|
101
|
+
expect(harness.getState()).toBe(7);
|
|
102
|
+
});
|
|
103
|
+
it("unsubscribes during cleanup", () => {
|
|
104
|
+
const harness = createStateHarness();
|
|
105
|
+
const unsubscribed = [];
|
|
106
|
+
client.unsubscribe = async (key) => {
|
|
107
|
+
unsubscribed.push({ key });
|
|
108
|
+
subscribeHandlers.delete(key);
|
|
109
|
+
};
|
|
110
|
+
const useSyncedState = createSyncStateHook({ hooks: harness.deps });
|
|
111
|
+
useSyncedState(0, "counter");
|
|
112
|
+
harness.runCleanups();
|
|
113
|
+
expect(unsubscribed).toEqual([{ key: "counter" }]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("cloudflare:workers", () => {
|
|
3
|
+
class DurableObject {
|
|
4
|
+
}
|
|
5
|
+
return { DurableObject, env: {} };
|
|
6
|
+
});
|
|
7
|
+
vi.mock("capnweb", () => ({
|
|
8
|
+
RpcTarget: class RpcTarget {
|
|
9
|
+
},
|
|
10
|
+
newWorkersRpcResponse: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../runtime/entries/router", () => ({
|
|
13
|
+
route: vi.fn((path, handler) => ({ path, handler })),
|
|
14
|
+
}));
|
|
15
|
+
import { SyncStateServer } from "../SyncStateServer.mjs";
|
|
16
|
+
describe("SyncStateProxy", () => {
|
|
17
|
+
let mockCoordinator;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mockCoordinator = new SyncStateServer({}, {});
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
SyncStateServer.registerKeyHandler(async (key) => key);
|
|
23
|
+
});
|
|
24
|
+
it("transforms keys before calling coordinator methods when handler is registered", async () => {
|
|
25
|
+
const handler = async (key) => `transformed:${key}`;
|
|
26
|
+
SyncStateServer.registerKeyHandler(handler);
|
|
27
|
+
const transformedKey = await handler("counter");
|
|
28
|
+
expect(transformedKey).toBe("transformed:counter");
|
|
29
|
+
mockCoordinator.setState(5, transformedKey);
|
|
30
|
+
const value = mockCoordinator.getState(transformedKey);
|
|
31
|
+
expect(value).toBe(5);
|
|
32
|
+
});
|
|
33
|
+
it("does not transform keys when no handler is registered", () => {
|
|
34
|
+
SyncStateServer.registerKeyHandler(async (key) => key);
|
|
35
|
+
const handler = SyncStateServer.getKeyHandler();
|
|
36
|
+
expect(handler).not.toBeNull();
|
|
37
|
+
});
|
|
38
|
+
it("passes through original key when handler returns it unchanged", async () => {
|
|
39
|
+
const handler = async (key) => key;
|
|
40
|
+
SyncStateServer.registerKeyHandler(handler);
|
|
41
|
+
const result = await handler("counter");
|
|
42
|
+
expect(result).toBe("counter");
|
|
43
|
+
});
|
|
44
|
+
it("handler can scope keys per user", async () => {
|
|
45
|
+
const handler = async (key) => {
|
|
46
|
+
const userId = "user123";
|
|
47
|
+
return `user:${userId}:${key}`;
|
|
48
|
+
};
|
|
49
|
+
SyncStateServer.registerKeyHandler(handler);
|
|
50
|
+
const result = await handler("settings");
|
|
51
|
+
expect(result).toBe("user:user123:settings");
|
|
52
|
+
});
|
|
53
|
+
it("allows errors from handler to propagate", async () => {
|
|
54
|
+
const handler = async (_key) => {
|
|
55
|
+
throw new Error("Handler error");
|
|
56
|
+
};
|
|
57
|
+
SyncStateServer.registerKeyHandler(handler);
|
|
58
|
+
await expect(handler("test")).rejects.toThrow("Handler error");
|
|
59
|
+
});
|
|
60
|
+
it("handles async operations in handler", async () => {
|
|
61
|
+
const handler = async (key) => {
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
63
|
+
return `async:${key}`;
|
|
64
|
+
};
|
|
65
|
+
SyncStateServer.registerKeyHandler(handler);
|
|
66
|
+
const result = await handler("data");
|
|
67
|
+
expect(result).toBe("async:data");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type SyncStateClient = {
|
|
2
|
+
getState(key: string): Promise<unknown>;
|
|
3
|
+
setState(value: unknown, key: string): Promise<void>;
|
|
4
|
+
subscribe(key: string, handler: (value: unknown) => void): Promise<void>;
|
|
5
|
+
unsubscribe(key: string, handler: (value: unknown) => void): Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
type InitOptions = {
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Initializes and caches an RPC client instance for the sync state endpoint.
|
|
12
|
+
* @param options Optional endpoint override.
|
|
13
|
+
* @returns Cached client instance or `null` when running without `window`.
|
|
14
|
+
*/
|
|
15
|
+
export declare const initSyncStateClient: (options?: InitOptions) => SyncStateClient | null;
|
|
16
|
+
/**
|
|
17
|
+
* Returns a cached client for the provided endpoint, creating it when necessary.
|
|
18
|
+
* @param endpoint Endpoint to connect to.
|
|
19
|
+
* @returns RPC client instance.
|
|
20
|
+
*/
|
|
21
|
+
export declare const getSyncStateClient: (endpoint?: string) => SyncStateClient;
|
|
22
|
+
/**
|
|
23
|
+
* Injects a client instance for tests and updates the cached endpoint.
|
|
24
|
+
* @param client Stub client instance or `null` to clear the cache.
|
|
25
|
+
* @param endpoint Endpoint associated with the injected client.
|
|
26
|
+
*/
|
|
27
|
+
export declare const setSyncStateClientForTesting: (client: SyncStateClient | null, endpoint?: string) => void;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { newWebSocketRpcSession } from "capnweb";
|
|
2
|
+
import { DEFAULT_SYNC_STATE_PATH } from "./constants.mjs";
|
|
3
|
+
let cachedClient = null;
|
|
4
|
+
let cachedEndpoint = DEFAULT_SYNC_STATE_PATH;
|
|
5
|
+
/**
|
|
6
|
+
* Initializes and caches an RPC client instance for the sync state endpoint.
|
|
7
|
+
* @param options Optional endpoint override.
|
|
8
|
+
* @returns Cached client instance or `null` when running without `window`.
|
|
9
|
+
*/
|
|
10
|
+
export const initSyncStateClient = (options = {}) => {
|
|
11
|
+
cachedEndpoint = options.endpoint ?? DEFAULT_SYNC_STATE_PATH;
|
|
12
|
+
if (typeof window === "undefined") {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
cachedClient = newWebSocketRpcSession(cachedEndpoint);
|
|
16
|
+
return cachedClient;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Returns a cached client for the provided endpoint, creating it when necessary.
|
|
20
|
+
* @param endpoint Endpoint to connect to.
|
|
21
|
+
* @returns RPC client instance.
|
|
22
|
+
*/
|
|
23
|
+
export const getSyncStateClient = (endpoint = cachedEndpoint) => {
|
|
24
|
+
if (cachedClient && endpoint === cachedEndpoint) {
|
|
25
|
+
return cachedClient;
|
|
26
|
+
}
|
|
27
|
+
cachedEndpoint = endpoint;
|
|
28
|
+
cachedClient = newWebSocketRpcSession(cachedEndpoint);
|
|
29
|
+
return cachedClient;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Injects a client instance for tests and updates the cached endpoint.
|
|
33
|
+
* @param client Stub client instance or `null` to clear the cache.
|
|
34
|
+
* @param endpoint Endpoint associated with the injected client.
|
|
35
|
+
*/
|
|
36
|
+
export const setSyncStateClientForTesting = (client, endpoint = DEFAULT_SYNC_STATE_PATH) => {
|
|
37
|
+
cachedClient = client;
|
|
38
|
+
cachedEndpoint = endpoint;
|
|
39
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DEFAULT_SYNC_STATE_PATH = "/__sync-state";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_SYNC_STATE_PATH = "/__sync-state";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { React } from "../runtime/client/client";
|
|
2
|
+
type HookDeps = {
|
|
3
|
+
useState: typeof React.useState;
|
|
4
|
+
useEffect: typeof React.useEffect;
|
|
5
|
+
useRef: typeof React.useRef;
|
|
6
|
+
useCallback: typeof React.useCallback;
|
|
7
|
+
};
|
|
8
|
+
type Setter<T> = (value: T | ((previous: T) => T)) => void;
|
|
9
|
+
export type CreateSyncStateHookOptions = {
|
|
10
|
+
url?: string;
|
|
11
|
+
hooks?: HookDeps;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Builds a `useSyncedState` hook configured with optional endpoint and hook overrides.
|
|
15
|
+
* @param options Optional overrides for endpoint and React primitives.
|
|
16
|
+
* @returns Hook that syncs state through the sync state service.
|
|
17
|
+
*/
|
|
18
|
+
export declare const createSyncStateHook: (options?: CreateSyncStateHookOptions) => <T>(initialValue: T, key: string) => [T, Setter<T>];
|
|
19
|
+
export declare const useSyncedState: <T>(initialValue: T, key: string) => [T, Setter<T>];
|
|
20
|
+
export {};
|