@multiplatform.one/frappe 7.7.6 → 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 (182) hide show
  1. package/README.md +31 -1
  2. package/dist/cjs/auth.native.js.map +1 -1
  3. package/dist/cjs/call.native.js.map +1 -1
  4. package/dist/cjs/collection.cjs +4 -1
  5. package/dist/cjs/collection.native.js +4 -1
  6. package/dist/cjs/collection.native.js.map +1 -1
  7. package/dist/cjs/common/types.native.js.map +1 -1
  8. package/dist/cjs/core/http.native.js.map +1 -1
  9. package/dist/cjs/core/types.native.js.map +1 -1
  10. package/dist/cjs/core/wire.native.js.map +1 -1
  11. package/dist/cjs/db.native.js.map +1 -1
  12. package/dist/cjs/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  13. package/dist/cjs/devtools/index.native.js.map +1 -1
  14. package/dist/cjs/devtools/plugin.native.js.map +1 -1
  15. package/dist/cjs/devtools/theme.native.js.map +1 -1
  16. package/dist/cjs/doctype.cjs +25 -8
  17. package/dist/cjs/doctype.native.js +26 -9
  18. package/dist/cjs/doctype.native.js.map +1 -1
  19. package/dist/cjs/file.native.js.map +1 -1
  20. package/dist/cjs/fixtureDb.native.js.map +1 -1
  21. package/dist/cjs/fixtures.native.js.map +1 -1
  22. package/dist/cjs/frappeApp.native.js.map +1 -1
  23. package/dist/cjs/frappeReact.cjs +1 -0
  24. package/dist/cjs/frappeReact.native.js +2 -1
  25. package/dist/cjs/frappeReact.native.js.map +1 -1
  26. package/dist/cjs/index.cjs +1 -0
  27. package/dist/cjs/index.native.js +3 -2
  28. package/dist/cjs/index.native.js.map +1 -1
  29. package/dist/cjs/optimistic.native.js.map +1 -1
  30. package/dist/cjs/pacer.cjs +1 -0
  31. package/dist/cjs/pacer.native.js +1 -0
  32. package/dist/cjs/pacer.native.js.map +1 -1
  33. package/dist/cjs/plugin/index.native.js.map +1 -1
  34. package/dist/cjs/realtime.native.js.map +1 -1
  35. package/dist/cjs/sync/backfill.native.js.map +1 -1
  36. package/dist/cjs/sync/filters.native.js.map +1 -1
  37. package/dist/cjs/sync/index.cjs +7 -0
  38. package/dist/cjs/sync/index.native.js +9 -0
  39. package/dist/cjs/sync/index.native.js.map +1 -1
  40. package/dist/cjs/sync/merge.native.js.map +1 -1
  41. package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
  42. package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
  43. package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
  44. package/dist/cjs/sync/persistence.native.js.map +1 -1
  45. package/dist/cjs/sync/realtime.native.js.map +1 -1
  46. package/dist/cjs/sync/rehydration.native.js.map +1 -1
  47. package/dist/cjs/sync/storage.native.js.map +1 -1
  48. package/dist/cjs/sync/subscriptionManager.cjs +96 -54
  49. package/dist/cjs/sync/subscriptionManager.native.js +136 -77
  50. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  51. package/dist/cjs/sync/types.cjs +3 -1
  52. package/dist/cjs/sync/types.native.js +3 -1
  53. package/dist/cjs/sync/types.native.js.map +1 -1
  54. package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
  55. package/dist/cjs/useLiveQuery.native.js.map +1 -1
  56. package/dist/cjs/util.native.js.map +1 -1
  57. package/dist/esm/auth.native.js.map +1 -1
  58. package/dist/esm/call.native.js.map +1 -1
  59. package/dist/esm/collection.mjs +5 -2
  60. package/dist/esm/collection.mjs.map +1 -1
  61. package/dist/esm/collection.native.js +5 -2
  62. package/dist/esm/collection.native.js.map +1 -1
  63. package/dist/esm/common/types.native.js.map +1 -1
  64. package/dist/esm/core/http.native.js.map +1 -1
  65. package/dist/esm/core/types.native.js.map +1 -1
  66. package/dist/esm/core/wire.native.js.map +1 -1
  67. package/dist/esm/db.native.js.map +1 -1
  68. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  69. package/dist/esm/devtools/index.native.js.map +1 -1
  70. package/dist/esm/devtools/plugin.native.js.map +1 -1
  71. package/dist/esm/devtools/theme.native.js.map +1 -1
  72. package/dist/esm/doctype.mjs +25 -8
  73. package/dist/esm/doctype.mjs.map +1 -1
  74. package/dist/esm/doctype.native.js +26 -9
  75. package/dist/esm/doctype.native.js.map +1 -1
  76. package/dist/esm/file.native.js.map +1 -1
  77. package/dist/esm/fixtureDb.native.js.map +1 -1
  78. package/dist/esm/fixtures.native.js.map +1 -1
  79. package/dist/esm/frappeApp.native.js.map +1 -1
  80. package/dist/esm/frappeReact.mjs +1 -0
  81. package/dist/esm/frappeReact.mjs.map +1 -1
  82. package/dist/esm/frappeReact.native.js +1 -0
  83. package/dist/esm/frappeReact.native.js.map +1 -1
  84. package/dist/esm/index.mjs +2 -2
  85. package/dist/esm/index.mjs.map +1 -1
  86. package/dist/esm/index.native.js +2 -2
  87. package/dist/esm/index.native.js.map +1 -1
  88. package/dist/esm/optimistic.native.js.map +1 -1
  89. package/dist/esm/pacer.mjs +1 -0
  90. package/dist/esm/pacer.mjs.map +1 -1
  91. package/dist/esm/pacer.native.js +1 -0
  92. package/dist/esm/pacer.native.js.map +1 -1
  93. package/dist/esm/plugin/index.native.js.map +1 -1
  94. package/dist/esm/realtime.native.js.map +1 -1
  95. package/dist/esm/sync/backfill.native.js.map +1 -1
  96. package/dist/esm/sync/filters.native.js.map +1 -1
  97. package/dist/esm/sync/index.mjs +7 -0
  98. package/dist/esm/sync/index.mjs.map +1 -1
  99. package/dist/esm/sync/index.native.js +9 -0
  100. package/dist/esm/sync/index.native.js.map +1 -1
  101. package/dist/esm/sync/merge.native.js.map +1 -1
  102. package/dist/esm/sync/mutationEngine.native.js.map +1 -1
  103. package/dist/esm/sync/mutationJournal.native.js.map +1 -1
  104. package/dist/esm/sync/normalizedCache.native.js.map +1 -1
  105. package/dist/esm/sync/persistence.native.js.map +1 -1
  106. package/dist/esm/sync/realtime.native.js.map +1 -1
  107. package/dist/esm/sync/rehydration.native.js.map +1 -1
  108. package/dist/esm/sync/storage.native.js.map +1 -1
  109. package/dist/esm/sync/subscriptionManager.mjs +97 -55
  110. package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
  111. package/dist/esm/sync/subscriptionManager.native.js +137 -78
  112. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  113. package/dist/esm/sync/types.mjs +2 -1
  114. package/dist/esm/sync/types.mjs.map +1 -1
  115. package/dist/esm/sync/types.native.js +2 -1
  116. package/dist/esm/sync/types.native.js.map +1 -1
  117. package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
  118. package/dist/esm/useLiveQuery.native.js.map +1 -1
  119. package/dist/esm/util.native.js.map +1 -1
  120. package/dist/jsx/collection.mjs +5 -2
  121. package/dist/jsx/collection.mjs.map +1 -1
  122. package/dist/jsx/collection.native.js +4 -1
  123. package/dist/jsx/collection.native.js.map +1 -1
  124. package/dist/jsx/doctype.mjs +25 -8
  125. package/dist/jsx/doctype.mjs.map +1 -1
  126. package/dist/jsx/doctype.native.js +26 -9
  127. package/dist/jsx/doctype.native.js.map +1 -1
  128. package/dist/jsx/frappeReact.mjs +1 -0
  129. package/dist/jsx/frappeReact.mjs.map +1 -1
  130. package/dist/jsx/frappeReact.native.js +1 -0
  131. package/dist/jsx/frappeReact.native.js.map +1 -1
  132. package/dist/jsx/index.js +2 -2
  133. package/dist/jsx/index.js.map +1 -1
  134. package/dist/jsx/index.mjs +2 -2
  135. package/dist/jsx/index.mjs.map +1 -1
  136. package/dist/jsx/index.native.js +1 -0
  137. package/dist/jsx/index.native.js.map +1 -1
  138. package/dist/jsx/pacer.mjs +1 -0
  139. package/dist/jsx/pacer.mjs.map +1 -1
  140. package/dist/jsx/pacer.native.js +1 -0
  141. package/dist/jsx/pacer.native.js.map +1 -1
  142. package/dist/jsx/sync/index.mjs +7 -0
  143. package/dist/jsx/sync/index.mjs.map +1 -1
  144. package/dist/jsx/sync/index.native.js +9 -0
  145. package/dist/jsx/sync/index.native.js.map +1 -1
  146. package/dist/jsx/sync/subscriptionManager.mjs +97 -55
  147. package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
  148. package/dist/jsx/sync/subscriptionManager.native.js +136 -77
  149. package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
  150. package/dist/jsx/sync/types.mjs +2 -1
  151. package/dist/jsx/sync/types.mjs.map +1 -1
  152. package/dist/jsx/sync/types.native.js +3 -1
  153. package/dist/jsx/sync/types.native.js.map +1 -1
  154. package/package.json +6 -6
  155. package/src/collection.ts +29 -4
  156. package/src/collectionLimitParity.spec.ts +135 -0
  157. package/src/devtools/FrappeDevtoolsPanel.spec.tsx +4 -2
  158. package/src/doctype.ts +47 -15
  159. package/src/frappeReact.ts +11 -2
  160. package/src/index.ts +6 -2
  161. package/src/pacer.ts +8 -3
  162. package/src/sync/index.ts +12 -0
  163. package/src/sync/refreshSubscriptions.lifecycle.spec.ts +268 -0
  164. package/src/sync/refreshSubscriptions.spec.ts +295 -0
  165. package/src/sync/subscriptionManager.spec.ts +41 -2
  166. package/src/sync/subscriptionManager.ts +115 -55
  167. package/src/sync/types.ts +10 -0
  168. package/types/collection.d.ts +24 -2
  169. package/types/collection.d.ts.map +1 -1
  170. package/types/doctype.d.ts.map +1 -1
  171. package/types/frappeReact.d.ts +6 -2
  172. package/types/frappeReact.d.ts.map +1 -1
  173. package/types/index.d.ts +2 -2
  174. package/types/index.d.ts.map +1 -1
  175. package/types/pacer.d.ts +6 -2
  176. package/types/pacer.d.ts.map +1 -1
  177. package/types/sync/index.d.ts +4 -0
  178. package/types/sync/index.d.ts.map +1 -1
  179. package/types/sync/subscriptionManager.d.ts +13 -10
  180. package/types/sync/subscriptionManager.d.ts.map +1 -1
  181. package/types/sync/types.d.ts +9 -0
  182. package/types/sync/types.d.ts.map +1 -1
