@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,135 @@
1
+ /**
2
+ * A collection created with no `limit` must return the same rows in fixture
3
+ * mode as it does against a live bench (MPO-238). Frappe clamps a request
4
+ * that carries no `limit_page_length` to 20 rows while a fixture provider
5
+ * slices only when asked, so development read every row, production read 20,
6
+ * and neither one errored to say so.
7
+ */
8
+ import { describe, expect, it, vi } from "vitest";
9
+ import { fixtureCollectionOptions } from "./collection";
10
+ import type { DbModule } from "./db";
11
+ import { FixtureDbModule } from "./fixtureDb";
12
+ import { InMemoryFixtureProvider } from "./fixtures";
13
+ import { NormalizedCache } from "./sync/normalizedCache";
14
+ import { SubscriptionManager } from "./sync/subscriptionManager";
15
+ import { defaultPageLength } from "./sync/types";
16
+ import type { SubscriptionQuery } from "./sync/types";
17
+
18
+ const rowCount = 50;
19
+
20
+ interface Todo {
21
+ name: string;
22
+ doctype: string;
23
+ description: string;
24
+ modified: string;
25
+ }
26
+
27
+ function todos(): Todo[] {
28
+ return Array.from({ length: rowCount }, (_, index) => ({
29
+ name: `TODO-${String(index + 1).padStart(4, "0")}`,
30
+ doctype: "ToDo",
31
+ description: `row ${index + 1}`,
32
+ modified: `2026-01-01T00:${String(index).padStart(2, "0")}:00`,
33
+ }));
34
+ }
35
+
36
+ /**
37
+ * Stands in for a live bench: /api/resource pages a request with no
38
+ * `limit_page_length` at 20 rows and reads `limit_page_length=0` as every
39
+ * row. Nothing else about the response differs from fixture mode, so any
40
+ * divergence these specs catch is the page length alone.
41
+ */
42
+ function benchDb(): DbModule {
43
+ const rows = todos();
44
+ return {
45
+ getDoc: async (_doctype: string, name: string) => rows.find((row) => row.name === name) ?? null,
46
+ getDocList: async (_doctype: string, options: { limit?: number; limitStart?: number } = {}) => {
47
+ const start = options.limitStart ?? 0;
48
+ const pageLength = options.limit ?? 20;
49
+ return rows.slice(start, pageLength === 0 ? undefined : start + pageLength);
50
+ },
51
+ } as unknown as DbModule;
52
+ }
53
+
54
+ function fixtureDb(): DbModule {
55
+ return new FixtureDbModule(new InMemoryFixtureProvider({ ToDo: todos() })) as unknown as DbModule;
56
+ }
57
+
58
+ async function subscribedNames(db: DbModule, query: SubscriptionQuery): Promise<string[]> {
59
+ const manager = new SubscriptionManager(db, new NormalizedCache());
60
+ const result = await manager.subscribe(query);
61
+ return result.rows.map((row) => String(row.name));
62
+ }
63
+
64
+ async function fixtureCollectionNames(limit?: number): Promise<string[]> {
65
+ const options = fixtureCollectionOptions<Todo>("ToDo", {
66
+ fixtures: new InMemoryFixtureProvider({ ToDo: todos() }),
67
+ ...(limit === undefined ? {} : { limit }),
68
+ });
69
+ const written: string[] = [];
70
+ const cleanup = options.sync!.sync({
71
+ begin: vi.fn(),
72
+ write: (message: any) => {
73
+ written.push(message.value.name);
74
+ },
75
+ commit: vi.fn(),
76
+ markReady: vi.fn(),
77
+ truncate: vi.fn(),
78
+ collection: {} as any,
79
+ } as any);
80
+ await new Promise((resolve) => setTimeout(resolve, 10));
81
+ if (typeof cleanup === "function") cleanup();
82
+ return written;
83
+ }
84
+
85
+ describe("collection row limit parity", () => {
86
+ it("reads the default page length with no limit, on both paths", async () => {
87
+ const query: SubscriptionQuery = { doctype: "ToDo" };
88
+
89
+ const live = await subscribedNames(benchDb(), query);
90
+ const fixture = await subscribedNames(fixtureDb(), query);
91
+
92
+ expect(fixture).toEqual(live);
93
+ expect(fixture).toHaveLength(defaultPageLength);
94
+ expect(fixture.at(-1)).toBe("TODO-0020");
95
+ });
96
+
97
+ it("reads every row with limit 0, on both paths", async () => {
98
+ const query: SubscriptionQuery = { doctype: "ToDo", limit: 0 };
99
+
100
+ const live = await subscribedNames(benchDb(), query);
101
+ const fixture = await subscribedNames(fixtureDb(), query);
102
+
103
+ expect(fixture).toEqual(live);
104
+ expect(fixture).toHaveLength(rowCount);
105
+ });
106
+
107
+ it("reads an explicit limit past the bench default, on both paths", async () => {
108
+ const query: SubscriptionQuery = { doctype: "ToDo", limit: 35 };
109
+
110
+ const live = await subscribedNames(benchDb(), query);
111
+ const fixture = await subscribedNames(fixtureDb(), query);
112
+
113
+ expect(fixture).toEqual(live);
114
+ expect(fixture).toHaveLength(35);
115
+ });
116
+
117
+ it("offsets by limitStart with no limit, on both paths", async () => {
118
+ const query: SubscriptionQuery = { doctype: "ToDo", limitStart: 20 };
119
+
120
+ const live = await subscribedNames(benchDb(), query);
121
+ const fixture = await subscribedNames(fixtureDb(), query);
122
+
123
+ expect(fixture).toEqual(live);
124
+ expect(fixture[0]).toBe("TODO-0021");
125
+ expect(fixture).toHaveLength(defaultPageLength);
126
+ });
127
+
128
+ it("gives the direct fixture collection the same default page length", async () => {
129
+ expect(await fixtureCollectionNames()).toHaveLength(defaultPageLength);
130
+ });
131
+
132
+ it("gives the direct fixture collection every row with limit 0", async () => {
133
+ expect(await fixtureCollectionNames(0)).toHaveLength(rowCount);
134
+ });
135
+ });
@@ -131,7 +131,9 @@ describe("FrappeDevtoolsPanel reads __debug()", () => {
131
131
  );
132
132
  expect(stub.module.__debug).toHaveBeenCalled();
133
133
  expect(container.textContent).toContain("ToDo");
134
+ expect(container.textContent).toContain("8 docs");
134
135
  expect(container.textContent).toContain("Note");
136
+ expect(container.textContent).toContain("3 docs");
135
137
  });
