@multiplatform.one/frappe 7.8.0 → 7.10.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.
Files changed (41) hide show
  1. package/README.md +31 -1
  2. package/dist/cjs/index.native.js.map +1 -1
  3. package/dist/cjs/sync/index.cjs +7 -0
  4. package/dist/cjs/sync/index.native.js +9 -0
  5. package/dist/cjs/sync/index.native.js.map +1 -1
  6. package/dist/cjs/sync/subscriptionManager.cjs +94 -50
  7. package/dist/cjs/sync/subscriptionManager.native.js +133 -73
  8. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  9. package/dist/esm/index.mjs.map +1 -1
  10. package/dist/esm/index.native.js.map +1 -1
  11. package/dist/esm/sync/index.mjs +7 -0
  12. package/dist/esm/sync/index.mjs.map +1 -1
  13. package/dist/esm/sync/index.native.js +9 -0
  14. package/dist/esm/sync/index.native.js.map +1 -1
  15. package/dist/esm/sync/subscriptionManager.mjs +94 -50
  16. package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
  17. package/dist/esm/sync/subscriptionManager.native.js +133 -73
  18. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  19. package/dist/jsx/index.js.map +1 -1
  20. package/dist/jsx/index.mjs.map +1 -1
  21. package/dist/jsx/index.native.js.map +1 -1
  22. package/dist/jsx/sync/index.mjs +7 -0
  23. package/dist/jsx/sync/index.mjs.map +1 -1
  24. package/dist/jsx/sync/index.native.js +9 -0
  25. package/dist/jsx/sync/index.native.js.map +1 -1
  26. package/dist/jsx/sync/subscriptionManager.mjs +94 -50
  27. package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
  28. package/dist/jsx/sync/subscriptionManager.native.js +133 -73
  29. package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
  30. package/package.json +6 -6
  31. package/src/index.ts +5 -1
  32. package/src/sync/index.ts +12 -0
  33. package/src/sync/refreshSubscriptions.lifecycle.spec.ts +268 -0
  34. package/src/sync/refreshSubscriptions.spec.ts +295 -0
  35. package/src/sync/subscriptionManager.ts +107 -44
  36. package/types/index.d.ts +1 -1
  37. package/types/index.d.ts.map +1 -1
  38. package/types/sync/index.d.ts +4 -0
  39. package/types/sync/index.d.ts.map +1 -1
  40. package/types/sync/subscriptionManager.d.ts +13 -10
  41. package/types/sync/subscriptionManager.d.ts.map +1 -1