@@ -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
+ );
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import type { FrappeDoc, FrappeDocData } from "../common/types";
3
3
  import { SubscriptionManager } from "./subscriptionManager";
4
- import { BackfillScope } from "./types";
4
+ import { BackfillScope, defaultPageLength } from "./types";
5
5
  import { Order } from "./types";
6
6
  import type { SubscriptionQuery, SyncOptions } from "./types";
7
7
 
@@ -156,7 +156,10 @@ describe("SubscriptionManager", () => {
156
156
  });
157
157
  });
158
158
 
159
- it("omits limit from the initial fetch when the query has none", async () => {
159
+ it("sends the default page length when the query has no limit", async () => {
160
+ // MPO-238: leaving limit_page_length off let the bench clamp to 20
161
+ // while fixture mode returned every row, so a no-limit collection read
162
+ // one count in development and another in production.
160
163
  const query: SubscriptionQuery = {
161
164
  doctype: "TestDoc",
162
165
  fields: ["title"],
@@ -168,6 +171,42 @@ describe("SubscriptionManager", () => {
168
171
  fields: ["title", "modified", "name"],
169
172
  filters: undefined,
170
173
  orderBy: undefined,
174
+ limit: defaultPageLength,
175
+ });
176
+ });
177
+
178
+ it("passes limit 0 through as Frappe's every-row page length", async () => {
179
+ const query: SubscriptionQuery = {
180
+ doctype: "TestDoc",
181
+ fields: ["title"],
182
+ limit: 0,
183
+ };
184
+
185
+ await subscriptionManager.subscribe(query);
186
+
187
+ expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
188
+ fields: ["title", "modified", "name"],
189
+ filters: undefined,
190
+ orderBy: undefined,
191
+ limit: 0,
192
+ });
193
+ });
194
+
195
+ it("applies limitStart without an explicit limit", async () => {
196
+ const query: SubscriptionQuery = {
197
+ doctype: "TestDoc",
198
+ fields: ["title"],
199
+ limitStart: 20,
200
+ };
201
+
202
+ await subscriptionManager.subscribe(query);
203
+
204
+ expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
205
+ fields: ["title", "modified", "name"],
206
+ filters: undefined,
207
+ orderBy: undefined,
208
+ limit: defaultPageLength,
209
+ limitStart: 20,
171
210
  });
172
211
  });
