@multiplatform.one/frappe 7.8.0 → 7.11.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 +89 -1
- package/dist/cjs/frappeReact.cjs +54 -30
- package/dist/cjs/frappeReact.native.js +67 -32
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/sync/index.cjs +97 -13
- package/dist/cjs/sync/index.native.js +127 -13
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/subscriptionManager.cjs +94 -50
- package/dist/cjs/sync/subscriptionManager.native.js +133 -73
- package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
- package/dist/cjs/sync/types.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/esm/frappeReact.mjs +55 -32
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +68 -34
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.mjs +2 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/sync/index.mjs +97 -13
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +127 -13
- package/dist/esm/sync/index.native.js.map +1 -1
- package/dist/esm/sync/subscriptionManager.mjs +94 -50
- package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
- package/dist/esm/sync/subscriptionManager.native.js +133 -73
- 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/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/jsx/frappeReact.mjs +55 -32
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +67 -32
- package/dist/jsx/frappeReact.native.js.map +1 -1
- package/dist/jsx/index.js +2 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +2 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +1 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/sync/index.mjs +97 -13
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +127 -13
- package/dist/jsx/sync/index.native.js.map +1 -1
- package/dist/jsx/sync/subscriptionManager.mjs +94 -50
- package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
- package/dist/jsx/sync/subscriptionManager.native.js +133 -73
- 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/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/package.json +9 -9
- package/src/frappeReact.catchUp.spec.tsx +105 -0
- package/src/frappeReact.ts +62 -38
- package/src/index.ts +8 -1
- package/src/sync/catchUp.spec.ts +142 -0
- package/src/sync/index.ts +108 -8
- package/src/sync/refreshSubscriptions.lifecycle.spec.ts +268 -0
- package/src/sync/refreshSubscriptions.spec.ts +295 -0
- package/src/sync/subscriptionManager.ts +107 -44
- package/src/sync/types.ts +9 -1
- package/src/syncOptionsKey.spec.ts +46 -0
- package/src/syncOptionsKey.ts +45 -0
- package/types/frappeReact.d.ts +6 -1
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +4 -4
- package/types/index.d.ts.map +1 -1
- package/types/sync/index.d.ts +13 -1
- package/types/sync/index.d.ts.map +1 -1
- package/types/sync/subscriptionManager.d.ts +13 -10
- package/types/sync/subscriptionManager.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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { createFrappeCollection } from "../collection";
|
|
3
|
+
import { SyncModule as PublicSyncModule } from "../index";
|
|
4
|
+
import { SyncModule } from "./index";
|
|
5
|
+
import { Order, defaultPageLength } from "./types";
|
|
6
|
+
import type { SubscriptionQuery } from "./types";
|
|
7
|
+
|
|
8
|
+
vi.mock("@multiplatform.one/platform", () => ({
|
|
9
|
+
isNative: false,
|
|
10
|
+
isServer: true,
|
|
11
|
+
isWindowDefined: false,
|
|
12
|
+
onAppFocus: () => () => {},
|
|
13
|
+
onOnline: () => () => {},
|
|
14
|
+
onVisibilityChange: () => () => {},
|
|
15
|
+
}));
|
|
16
|
+
const instances: SyncModule[] = [];
|
|
17
|
+
const cleanups: (() => unknown)[] = [];
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
try {
|
|
20
|
+
for (const cleanup of cleanups.splice(0)) await cleanup();
|
|
21
|
+
} finally {
|
|
22
|
+
for (const sync of instances.splice(0)) await sync.dispose();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
const row = (name: string, title = name) => ({
|
|
26
|
+
name,
|
|
27
|
+
title,
|
|
28
|
+
doctype: "Item",
|
|
29
|
+
modified: "2026-09-09T01:00:00Z",
|
|
30
|
+
});
|
|
31
|
+
function setup() {
|
|
32
|
+
const db = { getDocList: vi.fn().mockResolvedValue([row("a"), row("b")]) };
|
|
33
|
+
const sync = new SyncModule(db as never, undefined);
|
|
34
|
+
instances.push(sync);
|
|
35
|
+
return { db, sync };
|
|
36
|
+
}
|
|
37
|
+
function deferred<T>() {
|
|
38
|
+
let resolve!: (value: T) => void;
|
|
39
|
+
const promise = new Promise<T>((done) => {
|
|
40
|
+
resolve = done;
|
|
41
|
+
});
|
|
42
|
+
return { promise, resolve };
|
|
43
|
+
}
|
|
44
|
+
it("replaces mounted collection membership and clears authoritative empty snapshots", async () => {
|
|
45
|
+
const { db, sync } = setup();
|
|
46
|
+
const subscribe = vi.spyOn(sync, "subscribe");
|
|
47
|
+
const collection = createFrappeCollection<ReturnType<typeof row>>("Item", { sync });
|
|
48
|
+
cleanups.push(() => collection.cleanup());
|
|
49
|
+
await collection.preload();
|
|
50
|
+
const originalCollection = collection;
|
|
51
|
+
const id = (await subscribe.mock.results[0].value).subscriptionId;
|
|
52
|
+
const observed: string[][] = [];
|
|
53
|
+
cleanups.push(
|
|
54
|
+
sync.subscribeStore(() => {
|
|
55
|
+
observed.push(sync.getDocsForSubscription(id!).map((doc) => doc.name));
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
db.getDocList.mockResolvedValue({
|
|
59
|
+
data: [row("b", "updated"), row("c")],
|
|
60
|
+
cursor: "replacement-cursor",
|
|
61
|
+
});
|
|
62
|
+
const [result] = await sync.refreshSubscriptions();
|
|
63
|
+
expect(result.ok).toBe(true);
|
|
64
|
+
expect(result.subscriptionId).toBe(id);
|
|
65
|
+
expect(collection).toBe(originalCollection);
|
|
66
|
+
expect(
|
|
67
|
+
[...collection.state.values()]
|
|
68
|
+
.map(({ name, title }) => ({ name, title }))
|
|
69
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
70
|
+
).toEqual([
|
|
71
|
+
{ name: "b", title: "updated" },
|
|
72
|
+
{ name: "c", title: "c" },
|
|
73
|
+
]);
|
|
74
|
+
expect(observed).toEqual([["b", "c"]]);
|
|
75
|
+
expect(sync.getSubscription(id)?.cursor).toBe("replacement-cursor");
|
|
76
|
+
expect(sync.getDocFromCache("Item", "a")).toEqual(row("a"));
|
|
77
|
+
db.getDocList.mockResolvedValue([]);
|
|
78
|
+
expect(await sync.refreshSubscriptions()).toEqual([result]);
|
|
79
|
+
expect([...collection.state.values()]).toEqual([]);
|
|
80
|
+
expect(sync.getSubscription(result.subscriptionId)?.boundaries).toBeUndefined();
|
|
81
|
+
expect(collection).toBe(originalCollection);
|
|
82
|
+
expect(observed).toEqual([["b", "c"], []]);
|
|
83
|
+
expect(subscribe).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(sync.getSubscription(id)).toMatchObject({
|
|
85
|
+
id,
|
|
86
|
+
docIds: [],
|
|
87
|
+
metadata: { itemsBeforePage: 0, itemsAfterPage: 0 },
|
|
88
|
+
});
|
|
89
|
+
expect(sync.getSubscription(id)?.cursor).toBeUndefined();
|
|
90
|
+
expect(sync.getSubscription(id)?.checkpoint).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
it("retains data and checkpoint on failure and preserves the query", async () => {
|
|
93
|
+
const { db, sync } = setup();
|
|
94
|
+
db.getDocList.mockResolvedValue({ data: [row("a"), row("b")], cursor: "committed-cursor" });
|
|
95
|
+
const query = {
|
|
96
|
+
doctype: "Item",
|
|
97
|
+
fields: ["title"],
|
|
98
|
+
filters: { enabled: 1 },
|
|
99
|
+
orderBy: [{ field: "title", order: Order.Desc }],
|
|
100
|
+
limit: 3,
|
|
101
|
+
limitStart: 6,
|
|
102
|
+
};
|
|
103
|
+
const { subscriptionId } = await sync.subscribe(query);
|
|
104
|
+
const before = structuredClone(sync.getSubscription(subscriptionId));
|
|
105
|
+
const error = new Error("offline");
|
|
106
|
+
db.getDocList.mockRejectedValue(error);
|
|
107
|
+
const results = await sync.refreshSubscriptions();
|
|
108
|
+
expect(results).toEqual([{ subscriptionId, ok: false, error }]);
|
|
109
|
+
expect(results[0].error).toBe(error);
|
|
110
|
+
expect(sync.getSubscription(subscriptionId)).toEqual({ ...before, stale: true });
|
|
111
|
+
expect(db.getDocList.mock.calls).toEqual(
|
|
112
|
+
Array(2).fill([
|
|
113
|
+
"Item",
|
|
114
|
+
{
|
|
115
|
+
fields: ["title", "modified", "name"],
|
|
116
|
+
filters: { enabled: 1 },
|
|
117
|
+
orderBy: [{ field: "title", order: Order.Desc }],
|
|
118
|
+
limit: 3,
|
|
119
|
+
limitStart: 6,
|
|
120
|
+
},
|
|
121
|
+
]),
|
|
122
|
+
);
|
|
123
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([row("a"), row("b")]);
|
|
124
|
+
});
|
|
125
|
+
it("coalesces overlapping requests into a trailing authoritative fetch", async () => {
|
|
126
|
+
const { db, sync } = setup();
|
|
127
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Item" });
|
|
128
|
+
const first = deferred<ReturnType<typeof row>[]>();
|
|
129
|
+
const last = deferred<ReturnType<typeof row>[]>();
|
|
130
|
+
db.getDocList.mockReturnValueOnce(first.promise).mockReturnValueOnce(last.promise);
|
|
131
|
+
const one = sync.refreshSubscriptions();
|
|
132
|
+
await vi.waitFor(() => expect(db.getDocList).toHaveBeenCalledTimes(2));
|
|
133
|
+
const two = sync.refreshSubscriptions();
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
first.resolve([row("old")]);
|
|
136
|
+
await vi.waitFor(() => expect(db.getDocList).toHaveBeenCalledTimes(3));
|
|
137
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([row("a"), row("b")]);
|
|
138
|
+
last.resolve([row("new")]);
|
|
139
|
+
expect(await one).toEqual(await two);
|
|
140
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([row("new")]);
|
|
141
|
+
});
|
|
142
|
+
it("does not resurrect an unsubscribed request or modify its cached rows", async () => {
|
|
143
|
+
const { db, sync } = setup();
|
|
144
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Item" });
|
|
145
|
+
const pending = deferred<ReturnType<typeof row>[]>();
|
|
146
|
+
db.getDocList.mockReturnValueOnce(pending.promise);
|
|
147
|
+
const refresh = sync.refreshSubscriptions();
|
|
148
|
+
await vi.waitFor(() => expect(db.getDocList).toHaveBeenCalledTimes(2));
|
|
149
|
+
sync.unsubscribe(subscriptionId);
|
|
150
|
+
pending.resolve([row("a", "obsolete")]);
|
|
151
|
+
expect((await refresh)[0].ok).toBe(false);
|
|
152
|
+
expect(sync.getSubscription(subscriptionId)).toBeUndefined();
|
|
153
|
+
expect(sync.getDocFromCache("Item", "a")).toEqual(row("a"));
|
|
154
|
+
});
|
|
155
|
+
it("filters active doctypes and explicitly declines progressive refresh", async () => {
|
|
156
|
+
const { db, sync } = setup();
|
|
157
|
+
await sync.subscribe({ doctype: "Item" });
|
|
158
|
+
const progressive = await sync.subscribe({ doctype: "Paged", progressive: true });
|
|
159
|
+
const before = structuredClone(sync.getSubscription(progressive.subscriptionId));
|
|
160
|
+
const pageBefore = structuredClone(sync.getPageInfo(progressive.subscriptionId));
|
|
161
|
+
expect(db.getDocList).toHaveBeenCalledTimes(2);
|
|
162
|
+
db.getDocList.mockClear();
|
|
163
|
+
expect(await sync.refreshSubscriptions({ doctypes: [] })).toEqual([]);
|
|
164
|
+
expect(await sync.refreshSubscriptions({ doctypes: ["Unknown"] })).toEqual([]);
|
|
165
|
+
expect(db.getDocList).not.toHaveBeenCalled();
|
|
166
|
+
expect((await sync.refreshSubscriptions({ doctypes: ["Item"] }))[0].ok).toBe(true);
|
|
167
|
+
expect(db.getDocList).toHaveBeenCalledTimes(1);
|
|
168
|
+
const result = await sync.refreshSubscriptions({ doctypes: ["Paged"] });
|
|
169
|
+
expect(sync.getSubscription(progressive.subscriptionId)).toEqual(before);
|
|
170
|
+
expect(sync.getPageInfo(progressive.subscriptionId)).toEqual(pageBefore);
|
|
171
|
+
expect(result).toEqual([
|
|
172
|
+
{
|
|
173
|
+
subscriptionId: progressive.subscriptionId,
|
|
174
|
+
ok: false,
|
|
175
|
+
error: new Error("Snapshot refresh does not support progressive subscriptions"),
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
expect(db.getDocList).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("exports the public runtime constructor", () => {
|
|
182
|
+
expect(PublicSyncModule).toBe(SyncModule);
|
|
183
|
+
expect(PublicSyncModule.prototype.refreshSubscriptions).toBeTypeOf("function");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it.each([
|
|
187
|
+
[undefined, undefined, defaultPageLength],
|
|
188
|
+
[undefined, 8, defaultPageLength],
|
|
189
|
+
[null, 4, defaultPageLength],
|
|
190
|
+
[0, 0, 0],
|
|
191
|
+
[7, 2, 7],
|
|
192
|
+
])("preserves query window limit=%s offset=%s", async (limit, limitStart, expectedLimit) => {
|
|
193
|
+
const { sync, db } = setup();
|
|
194
|
+
const query = {
|
|
195
|
+
doctype: "Item",
|
|
196
|
+
fields: ["title", "name", "title"],
|
|
197
|
+
filters: { enabled: 1 },
|
|
198
|
+
orderBy: [{ field: "title", order: Order.Desc }],
|
|
199
|
+
...(limit === undefined ? {} : { limit }),
|
|
200
|
+
...(limitStart === undefined ? {} : { limitStart }),
|
|
201
|
+
} as SubscriptionQuery;
|
|
202
|
+
const { subscriptionId } = await sync.subscribe(query);
|
|
203
|
+
expect(sync.getSubscription(subscriptionId)?.boundaries?.pageSize).toBe(expectedLimit);
|
|
204
|
+
db.getDocList.mockResolvedValue({
|
|
205
|
+
data: [row("z"), { ...row("b"), modified: "2026-09-09T02:00:00Z" }],
|
|
206
|
+
cursor: "new-cursor",
|
|
207
|
+
});
|
|
208
|
+
await sync.refreshSubscriptions();
|
|
209
|
+
expect(db.getDocList.mock.calls).toEqual(
|
|
210
|
+
Array(2).fill([
|
|
211
|
+
"Item",
|
|
212
|
+
{
|
|
213
|
+
fields: ["title", "name", "modified"],
|
|
214
|
+
filters: query.filters,
|
|
215
|
+
orderBy: query.orderBy,
|
|
216
|
+
limit: expectedLimit,
|
|
217
|
+
...(limitStart === undefined ? {} : { limitStart }),
|
|
218
|
+
},
|
|
219
|
+
]),
|
|
220
|
+
);
|
|
221
|
+
expect(sync.getSubscription(subscriptionId)).toMatchObject({
|
|
222
|
+
docIds: ["z", "b"],
|
|
223
|
+
cursor: "new-cursor",
|
|
224
|
+
checkpoint: "2026-09-09T02:00:00Z",
|
|
225
|
+
boundaries: { pageSize: expectedLimit, offset: limitStart ?? 0 },
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it.each([404, 403])("handles single-name getDoc status %s", async (status) => {
|
|
230
|
+
const db = { getDoc: vi.fn().mockResolvedValue(row("a")), getDocList: vi.fn() };
|
|
231
|
+
const sync = new SyncModule(db as never, undefined);
|
|
232
|
+
instances.push(sync);
|
|
233
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Item", filters: { name: "a" } });
|
|
234
|
+
const before = structuredClone(sync.getSubscription(subscriptionId));
|
|
235
|
+
const error = Object.assign(new Error("getDoc failed"), { status });
|
|
236
|
+
db.getDoc.mockRejectedValue(error);
|
|
237
|
+
expect(await sync.refreshSubscriptions()).toEqual([
|
|
238
|
+
{ subscriptionId, ok: status === 404, ...(status === 404 ? {} : { error }) },
|
|
239
|
+
]);
|
|
240
|
+
expect(db.getDoc.mock.calls).toEqual([
|
|
241
|
+
["Item", "a"],
|
|
242
|
+
["Item", "a"],
|
|
243
|
+
]);
|
|
244
|
+
expect(db.getDocList).not.toHaveBeenCalled();
|
|
245
|
+
if (status === 404) {
|
|
246
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([]);
|
|
247
|
+
expect(sync.getSubscription(subscriptionId)?.boundaries).toBeUndefined();
|
|
248
|
+
} else expect(sync.getSubscription(subscriptionId)).toEqual({ ...before, stale: true });
|
|
249
|
+
expect(sync.getDocFromCache("Item", "a")).toEqual(row("a"));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("returns independent success, failure and progressive refusal", async () => {
|
|
253
|
+
const { sync, db } = setup();
|
|
254
|
+
const item = await sync.subscribe({ doctype: "Item" });
|
|
255
|
+
const other = await sync.subscribe({ doctype: "Other" });
|
|
256
|
+
const progressive = await sync.subscribe({ doctype: "Paged", progressive: true });
|
|
257
|
+
const before = structuredClone(sync.getSubscription(progressive.subscriptionId));
|
|
258
|
+
const pageBefore = structuredClone(sync.getPageInfo(progressive.subscriptionId));
|
|
259
|
+
const error = new Error("offline");
|
|
260
|
+
db.getDocList.mockRejectedValueOnce(error).mockResolvedValueOnce([row("success")]);
|
|
261
|
+
expect(await sync.refreshSubscriptions()).toEqual([
|
|
262
|
+
{ subscriptionId: item.subscriptionId, ok: false, error },
|
|
263
|
+
{ subscriptionId: other.subscriptionId, ok: true },
|
|
264
|
+
{
|
|
265
|
+
subscriptionId: progressive.subscriptionId,
|
|
266
|
+
ok: false,
|
|
267
|
+
error: new Error("Snapshot refresh does not support progressive subscriptions"),
|
|
268
|
+
},
|
|
269
|
+
]);
|
|
270
|
+
expect(sync.getSubscription(progressive.subscriptionId)).toEqual(before);
|
|
271
|
+
expect(sync.getPageInfo(progressive.subscriptionId)).toEqual(pageBefore);
|
|
272
|
+
expect(db.getDocList).toHaveBeenCalledTimes(5);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it.each(["resetCache", "dispose"] as const)(
|
|
276
|
+
"invalidates a pending refresh on %s",
|
|
277
|
+
async (action) => {
|
|
278
|
+
const { sync, db } = setup();
|
|
279
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Item" });
|
|
280
|
+
const pending = deferred<ReturnType<typeof row>[]>();
|
|
281
|
+
const started = deferred<void>();
|
|
282
|
+
db.getDocList.mockImplementationOnce(() => {
|
|
283
|
+
started.resolve();
|
|
284
|
+
return pending.promise;
|
|
285
|
+
});
|
|
286
|
+
const result = sync.refreshSubscriptions();
|
|
287
|
+
await started.promise;
|
|
288
|
+
await sync[action]();
|
|
289
|
+
pending.resolve([row("a", "obsolete"), row("obsolete")]);
|
|
290
|
+
expect((await result)[0].ok).toBe(false);
|
|
291
|
+
expect(sync.getSubscription(subscriptionId)).toBeUndefined();
|
|
292
|
+
expect(sync.getDocFromCache("Item", "a")).toBeUndefined();
|
|
293
|
+
expect(sync.getDocFromCache("Item", "obsolete")).toBeUndefined();
|
|
294
|
+
},
|
|
295
|
+
);
|
|
@@ -30,7 +30,24 @@ export interface SubscribeResult<Data extends FrappeDocData> {
|
|
|
30
30
|
refresh?: Promise<{ ok: boolean; error?: unknown }>;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface SubscriptionRefreshOptions {
|
|
34
|
+
doctypes?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SubscriptionRefreshResult {
|
|
38
|
+
subscriptionId: string;
|
|
39
|
+
ok: boolean;
|
|
40
|
+
error?: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
export class SubscriptionManager {
|
|
44
|
+
private refreshes = new Map<
|
|
45
|
+
string,
|
|
46
|
+
{
|
|
47
|
+
requested: boolean;
|
|
48
|
+
promise: Promise<SubscriptionRefreshResult>;
|
|
49
|
+
}
|
|
50
|
+
>();
|
|
34
51
|
private subscriptions = new Map<string, Subscription>();
|
|
35
52
|
private virtualPageManager: VirtualPageManager;
|
|
36
53
|
private backfillManager: BackfillManager;
|
|
@@ -85,6 +102,7 @@ export class SubscriptionManager {
|
|
|
85
102
|
|
|
86
103
|
const subscriptionId = cryptoRandomId();
|
|
87
104
|
const { rows, cursor, checkpoint } = await this.fetchInitialRows<Data>(query);
|
|
105
|
+
this.seedRows(query, rows);
|
|
88
106
|
// Materialize the in-memory subscription record with initial membership
|
|
89
107
|
const subscription: Subscription = {
|
|
90
108
|
id: subscriptionId,
|
|
@@ -100,8 +118,8 @@ export class SubscriptionManager {
|
|
|
100
118
|
subscription.boundaries = {
|
|
101
119
|
firstItem: rows[0] as unknown as FrappeDoc<Data>,
|
|
102
120
|
lastItem: rows[rows.length - 1] as unknown as FrappeDoc<Data>,
|
|
103
|
-
offset: query.limitStart
|
|
104
|
-
pageSize: query.limit
|
|
121
|
+
offset: query.limitStart ?? 0,
|
|
122
|
+
pageSize: query.limit ?? defaultPageLength,
|
|
105
123
|
};
|
|
106
124
|
}
|
|
107
125
|
|
|
@@ -130,9 +148,85 @@ export class SubscriptionManager {
|
|
|
130
148
|
return { subscriptionId: subscriptionId, rows, checkpoint };
|
|
131
149
|
}
|
|
132
150
|
|
|
151
|
+
async refreshSubscriptions(
|
|
152
|
+
options: SubscriptionRefreshOptions = {},
|
|
153
|
+
): Promise<SubscriptionRefreshResult[]> {
|
|
154
|
+
const subscriptions = [...this.subscriptions.values()].filter(
|
|
155
|
+
(subscription) =>
|
|
156
|
+
(this.subRefCounts.get(subscription.id) ?? 0) > 0 &&
|
|
157
|
+
(!options.doctypes || options.doctypes.includes(subscription.query.doctype)),
|
|
158
|
+
);
|
|
159
|
+
return Promise.all(subscriptions.map((subscription) => this.refreshSubscription(subscription)));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private refreshSubscription(subscription: Subscription): Promise<SubscriptionRefreshResult> {
|
|
163
|
+
const subscriptionId = subscription.id;
|
|
164
|
+
if (subscription.query.progressive) {
|
|
165
|
+
return Promise.resolve({
|
|
166
|
+
subscriptionId,
|
|
167
|
+
ok: false,
|
|
168
|
+
error: new Error("Snapshot refresh does not support progressive subscriptions"),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const existing = this.refreshes.get(subscriptionId);
|
|
172
|
+
if (existing) {
|
|
173
|
+
existing.requested = true;
|
|
174
|
+
return existing.promise;
|
|
175
|
+
}
|
|
176
|
+
const state = {
|
|
177
|
+
requested: false,
|
|
178
|
+
promise: Promise.resolve<SubscriptionRefreshResult>({ subscriptionId, ok: false }),
|
|
179
|
+
};
|
|
180
|
+
const active = () =>
|
|
181
|
+
this.refreshes.get(subscriptionId) === state &&
|
|
182
|
+
this.subscriptions.get(subscriptionId) === subscription &&
|
|
183
|
+
(this.subRefCounts.get(subscriptionId) ?? 0) > 0;
|
|
184
|
+
state.promise = Promise.resolve().then(async () => {
|
|
185
|
+
try {
|
|
186
|
+
while (true) {
|
|
187
|
+
state.requested = false;
|
|
188
|
+
try {
|
|
189
|
+
const { rows, cursor, checkpoint } = await this.fetchInitialRows(subscription.query);
|
|
190
|
+
if (!active()) {
|
|
191
|
+
return {
|
|
192
|
+
subscriptionId,
|
|
193
|
+
ok: false,
|
|
194
|
+
error: new Error("Subscription is no longer active"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (state.requested) continue;
|
|
198
|
+
this.seedRows(subscription.query, rows);
|
|
199
|
+
subscription.docIds = rows.map((row) => row.name);
|
|
200
|
+
subscription.cursor = cursor;
|
|
201
|
+
subscription.checkpoint = checkpoint;
|
|
202
|
+
subscription.boundaries = rows.length
|
|
203
|
+
? {
|
|
204
|
+
firstItem: rows[0],
|
|
205
|
+
lastItem: rows[rows.length - 1],
|
|
206
|
+
offset: subscription.query.limitStart ?? 0,
|
|
207
|
+
pageSize: subscription.query.limit ?? defaultPageLength,
|
|
208
|
+
}
|
|
209
|
+
: undefined;
|
|
210
|
+
subscription.metadata = { itemsBeforePage: 0, itemsAfterPage: 0 };
|
|
211
|
+
subscription.stale = false;
|
|
212
|
+
return { subscriptionId, ok: true };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (active() && state.requested) continue;
|
|
215
|
+
if (active()) subscription.stale = true;
|
|
216
|
+
return { subscriptionId, ok: false, error };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
if (this.refreshes.get(subscriptionId) === state) this.refreshes.delete(subscriptionId);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
this.refreshes.set(subscriptionId, state);
|
|
224
|
+
return state.promise;
|
|
225
|
+
}
|
|
226
|
+
|
|
133
227
|
/**
|
|
134
228
|
* Performs the initial subscription fetch (single doc via getDoc, lists via
|
|
135
|
-
* getDocList)
|
|
229
|
+
* getDocList) and computes the CDC checkpoint
|
|
136
230
|
* as the max `modified` across returned rows. Shared by fresh subscribes
|
|
137
231
|
* and adopted-subscription refreshes so both have identical semantics.
|
|
138
232
|
*/
|
|
@@ -186,13 +280,6 @@ export class SubscriptionManager {
|
|
|
186
280
|
rows = response;
|
|
187
281
|
}
|
|
188
282
|
}
|
|
189
|
-
// Seed the normalized cache with every returned row for instant local reads.
|
|
190
|
-
// Frappe's standard get_list API omits `doctype` from each row, so inject
|
|
191
|
-
// it from the subscription query to ensure the cache keys correctly.
|
|
192
|
-
for (const row of rows) {
|
|
193
|
-
if (!row.doctype) row.doctype = query.doctype;
|
|
194
|
-
this.normalizedCache.upsert(row);
|
|
195
|
-
}
|
|
196
283
|
// Compute checkpoint for CDC as the max `modified` across returned rows (robust to server ordering)
|
|
197
284
|
let checkpoint: string | undefined = undefined;
|
|
198
285
|
for (const row of rows) {
|
|
@@ -202,6 +289,13 @@ export class SubscriptionManager {
|
|
|
202
289
|
return { rows, cursor, checkpoint };
|
|
203
290
|
}
|
|
204
291
|
|
|
292
|
+
private seedRows<Data extends FrappeDocData>(query: SubscriptionQuery, rows: FrappeDoc<Data>[]) {
|
|
293
|
+
for (const row of rows) {
|
|
294
|
+
if (!row.doctype) row.doctype = query.doctype;
|
|
295
|
+
this.normalizedCache.upsert(row);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
205
299
|
/**
|
|
206
300
|
* Re-adopts a subscription restored from a persisted snapshot when an
|
|
207
301
|
* identical query subscribes: the cached membership renders immediately and
|
|
@@ -225,44 +319,11 @@ export class SubscriptionManager {
|
|
|
225
319
|
this.queryKeyToSubId.set(queryKey, restoredId);
|
|
226
320
|
this.subRefCounts.set(restoredId, 1);
|
|
227
321
|
|
|
228
|
-
const refresh = this.
|
|
322
|
+
const refresh = this.refreshSubscription(subscription);
|
|
229
323
|
const rows = this.getDocsForSubscription<Data>(restoredId);
|
|
230
324
|
return { subscriptionId: restoredId, rows, checkpoint: subscription.checkpoint, refresh };
|
|
231
325
|
}
|
|
232
326
|
|
|
233
|
-
/**
|
|
234
|
-
* Network refresh for an adopted subscription with fresh-subscribe
|
|
235
|
-
* semantics: an authoritative page fetch replaces membership and recomputes
|
|
236
|
-
* the checkpoint (identical to what a brand-new subscribe would produce).
|
|
237
|
-
* On failure (offline) the cached membership and the restored CDC
|
|
238
|
-
* checkpoint are kept and the subscription is marked stale — the next
|
|
239
|
-
* backfill trigger drains changes from that checkpoint. Never rejects.
|
|
240
|
-
*/
|
|
241
|
-
private async refreshAdoptedSubscription<Data extends FrappeDocData>(
|
|
242
|
-
subscription: Subscription,
|
|
243
|
-
): Promise<{ ok: boolean; error?: unknown }> {
|
|
244
|
-
try {
|
|
245
|
-
const { rows, cursor, checkpoint } = await this.fetchInitialRows<Data>(subscription.query);
|
|
246
|
-
subscription.checkpoint = checkpoint;
|
|
247
|
-
subscription.cursor = cursor;
|
|
248
|
-
subscription.docIds = rows.map((row) => row.name);
|
|
249
|
-
if (rows.length > 0) {
|
|
250
|
-
subscription.boundaries = {
|
|
251
|
-
firstItem: rows[0] as unknown as FrappeDoc<FrappeDocData>,
|
|
252
|
-
lastItem: rows[rows.length - 1] as unknown as FrappeDoc<FrappeDocData>,
|
|
253
|
-
offset: subscription.query.limitStart || 0,
|
|
254
|
-
pageSize: subscription.query.limit || 20,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
subscription.metadata = { itemsBeforePage: 0, itemsAfterPage: 0 };
|
|
258
|
-
subscription.stale = false;
|
|
259
|
-
return { ok: true };
|
|
260
|
-
} catch (error) {
|
|
261
|
-
subscription.stale = true;
|
|
262
|
-
return { ok: false, error };
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
327
|
/**
|
|
267
328
|
* Registers a subscription restored from a persisted snapshot. Restored
|
|
268
329
|
* subscriptions are dormant: they hold the persisted query, CDC checkpoint,
|
|
@@ -343,6 +404,7 @@ export class SubscriptionManager {
|
|
|
343
404
|
this.subRefCounts.set(subscriptionId, refCount);
|
|
344
405
|
return;
|
|
345
406
|
}
|
|
407
|
+
this.refreshes.delete(subscriptionId);
|
|
346
408
|
// Last consumer released — clean up
|
|
347
409
|
this.subRefCounts.delete(subscriptionId);
|
|
348
410
|
let queryKey: string | undefined;
|
|
@@ -753,6 +815,7 @@ export class SubscriptionManager {
|
|
|
753
815
|
}
|
|
754
816
|
|
|
755
817
|
clear() {
|
|
818
|
+
this.refreshes.clear();
|
|
756
819
|
this.subscriptions.clear();
|
|
757
820
|
this.queryKeyToSubId.clear();
|
|
758
821
|
this.subRefCounts.clear();
|
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
|
+
}
|
package/types/frappeReact.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Collection } from "@tanstack/db";
|
|
2
2
|
import { type FrappeCollectionOptionsConfig } from "./collection";
|
|
3
3
|
import { SyncModule } from "./sync";
|
|
4
|
-
import type { DebugMutationEntry } from "./sync/types";
|
|
4
|
+
import type { DebugMutationEntry, SyncCatchUpState } from "./sync/types";
|
|
5
5
|
/**
|
|
6
6
|
* Configuration for creating a Frappe collection with connection details.
|
|
7
7
|
*/
|
|
@@ -115,6 +115,11 @@ export declare function useFrappeCollection<TDoc extends object & {
|
|
|
115
115
|
name: string;
|
|
116
116
|
doctype: string;
|
|
117
117
|
}>(config: UseFrappeCollectionConfig | undefined, doctype: string, options?: Omit<FrappeCollectionOptionsConfig<TDoc>, "sync">): Collection<TDoc, string> | undefined;
|
|
118
|
+
export interface UseFrappeCatchUpResult extends SyncCatchUpState {
|
|
119
|
+
retry: () => Promise<SyncCatchUpState>;
|
|
120
|
+
}
|
|
121
|
+
/** Connection-level refresh qualification. Retry resolves with state, including failures. */
|
|
122
|
+
export declare function useFrappeCatchUp(config: UseFrappeCollectionConfig | undefined): UseFrappeCatchUpResult;
|
|
118
123
|
/** Snapshot returned by {@link useFrappeConnection}. */
|
|
119
124
|
export interface FrappeConnectionState {
|
|
120
125
|
/** Socket connected right now. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"frappeReact.d.ts","sourceRoot":"","sources":["../src/frappeReact.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C,OAAO,EAGL,KAAK,6BAA6B,EACnC,MAAM,cAAc,CAAC;AAMtB,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAEpC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"frappeReact.d.ts","sourceRoot":"","sources":["../src/frappeReact.ts"],"names":[],"mappings":"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"}
|