@multiplatform.one/frappe 6.6.0 → 7.0.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 (224) hide show
  1. package/README.md +1 -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.cjs +45 -1
  7. package/dist/cjs/core/http.native.js +72 -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 +15 -2
  17. package/dist/cjs/doctype.native.js +22 -5
  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 +55 -1
  24. package/dist/cjs/frappeReact.native.js +67 -2
  25. package/dist/cjs/frappeReact.native.js.map +1 -1
  26. package/dist/cjs/index.cjs +8 -0
  27. package/dist/cjs/index.native.js +10 -2
  28. package/dist/cjs/index.native.js.map +1 -1
  29. package/dist/cjs/optimistic.cjs +32 -2
  30. package/dist/cjs/optimistic.native.js +34 -1
  31. package/dist/cjs/optimistic.native.js.map +1 -1
  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.cjs +234 -11
  35. package/dist/cjs/realtime.native.js +368 -18
  36. package/dist/cjs/realtime.native.js.map +1 -1
  37. package/dist/cjs/sync/backfill.native.js.map +1 -1
  38. package/dist/cjs/sync/filters.native.js.map +1 -1
  39. package/dist/cjs/sync/index.cjs +45 -1
  40. package/dist/cjs/sync/index.native.js +71 -1
  41. package/dist/cjs/sync/index.native.js.map +1 -1
  42. package/dist/cjs/sync/merge.native.js.map +1 -1
  43. package/dist/cjs/sync/mutationEngine.cjs +51 -15
  44. package/dist/cjs/sync/mutationEngine.native.js +69 -16
  45. package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
  46. package/dist/cjs/sync/mutationJournal.cjs +8 -0
  47. package/dist/cjs/sync/mutationJournal.native.js +10 -0
  48. package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
  49. package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
  50. package/dist/cjs/sync/persistence.native.js.map +1 -1
  51. package/dist/cjs/sync/realtime.native.js.map +1 -1
  52. package/dist/cjs/sync/rehydration.native.js.map +1 -1
  53. package/dist/cjs/sync/storage.native.js.map +1 -1
  54. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  55. package/dist/cjs/sync/types.native.js.map +1 -1
  56. package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
  57. package/dist/cjs/useLiveQuery.native.js.map +1 -1
  58. package/dist/cjs/util.native.js.map +1 -1
  59. package/dist/esm/auth.native.js.map +1 -1
  60. package/dist/esm/call.native.js.map +1 -1
  61. package/dist/esm/collection.native.js +1 -1
  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.mjs +45 -2
  65. package/dist/esm/core/http.mjs.map +1 -1
  66. package/dist/esm/core/http.native.js +74 -4
  67. package/dist/esm/core/http.native.js.map +1 -1
  68. package/dist/esm/core/types.native.js.map +1 -1
  69. package/dist/esm/core/wire.native.js.map +1 -1
  70. package/dist/esm/db.native.js +1 -1
  71. package/dist/esm/db.native.js.map +1 -1
  72. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js +2 -2
  73. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  74. package/dist/esm/devtools/index.native.js +2 -2
  75. package/dist/esm/devtools/index.native.js.map +1 -1
  76. package/dist/esm/devtools/plugin.native.js +1 -1
  77. package/dist/esm/devtools/plugin.native.js.map +1 -1
  78. package/dist/esm/devtools/theme.native.js.map +1 -1
  79. package/dist/esm/doctype.mjs +15 -2
  80. package/dist/esm/doctype.mjs.map +1 -1
  81. package/dist/esm/doctype.native.js +23 -6
  82. package/dist/esm/doctype.native.js.map +1 -1
  83. package/dist/esm/file.native.js.map +1 -1
  84. package/dist/esm/fixtureDb.native.js +1 -1
  85. package/dist/esm/fixtureDb.native.js.map +1 -1
  86. package/dist/esm/fixtures.native.js.map +1 -1
  87. package/dist/esm/frappeApp.native.js +7 -7
  88. package/dist/esm/frappeApp.native.js.map +1 -1
  89. package/dist/esm/frappeReact.mjs +55 -3
  90. package/dist/esm/frappeReact.mjs.map +1 -1
  91. package/dist/esm/frappeReact.native.js +73 -10
  92. package/dist/esm/frappeReact.native.js.map +1 -1
  93. package/dist/esm/index.mjs +5 -5
  94. package/dist/esm/index.mjs.map +1 -1
  95. package/dist/esm/index.native.js +22 -22
  96. package/dist/esm/index.native.js.map +1 -1
  97. package/dist/esm/optimistic.mjs +27 -1
  98. package/dist/esm/optimistic.mjs.map +1 -1
  99. package/dist/esm/optimistic.native.js +30 -1
  100. package/dist/esm/optimistic.native.js.map +1 -1
  101. package/dist/esm/pacer.mjs.map +1 -1
  102. package/dist/esm/pacer.native.js +4 -4
  103. package/dist/esm/pacer.native.js.map +1 -1
  104. package/dist/esm/plugin/index.native.js.map +1 -1
  105. package/dist/esm/realtime.mjs +234 -11
  106. package/dist/esm/realtime.mjs.map +1 -1
  107. package/dist/esm/realtime.native.js +369 -19
  108. package/dist/esm/realtime.native.js.map +1 -1
  109. package/dist/esm/sync/backfill.native.js +1 -1
  110. package/dist/esm/sync/backfill.native.js.map +1 -1
  111. package/dist/esm/sync/filters.native.js.map +1 -1
  112. package/dist/esm/sync/index.mjs +45 -1
  113. package/dist/esm/sync/index.mjs.map +1 -1
  114. package/dist/esm/sync/index.native.js +79 -9
  115. package/dist/esm/sync/index.native.js.map +1 -1
  116. package/dist/esm/sync/merge.native.js.map +1 -1
  117. package/dist/esm/sync/mutationEngine.mjs +51 -15
  118. package/dist/esm/sync/mutationEngine.mjs.map +1 -1
  119. package/dist/esm/sync/mutationEngine.native.js +73 -20
  120. package/dist/esm/sync/mutationEngine.native.js.map +1 -1
  121. package/dist/esm/sync/mutationJournal.mjs +8 -0
  122. package/dist/esm/sync/mutationJournal.mjs.map +1 -1
  123. package/dist/esm/sync/mutationJournal.native.js +11 -1
  124. package/dist/esm/sync/mutationJournal.native.js.map +1 -1
  125. package/dist/esm/sync/normalizedCache.native.js.map +1 -1
  126. package/dist/esm/sync/persistence.native.js +2 -2
  127. package/dist/esm/sync/persistence.native.js.map +1 -1
  128. package/dist/esm/sync/realtime.native.js +1 -1
  129. package/dist/esm/sync/realtime.native.js.map +1 -1
  130. package/dist/esm/sync/rehydration.native.js +1 -1
  131. package/dist/esm/sync/rehydration.native.js.map +1 -1
  132. package/dist/esm/sync/storage.native.js.map +1 -1
  133. package/dist/esm/sync/subscriptionManager.native.js +4 -4
  134. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  135. package/dist/esm/sync/types.mjs.map +1 -1
  136. package/dist/esm/sync/types.native.js.map +1 -1
  137. package/dist/esm/sync/virtualPageManager.native.js +1 -1
  138. package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
  139. package/dist/esm/useLiveQuery.native.js.map +1 -1
  140. package/dist/esm/util.native.js.map +1 -1
  141. package/dist/jsx/core/http.mjs +45 -2
  142. package/dist/jsx/core/http.mjs.map +1 -1
  143. package/dist/jsx/core/http.native.js +72 -1
  144. package/dist/jsx/core/http.native.js.map +1 -1
  145. package/dist/jsx/doctype.mjs +15 -2
  146. package/dist/jsx/doctype.mjs.map +1 -1
  147. package/dist/jsx/doctype.native.js +22 -5
  148. package/dist/jsx/doctype.native.js.map +1 -1
  149. package/dist/jsx/frappeReact.mjs +55 -3
  150. package/dist/jsx/frappeReact.mjs.map +1 -1
  151. package/dist/jsx/frappeReact.native.js +66 -1
  152. package/dist/jsx/frappeReact.native.js.map +1 -1
  153. package/dist/jsx/index.js +5 -5
  154. package/dist/jsx/index.js.map +1 -1
  155. package/dist/jsx/index.mjs +5 -5
  156. package/dist/jsx/index.mjs.map +1 -1
  157. package/dist/jsx/index.native.js +8 -0
  158. package/dist/jsx/index.native.js.map +1 -1
  159. package/dist/jsx/optimistic.mjs +27 -1
  160. package/dist/jsx/optimistic.mjs.map +1 -1
  161. package/dist/jsx/optimistic.native.js +34 -1
  162. package/dist/jsx/optimistic.native.js.map +1 -1
  163. package/dist/jsx/pacer.mjs.map +1 -1
  164. package/dist/jsx/pacer.native.js.map +1 -1
  165. package/dist/jsx/realtime.mjs +234 -11
  166. package/dist/jsx/realtime.mjs.map +1 -1
  167. package/dist/jsx/realtime.native.js +368 -18
  168. package/dist/jsx/realtime.native.js.map +1 -1
  169. package/dist/jsx/sync/index.mjs +45 -1
  170. package/dist/jsx/sync/index.mjs.map +1 -1
  171. package/dist/jsx/sync/index.native.js +71 -1
  172. package/dist/jsx/sync/index.native.js.map +1 -1
  173. package/dist/jsx/sync/mutationEngine.mjs +51 -15
  174. package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
  175. package/dist/jsx/sync/mutationEngine.native.js +69 -16
  176. package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
  177. package/dist/jsx/sync/mutationJournal.mjs +8 -0
  178. package/dist/jsx/sync/mutationJournal.mjs.map +1 -1
  179. package/dist/jsx/sync/mutationJournal.native.js +10 -0
  180. package/dist/jsx/sync/mutationJournal.native.js.map +1 -1
  181. package/dist/jsx/sync/types.mjs.map +1 -1
  182. package/dist/jsx/sync/types.native.js.map +1 -1
  183. package/package.json +8 -8
  184. package/src/core/http.spec.ts +81 -0
  185. package/src/core/http.ts +80 -3
  186. package/src/doctype.spec.ts +82 -3
  187. package/src/doctype.ts +51 -7
  188. package/src/frappeReact.ts +126 -4
  189. package/src/index.ts +28 -3
  190. package/src/optimistic.spec.ts +113 -2
  191. package/src/optimistic.ts +95 -0
  192. package/src/pacer.ts +8 -0
  193. package/src/realtime.spec.ts +62 -0
  194. package/src/realtime.ts +440 -14
  195. package/src/realtimeSse.spec.ts +547 -0
  196. package/src/sync/index.ts +44 -1
  197. package/src/sync/mutationEngine.spec.ts +7 -2
  198. package/src/sync/mutationEngine.ts +83 -27
  199. package/src/sync/mutationJournal.ts +9 -0
  200. package/src/sync/mutations.spec.ts +1 -1
  201. package/src/sync/syncModule.spec.ts +255 -1
  202. package/src/sync/types.ts +20 -0
  203. package/types/core/http.d.ts +11 -0
  204. package/types/core/http.d.ts.map +1 -1
  205. package/types/doctype.d.ts +7 -0
  206. package/types/doctype.d.ts.map +1 -1
  207. package/types/frappeReact.d.ts +50 -2
  208. package/types/frappeReact.d.ts.map +1 -1
  209. package/types/index.d.ts +8 -6
  210. package/types/index.d.ts.map +1 -1
  211. package/types/optimistic.d.ts +38 -0
  212. package/types/optimistic.d.ts.map +1 -1
  213. package/types/pacer.d.ts +8 -0
  214. package/types/pacer.d.ts.map +1 -1
  215. package/types/realtime.d.ts +183 -1
  216. package/types/realtime.d.ts.map +1 -1
  217. package/types/sync/index.d.ts +16 -1
  218. package/types/sync/index.d.ts.map +1 -1
  219. package/types/sync/mutationEngine.d.ts +7 -0
  220. package/types/sync/mutationEngine.d.ts.map +1 -1
  221. package/types/sync/mutationJournal.d.ts +6 -0
  222. package/types/sync/mutationJournal.d.ts.map +1 -1
  223. package/types/sync/types.d.ts +20 -0
  224. package/types/sync/types.d.ts.map +1 -1