173
212
 
@@ -4,7 +4,7 @@ import type { DbModule } from "../db";
4
4
  import { BackfillManager } from "./backfill";
5
5
  import { extractSingleName, matchesFilters } from "./filters";
6
6
  import type { NormalizedCache } from "./normalizedCache";
7
- import { BackfillScope, Order } from "./types";
7
+ import { BackfillScope, defaultPageLength, Order } from "./types";
8
8
  import type {
9
9
  BackfillArgs,
10
10
  OrderByField,
@@ -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 || 0,
104
- pageSize: query.limit || 20,
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), seeds the normalized cache, and computes the CDC checkpoint
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
  */
@@ -168,16 +262,13 @@ export class SubscriptionManager {
168
262
  filters: query.filters,
169
263
  orderBy: query.orderBy,
170
264
  };
171
- // Honor an explicit subscription limit on the INITIAL fetch. Without
172
- // this the request carries no limit_page_length and the bench clamps
173
- // to its default page length (20) — collections created with
174
- // `limit: N` silently truncated to 20 rows (surfaced by the pokemon
175
- // moves accordion once rosters grew past 20 junction rows). No
176
- // explicit limit keeps the previous no-param behavior.
177
- if (query.limit != null) {
178
- listOptions.limit = query.limit;
179
- if (query.limitStart != null) listOptions.limitStart = query.limitStart;
180
- }
265
+ // Always send a page length on the INITIAL fetch. A request with no
266
+ // limit_page_length is clamped to 20 rows by the bench while a fixture
267
+ // provider returns every row, so an absent limit made development and
268
+ // production disagree with nothing on screen to say so. `limit: 0`
269
+ // (every row) passes through untouched.
270
+ listOptions.limit = query.limit ?? defaultPageLength;
271
+ if (query.limitStart != null) listOptions.limitStart = query.limitStart;
181
272
  if (this.options.useCursorPagination) listOptions.cursor = undefined;