136
138
 
137
139
  it("an empty snapshot still paints the shell, not a blank div", () => {
@@ -262,8 +264,8 @@ describe("FrappeDevtoolsPanel theme prop", () => {
262
264
  );
263
265
  const darkBg = (dark.container.firstElementChild as HTMLElement).style.backgroundColor;
264
266
  expect(lightBg).not.toBe(darkBg);
265
- expect(lightBg).toBeTruthy();
266
- expect(darkBg).toBeTruthy();
267
+ expect(lightBg).toBe(themes.light.bg);
268
+ expect(darkBg).toBe(themes.dark.bg);
267
269
  // The panel owns its own palette (devtools/theme.ts); it never reads the app knobs.
268
270
  expect(Object.keys(themes)).toEqual(["light", "dark"]);
269
271
  });
package/src/doctype.ts CHANGED
@@ -372,7 +372,8 @@ export function parseFormLayout(fields: DocTypeField[]): FormSection[] {
372
372
  // Internal helpers
373
373
  // ---------------------------------------------------------------------------
374
374
 
375
- function createHttpClient(config: FrappeConnectionConfig): HttpClient {
375
+ function createHttpClient(config: FrappeConnectionConfig): HttpClient | undefined {
376
+ if (!config.baseURL) return undefined;
376
377
  const authConfig: AuthConfig | undefined = config.auth
377
378
  ? {
378
379
  useToken: config.auth.useToken,
@@ -474,6 +475,27 @@ function parseRawDocTypeMeta(raw: RawDocTypeResponse): DocTypeMeta {
474
475
  };
475
476
  }
476
477
 
478
+ /**
479
+ * Cache identity for a connection. A fixtures-only connection has no
480
+ * baseURL, so it is keyed by its FixtureProvider instance instead — two
481
+ * corpora must not share one cached DocType (the cross-test pollution the
482
+ * SyncModule cache already avoids the same way).
483
+ */
484
+ const fixtureConnectionIds = new WeakMap<object, string>();
485
+ let nextFixtureConnectionId = 0;
486
+
487
+ function connectionKey(config: FrappeConnectionConfig): string {
488
+ if (config.baseURL) return config.baseURL;
489
+ const fixtures = config.fixtures;
490
+ if (!fixtures) return "";
491
+ let id = fixtureConnectionIds.get(fixtures);
492
+ if (!id) {
493
+ id = `fixtures#${++nextFixtureConnectionId}`;
494
+ fixtureConnectionIds.set(fixtures, id);
495
+ }
496
+ return id;
497
+ }
498
+
477
499
  /**
478
500
  * Setup pattern for a stable HttpClient ref, following the same pattern
479
501
  * used in `useDebouncedSearch` for DbModule.
@@ -538,7 +560,7 @@ export function useDocTypeMeta(
538
560
  error: Error | null;
539
561
  refetch: () => void;
540
562
  } {
541
- const cacheKey = config ? `${config.baseURL}:${doctype}` : "";
563
+ const cacheKey = config ? `${connectionKey(config)}:${doctype}` : "";
542
564
 
543
565
  const [meta, setMeta] = useState<DocTypeMeta | undefined>(() =>
544
566
  cacheKey ? metaCache.get(cacheKey) : undefined,
@@ -571,12 +593,14 @@ export function useDocTypeMeta(
571
593
  return;
572
594
  }
573
595
 
574
- const key = `${config.baseURL}:${doctype}`;
596
+ const key = `${connectionKey(config)}:${doctype}`;
575
597
  // Already have cached data (synchronized during render)
576
598
  if (metaCache.has(key)) return;
577
599
 
578
600
  const http = httpRef.current;
579
- if (!http) return;
601
+ // No client AND no corpus: nothing can answer, so park rather than
602
+ // dial `undefined` (a fixtures-only host has no baseURL by design).
603
+ if (!http && !config.fixtures) return;
580
604
 
581
605
  let cancelled = false;
582
606
  setIsLoading(true);
@@ -596,6 +620,14 @@ export function useDocTypeMeta(
596
620
  let raw = fromFixtures?.fields ? fromFixtures : undefined;
597
621
 
598
622
  if (!raw) {
623
+ if (!http) {
624
+ // Fixtures-only host: there is no server behind the corpus, so a
625
+ // missing DocType entry is the honest end of the road.
626
+ throw new Error(
627
+ `DocType "${doctype}" is not in the fixture corpus and no baseURL is configured`,
628
+ );
629
+ }
630
+
599
631
  // `getdoctype` is the whitelisted RPC the Desk itself loads form
600
632
  // metadata with, and every logged-in user may call it. Reading
601
633
  // `/api/resource/DocType/<name>` instead asks for the DocType
@@ -612,20 +644,20 @@ export function useDocTypeMeta(
612
644
  // docs[0] is the requested doctype; docs[1..] are its child tables.
613
645
  const docs = response.payload?.docs;
614
646
  raw = docs?.find((doc) => doc?.name === doctype) ?? docs?.[0];
615
- }
616
647
 
617
- if (!raw) {
618
- // Sites that expose the REST API without the desk leave the RPC
619
- // above unregistered, so it answers with no `docs`. The document
620
- // route still serves meta there, and such a site's API clients are
621
- // typically System Managers, who may read it.
622
- const rest = await http.get<{ data?: RawDocTypeResponse }>(
623
- `/api/resource/DocType/${encodeURIComponent(doctype)}`,
624
- );
648
+ if (!raw) {
649
+ // Sites that expose the REST API without the desk leave the RPC
650
+ // above unregistered, so it answers with no `docs`. The document
651
+ // route still serves meta there, and such a site's API clients are
652
+ // typically System Managers, who may read it.
653
+ const rest = await http.get<{ data?: RawDocTypeResponse }>(
654
+ `/api/resource/DocType/${encodeURIComponent(doctype)}`,
655
+ );
625
656
 
626
- if (cancelled) return;
657
+ if (cancelled) return;
627
658
 
628
- raw = rest.payload?.data;
659
+ raw = rest.payload?.data;
660
+ }
629
661
  }
630
662
 
631
663
  if (!raw) {
@@ -20,8 +20,12 @@ import { isBrowser } from "./util";
20
20
  * Configuration for creating a Frappe collection with connection details.
21
21
  */
22
22
  export interface UseFrappeCollectionConfig {
23
- /** The base URL of the Frappe server */
24
- baseURL: string;
23
+ /**
24
+ * The base URL of the Frappe server. Required unless `fixtures` is set —
25
+ * a fixture corpus is the whole data source and nothing is ever dialled,
26
+ * so a fixtures-only host legitimately has no server to name.
27
+ */
28
+ baseURL?: string;
25
29
  /** False keeps HTTP sync without constructing a realtime transport. Defaults to true. */
26
30
  realtime?: boolean;
27
31
  /**
@@ -122,6 +126,11 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
122
126
  return sync;
123
127
  }
124
128
 
129
+ // No fixtures and no baseURL: there is nothing to dial. Same contract as
130
+ // SSR above — consumers render their loading/empty state instead of
131
+ // constructing an HttpClient against `undefined`.
132
+ if (!config.baseURL) return undefined;
133
+
125
134
  // Key on the full socket target, not just baseURL: socketPort /
126
135
  // socketBaseURL / siteName all change where (or how) the realtime socket
127
136
  // dials, so configs that differ in them must not share a SyncModule —
package/src/index.ts CHANGED
@@ -64,7 +64,11 @@ export type {
64
64
  UseFrappeInfiniteListConfig,
65
65
  UseFrappeInfiniteListResult,
66
66
  } from "./frappeReact";
67
- export type { SyncRealtimeEvent } from "./sync";
67
+ export type {
68
+ SyncRealtimeEvent,
69
+ SubscriptionRefreshOptions,
70
+ SubscriptionRefreshResult,
71
+ } from "./sync";
68
72
 
69
73
  // TanStack Pacer utilities for Frappe
70
74
  export { useDebouncedSearch, useThrottledPagination, createRateLimitedBatch } from "./pacer";
@@ -201,7 +205,7 @@ export type {
201
205
  DebugMutationEntry,
202
206
  PageIsland,
203
207
  } from "./sync/types";
204
- export { Order, Operation } from "./sync/types";
208
+ export { defaultPageLength, Order, Operation } from "./sync/types";
205
209
  export { MergeStrategy, FieldMergeStrategy } from "./sync/merge";
206
210
  export type { FieldMergeStrategyOrFunction, MergeConfig, MergeResult } from "./sync/merge";
207
211
 
package/src/pacer.ts CHANGED
@@ -53,8 +53,12 @@ export interface DebouncedSearchResult<TDoc extends FrappeDocData> {
53
53
  * Connection configuration for hooks that need a Frappe backend.
54
54
  */
55
55
  export interface FrappeConnectionConfig {
56
- /** The base URL of the Frappe server */
57
- baseURL: string;
56
+ /**
57
+ * The base URL of the Frappe server. Required unless `fixtures` is set:
58
+ * a fixtures-only host has no server, and the hooks here read the corpus
59
+ * (or park) instead of dialling one.
60
+ */
61
+ baseURL?: string;
58
62
  /** False disables realtime when this connection is used by collection hooks. */
59
63
  realtime?: boolean;
60
64
  /** Override port for Socket.IO realtime connection (e.g. 9000 in dev). */
@@ -74,7 +78,8 @@ export interface FrappeConnectionConfig {
74
78
  fixtures?: FixtureProvider;
75
79
  }
76
80
 
77
- function createDbModule(config: FrappeConnectionConfig): DbModule {
81
+ function createDbModule(config: FrappeConnectionConfig): DbModule | undefined {
82
+ if (!config.baseURL) return undefined;
78
83
  const authConfig: AuthConfig | undefined = config.auth
79
84
  ? {
80
85
  useToken: config.auth.useToken,
package/src/sync/index.ts CHANGED
@@ -8,6 +8,8 @@ import { NormalizedCache } from "./normalizedCache";
8
8
  import { PersistenceManager } from "./persistence";
9
9
  import { RealtimeHandler } from "./realtime";
10
10
  import { RehydrationManager } from "./rehydration";
11
+ import type { SubscriptionRefreshOptions, SubscriptionRefreshResult } from "./subscriptionManager";
12
+ export type { SubscriptionRefreshOptions, SubscriptionRefreshResult } from "./subscriptionManager";
11
13
  import { SubscriptionManager } from "./subscriptionManager";
12
14
  import { BackfillScope, Status } from "./types";
13
15
  import type {
@@ -261,6 +263,16 @@ export class SyncModule {
261
263
  this.emit();
262
264
  }
263
265
 
266
+ /** Refresh active query snapshots in place. Failures retain the last committed data. */
267
+ async refreshSubscriptions(
268
+ options?: SubscriptionRefreshOptions,
269
+ ): Promise<SubscriptionRefreshResult[]> {
270
+ await this.persistenceManager.whenReady();
271
+ const results = await this.subscriptionManager.refreshSubscriptions(options);
272
+ this.emit();
273
+ return results;
274
+ }
275
+
264
276
  /**
265
277
  * Reconciles local state by fetching changes since stored cursors.
266
278
  */
@@ -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
+ });