package/src/core/http.ts CHANGED
@@ -23,6 +23,85 @@ function isAbortError(err: unknown, signal?: AbortSignal): boolean {
23
23
  if (signal?.aborted) return true;
24
24
  return err instanceof Error && err.name === "AbortError";
25
25
  }
26
+
27
+ /**
28
+ * Frappe's `_server_messages` is a JSON array of JSON strings, each an object
29
+ * like `{"message": "You don't have access to Report: ToDo", "title": "Message"}`,
30
+ * and the messages carry inline HTML (`<strong>…</strong>`).
31
+ */
32
+ function readServerMessage(raw: unknown): string | undefined {
33
+ if (typeof raw !== "string" || !raw.trim()) return undefined;
34
+ let entries: unknown;
35
+ try {
36
+ entries = JSON.parse(raw);
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ if (!Array.isArray(entries)) return undefined;
41
+ for (const entry of entries) {
42
+ let text: unknown = entry;
43
+ if (typeof entry === "string") {
44
+ try {
45
+ text = (JSON.parse(entry) as { message?: unknown })?.message ?? entry;
46
+ } catch {
47
+ text = entry;
48
+ }
49
+ } else if (entry && typeof entry === "object") {
50
+ text = (entry as { message?: unknown }).message;
51
+ }
52
+ if (typeof text === "string" && text.trim()) {
53
+ // Strip the inline markup so the sentence survives into plain-text UI.
54
+ // Frappe separates multiple sentences inside ONE message with `<br>`, so
55
+ // line-breaking tags become spaces or the sentences run together.
56
+ const plain = text
57
+ .replace(/<\s*br\s*\/?\s*>|<\/\s*(?:p|div|li)\s*>/gi, " ")
58
+ .replace(/<[^>]+>/g, "")
59
+ .replace(/\s+/g, " ")
60
+ .trim();
61
+ if (plain) return plain;
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ /**
68
+ * The user-facing sentence for a failed response.
69
+ *
70
+ * With `developer_mode` on, Frappe puts a full python traceback in `exc`, and
71
+ * reading that first meant every permission failure surfaced a traceback as its
72
+ * error message — which components then rendered verbatim into the UI. The
73
+ * readable sentence is in `_server_messages`, and it is preferred here; `exc`
74
+ * is a last resort and contributes only its final line (the actual raise),
75
+ * never the whole stack.
76
+ */
77
+ export function errorMessageFromPayload(payload: unknown, statusText: string): string {
78
+ // A non-JSON body is usually an HTML error page — frappe's dev server emits a
79
+ // full werkzeug debug page for a dropped connection. The status line is the
80
+ // only part of that safe to show a user.
81
+ if (typeof payload === "string") return statusText || payload;
82
+ if (!payload || typeof payload !== "object") return statusText;
83
+ const body = payload as Record<string, unknown>;
84
+
85
+ if (typeof body.message === "string" && body.message.trim()) return body.message;
86
+
87
+ const serverMessage = readServerMessage(body._server_messages);
88
+ if (serverMessage) return serverMessage;
89
+
90
+ if (typeof body.exception === "string" && body.exception.trim()) return body.exception;
91
+
92
+ if (typeof body.exc === "string" && body.exc.trim()) {
93
+ const lines = body.exc
94
+ .split("\\n")
95
+ .flatMap((line) => line.split("\n"))
96
+ .map((line) => line.trim())
97
+ .filter((line) => line && line !== '"]' && line !== '["');
98
+ const lastFrame = lines.at(-1);
99
+ if (lastFrame) return lastFrame.replace(/^\[?"?/, "").replace(/"?\]?$/, "");
100
+ }
101
+
102
+ if (body.message !== undefined && body.message !== null) return String(body.message);
103
+ return statusText;
104
+ }
26
105
  export type RequestBody = BodyInit | Record<string, unknown> | unknown[] | ArrayBufferView;
27
106
 
28
107
  export interface HttpResponse<T> {
@@ -290,9 +369,7 @@ export class HttpClient {
290
369
  const payload = contentType?.includes("application/json")
291
370
  ? await res.json()
292
371
  : await res.text();
293
- const message =
294
- (payload && (payload.message || payload.exc || payload._server_messages)) ||
295
- res.statusText;
372
+ const message = errorMessageFromPayload(payload, res.statusText);
296
373
  const err = new FrappeError(message, res.status, payload);
297
374
  // Native cookie-auth: a 400 on a mutating request may be frappe's
298
375
  // CSRFTokenError from a rotated session — drop the cached token so
@@ -271,7 +271,7 @@ describe("useDocTypeMeta", () => {
271
271
  // Config must be stable (defined outside renderHook) — hooks with [config]
272
272
  // in deps loop infinitely if a new object is created on every render.
273
273
  const config = { baseURL: `https://meta-${testId}.example.com` };
274
- mockGetFn.mockResolvedValueOnce({ payload: { data: rawDocType(doctype) } });
274
+ mockGetFn.mockResolvedValueOnce({ payload: { docs: [rawDocType(doctype)] } });
275
275
 
276
276
  const { result } = renderHook(() => useDocTypeMeta(config, doctype));
277
277
 
@@ -285,6 +285,85 @@ describe("useDocTypeMeta", () => {
285
285
  expect(result.current.meta!.permissions[0].read).toBe(true);
286
286
  });
287
287
 
288
+ // The REST route reads the DocType *document* — System-Manager-only, so
289
+ // ordinary users got a 403 traceback instead of a form. Assert the endpoint
290
+ // itself, not just that meta parsed: only the URL distinguishes the two.
291
+ it("loads meta over the whitelisted getdoctype RPC, not the DocType resource route", async () => {
292
+ const doctype = `DocRpc${testId}`;
293
+ const config = { baseURL: `https://meta-rpc-${testId}.example.com` };
294
+ mockGetFn.mockResolvedValueOnce({ payload: { docs: [rawDocType(doctype)] } });
295
+
296
+ const { result } = renderHook(() => useDocTypeMeta(config, doctype));
297
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
298
+
299
+ expect(mockGetFn).toHaveBeenCalledWith("/api/method/frappe.desk.form.load.getdoctype", {
300
+ doctype,
301
+ });
302
+ const requestedPaths = mockGetFn.mock.calls.map((call) => String(call[0]));
303
+ expect(requestedPaths.some((path) => path.includes("/api/resource/DocType/"))).toBe(false);
304
+ });
305
+
306
+ it("falls back to the DocType document route when the RPC returns no docs", async () => {
307
+ const doctype = `DocFallback${testId}`;
308
+ const config = { baseURL: `https://meta-fallback-${testId}.example.com` };
309
+ // desk RPCs unregistered → the call resolves, but carries no `docs`
310
+ mockGetFn.mockResolvedValueOnce({ payload: {} });
311
+ mockGetFn.mockResolvedValueOnce({ payload: { data: rawDocType(doctype) } });
312
+
313
+ const { result } = renderHook(() => useDocTypeMeta(config, doctype));
314
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
315
+
316
+ expect(result.current.meta!.name).toBe(doctype);
317
+ expect(mockGetFn.mock.calls.map((call) => String(call[0]))).toEqual([
318
+ "/api/method/frappe.desk.form.load.getdoctype",
319
+ `/api/resource/DocType/${doctype}`,
320
+ ]);
321
+ });
322
+
323
+ it("picks the requested doctype out of a bundle that also carries child tables", async () => {
324
+ const doctype = `DocParent${testId}`;
325
+ const config = { baseURL: `https://meta-bundle-${testId}.example.com` };
326
+ mockGetFn.mockResolvedValueOnce({
327
+ payload: { docs: [rawDocType(`${doctype} Child`), rawDocType(doctype)] },
328
+ });
329
+
330
+ const { result } = renderHook(() => useDocTypeMeta(config, doctype));
331
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
332
+
333
+ expect(result.current.meta!.name).toBe(doctype);
334
+ });
335
+
336
+ it("reads meta from the fixture provider's DocType collection without any request", async () => {
337
+ const doctype = `DocFix${testId}`;
338
+ const fixtures = {
339
+ getDoc: vi.fn(async () => rawDocType(doctype)),
340
+ } as unknown as NonNullable<Parameters<typeof useDocTypeMeta>[0]>["fixtures"];
341
+ const config = { baseURL: `https://meta-fix-${testId}.example.com`, fixtures };
342
+
343
+ const { result } = renderHook(() => useDocTypeMeta(config, doctype));
344
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
345
+
346
+ expect(result.current.meta!.name).toBe(doctype);
347
+ expect(mockGetFn).not.toHaveBeenCalled();
348
+ });
349
+
350
+ it("falls through to the RPC when the fixture provider has no meta for the doctype", async () => {
351
+ const doctype = `DocFixMiss${testId}`;
352
+ const fixtures = {
353
+ getDoc: vi.fn(async () => null),
354
+ } as unknown as NonNullable<Parameters<typeof useDocTypeMeta>[0]>["fixtures"];
355
+ const config = { baseURL: `https://meta-fixmiss-${testId}.example.com`, fixtures };
356
+ mockGetFn.mockResolvedValueOnce({ payload: { docs: [rawDocType(doctype)] } });
357
+
358
+ const { result } = renderHook(() => useDocTypeMeta(config, doctype));
359
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
360
+
361
+ expect(result.current.meta!.name).toBe(doctype);
362
+ expect(mockGetFn).toHaveBeenCalledWith("/api/method/frappe.desk.form.load.getdoctype", {
363
+ doctype,
364
+ });
365
+ });
366
+
288
367
  it("sets error state when the fetch fails", async () => {
289
368
  const doctype = `DocB${testId}`;
290
369
  const config = { baseURL: `https://meta-err-${testId}.example.com` };
@@ -314,7 +393,7 @@ describe("useDocTypeMeta", () => {
314
393
  ...rawDocType(doctype),
315
394
  field_order: ["status", "name"], // reversed
316
395
  };
317
- mockGetFn.mockResolvedValueOnce({ payload: { data: raw } });
396
+ mockGetFn.mockResolvedValueOnce({ payload: { docs: [raw] } });
318
397
 
319
398
  const { result } = renderHook(() => useDocTypeMeta(config, doctype));
320
399
 
@@ -326,7 +405,7 @@ describe("useDocTypeMeta", () => {
326
405
  it("refetch() clears cache and re-fetches", async () => {
327
406
  const doctype = `DocD${testId}`;
328
407
  const config = { baseURL: `https://meta-refetch-${testId}.example.com` };
329
- mockGetFn.mockResolvedValue({ payload: { data: rawDocType(doctype) } });
408
+ mockGetFn.mockResolvedValue({ payload: { docs: [rawDocType(doctype)] } });
330
409
 
331
410
  const { result } = renderHook(() => useDocTypeMeta(config, doctype));
332
411
  await waitFor(() => expect(result.current.isLoading).toBe(false));
package/src/doctype.ts CHANGED
@@ -503,6 +503,13 @@ function useHttpClientRef(
503
503
  /**
504
504
  * Fetch and cache DocType metadata (schema, fields, layout, permissions).
505
505
  *
506
+ * Metadata comes from `frappe.desk.form.load.getdoctype`, the whitelisted RPC
507
+ * the Desk itself uses, so ordinary users load forms too — the REST
508
+ * `/api/resource/DocType/<name>` route reads the DocType *document* and is
509
+ * System-Manager-only. That route stays as a fallback for desk-less sites
510
+ * where the RPC is not registered. When `config.fixtures` carries a "DocType"
511
+ * entry for the doctype it is used instead and no request is made.
512
+ *
506
513
  * Responses are cached per-session in memory keyed by `{baseURL}:{doctype}`.
507
514
  * The `refetch` function clears the cache entry and forces a fresh fetch.
508
515
  *
@@ -576,13 +583,50 @@ export function useDocTypeMeta(
576
583
 
577
584
  (async () => {
578
585
  try {
579
- const response = await http.get<{ data: RawDocTypeResponse }>(
580
- `/api/resource/DocType/${encodeURIComponent(doctype)}`,
581
- );
582
-
586
+ // Fixture hosts (stories/tests) keep the getdoctype-shaped meta doc in
587
+ // the provider's "DocType" collection — the same convention the
588
+ // backoffice meta loader uses. Providers that stub only documents fall
589
+ // through to the network below.
590
+ const fromFixtures = config.fixtures
591
+ ? ((await config.fixtures.getDoc("DocType", doctype)) as RawDocTypeResponse | null)
592
+ : null;
583
593
  if (cancelled) return;
584
594
 
585
- const raw = response.payload?.data;
595
+ let raw = fromFixtures?.fields ? fromFixtures : undefined;
596
+
597
+ if (!raw) {
598
+ // `getdoctype` is the whitelisted RPC the Desk itself loads form
599
+ // metadata with, and every logged-in user may call it. Reading
600
+ // `/api/resource/DocType/<name>` instead asks for the DocType
601
+ // *document*, which only System Manager holds read permission on, so
602
+ // ordinary users got a 403 PermissionError — and Frappe's
603
+ // developer-mode traceback rendered into the UI instead of a form.
604
+ const response = await http.get<{ docs?: RawDocTypeResponse[] }>(
605
+ "/api/method/frappe.desk.form.load.getdoctype",
606
+ { doctype },
607
+ );
608
+
609
+ if (cancelled) return;
610
+
611
+ // docs[0] is the requested doctype; docs[1..] are its child tables.
612
+ const docs = response.payload?.docs;
613
+ raw = docs?.find((doc) => doc?.name === doctype) ?? docs?.[0];
614
+ }
615
+
616
+ if (!raw) {
617
+ // Sites that expose the REST API without the desk leave the RPC
618
+ // above unregistered, so it answers with no `docs`. The document
619
+ // route still serves meta there, and such a site's API clients are
620
+ // typically System Managers, who may read it.
621
+ const rest = await http.get<{ data?: RawDocTypeResponse }>(
622
+ `/api/resource/DocType/${encodeURIComponent(doctype)}`,
623
+ );
624
+
625
+ if (cancelled) return;
626
+
627
+ raw = rest.payload?.data;
628
+ }
629
+
586
630
  if (!raw) {
587
631
  throw new Error(`No data returned for DocType "${doctype}"`);
588
632
  }
@@ -605,8 +649,8 @@ export function useDocTypeMeta(
605
649
  // Depend on the stable string `cacheKey` (baseURL + doctype), NOT the
606
650
  // `config` object — callers frequently pass a freshly-allocated config
607
651
  // object every render. Keying on the object identity would re-run this
608
- // effect on every render and, when the request fails (e.g. a Guest hitting
609
- // the permission-gated DocType meta endpoint → 403, which never populates
652
+ // effect on every render and, when the request fails (e.g. a Guest calling
653
+ // the login-gated getdoctype RPC → 403, which never populates
610
654
  // `metaCache`), spin into an infinite refetch loop.
611
655
  }, [cacheKey, version]);
612
656
 
@@ -1,6 +1,6 @@
1
1
  import { isWindowDefined } from "@multiplatform.one/platform";
2
2
  import type { Collection } from "@tanstack/db";
3
- import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import {
5
5
  createFrappeCollection,
6
6
  releaseFrappeCollectionSync,
@@ -12,6 +12,8 @@ import { DbModule } from "./db";
12
12
  import { FixtureDbModule } from "./fixtureDb";
13
13
  import { RealtimeModule } from "./realtime";
14
14
  import { SyncModule } from "./sync";
15
+ import { Status } from "./sync/types";
16
+ import type { DebugMutationEntry } from "./sync/types";
15
17
  import { isBrowser } from "./util";
16
18
 
17
19
  /**
@@ -26,6 +28,20 @@ export interface UseFrappeCollectionConfig {
26
28
  * behind a reverse proxy so the base URL port is used.
27
29
  */
28
30
  socketPort?: number;
31
+ /**
32
+ * Dial the realtime socket at this origin instead of deriving it from
33
+ * `baseURL` + `socketPort` — for dev servers that proxy `/socket.io` on
34
+ * their own origin (same-origin socket). See
35
+ * `RealtimeConfig.socketBaseURL`.
36
+ */
37
+ socketBaseURL?: string;
38
+ /**
39
+ * Frappe site name — the Socket.IO namespace the realtime connection must
40
+ * join. Required whenever the `baseURL` hostname is not the bench site
41
+ * name (e.g. baseURL http://127.0.0.1:8000 serving site "localhost"). See
42
+ * `RealtimeConfig.siteName`.
43
+ */
44
+ siteName?: string;
29
45
  /** Authentication configuration */
30
46
  auth?: {
31
47
  useToken?: boolean;
@@ -61,6 +77,20 @@ export function __getActiveSyncModule(): SyncModule | undefined {
61
77
  return undefined;
62
78
  }
63
79
 
80
+ /**
81
+ * Public accessor for the shared SyncModule behind a connection config —
82
+ * the seam consumers use to issue engine mutations that a collection can't
83
+ * express (e.g. custom-transport writes via `mutation.execute`). Returns
84
+ * the same refcounted instance the collection hooks use, or undefined
85
+ * during SSR.
86
+ */
87
+ export function getFrappeSyncModule(
88
+ config: UseFrappeCollectionConfig | undefined,
89
+ ): SyncModule | undefined {
90
+ if (!config) return undefined;
91
+ return getSyncModule(config);
92
+ }
93
+
64
94
  /**
65
95
  * Gets or creates a shared SyncModule for the given config.
66
96
  * SyncModules are cached by baseURL to prevent duplicate connections.
@@ -90,7 +120,18 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
90
120
  return sync;
91
121
  }
92
122
 
93
- const key = config.baseURL;
123
+ // Key on the full socket target, not just baseURL: socketPort /
124
+ // socketBaseURL / siteName all change where (or how) the realtime socket
125
+ // dials, so configs that differ in them must not share a SyncModule —
126
+ // sharing would silently keep the FIRST config's socket target for every
127
+ // later consumer. Consumers reading the same FrappeProvider still coalesce
128
+ // (identical fields → identical key).
129
+ const key = [
130
+ config.baseURL,
131
+ config.socketPort ?? "",
132
+ config.socketBaseURL ?? "",
133
+ config.siteName ?? "",
134
+ ].join("|");
94
135
  let sync = syncModuleCache.get(key);
95
136
  if (!sync) {
96
137
  // The FIRST config for a baseURL wins for every later consumer (the
@@ -114,6 +155,12 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
114
155
  const realtimeConfig = {
115
156
  baseURL: config.baseURL,
116
157
  socketPort: config.socketPort,
158
+ socketBaseURL: config.socketBaseURL,
159
+ // Forward siteName: Frappe's realtime server only emits events on the
160
+ // site's namespace, so dropping it here silently connected sockets to
161
+ // a namespace the server rejects whenever the baseURL hostname isn't
162
+ // the site name (e.g. 127.0.0.1 vs "localhost").
163
+ siteName: config.siteName,
117
164
  auth: authConfig,
118
165
  socketOptions: config.socketOptions,
119
166
  };
@@ -601,6 +648,81 @@ export function useFrappeDoctypeEvents(
601
648
  }, [enabled, config?.baseURL, !!config?.fixtures, doctype]);
602
649
  }
603
650
 
651
+ // ---------------------------------------------------------------------------
652
+ // Mutation ledger hook
653
+ // ---------------------------------------------------------------------------
654
+
655
+ /** Result of {@link useFrappePendingMutations}. */
656
+ export interface UseFrappePendingMutationsResult {
657
+ /** All live ledger entries (pending + failed), submission order. */
658
+ entries: DebugMutationEntry[];
659
+ /** Entries still awaiting a server ack. */
660
+ pending: DebugMutationEntry[];
661
+ /** Entries the server rejected — rollback already ran; show these. */
662
+ failed: DebugMutationEntry[];
663
+ /** Re-issues every pending/failed entry with its ORIGINAL mutation id
664
+ * (idempotent on the server via Idempotency-Key). */
665
+ retry: () => Promise<void>;
666
+ /** Drops one failed entry without retrying (clears the error surface). */
667
+ dismiss: (id: string) => void;
668
+ }
669
+
670
+ const EMPTY_LEDGER: DebugMutationEntry[] = [];
671
+
672
+ /**
673
+ * React hook exposing the mutation ledger for a connection (optionally
674
+ * scoped to one doctype): which optimistic writes are still in flight and
675
+ * which the server rejected. This is the state behind the rejected-write
676
+ * UI contract — a surface that initiated a write renders `failed` entries
677
+ * inline (e.g. via frappe-ui's `SyncErrorNotice`) with `retry`/`dismiss`.
678
+ *
679
+ * Reactive: re-reads on every store emit, but only re-renders when the
680
+ * ledger signature (ids × status × retryCount) actually changes.
681
+ */
682
+ export function useFrappePendingMutations(
683
+ config: UseFrappeCollectionConfig | undefined,
684
+ doctype?: string,
685
+ ): UseFrappePendingMutationsResult {
686
+ const [entries, setEntries] = useState<DebugMutationEntry[]>(EMPTY_LEDGER);
687
+ const syncModuleRef = useRef<SyncModule | undefined>(undefined);
688
+
689
+ useEffect(() => {
690
+ if (!config || !isBrowser()) {
691
+ setEntries(EMPTY_LEDGER);
692
+ return;
693
+ }
694
+ const syncModule = getSyncModule(config);
695
+ if (!syncModule) return;
696
+ syncModuleRef.current = syncModule;
697
+
698
+ let lastSignature = "";
699
+ const read = () => {
700
+ const next = syncModule.getMutationEntries(doctype);
701
+ const signature = next.map((e) => `${e.id}:${e.status}:${e.retryCount}`).join("|");
702
+ if (signature === lastSignature) return;
703
+ lastSignature = signature;
704
+ setEntries(next.length === 0 ? EMPTY_LEDGER : next);
705
+ };
706
+ read();
707
+ return syncModule.subscribeStore(read);
708
+ // eslint-disable-next-line react-hooks/exhaustive-deps
709
+ }, [config?.baseURL, !!config?.fixtures, doctype]);
710
+
711
+ const retry = useCallback(async () => {
712
+ await syncModuleRef.current?.replayPending();
713
+ }, []);
714
+
715
+ const dismiss = useCallback((id: string) => {
716
+ syncModuleRef.current?.dismissMutation(id);
717
+ }, []);
718
+
719
+ return useMemo(() => {
720
+ const pending = entries.filter((e) => e.status === Status.Pending);
721
+ const failed = entries.filter((e) => e.status === Status.Failed);
722
+ return { entries, pending, failed, retry, dismiss };
723
+ }, [entries, retry, dismiss]);
724
+ }
725
+
604
726
  // ---------------------------------------------------------------------------
605
727
  // Infinite Scroll Hook
606
728
  // ---------------------------------------------------------------------------
@@ -663,7 +785,7 @@ export interface UseInfiniteScrollResult {
663
785
  *
664
786
  * return (
665
787
  * <div>
666
- * {data.map(pokemon => <PokemonCard key={pokemon.name} {...pokemon} />)}
788
+ * {data.map(pokemon => <PokemonCard key={pokemon.name} pokemon={pokemon} />)}
667
789
  * <div ref={sentinelRef}>
668
790
  * {isLoadingMore ? <Spinner /> : hasMore ? "Scroll for more" : "No more items"}
669
791
  * </div>
@@ -865,7 +987,7 @@ export interface UseFrappeInfiniteListResult<TDoc> {
865
987
  *
866
988
  * return (
867
989
  * <div>
868
- * {items.map(pokemon => <PokemonCard key={pokemon.name} {...pokemon} />)}
990
+ * {items.map(pokemon => <PokemonCard key={pokemon.name} pokemon={pokemon} />)}
869
991
  * <div ref={sentinelRef}>
870
992
  * {hasMore ? <Spinner /> : "No more pokemon"}
871
993
  * </div>
package/src/index.ts CHANGED
@@ -12,7 +12,19 @@ export { AuthModule } from "./auth";
12
12
  export { CallModule } from "./call";
13
13
  export { DbModule } from "./db";
14
14
  export { FileModule } from "./file";
15
- export { RealtimeModule } from "./realtime";
15
+ export { RealtimeModule, RealtimePlatform } from "./realtime";
16
+ export type {
17
+ RealtimeConfig,
18
+ RealtimeTransport,
19
+ RealtimeSSEConfig,
20
+ SSEFrame,
21
+ SSEMappedEvent,
22
+ SSEMessageEventLike,
23
+ SSEEventSourceLike,
24
+ SSEEventSourceConstructor,
25
+ DocRoom,
26
+ ListRoom,
27
+ } from "./realtime";
16
28
  export { SyncModule } from "./sync";
17
29
 
18
30
  // TanStack DB collection integration
@@ -23,20 +35,30 @@ export {
23
35
  fixtureCollectionOptions,
24
36
  } from "./collection";
25
37
  export type { FrappeCollectionOptionsConfig, FixtureCollectionOptionsConfig } from "./collection";
26
- export { createOptimisticAction } from "./optimistic";
38
+ export {
39
+ createOptimisticAction,
40
+ isOptimisticTempId,
41
+ newOptimisticTempId,
42
+ OPTIMISTIC_TEMP_PREFIX,
43
+ runOptimisticRpc,
44
+ } from "./optimistic";
45
+ export type { OptimisticRpcContext, OptimisticRpcOptions } from "./optimistic";
27
46
 
28
47
  // React hooks for Frappe + TanStack DB
29
48
  export {
30
49
  useFrappeCollection,
31
50
  useFrappeConnection,
32
51
  useFrappeDoctypeEvents,
52
+ useFrappePendingMutations,
33
53
  useInfiniteScroll,
34
54
  useFrappeInfiniteList,
55
+ getFrappeSyncModule,
35
56
  __getActiveSyncModule,
36
57
  } from "./frappeReact";
37
58
  export type {
38
59
  FrappeConnectionState,
39
60
  UseFrappeCollectionConfig,
61
+ UseFrappePendingMutationsResult,
40
62
  UseInfiniteScrollConfig,
41
63
  UseInfiniteScrollResult,
42
64
  UseFrappeInfiniteListConfig,
@@ -168,6 +190,9 @@ export type {
168
190
  PageBoundaries,
169
191
  RehydrationStrategy,
170
192
  Mutation,
193
+ CreateMutation,
194
+ UpdateMutation,
195
+ DeleteMutation,
171
196
  ConnectionConfig,
172
197
  TabCoordinationConfig,
173
198
  TabMessage,
@@ -176,7 +201,7 @@ export type {
176
201
  DebugMutationEntry,
177
202
  PageIsland,
178
203
  } from "./sync/types";
179
- export { Order } from "./sync/types";
204
+ export { Order, Operation } from "./sync/types";
180
205
  export { MergeStrategy, FieldMergeStrategy } from "./sync/merge";
181
206
  export type { FieldMergeStrategyOrFunction, MergeConfig, MergeResult } from "./sync/merge";
182
207
 
@@ -1,6 +1,12 @@
1
1
  import { createOptimisticAction as tanstackCreateOptimisticAction } from "@tanstack/db";
2
- import { describe, expect, it } from "vitest";
3
- import { createOptimisticAction } from "./optimistic";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ OPTIMISTIC_TEMP_PREFIX,
5
+ createOptimisticAction,
6
+ isOptimisticTempId,
7
+ newOptimisticTempId,
8
+ runOptimisticRpc,
9
+ } from "./optimistic";
4
10
 
5
11
  describe("createOptimisticAction re-export", () => {
6
12
  it("is the same reference as @tanstack/db createOptimisticAction", () => {
@@ -11,3 +17,108 @@ describe("createOptimisticAction re-export", () => {
11
17
  expect(typeof createOptimisticAction).toBe("function");
12
18
  });
13
19
  });
20
+
21
+ describe("newOptimisticTempId / isOptimisticTempId", () => {
22
+ it("issues unique tmp_-prefixed identities (MutationEngine convention)", () => {
23
+ const a = newOptimisticTempId();
24
+ const b = newOptimisticTempId();
25
+ expect(a.startsWith(OPTIMISTIC_TEMP_PREFIX)).toBe(true);
26
+ expect(b.startsWith(OPTIMISTIC_TEMP_PREFIX)).toBe(true);
27
+ expect(a).not.toBe(b);
28
+ expect(isOptimisticTempId(a)).toBe(true);
29
+ });
30
+
31
+ it("rejects server-issued names and non-strings", () => {
32
+ expect(isOptimisticTempId("TODO-0001")).toBe(false);
33
+ expect(isOptimisticTempId(undefined)).toBe(false);
34
+ expect(isOptimisticTempId(42)).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("runOptimisticRpc lifecycle", () => {
39
+ it("applies locally BEFORE the request resolves (local apply is the first reveal)", async () => {
40
+ const order: string[] = [];
41
+ let releaseSend: (value: string) => void = () => {};
42
+ const sendGate = new Promise<string>((resolve) => {
43
+ releaseSend = resolve;
44
+ });
45
+
46
+ const promise = runOptimisticRpc<string>({
47
+ apply: () => {
48
+ order.push("apply");
49
+ return () => order.push("undo");
50
+ },
51
+ send: async () => {
52
+ order.push("send");
53
+ return sendGate;
54
+ },
55
+ reconcile: () => order.push("reconcile"),
56
+ });
57
+
58
+ // Apply and send already happened synchronously; the ack has not landed.
59
+ expect(order).toEqual(["apply", "send"]);
60
+ releaseSend("ack");
61
+ await expect(promise).resolves.toBe("ack");
62
+ expect(order).toEqual(["apply", "send", "reconcile"]);
63
+ });
64
+
65
+ it("passes the SAME temp id through apply, send, and reconcile (identity convergence)", async () => {
66
+ const seen: string[] = [];
67
+ await runOptimisticRpc({
68
+ apply: (ctx) => {
69
+ seen.push(ctx.tempId);
70
+ },
71
+ send: async (ctx) => {
72
+ seen.push(ctx.tempId);
73
+ return "ok";
74
+ },
75
+ reconcile: (_ack, ctx) => {
76
+ seen.push(ctx.tempId);
77
+ },
78
+ });
79
+ expect(seen).toHaveLength(3);
80
+ expect(new Set(seen).size).toBe(1);
81
+ expect(isOptimisticTempId(seen[0])).toBe(true);
82
+ });
83
+
84
+ it("rolls back (undo) then surfaces the error visibly (onError) on failure, and rethrows", async () => {
85
+ const order: string[] = [];
86
+ const failure = new Error("server said no");
87
+ const onError = vi.fn((error: unknown) => {
88
+ order.push("onError");
89
+ expect(error).toBe(failure);
90
+ });
91
+
92
+ await expect(
93
+ runOptimisticRpc({
94
+ apply: () => {
95
+ order.push("apply");
96
+ return () => order.push("undo");
97
+ },
98
+ send: async () => {
99
+ throw failure;
100
+ },
101
+ reconcile: () => order.push("reconcile"),
102
+ onError,
103
+ }),
104
+ ).rejects.toBe(failure);
105
+
106
+ // Rollback precedes the visible error; reconcile never runs.
107
+ expect(order).toEqual(["apply", "undo", "onError"]);
108
+ expect(onError).toHaveBeenCalledTimes(1);
109
+ });
110
+
111
+ it("still surfaces onError when apply returned no undo", async () => {
112
+ const onError = vi.fn();
113
+ await expect(
114
+ runOptimisticRpc({
115
+ apply: () => {},
116
+ send: async () => {
117
+ throw new Error("boom");
118
+ },
119
+ onError,
120
+ }),
121
+ ).rejects.toThrow("boom");
122
+ expect(onError).toHaveBeenCalledTimes(1);
123
+ });
124
+ });