@@ -0,0 +1,268 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { NormalizedCache } from "./normalizedCache";
3
+ import { SubscriptionManager } from "./subscriptionManager";
4
+ import type { SyncOptions } from "./types";
5
+
6
+ function deferred<T>() {
7
+ let resolve!: (value: T) => void;
8
+ let reject!: (error: unknown) => void;
9
+ const promise = new Promise<T>((yes, no) => {
10
+ resolve = yes;
11
+ reject = no;
12
+ });
13
+ return { promise, resolve, reject };
14
+ }
15
+ const row = (name: string, title = name) => ({
16
+ name,
17
+ title,
18
+ doctype: "Item",
19
+ modified: "2026-09-09T01:00:00Z",
20
+ });
21
+ const query = { doctype: "Item" };
22
+ async function setup(options: SyncOptions = {}) {
23
+ const db = { getDocList: vi.fn().mockResolvedValue([row("a")]) };
24
+ const cache = new NormalizedCache();
25
+ const manager = new SubscriptionManager(db as never, cache, options);
26
+ const { subscriptionId: id } = await manager.subscribe(query);
27
+ return { db, cache, manager, id };
28
+ }
29
+ function pending(db: Awaited<ReturnType<typeof setup>>["db"]) {
30
+ const response = deferred<ReturnType<typeof row>[]>();
31
+ const started = deferred<void>();
32
+ db.getDocList.mockImplementationOnce(() => {
33
+ started.resolve();
34
+ return response.promise;
35
+ });
36
+ return { ...response, started: started.promise };
37
+ }
38
+
39
+ describe("snapshot refresh lifecycle", () => {
40
+ it.each(["resolve", "reject"] as const)(
41
+ "invalidates pre-detach refresh that later %ss after re-adoption",
42
+ async (outcome) => {
43
+ const { manager, db, cache, id } = await setup({ persistKey: "lifecycle" });
44
+ const old = pending(db);
45
+ const oldResult = manager.refreshSubscriptions();
46
+ await old.started;
47
+ manager.unsubscribe(id);
48
+ const fresh = pending(db);
49
+ const adopted = await manager.subscribe(query);
50
+ expect(adopted.subscriptionId).toBe(id);
51
+ expect(adopted.rows).toEqual([row("a")]);
52
+ await fresh.started;
53
+ fresh.resolve([row("a", "new-adoption")]);
54
+ expect(await adopted.refresh).toMatchObject({ ok: true });
55
+ if (outcome === "resolve") old.resolve([row("a", "obsolete-before-detach"), row("obsolete")]);
56
+ else old.reject(new Error("old failure"));
57
+ expect((await oldResult)[0].ok).toBe(false);
58
+ expect(manager.getDocsForSubscription(id)).toEqual([row("a", "new-adoption")]);
59
+ expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
60
+ expect(manager.getSubscription(id)?.stale).toBe(false);
61
+ },
62
+ );
63
+
64
+ it("does not let an old finally erase the new adoption operation", async () => {
65
+ const { manager, db, cache, id } = await setup({ persistKey: "lifecycle" });
66
+ const old = pending(db);
67
+ const oldResult = manager.refreshSubscriptions();
68
+ await old.started;
69
+ manager.unsubscribe(id);
70
+ const adoption = pending(db);
71
+ const adopted = await manager.subscribe(query);
72
+ await adoption.started;
73
+ old.resolve([row("obsolete")]);
74
+ expect((await oldResult)[0].ok).toBe(false);
75
+ const trailing = pending(db);
76
+ const explicit = manager.refreshSubscriptions();
77
+ expect(db.getDocList).toHaveBeenCalledTimes(3);
78
+ adoption.resolve([row("discarded-adoption")]);
79
+ await trailing.started;
80
+ expect(cache.get({ doctype: "Item", name: "discarded-adoption" })).toBeUndefined();
81
+ expect(manager.getDocsForSubscription(id)).toEqual([row("a")]);
82
+ trailing.resolve([row("final")]);
83
+ expect(await adopted.refresh).toEqual((await explicit)[0]);
84
+ expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
85
+ expect(db.getDocList).toHaveBeenCalledTimes(4);
86
+ });
87
+
88
+ it.each([false, true])(
89
+ "coalesces requests and retains the committed snapshot when trailing fails=%s",
90
+ async (fails) => {
91
+ const { manager, db, cache, id } = await setup();
92
+ const before = structuredClone(manager.getSubscription(id));
93
+ const first = pending(db);
94
+ const one = manager.refreshSubscriptions();
95
+ await first.started;
96
+ const last = pending(db);
97
+ const two = manager.refreshSubscriptions();
98
+ const three = manager.refreshSubscriptions();
99
+ first.resolve([row("a", "discarded"), row("obsolete")]);
100
+ await last.started;
101
+ expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
102
+ expect(manager.getDocsForSubscription(id)).toEqual([row("a")]);
103
+ const error = new Error("trailing failed");
104
+ if (fails) last.reject(error);
105
+ else last.resolve([row("final")]);
106
+ const results = await Promise.all([one, two, three]);
107
+ expect(results).toEqual(
108
+ Array(3).fill([{ subscriptionId: id, ok: !fails, ...(fails ? { error } : {}) }]),
109
+ );
110
+ if (fails) for (const result of results) expect(result[0].error).toBe(error);
111
+ expect(db.getDocList).toHaveBeenCalledTimes(3);
112
+ if (fails) expect(manager.getSubscription(id)).toEqual({ ...before, stale: true });
113
+ else expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
114
+ await manager.refreshSubscriptions();
115
+ expect(db.getDocList).toHaveBeenCalledTimes(4);
116
+ },
117
+ );
118
+
119
+ it("trails a failed attempt only when another caller requested it", async () => {
120
+ const { manager, db, id } = await setup();
121
+ const first = pending(db);
122
+ const one = manager.refreshSubscriptions();
123
+ await first.started;
124
+ const last = pending(db);
125
+ const two = manager.refreshSubscriptions();
126
+ first.reject(new Error("superseded failure"));
127
+ await last.started;
128
+ last.resolve([row("final")]);
129
+ expect(await one).toEqual(await two);
130
+ expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
131
+ expect(db.getDocList).toHaveBeenCalledTimes(3);
132
+ });
133
+
134
+ it("joins an adoption already fetching and discards its superseded rows", async () => {
135
+ const { manager, db, cache, id } = await setup({ persistKey: "lifecycle" });
136
+ manager.unsubscribe(id);
137
+ const adoption = pending(db);
138
+ const adopted = await manager.subscribe(query);
139
+ await adoption.started;
140
+ const last = pending(db);
141
+ const explicit = manager.refreshSubscriptions();
142
+ adoption.resolve([row("obsolete")]);
143
+ await last.started;
144
+ expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
145
+ last.resolve([row("final")]);
146
+ expect(await adopted.refresh).toEqual((await explicit)[0]);
147
+ expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
148
+ });
149
+
150
+ it("invalidates the last release without corrupting cached documents", async () => {
151
+ const { manager, db, cache, id } = await setup();
152
+ const read = pending(db);
153
+ const result = manager.refreshSubscriptions();
154
+ await read.started;
155
+ manager.unsubscribe(id);
156
+ read.resolve([row("a", "obsolete"), row("new")]);
157
+ expect((await result)[0].ok).toBe(false);
158
+ expect(manager.getSubscription(id)).toBeUndefined();
159
+ expect(cache.get({ doctype: "Item", name: "a" })).toEqual(row("a"));
160
+ expect(cache.get({ doctype: "Item", name: "new" })).toBeUndefined();
161
+ });
162
+
163
+ it("keeps shared refreshes active until the last consumer releases", async () => {
164
+ const { manager, db, id } = await setup();
165
+ expect((await manager.subscribe(query)).subscriptionId).toBe(id);
166
+ const first = pending(db);
167
+ const one = manager.refreshSubscriptions();
168
+ await first.started;
169
+ manager.unsubscribe(id);
170
+ first.resolve([row("updated")]);
171
+ expect(await one).toEqual([{ subscriptionId: id, ok: true }]);
172
+ const last = pending(db);
173
+ const two = manager.refreshSubscriptions();
174
+ await last.started;
175
+ manager.unsubscribe(id);
176
+ last.resolve([row("obsolete")]);
177
+ expect((await two)[0].ok).toBe(false);
178
+ expect(manager.getSubscription(id)).toBeUndefined();
179
+ });
180
+
181
+ it("invalidates clear and allows a subsequent activation", async () => {
182
+ const { manager, db, cache, id } = await setup();
183
+ const old = pending(db);
184
+ const result = manager.refreshSubscriptions();
185
+ await old.started;
186
+ manager.clear();
187
+ cache.clear();
188
+ const fresh = await manager.subscribe(query);
189
+ old.resolve([row("a", "obsolete"), row("obsolete")]);
190
+ expect((await result)[0].ok).toBe(false);
191
+ expect(manager.getSubscription(id)).toBeUndefined();
192
+ expect(manager.getDocsForSubscription(fresh.subscriptionId)).toEqual([row("a")]);
193
+ expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
194
+ });
195
+
196
+ it("does not seed in the microtask between HTTP completion and acceptance", async () => {
197
+ const { manager, db, cache, id } = await setup();
198
+ const first = pending(db);
199
+ const one = manager.refreshSubscriptions();
200
+ await first.started;
201
+ const last = pending(db);
202
+ first.resolve([row("obsolete")]);
203
+ const two = Promise.resolve().then(() => manager.refreshSubscriptions());
204
+ await last.started;
205
+ expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
206
+ expect(manager.getDocsForSubscription(id)).toEqual([row("a")]);
207
+ last.resolve([row("final")]);
208
+ expect(await one).toEqual(await two);
209
+ });
210
+
211
+ it("allows different subscriptions to finish independently", async () => {
212
+ const { manager, db, id } = await setup();
213
+ const second = await manager.subscribe({ doctype: "Other" });
214
+ const slow = pending(db);
215
+ const fast = pending(db);
216
+ const result = manager.refreshSubscriptions();
217
+ await Promise.all([slow.started, fast.started]);
218
+ fast.resolve([row("fast")]);
219
+ await Promise.resolve();
220
+ await Promise.resolve();
221
+ expect(manager.getSubscription(second.subscriptionId)?.docIds).toEqual(["fast"]);
222
+ expect(manager.getSubscription(id)?.docIds).toEqual(["a"]);
223
+ slow.resolve([row("slow")]);
224
+ expect((await result).every((entry) => entry.ok)).toBe(true);
225
+ });
226
+ });
227
+
228
+ it("excludes dormant restored subscriptions and selects each shared id once", async () => {
229
+ const { manager, db, id } = await setup();
230
+ await manager.subscribe(query);
231
+ manager.restoreSubscription({ id: "dormant", query: { doctype: "Dormant" }, docIds: ["cached"] });
232
+ expect(await manager.refreshSubscriptions({ doctypes: ["Dormant"] })).toEqual([]);
233
+ expect(await manager.refreshSubscriptions({ doctypes: [] })).toEqual([]);
234
+ expect(db.getDocList).toHaveBeenCalledTimes(1);
235
+ expect(await manager.refreshSubscriptions()).toEqual([{ subscriptionId: id, ok: true }]);
236
+ expect(db.getDocList).toHaveBeenCalledTimes(2);
237
+ manager.unsubscribe(id);
238
+ manager.unsubscribe(id);
239
+ expect(manager.getSubscription(id)).toBeUndefined();
240
+ });
241
+
242
+ it("retains cached projections and documents outside the refreshed membership", async () => {
243
+ const { manager, db, cache, id } = await setup();
244
+ cache.upsert({ ...row("a"), detail: "other view" });
245
+ db.getDocList.mockResolvedValueOnce([row("b")]);
246
+ const other = await manager.subscribe({ doctype: "Item", filters: { enabled: 1 } });
247
+ db.getDocList
248
+ .mockResolvedValueOnce([{ name: "a", title: "projected" }])
249
+ .mockResolvedValueOnce([]);
250
+ await manager.refreshSubscriptions();
251
+ expect(manager.getDocsForSubscription(id)).toEqual([
252
+ { ...row("a", "projected"), detail: "other view" },
253
+ ]);
254
+ expect(manager.getDocsForSubscription(other.subscriptionId)).toEqual([]);
255
+ expect(cache.get({ doctype: "Item", name: "b" })).toEqual(row("b"));
256
+ });
257
+
258
+ it("shares the first read when callers arrive before it starts", async () => {
259
+ const { manager, db, id } = await setup();
260
+ const first = pending(db);
261
+ const one = manager.refreshSubscriptions();
262
+ const two = manager.refreshSubscriptions();
263
+ await first.started;
264
+ first.resolve([row("final")]);
265
+ expect(await one).toEqual(await two);
266
+ expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
267
+ expect(db.getDocList).toHaveBeenCalledTimes(2);
268
+ });
@@ -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
+ );