182
273
  const response = await this.db.getDocList<Data>(query.doctype, listOptions);
183
274
 
@@ -189,13 +280,6 @@ export class SubscriptionManager {
189
280
  rows = response;
190
281
  }
191
282
  }
192
- // Seed the normalized cache with every returned row for instant local reads.
193
- // Frappe's standard get_list API omits `doctype` from each row, so inject
194
- // it from the subscription query to ensure the cache keys correctly.
195
- for (const row of rows) {
196
- if (!row.doctype) row.doctype = query.doctype;
197
- this.normalizedCache.upsert(row);
198
- }
199
283
  // Compute checkpoint for CDC as the max `modified` across returned rows (robust to server ordering)
200
284
  let checkpoint: string | undefined = undefined;
201
285
  for (const row of rows) {
@@ -205,6 +289,13 @@ export class SubscriptionManager {
205
289
  return { rows, cursor, checkpoint };
206
290
  }
207
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
+
208
299
  /**
209
300
  * Re-adopts a subscription restored from a persisted snapshot when an
210
301
  * identical query subscribes: the cached membership renders immediately and
@@ -228,44 +319,11 @@ export class SubscriptionManager {
228
319
  this.queryKeyToSubId.set(queryKey, restoredId);
229
320
  this.subRefCounts.set(restoredId, 1);
230
321
 
231
- const refresh = this.refreshAdoptedSubscription<Data>(subscription);
322
+ const refresh = this.refreshSubscription(subscription);
232
323
  const rows = this.getDocsForSubscription<Data>(restoredId);
233
324
  return { subscriptionId: restoredId, rows, checkpoint: subscription.checkpoint, refresh };
234
325
  }
235
326
 
236
- /**
237
- * Network refresh for an adopted subscription with fresh-subscribe
238
- * semantics: an authoritative page fetch replaces membership and recomputes
239
- * the checkpoint (identical to what a brand-new subscribe would produce).
240
- * On failure (offline) the cached membership and the restored CDC
241
- * checkpoint are kept and the subscription is marked stale — the next
242
- * backfill trigger drains changes from that checkpoint. Never rejects.
243
- */
244
- private async refreshAdoptedSubscription<Data extends FrappeDocData>(
245
- subscription: Subscription,
246
- ): Promise<{ ok: boolean; error?: unknown }> {
247
- try {
248
- const { rows, cursor, checkpoint } = await this.fetchInitialRows<Data>(subscription.query);
249
- subscription.checkpoint = checkpoint;
250
- subscription.cursor = cursor;
251
- subscription.docIds = rows.map((row) => row.name);
252
- if (rows.length > 0) {
253
- subscription.boundaries = {
254
- firstItem: rows[0] as unknown as FrappeDoc<FrappeDocData>,
255
- lastItem: rows[rows.length - 1] as unknown as FrappeDoc<FrappeDocData>,
256
- offset: subscription.query.limitStart || 0,
257
- pageSize: subscription.query.limit || 20,
258
- };
259
- }
260
- subscription.metadata = { itemsBeforePage: 0, itemsAfterPage: 0 };
261
- subscription.stale = false;
262
- return { ok: true };
263
- } catch (error) {
264
- subscription.stale = true;
265
- return { ok: false, error };
266
- }
267
- }
268
-
269
327
  /**
270
328
  * Registers a subscription restored from a persisted snapshot. Restored
271
329
  * subscriptions are dormant: they hold the persisted query, CDC checkpoint,
@@ -346,6 +404,7 @@ export class SubscriptionManager {
346
404
  this.subRefCounts.set(subscriptionId, refCount);
347
405
  return;
348
406
  }
407
+ this.refreshes.delete(subscriptionId);
349
408
  // Last consumer released — clean up
350
409
  this.subRefCounts.delete(subscriptionId);
351
410
  let queryKey: string | undefined;
@@ -756,6 +815,7 @@ export class SubscriptionManager {
756
815
  }
757
816
 
758
817
  clear() {
818
+ this.refreshes.clear();
759
819
  this.subscriptions.clear();
760
820
  this.queryKeyToSubId.clear();
761
821
  this.subRefCounts.clear();
package/src/sync/types.ts CHANGED
@@ -13,10 +13,20 @@ export interface OrderByField {
13
13
 
14
14
  export type OrderBy = OrderByField | OrderByField[];
15
15
 
16
+ /**
17
+ * Page length used when a subscription (and so a collection) carries no
18
+ * explicit `limit`. Frappe's /api/resource clamps a request with no
19
+ * `limit_page_length` to 20 rows while a fixture provider slices only when
20
+ * asked, so both paths send this value explicitly and return the same rows.
21
+ * `limit: 0` is Frappe's "every row" and means the same in fixture mode.
22
+ */
23
+ export const defaultPageLength = 20;
24
+
16
25
  export interface SubscriptionQuery {
17
26
  doctype: string;
18
27
  fields?: readonly string[];
19
28
  filters?: Array<[string, string, unknown]> | Record<string, unknown>;
29
+ /** Rows per page. Absent means {@link defaultPageLength}; `0` means every row. */
20
30
  limit?: number;
21
31
  limitStart?: number;
22
32
  orderBy?: OrderBy;