@multiplatform.one/frappe 7.10.0 → 7.15.0

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