@multiplatform.one/frappe 7.10.0 → 7.15.0
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/README.md +59 -1
- package/dist/cjs/auth.native.js.map +1 -1
- package/dist/cjs/call.native.js.map +1 -1
- package/dist/cjs/collection.native.js.map +1 -1
- package/dist/cjs/common/types.native.js.map +1 -1
- package/dist/cjs/core/http.native.js.map +1 -1
- package/dist/cjs/core/types.native.js.map +1 -1
- package/dist/cjs/core/wire.native.js.map +1 -1
- package/dist/cjs/db.native.js.map +1 -1
- package/dist/cjs/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
- package/dist/cjs/devtools/index.native.js.map +1 -1
- package/dist/cjs/devtools/plugin.native.js.map +1 -1
- package/dist/cjs/devtools/theme.native.js.map +1 -1
- package/dist/cjs/doctype.cjs +25 -43
- package/dist/cjs/doctype.native.js +27 -43
- package/dist/cjs/doctype.native.js.map +1 -1
- package/dist/cjs/file.native.js.map +1 -1
- package/dist/cjs/fixtureDb.native.js.map +1 -1
- package/dist/cjs/fixtures.cjs +3 -0
- package/dist/cjs/fixtures.native.js +3 -0
- package/dist/cjs/fixtures.native.js.map +1 -1
- package/dist/cjs/frappeApp.native.js.map +1 -1
- package/dist/cjs/frappeReact.cjs +67 -31
- package/dist/cjs/frappeReact.native.js +87 -34
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +7 -0
- package/dist/cjs/index.native.js +9 -2
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/optimistic.native.js.map +1 -1
- package/dist/cjs/pacer.native.js.map +1 -1
- package/dist/cjs/plugin/index.native.js.map +1 -1
- package/dist/cjs/realtime.native.js.map +1 -1
- package/dist/cjs/serverLoaders.cjs +74 -0
- package/dist/cjs/serverLoaders.native.js +88 -0
- package/dist/cjs/serverLoaders.native.js.map +1 -0
- package/dist/cjs/sync/backfill.native.js.map +1 -1
- package/dist/cjs/sync/filters.native.js.map +1 -1
- package/dist/cjs/sync/index.cjs +99 -14
- package/dist/cjs/sync/index.native.js +129 -14
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/merge.native.js.map +1 -1
- package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
- package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
- package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
- package/dist/cjs/sync/persistence.native.js.map +1 -1
- package/dist/cjs/sync/realtime.native.js.map +1 -1
- package/dist/cjs/sync/rehydration.cjs +4 -0
- package/dist/cjs/sync/rehydration.native.js +6 -0
- package/dist/cjs/sync/rehydration.native.js.map +1 -1
- package/dist/cjs/sync/storage.native.js.map +1 -1
- package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
- package/dist/cjs/sync/types.native.js.map +1 -1
- package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
- package/dist/cjs/syncOptionsKey.cjs +59 -0
- package/dist/cjs/syncOptionsKey.native.js +82 -0
- package/dist/cjs/syncOptionsKey.native.js.map +1 -0
- package/dist/cjs/useLiveQuery.cjs +2 -20
- package/dist/cjs/useLiveQuery.native.js +1 -22
- package/dist/cjs/useLiveQuery.native.js.map +1 -1
- package/dist/cjs/util.native.js.map +1 -1
- package/dist/esm/auth.native.js.map +1 -1
- package/dist/esm/call.native.js.map +1 -1
- package/dist/esm/collection.native.js.map +1 -1
- package/dist/esm/common/types.native.js.map +1 -1
- package/dist/esm/core/http.native.js.map +1 -1
- package/dist/esm/core/types.native.js.map +1 -1
- package/dist/esm/core/wire.native.js.map +1 -1
- package/dist/esm/db.native.js.map +1 -1
- package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
- package/dist/esm/devtools/index.native.js.map +1 -1
- package/dist/esm/devtools/plugin.native.js.map +1 -1
- package/dist/esm/devtools/theme.native.js.map +1 -1
- package/dist/esm/doctype.mjs +23 -44
- package/dist/esm/doctype.mjs.map +1 -1
- package/dist/esm/doctype.native.js +25 -44
- package/dist/esm/doctype.native.js.map +1 -1
- package/dist/esm/file.native.js.map +1 -1
- package/dist/esm/fixtureDb.native.js.map +1 -1
- package/dist/esm/fixtures.mjs +3 -0
- package/dist/esm/fixtures.mjs.map +1 -1
- package/dist/esm/fixtures.native.js +3 -0
- package/dist/esm/fixtures.native.js.map +1 -1
- package/dist/esm/frappeApp.native.js.map +1 -1
- package/dist/esm/frappeReact.mjs +68 -33
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +87 -35
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.mjs +4 -3
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +4 -3
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/optimistic.native.js.map +1 -1
- package/dist/esm/pacer.native.js.map +1 -1
- package/dist/esm/plugin/index.native.js.map +1 -1
- package/dist/esm/realtime.native.js.map +1 -1
- package/dist/esm/serverLoaders.mjs +48 -0
- package/dist/esm/serverLoaders.mjs.map +1 -0
- package/dist/esm/serverLoaders.native.js +59 -0
- package/dist/esm/serverLoaders.native.js.map +1 -0
- package/dist/esm/sync/backfill.native.js.map +1 -1
- package/dist/esm/sync/filters.native.js.map +1 -1
- package/dist/esm/sync/index.mjs +99 -14
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +129 -14
- package/dist/esm/sync/index.native.js.map +1 -1
- package/dist/esm/sync/merge.native.js.map +1 -1
- package/dist/esm/sync/mutationEngine.native.js.map +1 -1
- package/dist/esm/sync/mutationJournal.native.js.map +1 -1
- package/dist/esm/sync/normalizedCache.native.js.map +1 -1
- package/dist/esm/sync/persistence.native.js.map +1 -1
- package/dist/esm/sync/realtime.native.js.map +1 -1
- package/dist/esm/sync/rehydration.mjs +4 -0
- package/dist/esm/sync/rehydration.mjs.map +1 -1
- package/dist/esm/sync/rehydration.native.js +6 -0
- package/dist/esm/sync/rehydration.native.js.map +1 -1
- package/dist/esm/sync/storage.native.js.map +1 -1
- package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
- package/dist/esm/sync/types.mjs.map +1 -1
- package/dist/esm/sync/types.native.js.map +1 -1
- package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
- package/dist/esm/syncOptionsKey.mjs +34 -0
- package/dist/esm/syncOptionsKey.mjs.map +1 -0
- package/dist/esm/syncOptionsKey.native.js +54 -0
- package/dist/esm/syncOptionsKey.native.js.map +1 -0
- package/dist/esm/useLiveQuery.mjs +1 -19
- package/dist/esm/useLiveQuery.mjs.map +1 -1
- package/dist/esm/useLiveQuery.native.js +1 -22
- package/dist/esm/useLiveQuery.native.js.map +1 -1
- package/dist/esm/util.native.js.map +1 -1
- package/dist/jsx/auth.native.js.map +1 -1
- package/dist/jsx/call.native.js.map +1 -1
- package/dist/jsx/collection.native.js.map +1 -1
- package/dist/jsx/common/types.native.js.map +1 -1
- package/dist/jsx/core/http.native.js.map +1 -1
- package/dist/jsx/core/types.native.js.map +1 -1
- package/dist/jsx/core/wire.native.js.map +1 -1
- package/dist/jsx/db.native.js.map +1 -1
- package/dist/jsx/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
- package/dist/jsx/devtools/index.native.js.map +1 -1
- package/dist/jsx/devtools/plugin.native.js.map +1 -1
- package/dist/jsx/devtools/theme.native.js.map +1 -1
- package/dist/jsx/doctype.mjs +23 -44
- package/dist/jsx/doctype.mjs.map +1 -1
- package/dist/jsx/doctype.native.js +27 -43
- package/dist/jsx/doctype.native.js.map +1 -1
- package/dist/jsx/file.native.js.map +1 -1
- package/dist/jsx/fixtureDb.native.js.map +1 -1
- package/dist/jsx/fixtures.mjs +3 -0
- package/dist/jsx/fixtures.mjs.map +1 -1
- package/dist/jsx/fixtures.native.js +3 -0
- package/dist/jsx/fixtures.native.js.map +1 -1
- package/dist/jsx/frappeApp.native.js.map +1 -1
- package/dist/jsx/frappeReact.mjs +68 -33
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +87 -34
- package/dist/jsx/frappeReact.native.js.map +1 -1
- package/dist/jsx/index.js +4 -3
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +4 -3
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +9 -2
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/optimistic.native.js.map +1 -1
- package/dist/jsx/pacer.native.js.map +1 -1
- package/dist/jsx/plugin/index.native.js.map +1 -1
- package/dist/jsx/realtime.native.js.map +1 -1
- package/dist/jsx/serverLoaders.mjs +48 -0
- package/dist/jsx/serverLoaders.mjs.map +1 -0
- package/dist/jsx/serverLoaders.native.js +88 -0
- package/dist/jsx/serverLoaders.native.js.map +1 -0
- package/dist/jsx/sync/backfill.native.js.map +1 -1
- package/dist/jsx/sync/filters.native.js.map +1 -1
- package/dist/jsx/sync/index.mjs +99 -14
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +129 -14
- package/dist/jsx/sync/index.native.js.map +1 -1
- package/dist/jsx/sync/merge.native.js.map +1 -1
- package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
- package/dist/jsx/sync/mutationJournal.native.js.map +1 -1
- package/dist/jsx/sync/normalizedCache.native.js.map +1 -1
- package/dist/jsx/sync/persistence.native.js.map +1 -1
- package/dist/jsx/sync/realtime.native.js.map +1 -1
- package/dist/jsx/sync/rehydration.mjs +4 -0
- package/dist/jsx/sync/rehydration.mjs.map +1 -1
- package/dist/jsx/sync/rehydration.native.js +6 -0
- package/dist/jsx/sync/rehydration.native.js.map +1 -1
- package/dist/jsx/sync/storage.native.js.map +1 -1
- package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
- package/dist/jsx/sync/types.mjs.map +1 -1
- package/dist/jsx/sync/types.native.js.map +1 -1
- package/dist/jsx/sync/virtualPageManager.native.js.map +1 -1
- package/dist/jsx/syncOptionsKey.mjs +34 -0
- package/dist/jsx/syncOptionsKey.mjs.map +1 -0
- package/dist/jsx/syncOptionsKey.native.js +82 -0
- package/dist/jsx/syncOptionsKey.native.js.map +1 -0
- package/dist/jsx/useLiveQuery.mjs +1 -19
- package/dist/jsx/useLiveQuery.mjs.map +1 -1
- package/dist/jsx/useLiveQuery.native.js +1 -22
- package/dist/jsx/useLiveQuery.native.js.map +1 -1
- package/dist/jsx/util.native.js.map +1 -1
- package/package.json +12 -12
- package/src/doctype.spec.ts +148 -5
- package/src/doctype.ts +202 -173
- package/src/fixtures.ts +1 -0
- package/src/frappeReact.catchUp.spec.tsx +105 -0
- package/src/frappeReact.hydration.spec.tsx +164 -0
- package/src/frappeReact.spec.ts +56 -0
- package/src/frappeReact.ts +93 -43
- package/src/index.ts +14 -0
- package/src/serverLoaders.ts +77 -0
- package/src/sync/catchUp.spec.ts +142 -0
- package/src/sync/index.ts +106 -9
- package/src/sync/rehydration.ts +5 -0
- package/src/sync/syncModule.spec.ts +20 -0
- package/src/sync/types.ts +9 -1
- package/src/syncOptionsKey.spec.ts +46 -0
- package/src/syncOptionsKey.ts +45 -0
- package/src/useLiveQuery.hydration.spec.tsx +129 -0
- package/src/useLiveQuery.spec.ts +3 -11
- package/src/useLiveQuery.ts +3 -57
- package/types/doctype.d.ts +117 -33
- package/types/doctype.d.ts.map +1 -1
- package/types/fixtures.d.ts.map +1 -1
- package/types/frappeReact.d.ts +9 -3
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +6 -5
- package/types/index.d.ts.map +1 -1
- package/types/serverLoaders.d.ts +40 -0
- package/types/serverLoaders.d.ts.map +1 -0
- package/types/sync/index.d.ts +15 -1
- package/types/sync/index.d.ts.map +1 -1
- package/types/sync/rehydration.d.ts +2 -0
- package/types/sync/rehydration.d.ts.map +1 -1
- package/types/sync/types.d.ts +8 -1
- package/types/sync/types.d.ts.map +1 -1
- package/types/syncOptionsKey.d.ts +3 -0
- package/types/syncOptionsKey.d.ts.map +1 -0
- package/types/useLiveQuery.d.ts +3 -13
- package/types/useLiveQuery.d.ts.map +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { HttpClient } from "../core/http";
|
|
3
|
+
import { DbModule } from "../db";
|
|
4
|
+
import { SyncModule } from "./index";
|
|
5
|
+
import { BackfillScope, type SyncOptions } from "./types";
|
|
6
|
+
|
|
7
|
+
const modules: SyncModule[] = [];
|
|
8
|
+
const row = (name: string) => ({ name, doctype: "Task", modified: "2026-09-09T00:00:00Z" });
|
|
9
|
+
function setup(options: SyncOptions = {}) {
|
|
10
|
+
const transport = vi.fn(async (_url: string) => Response.json({ data: [row("a")] }));
|
|
11
|
+
vi.stubGlobal("fetch", transport);
|
|
12
|
+
const sync = new SyncModule(
|
|
13
|
+
new DbModule(new HttpClient("https://catch-up.test", undefined, { maxRetries: 0 }), {
|
|
14
|
+
appName: "live.live",
|
|
15
|
+
}),
|
|
16
|
+
undefined,
|
|
17
|
+
options,
|
|
18
|
+
);
|
|
19
|
+
modules.push(sync);
|
|
20
|
+
return { sync, transport };
|
|
21
|
+
}
|
|
22
|
+
function deferred<T>() {
|
|
23
|
+
let resolve!: (value: T) => void;
|
|
24
|
+
const promise = new Promise<T>((done) => {
|
|
25
|
+
resolve = done;
|
|
26
|
+
});
|
|
27
|
+
return { promise, resolve };
|
|
28
|
+
}
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
for (const sync of modules.splice(0)) await sync.dispose();
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("refreshes resource snapshots through focus, online and the optional interval", async () => {
|
|
36
|
+
vi.useFakeTimers();
|
|
37
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot", cdcInterval: 1000 });
|
|
38
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Task" });
|
|
39
|
+
expect(sync.getCatchUpState()).toEqual({ refreshing: false, stale: false, error: null });
|
|
40
|
+
for (const trigger of ["focus", "online", "interval"]) {
|
|
41
|
+
transport.mockResolvedValue(Response.json({ data: [row(trigger)] }));
|
|
42
|
+
if (trigger === "interval") await vi.advanceTimersByTimeAsync(1000);
|
|
43
|
+
else window.dispatchEvent(new Event(trigger));
|
|
44
|
+
await vi.waitFor(() =>
|
|
45
|
+
expect(sync.getDocsForSubscription(subscriptionId)[0]?.name).toBe(trigger),
|
|
46
|
+
);
|
|
47
|
+
await vi.waitFor(() => expect(sync.getCatchUpState().refreshing).toBe(false));
|
|
48
|
+
}
|
|
49
|
+
expect(
|
|
50
|
+
transport.mock.calls.every(([url]) => new URL(url).pathname === "/api/resource/Task"),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
expect(transport).toHaveBeenCalledTimes(4);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("retains rows and stale/error qualification through a deferred retry", async () => {
|
|
56
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
|
|
57
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Task" });
|
|
58
|
+
transport.mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
|
|
59
|
+
const failed = await sync.catchUp();
|
|
60
|
+
expect(failed).toMatchObject({ refreshing: false, stale: true });
|
|
61
|
+
expect(failed.error).toBeInstanceOf(AggregateError);
|
|
62
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([row("a")]);
|
|
63
|
+
const read = deferred<Response>();
|
|
64
|
+
transport.mockImplementation(() => read.promise);
|
|
65
|
+
const retry = sync.catchUp();
|
|
66
|
+
expect(sync.getCatchUpState()).toMatchObject({
|
|
67
|
+
refreshing: true,
|
|
68
|
+
stale: true,
|
|
69
|
+
error: failed.error,
|
|
70
|
+
});
|
|
71
|
+
read.resolve(Response.json({ data: [] }));
|
|
72
|
+
expect(await retry).toEqual({ refreshing: false, stale: false, error: null });
|
|
73
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps CDC as default, observes its error and preserves explicit backfill rejection", async () => {
|
|
77
|
+
const { sync, transport } = setup();
|
|
78
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Task" });
|
|
79
|
+
transport.mockImplementation(async () => Response.json({ message: "no CDC" }, { status: 404 }));
|
|
80
|
+
const result = await sync.catchUp();
|
|
81
|
+
expect(result).toMatchObject({ stale: true, error: { status: 404 } });
|
|
82
|
+
expect(new URL(transport.mock.calls[1][0]).pathname).toBe("/api/method/live.live.api.backfill");
|
|
83
|
+
await expect(sync.backfill({ scope: BackfillScope.All })).rejects.toMatchObject({ status: 404 });
|
|
84
|
+
transport.mockImplementation(async () =>
|
|
85
|
+
Response.json({
|
|
86
|
+
message: {
|
|
87
|
+
rows: [row("b")],
|
|
88
|
+
deleted: [{ name: "a" }],
|
|
89
|
+
checkpoint: "next",
|
|
90
|
+
has_more: false,
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
expect(await sync.catchUp()).toEqual({ refreshing: false, stale: false, error: null });
|
|
95
|
+
expect(sync.getDocsForSubscription(subscriptionId).map((doc) => doc.name)).toEqual(["b"]);
|
|
96
|
+
expect(sync.getSubscription(subscriptionId)?.checkpoint).toBe("next");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects progressive snapshot subscriptions before any fetch", async () => {
|
|
100
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
|
|
101
|
+
await expect(sync.subscribe({ doctype: "Task", progressive: true })).rejects.toThrow(
|
|
102
|
+
/progressive/,
|
|
103
|
+
);
|
|
104
|
+
expect(transport).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("coalesces overlap into at most one trailing request and settles despite more events", async () => {
|
|
108
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
|
|
109
|
+
await sync.subscribe({ doctype: "Task" });
|
|
110
|
+
const first = deferred<Response>();
|
|
111
|
+
const last = deferred<Response>();
|
|
112
|
+
transport.mockImplementationOnce(() => first.promise).mockImplementationOnce(() => last.promise);
|
|
113
|
+
const one = sync.catchUp();
|
|
114
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
|
|
115
|
+
expect(sync.catchUp()).toBe(one);
|
|
116
|
+
expect(sync.catchUp()).toBe(one);
|
|
117
|
+
first.resolve(Response.json({ data: [row("first")] }));
|
|
118
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(3));
|
|
119
|
+
for (let i = 0; i < 20; i++) expect(sync.catchUp()).toBe(one);
|
|
120
|
+
last.resolve(Response.json({ data: [row("last")] }));
|
|
121
|
+
expect(await one).toEqual({ refreshing: false, stale: false, error: null });
|
|
122
|
+
expect(transport).toHaveBeenCalledTimes(3);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it.each(["reset", "dispose"])("does not publish a late failed catch-up after %s", async (end) => {
|
|
126
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
|
|
127
|
+
await sync.subscribe({ doctype: "Task" });
|
|
128
|
+
const read = deferred<Response>();
|
|
129
|
+
transport.mockImplementation(() => read.promise);
|
|
130
|
+
const pending = sync.catchUp();
|
|
131
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
|
|
132
|
+
if (end === "reset") sync.resetCache();
|
|
133
|
+
else await sync.dispose();
|
|
134
|
+
const afterReset = sync.getCatchUpState();
|
|
135
|
+
const listener = vi.fn();
|
|
136
|
+
const unsubscribe = sync.subscribeStore(listener);
|
|
137
|
+
read.resolve(Response.json({ message: "obsolete" }, { status: 503 }));
|
|
138
|
+
await pending;
|
|
139
|
+
expect(sync.getCatchUpState()).toBe(afterReset);
|
|
140
|
+
expect(listener).not.toHaveBeenCalled();
|
|
141
|
+
unsubscribe();
|
|
142
|
+
});
|
package/src/sync/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
DebugMutationEntry,
|
|
18
18
|
DebugSubscription,
|
|
19
19
|
SyncOptions,
|
|
20
|
+
SyncCatchUpState,
|
|
20
21
|
FrappeDebugState,
|
|
21
22
|
Mutation,
|
|
22
23
|
RealtimeEventType,
|
|
@@ -42,6 +43,12 @@ function getBrowserJournalStorage(): Storage | undefined {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const idleCatchUpState: SyncCatchUpState = Object.freeze({
|
|
47
|
+
refreshing: false,
|
|
48
|
+
stale: false,
|
|
49
|
+
error: null,
|
|
50
|
+
});
|
|
51
|
+
|
|
45
52
|
/**
|
|
46
53
|
* Synchronization engine that provides Zero-style UX on top of Frappe's REST API.
|
|
47
54
|
*
|
|
@@ -113,6 +120,14 @@ export class SyncModule {
|
|
|
113
120
|
private readonly db: DbModule;
|
|
114
121
|
private readonly realtime?: RealtimeModule;
|
|
115
122
|
private readonly options: SyncOptions;
|
|
123
|
+
private catchUpState = idleCatchUpState;
|
|
124
|
+
private catchUpEpoch = 0;
|
|
125
|
+
private disposed = false;
|
|
126
|
+
private catchUpRun?: {
|
|
127
|
+
trailing: boolean;
|
|
128
|
+
attempt: number;
|
|
129
|
+
promise: Promise<SyncCatchUpState>;
|
|
130
|
+
};
|
|
116
131
|
|
|
117
132
|
constructor(db: DbModule, realtime?: RealtimeModule, options: SyncOptions = {}) {
|
|
118
133
|
this.db = db;
|
|
@@ -159,7 +174,9 @@ export class SyncModule {
|
|
|
159
174
|
const realtimeOptions: SyncOptions = {
|
|
160
175
|
...this.options,
|
|
161
176
|
onResetCache: () => this.resetCache(),
|
|
162
|
-
onBackfillAll: () =>
|
|
177
|
+
onBackfillAll: () => {
|
|
178
|
+
void this.catchUp();
|
|
179
|
+
},
|
|
163
180
|
// Realtime reconciliation mutates cache/membership outside the public
|
|
164
181
|
// mutate/backfill entry points — bridge those changes to store listeners.
|
|
165
182
|
onStoreChange: () => this.emit(),
|
|
@@ -194,7 +211,9 @@ export class SyncModule {
|
|
|
194
211
|
* Stores cleanup functions returned by each subscription.
|
|
195
212
|
*/
|
|
196
213
|
private setupWindowEvents() {
|
|
197
|
-
this.cleanupFocus = onAppFocus(() =>
|
|
214
|
+
this.cleanupFocus = onAppFocus(() => {
|
|
215
|
+
void this.catchUp();
|
|
216
|
+
});
|
|
198
217
|
// Journal replay on foreground: React Native never fires `online`
|
|
199
218
|
// (AppState is its only lifecycle signal), and on web this recovers
|
|
200
219
|
// server-restart cases where connectivity never flipped. Guarded so it
|
|
@@ -202,14 +221,15 @@ export class SyncModule {
|
|
|
202
221
|
this.cleanupFocusReplay = onAppFocus(() => {
|
|
203
222
|
if (this.mutationJournal.listPending().length > 0) void this.replayPending();
|
|
204
223
|
});
|
|
205
|
-
this.cleanupOnlineBackfill = onOnline(() =>
|
|
224
|
+
this.cleanupOnlineBackfill = onOnline(() => {
|
|
225
|
+
void this.catchUp();
|
|
226
|
+
});
|
|
206
227
|
this.cleanupOnlineReplay = onOnline(() => this.replayPending());
|
|
207
228
|
|
|
208
229
|
if (this.options.cdcInterval && this.options.cdcInterval > 0) {
|
|
209
|
-
this.cdcIntervalHandle = setInterval(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
);
|
|
230
|
+
this.cdcIntervalHandle = setInterval(() => {
|
|
231
|
+
void this.catchUp();
|
|
232
|
+
}, this.options.cdcInterval);
|
|
213
233
|
}
|
|
214
234
|
}
|
|
215
235
|
|
|
@@ -223,6 +243,9 @@ export class SyncModule {
|
|
|
223
243
|
* the subscription is marked stale until the next successful backfill.
|
|
224
244
|
*/
|
|
225
245
|
async subscribe<Data extends FrappeDocData>(query: SubscriptionQuery) {
|
|
246
|
+
if (this.options.catchUpStrategy === "snapshot" && query.progressive) {
|
|
247
|
+
throw new Error("Snapshot catch-up does not support progressive subscriptions");
|
|
248
|
+
}
|
|
226
249
|
// Wait for any persisted snapshot restore so a matching restored
|
|
227
250
|
// subscription can be adopted instead of racing the async load.
|
|
228
251
|
await this.persistenceManager.whenReady();
|
|
@@ -263,22 +286,92 @@ export class SyncModule {
|
|
|
263
286
|
this.emit();
|
|
264
287
|
}
|
|
265
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The name the server assigned a document this module created under
|
|
291
|
+
* `temporaryName`, once the create is acknowledged. Undefined while it is
|
|
292
|
+
* pending, or when the server kept the name it was given.
|
|
293
|
+
*/
|
|
294
|
+
createdName(temporaryName: string): string | undefined {
|
|
295
|
+
return this.rehydrationManager.resolveTempId(temporaryName);
|
|
296
|
+
}
|
|
297
|
+
|
|
266
298
|
/** Refresh active query snapshots in place. Failures retain the last committed data. */
|
|
267
299
|
async refreshSubscriptions(
|
|
268
300
|
options?: SubscriptionRefreshOptions,
|
|
269
301
|
): Promise<SubscriptionRefreshResult[]> {
|
|
302
|
+
const epoch = this.catchUpEpoch;
|
|
270
303
|
await this.persistenceManager.whenReady();
|
|
304
|
+
if (this.disposed || epoch !== this.catchUpEpoch) return [];
|
|
271
305
|
const results = await this.subscriptionManager.refreshSubscriptions(options);
|
|
272
|
-
this.emit();
|
|
306
|
+
if (!this.disposed && epoch === this.catchUpEpoch) this.emit();
|
|
273
307
|
return results;
|
|
274
308
|
}
|
|
275
309
|
|
|
310
|
+
getCatchUpState(): SyncCatchUpState {
|
|
311
|
+
return this.catchUpState;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private publishCatchUp(state: SyncCatchUpState) {
|
|
315
|
+
this.catchUpState = Object.freeze(state);
|
|
316
|
+
for (const listener of this.storeListeners) listener();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Observes lifecycle/retry failures in state; explicit backfill still rejects. */
|
|
320
|
+
catchUp(): Promise<SyncCatchUpState> {
|
|
321
|
+
if (this.disposed) return Promise.resolve(this.catchUpState);
|
|
322
|
+
if (this.catchUpRun) {
|
|
323
|
+
if (this.catchUpRun.attempt === 0) this.catchUpRun.trailing = true;
|
|
324
|
+
return this.catchUpRun.promise;
|
|
325
|
+
}
|
|
326
|
+
const epoch = this.catchUpEpoch;
|
|
327
|
+
const run = { trailing: false, attempt: 0, promise: Promise.resolve(this.catchUpState) };
|
|
328
|
+
this.catchUpRun = run;
|
|
329
|
+
run.promise = Promise.resolve().then(async () => {
|
|
330
|
+
let error: unknown = null;
|
|
331
|
+
const active = () => !this.disposed && this.catchUpEpoch === epoch;
|
|
332
|
+
do {
|
|
333
|
+
if (!active()) return this.catchUpState;
|
|
334
|
+
run.trailing = false;
|
|
335
|
+
error = null;
|
|
336
|
+
try {
|
|
337
|
+
if (this.options.catchUpStrategy === "snapshot") {
|
|
338
|
+
const results = await this.refreshSubscriptions();
|
|
339
|
+
const failures = results.filter(
|
|
340
|
+
(result) =>
|
|
341
|
+
!result.ok &&
|
|
342
|
+
this.subscriptionManager.getSubscription(result.subscriptionId) &&
|
|
343
|
+
!this.subscriptionManager.isRestoredSubscription(result.subscriptionId),
|
|
344
|
+
);
|
|
345
|
+
if (failures.length)
|
|
346
|
+
error = new AggregateError(
|
|
347
|
+
failures.map((failure) => failure.error),
|
|
348
|
+
"Some subscriptions could not refresh. Showing previous data.",
|
|
349
|
+
);
|
|
350
|
+
} else {
|
|
351
|
+
await this.backfill({ scope: BackfillScope.All });
|
|
352
|
+
}
|
|
353
|
+
} catch (caught) {
|
|
354
|
+
error = caught;
|
|
355
|
+
}
|
|
356
|
+
// Bound a burst to one trailing read, including triggers during that read.
|
|
357
|
+
} while (active() && run.trailing && run.attempt++ === 0);
|
|
358
|
+
if (active()) {
|
|
359
|
+
this.catchUpRun = undefined;
|
|
360
|
+
this.publishCatchUp({ refreshing: false, stale: error !== null, error });
|
|
361
|
+
}
|
|
362
|
+
return this.catchUpState;
|
|
363
|
+
});
|
|
364
|
+
this.publishCatchUp({ ...this.catchUpState, refreshing: true });
|
|
365
|
+
return run.promise;
|
|
366
|
+
}
|
|
367
|
+
|
|
276
368
|
/**
|
|
277
369
|
* Reconciles local state by fetching changes since stored cursors.
|
|
278
370
|
*/
|
|
279
371
|
async backfill(args: BackfillArgs) {
|
|
372
|
+
const epoch = this.catchUpEpoch;
|
|
280
373
|
await this.subscriptionManager.backfill(args);
|
|
281
|
-
this.emit();
|
|
374
|
+
if (!this.disposed && epoch === this.catchUpEpoch) this.emit();
|
|
282
375
|
}
|
|
283
376
|
|
|
284
377
|
/**
|
|
@@ -450,6 +543,9 @@ export class SyncModule {
|
|
|
450
543
|
|
|
451
544
|
/** Clears subscriptions, cache, journal, and temp id mappings. */
|
|
452
545
|
resetCache() {
|
|
546
|
+
this.catchUpEpoch++;
|
|
547
|
+
this.catchUpRun = undefined;
|
|
548
|
+
this.catchUpState = idleCatchUpState;
|
|
453
549
|
// Leave all realtime doctype rooms alongside dropping the subscriptions
|
|
454
550
|
this.realtimeHandler.unwatchAll();
|
|
455
551
|
// Drop all live subs, in-memory cache, journal, and temp mappings
|
|
@@ -463,6 +559,7 @@ export class SyncModule {
|
|
|
463
559
|
|
|
464
560
|
/** Cleanup and dispose of resources */
|
|
465
561
|
async dispose() {
|
|
562
|
+
this.disposed = true;
|
|
466
563
|
// Clear all listeners
|
|
467
564
|
this.storeListeners.clear();
|
|
468
565
|
this.realtimeEventListeners.clear();
|
package/src/sync/rehydration.ts
CHANGED
|
@@ -370,6 +370,11 @@ export class RehydrationManager {
|
|
|
370
370
|
this.temporaryIdMap.set(tempId, realName);
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
/** The name the server gave a document created under a temp id. */
|
|
374
|
+
resolveTempId(tempId: string): string | undefined {
|
|
375
|
+
return this.temporaryIdMap.get(tempId);
|
|
376
|
+
}
|
|
377
|
+
|
|
373
378
|
/**
|
|
374
379
|
* Clears the rehydration queue.
|
|
375
380
|
*/
|
|
@@ -65,6 +65,26 @@ describe("SyncModule", () => {
|
|
|
65
65
|
return new SyncModule(mockDb, mockRealtime, options);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
describe("createdName (MPO-333)", () => {
|
|
69
|
+
it("names the document the server created under a temporary name", async () => {
|
|
70
|
+
mockDb.createDoc.mockResolvedValue({
|
|
71
|
+
name: "a1b2c3",
|
|
72
|
+
doctype: "TestDoc",
|
|
73
|
+
modified: "2024-01-01T11:00:00",
|
|
74
|
+
title: "hash named",
|
|
75
|
+
});
|
|
76
|
+
sync = createSync();
|
|
77
|
+
expect(sync.createdName("new-1")).toBeUndefined();
|
|
78
|
+
await sync.mutate({
|
|
79
|
+
mutationId: "create-hash",
|
|
80
|
+
operation: Operation.Create,
|
|
81
|
+
doc: { name: "new-1", doctype: "TestDoc", title: "hash named" },
|
|
82
|
+
});
|
|
83
|
+
expect(sync.createdName("new-1")).toBe("a1b2c3");
|
|
84
|
+
expect(sync.createdName("new-2")).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
68
88
|
describe("automatic journal storage", () => {
|
|
69
89
|
it("reads HTTP rows during SSR with Node's incomplete localStorage object", async () => {
|
|
70
90
|
vi.stubGlobal("window", undefined);
|
package/src/sync/types.ts
CHANGED
|
@@ -197,7 +197,15 @@ export enum RealtimeEventType {
|
|
|
197
197
|
PermEpochBump = "perm_epoch_bump",
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
export interface SyncCatchUpState {
|
|
201
|
+
readonly refreshing: boolean;
|
|
202
|
+
readonly stale: boolean;
|
|
203
|
+
readonly error: unknown | null;
|
|
204
|
+
}
|
|
205
|
+
|
|
200
206
|
export interface SyncOptions {
|
|
207
|
+
/** Lifecycle catch-up uses CDC by default; snapshot requires only resource reads. */
|
|
208
|
+
catchUpStrategy?: "cdc" | "snapshot";
|
|
201
209
|
pageSize?: number;
|
|
202
210
|
persistKey?: string;
|
|
203
211
|
onConflict?: (info: { doctype: string; name?: string }) => void;
|
|
@@ -261,7 +269,7 @@ export interface SyncOptions {
|
|
|
261
269
|
/** Rate limit for mutation replay - max mutations per second (default: 10) */
|
|
262
270
|
replayRateLimit?: number;
|
|
263
271
|
/**
|
|
264
|
-
* Optional interval (in milliseconds) for
|
|
272
|
+
* Optional interval (in milliseconds) for the selected catch-up strategy.
|
|
265
273
|
* Acts as a safety net for silent socket drops. Set to 0 or omit
|
|
266
274
|
* to rely solely on event-driven backfill (focus, online, reconnect).
|
|
267
275
|
* Recommended: 30000 (30 seconds) for production.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { syncOptionsKey } from "./syncOptionsKey";
|
|
3
|
+
|
|
4
|
+
it("normalizes lifecycle defaults and plain options independent of key order", () => {
|
|
5
|
+
expect(syncOptionsKey()).toBe(syncOptionsKey({ catchUpStrategy: "cdc", cdcInterval: 0 }));
|
|
6
|
+
expect(syncOptionsKey({ cdcInterval: -1 })).toBe(syncOptionsKey());
|
|
7
|
+
expect(
|
|
8
|
+
syncOptionsKey({ versionFields: { Task: "modified", Item: "version" }, pageSize: 20 }),
|
|
9
|
+
).toBe(
|
|
10
|
+
syncOptionsKey({
|
|
11
|
+
pageSize: 20,
|
|
12
|
+
versionFields: { Item: "version", Task: "modified" },
|
|
13
|
+
persistKey: undefined,
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("separates strategy, interval, persistence and cursor capabilities", () => {
|
|
19
|
+
const keys = [
|
|
20
|
+
undefined,
|
|
21
|
+
{ catchUpStrategy: "snapshot" as const },
|
|
22
|
+
{ cdcInterval: 1000 },
|
|
23
|
+
{ persistKey: "one" },
|
|
24
|
+
{ useCursorPagination: true },
|
|
25
|
+
].map(syncOptionsKey);
|
|
26
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps callback and storage identities without dropping functions", () => {
|
|
30
|
+
const callback = () => 0;
|
|
31
|
+
const storage = {
|
|
32
|
+
getItem: async () => null,
|
|
33
|
+
setItem: async () => {},
|
|
34
|
+
removeItem: async () => {},
|
|
35
|
+
};
|
|
36
|
+
expect(syncOptionsKey({ onBackfill: callback, storage })).toBe(
|
|
37
|
+
syncOptionsKey({ storage, onBackfill: callback }),
|
|
38
|
+
);
|
|
39
|
+
expect(syncOptionsKey({ onBackfill: callback })).not.toBe(
|
|
40
|
+
syncOptionsKey({ onBackfill: () => {} }),
|
|
41
|
+
);
|
|
42
|
+
expect(syncOptionsKey({ storage })).not.toBe(syncOptionsKey({ storage: { ...storage } }));
|
|
43
|
+
expect(syncOptionsKey({ fieldMergeConfig: { Task: { value: callback } } })).not.toBe(
|
|
44
|
+
syncOptionsKey(),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SyncOptions } from "./sync/types";
|
|
2
|
+
|
|
3
|
+
const identities = new WeakMap<object, number>();
|
|
4
|
+
let nextIdentity = 0;
|
|
5
|
+
function identity(value: object) {
|
|
6
|
+
let id = identities.get(value);
|
|
7
|
+
if (id === undefined) {
|
|
8
|
+
id = ++nextIdentity;
|
|
9
|
+
identities.set(value, id);
|
|
10
|
+
}
|
|
11
|
+
return ["reference", id];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function stableValue(value: unknown, ancestors = new Set<object>()): unknown {
|
|
15
|
+
if (typeof value === "function") return identity(value);
|
|
16
|
+
if (value === null || typeof value !== "object") return [typeof value, String(value)];
|
|
17
|
+
const prototype = Object.getPrototypeOf(value);
|
|
18
|
+
if (
|
|
19
|
+
ancestors.has(value) ||
|
|
20
|
+
(!Array.isArray(value) && prototype !== Object.prototype && prototype !== null)
|
|
21
|
+
) {
|
|
22
|
+
return identity(value);
|
|
23
|
+
}
|
|
24
|
+
const nested = new Set(ancestors).add(value);
|
|
25
|
+
if (Array.isArray(value)) return ["array", value.map((item) => stableValue(item, nested))];
|
|
26
|
+
return [
|
|
27
|
+
"record",
|
|
28
|
+
Object.entries(value)
|
|
29
|
+
.filter(([, item]) => item !== undefined)
|
|
30
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
31
|
+
.map(([key, item]) => [key, stableValue(item, nested)]),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function syncOptionsKey(options: SyncOptions = {}): string {
|
|
36
|
+
const { storage, ...values } = options;
|
|
37
|
+
return JSON.stringify([
|
|
38
|
+
stableValue({
|
|
39
|
+
...values,
|
|
40
|
+
catchUpStrategy: options.catchUpStrategy ?? "cdc",
|
|
41
|
+
cdcInterval: options.cdcInterval && options.cdcInterval > 0 ? options.cdcInterval : 0,
|
|
42
|
+
}),
|
|
43
|
+
storage ? identity(storage) : null,
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { act } from "react";
|
|
2
|
+
import { hydrateRoot } from "react-dom/client";
|
|
3
|
+
import { renderToString } from "react-dom/server";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const platform = vi.hoisted(() => ({ server: true }));
|
|
7
|
+
vi.mock("@multiplatform.one/platform", () => ({
|
|
8
|
+
get isServer() {
|
|
9
|
+
return platform.server;
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
it.each([
|
|
14
|
+
{ warm: false, enabled: true },
|
|
15
|
+
{ warm: true, enabled: true },
|
|
16
|
+
{ warm: true, enabled: false },
|
|
17
|
+
])("hydrates real queries and preserves updates/cleanup (%j)", async ({ warm, enabled }) => {
|
|
18
|
+
platform.server = true;
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
const serverDb = await import("@tanstack/db");
|
|
21
|
+
const serverCollection = serverDb.createCollection(
|
|
22
|
+
serverDb.localOnlyCollectionOptions({
|
|
23
|
+
getKey: (row: { id: string; label: string }) => row.id,
|
|
24
|
+
initialData: [{ id: "alpha", label: "Alpha" }],
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
const { useLiveQuery: serverQuery } = await import("./useLiveQuery");
|
|
28
|
+
function ServerView() {
|
|
29
|
+
const result = serverQuery((q) => (enabled ? q.from({ item: serverCollection }) : undefined));
|
|
30
|
+
return (
|
|
31
|
+
<div data-testid="result">
|
|
32
|
+
{!result.isEnabled
|
|
33
|
+
? "Disabled"
|
|
34
|
+
: result.data?.map((row) => row.label).join(",") || "Loading"}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const container = document.createElement("div");
|
|
39
|
+
container.innerHTML = renderToString(<ServerView />);
|
|
40
|
+
document.body.append(container);
|
|
41
|
+
const serverNode = container.firstChild;
|
|
42
|
+
platform.server = false;
|
|
43
|
+
vi.resetModules();
|
|
44
|
+
const clientDb = await import("@tanstack/db");
|
|
45
|
+
const collection = clientDb.createCollection(
|
|
46
|
+
clientDb.localOnlyCollectionOptions({
|
|
47
|
+
getKey: (row: { id: string; label: string }) => row.id,
|
|
48
|
+
initialData: [{ id: "alpha", label: "Alpha" }],
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
if (warm) await collection.preload();
|
|
52
|
+
const { useLiveQuery: clientQuery } = await import("./useLiveQuery");
|
|
53
|
+
let observedQuery: { subscriberCount: number; status: string } | undefined;
|
|
54
|
+
function ClientView({ active = enabled, selected }: { active?: boolean; selected?: string }) {
|
|
55
|
+
const result = clientQuery(
|
|
56
|
+
(q) => {
|
|
57
|
+
if (!active) return undefined;
|
|
58
|
+
const query = q.from({ item: collection });
|
|
59
|
+
return selected ? query.where(({ item }) => clientDb.eq(item.id, selected)) : query;
|
|
60
|
+
},
|
|
61
|
+
[active, selected],
|
|
62
|
+
);
|
|
63
|
+
observedQuery = result.collection;
|
|
64
|
+
return (
|
|
65
|
+
<div data-testid="result">
|
|
66
|
+
{!result.isEnabled
|
|
67
|
+
? "Disabled"
|
|
68
|
+
: result.data?.map((row) => row.label).join(",") || "Loading"}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const onRecoverableError = vi.fn();
|
|
73
|
+
let root: ReturnType<typeof hydrateRoot> | undefined;
|
|
74
|
+
try {
|
|
75
|
+
await act(async () => {
|
|
76
|
+
root = hydrateRoot(container, <ClientView />, { onRecoverableError });
|
|
77
|
+
});
|
|
78
|
+
expect(
|
|
79
|
+
onRecoverableError.mock.calls.map(([error]) => error.cause?.message || error.message),
|
|
80
|
+
).toEqual([]);
|
|
81
|
+
expect(container.firstChild).toBe(serverNode);
|
|
82
|
+
await vi.waitFor(() => expect(container.textContent).toBe(enabled ? "Alpha" : "Disabled"));
|
|
83
|
+
if (!enabled) {
|
|
84
|
+
expect(collection.subscriberCount).toBe(0);
|
|
85
|
+
await act(async () => root!.render(<ClientView active />));
|
|
86
|
+
}
|
|
87
|
+
await act(async () => {
|
|
88
|
+
const transaction = collection.insert({ id: "beta", label: "Beta" });
|
|
89
|
+
await transaction.isPersisted.promise;
|
|
90
|
+
});
|
|
91
|
+
await vi.waitFor(() => expect(container.textContent).toBe("Alpha,Beta"));
|
|
92
|
+
await act(async () => root!.render(<ClientView active selected="beta" />));
|
|
93
|
+
await vi.waitFor(() => expect(container.textContent).toBe("Beta"));
|
|
94
|
+
const { createOptimisticAction } = await import("./optimistic");
|
|
95
|
+
let rejectWrite: (reason: Error) => void = () => {};
|
|
96
|
+
const write = new Promise<void>((_resolve, reject) => {
|
|
97
|
+
rejectWrite = reject;
|
|
98
|
+
});
|
|
99
|
+
const rename = createOptimisticAction({
|
|
100
|
+
onMutate: () =>
|
|
101
|
+
collection.update("beta", (row) => {
|
|
102
|
+
row.label = "Pending";
|
|
103
|
+
}),
|
|
104
|
+
mutationFn: () => write,
|
|
105
|
+
});
|
|
106
|
+
let transaction: ReturnType<typeof rename>;
|
|
107
|
+
await act(async () => {
|
|
108
|
+
transaction = rename(undefined);
|
|
109
|
+
});
|
|
110
|
+
await vi.waitFor(() => expect(container.textContent).toBe("Pending"));
|
|
111
|
+
await act(async () => {
|
|
112
|
+
const refused = expect(transaction!.isPersisted.promise).rejects.toThrow("Write refused");
|
|
113
|
+
rejectWrite(new Error("Write refused"));
|
|
114
|
+
await refused;
|
|
115
|
+
});
|
|
116
|
+
await vi.waitFor(() => expect(container.textContent).toBe("Beta"));
|
|
117
|
+
const lastQuery = observedQuery;
|
|
118
|
+
expect(lastQuery?.subscriberCount).toBeGreaterThan(0);
|
|
119
|
+
await act(async () => root!.unmount());
|
|
120
|
+
root = undefined;
|
|
121
|
+
await vi.waitFor(() => expect(lastQuery?.subscriberCount).toBe(0));
|
|
122
|
+
await vi.waitFor(() => expect(lastQuery?.status).toBe("cleaned-up"));
|
|
123
|
+
} finally {
|
|
124
|
+
await act(async () => root?.unmount());
|
|
125
|
+
container.remove();
|
|
126
|
+
await collection.cleanup();
|
|
127
|
+
await serverCollection.cleanup();
|
|
128
|
+
}
|
|
129
|
+
});
|
package/src/useLiveQuery.spec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { useLiveQuery as upstreamUseLiveQuery } from "@tanstack/react-db";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
5
|
import { useLiveQuery } from "./useLiveQuery";
|
|
6
6
|
|
|
@@ -22,9 +22,8 @@ describe("useLiveQuery carries no import.meta (MPO-204)", () => {
|
|
|
22
22
|
expect(source).not.toMatch(/import\.meta/);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
it("
|
|
26
|
-
expect(
|
|
27
|
-
expect(source).toMatch(/const IS_SSR: boolean = isServer;/);
|
|
25
|
+
it("preserves the upstream hook and its server snapshot lifecycle", () => {
|
|
26
|
+
expect(useLiveQuery).toBe(upstreamUseLiveQuery);
|
|
28
27
|
});
|
|
29
28
|
|
|
30
29
|
it("has no platform twin — a .web twin replaced the bake once already (MPO-192)", () => {
|
|
@@ -34,10 +33,3 @@ describe("useLiveQuery carries no import.meta (MPO-204)", () => {
|
|
|
34
33
|
}
|
|
35
34
|
});
|
|
36
35
|
});
|
|
37
|
-
|
|
38
|
-
describe("useLiveQuery answers client in a browser-like environment", () => {
|
|
39
|
-
it("resolves isServer false, so the hook delegates to @tanstack/react-db", () => {
|
|
40
|
-
expect(isServer).toBe(false);
|
|
41
|
-
expect(typeof useLiveQuery).toBe("function");
|
|
42
|
-
});
|
|
43
|
-
});
|