@multiplatform.one/frappe 6.7.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 (153) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/core/http.cjs +45 -1
  3. package/dist/cjs/core/http.native.js +72 -1
  4. package/dist/cjs/core/http.native.js.map +1 -1
  5. package/dist/cjs/doctype.cjs +15 -2
  6. package/dist/cjs/doctype.native.js +22 -5
  7. package/dist/cjs/doctype.native.js.map +1 -1
  8. package/dist/cjs/frappeReact.cjs +55 -1
  9. package/dist/cjs/frappeReact.native.js +66 -1
  10. package/dist/cjs/frappeReact.native.js.map +1 -1
  11. package/dist/cjs/index.cjs +7 -0
  12. package/dist/cjs/index.native.js +7 -0
  13. package/dist/cjs/index.native.js.map +1 -1
  14. package/dist/cjs/optimistic.cjs +32 -2
  15. package/dist/cjs/optimistic.native.js +34 -1
  16. package/dist/cjs/optimistic.native.js.map +1 -1
  17. package/dist/cjs/pacer.native.js.map +1 -1
  18. package/dist/cjs/realtime.cjs +12 -0
  19. package/dist/cjs/realtime.native.js +16 -4
  20. package/dist/cjs/realtime.native.js.map +1 -1
  21. package/dist/cjs/sync/index.cjs +45 -1
  22. package/dist/cjs/sync/index.native.js +71 -1
  23. package/dist/cjs/sync/index.native.js.map +1 -1
  24. package/dist/cjs/sync/mutationEngine.cjs +51 -15
  25. package/dist/cjs/sync/mutationEngine.native.js +69 -16
  26. package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
  27. package/dist/cjs/sync/mutationJournal.cjs +8 -0
  28. package/dist/cjs/sync/mutationJournal.native.js +10 -0
  29. package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
  30. package/dist/cjs/sync/types.native.js.map +1 -1
  31. package/dist/esm/core/http.mjs +45 -2
  32. package/dist/esm/core/http.mjs.map +1 -1
  33. package/dist/esm/core/http.native.js +72 -2
  34. package/dist/esm/core/http.native.js.map +1 -1
  35. package/dist/esm/doctype.mjs +15 -2
  36. package/dist/esm/doctype.mjs.map +1 -1
  37. package/dist/esm/doctype.native.js +22 -5
  38. package/dist/esm/doctype.native.js.map +1 -1
  39. package/dist/esm/frappeReact.mjs +55 -3
  40. package/dist/esm/frappeReact.mjs.map +1 -1
  41. package/dist/esm/frappeReact.native.js +66 -3
  42. package/dist/esm/frappeReact.native.js.map +1 -1
  43. package/dist/esm/index.mjs +4 -4
  44. package/dist/esm/index.mjs.map +1 -1
  45. package/dist/esm/index.native.js +4 -4
  46. package/dist/esm/index.native.js.map +1 -1
  47. package/dist/esm/optimistic.mjs +27 -1
  48. package/dist/esm/optimistic.mjs.map +1 -1
  49. package/dist/esm/optimistic.native.js +30 -1
  50. package/dist/esm/optimistic.native.js.map +1 -1
  51. package/dist/esm/pacer.mjs.map +1 -1
  52. package/dist/esm/pacer.native.js.map +1 -1
  53. package/dist/esm/realtime.mjs +12 -0
  54. package/dist/esm/realtime.mjs.map +1 -1
  55. package/dist/esm/realtime.native.js +16 -4
  56. package/dist/esm/realtime.native.js.map +1 -1
  57. package/dist/esm/sync/index.mjs +45 -1
  58. package/dist/esm/sync/index.mjs.map +1 -1
  59. package/dist/esm/sync/index.native.js +71 -1
  60. package/dist/esm/sync/index.native.js.map +1 -1
  61. package/dist/esm/sync/mutationEngine.mjs +51 -15
  62. package/dist/esm/sync/mutationEngine.mjs.map +1 -1
  63. package/dist/esm/sync/mutationEngine.native.js +69 -16
  64. package/dist/esm/sync/mutationEngine.native.js.map +1 -1
  65. package/dist/esm/sync/mutationJournal.mjs +8 -0
  66. package/dist/esm/sync/mutationJournal.mjs.map +1 -1
  67. package/dist/esm/sync/mutationJournal.native.js +10 -0
  68. package/dist/esm/sync/mutationJournal.native.js.map +1 -1
  69. package/dist/esm/sync/types.mjs.map +1 -1
  70. package/dist/esm/sync/types.native.js.map +1 -1
  71. package/dist/jsx/core/http.mjs +45 -2
  72. package/dist/jsx/core/http.mjs.map +1 -1
  73. package/dist/jsx/core/http.native.js +72 -1
  74. package/dist/jsx/core/http.native.js.map +1 -1
  75. package/dist/jsx/doctype.mjs +15 -2
  76. package/dist/jsx/doctype.mjs.map +1 -1
  77. package/dist/jsx/doctype.native.js +22 -5
  78. package/dist/jsx/doctype.native.js.map +1 -1
  79. package/dist/jsx/frappeReact.mjs +55 -3
  80. package/dist/jsx/frappeReact.mjs.map +1 -1
  81. package/dist/jsx/frappeReact.native.js +66 -1
  82. package/dist/jsx/frappeReact.native.js.map +1 -1
  83. package/dist/jsx/index.js +4 -4
  84. package/dist/jsx/index.js.map +1 -1
  85. package/dist/jsx/index.mjs +4 -4
  86. package/dist/jsx/index.mjs.map +1 -1
  87. package/dist/jsx/index.native.js +7 -0
  88. package/dist/jsx/index.native.js.map +1 -1
  89. package/dist/jsx/optimistic.mjs +27 -1
  90. package/dist/jsx/optimistic.mjs.map +1 -1
  91. package/dist/jsx/optimistic.native.js +34 -1
  92. package/dist/jsx/optimistic.native.js.map +1 -1
  93. package/dist/jsx/pacer.mjs.map +1 -1
  94. package/dist/jsx/pacer.native.js.map +1 -1
  95. package/dist/jsx/realtime.mjs +12 -0
  96. package/dist/jsx/realtime.mjs.map +1 -1
  97. package/dist/jsx/realtime.native.js +16 -4
  98. package/dist/jsx/realtime.native.js.map +1 -1
  99. package/dist/jsx/sync/index.mjs +45 -1
  100. package/dist/jsx/sync/index.mjs.map +1 -1
  101. package/dist/jsx/sync/index.native.js +71 -1
  102. package/dist/jsx/sync/index.native.js.map +1 -1
  103. package/dist/jsx/sync/mutationEngine.mjs +51 -15
  104. package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
  105. package/dist/jsx/sync/mutationEngine.native.js +69 -16
  106. package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
  107. package/dist/jsx/sync/mutationJournal.mjs +8 -0
  108. package/dist/jsx/sync/mutationJournal.mjs.map +1 -1
  109. package/dist/jsx/sync/mutationJournal.native.js +10 -0
  110. package/dist/jsx/sync/mutationJournal.native.js.map +1 -1
  111. package/dist/jsx/sync/types.mjs.map +1 -1
  112. package/dist/jsx/sync/types.native.js.map +1 -1
  113. package/package.json +7 -7
  114. package/src/core/http.spec.ts +81 -0
  115. package/src/core/http.ts +80 -3
  116. package/src/doctype.spec.ts +82 -3
  117. package/src/doctype.ts +51 -7
  118. package/src/frappeReact.ts +126 -4
  119. package/src/index.ts +15 -2
  120. package/src/optimistic.spec.ts +113 -2
  121. package/src/optimistic.ts +95 -0
  122. package/src/pacer.ts +8 -0
  123. package/src/realtime.spec.ts +62 -0
  124. package/src/realtime.ts +34 -0
  125. package/src/sync/index.ts +44 -1
  126. package/src/sync/mutationEngine.spec.ts +7 -2
  127. package/src/sync/mutationEngine.ts +83 -27
  128. package/src/sync/mutationJournal.ts +9 -0
  129. package/src/sync/mutations.spec.ts +1 -1
  130. package/src/sync/syncModule.spec.ts +255 -1
  131. package/src/sync/types.ts +20 -0
  132. package/types/core/http.d.ts +11 -0
  133. package/types/core/http.d.ts.map +1 -1
  134. package/types/doctype.d.ts +7 -0
  135. package/types/doctype.d.ts.map +1 -1
  136. package/types/frappeReact.d.ts +50 -2
  137. package/types/frappeReact.d.ts.map +1 -1
  138. package/types/index.d.ts +6 -5
  139. package/types/index.d.ts.map +1 -1
  140. package/types/optimistic.d.ts +38 -0
  141. package/types/optimistic.d.ts.map +1 -1
  142. package/types/pacer.d.ts +8 -0
  143. package/types/pacer.d.ts.map +1 -1
  144. package/types/realtime.d.ts +17 -0
  145. package/types/realtime.d.ts.map +1 -1
  146. package/types/sync/index.d.ts +16 -1
  147. package/types/sync/index.d.ts.map +1 -1
  148. package/types/sync/mutationEngine.d.ts +7 -0
  149. package/types/sync/mutationEngine.d.ts.map +1 -1
  150. package/types/sync/mutationJournal.d.ts +6 -0
  151. package/types/sync/mutationJournal.d.ts.map +1 -1
  152. package/types/sync/types.d.ts +20 -0
  153. package/types/sync/types.d.ts.map +1 -1
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
@@ -35,20 +35,30 @@ export {
35
35
  fixtureCollectionOptions,
36
36
  } from "./collection";
37
37
  export type { FrappeCollectionOptionsConfig, FixtureCollectionOptionsConfig } from "./collection";
38
- 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";
39
46
 
40
47
  // React hooks for Frappe + TanStack DB
41
48
  export {
42
49
  useFrappeCollection,
43
50
  useFrappeConnection,
44
51
  useFrappeDoctypeEvents,
52
+ useFrappePendingMutations,
45
53
  useInfiniteScroll,
46
54
  useFrappeInfiniteList,
55
+ getFrappeSyncModule,
47
56
  __getActiveSyncModule,
48
57
  } from "./frappeReact";
49
58
  export type {
50
59
  FrappeConnectionState,
51
60
  UseFrappeCollectionConfig,
61
+ UseFrappePendingMutationsResult,
52
62
  UseInfiniteScrollConfig,
53
63
  UseInfiniteScrollResult,
54
64
  UseFrappeInfiniteListConfig,
@@ -180,6 +190,9 @@ export type {
180
190
  PageBoundaries,
181
191
  RehydrationStrategy,
182
192
  Mutation,
193
+ CreateMutation,
194
+ UpdateMutation,
195
+ DeleteMutation,
183
196
  ConnectionConfig,
184
197
  TabCoordinationConfig,
185
198
  TabMessage,
@@ -188,7 +201,7 @@ export type {
188
201
  DebugMutationEntry,
189
202
  PageIsland,
190
203
  } from "./sync/types";
191
- export { Order } from "./sync/types";
204
+ export { Order, Operation } from "./sync/types";
192
205
  export { MergeStrategy, FieldMergeStrategy } from "./sync/merge";
193
206
  export type { FieldMergeStrategyOrFunction, MergeConfig, MergeResult } from "./sync/merge";
194
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
+ });
package/src/optimistic.ts CHANGED
@@ -37,3 +37,98 @@ export { tanstackCreateOptimisticAction as createOptimisticAction };
37
37
  * await tx.isPersisted.promise;
38
38
  * ```
39
39
  */
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Optimistic RPC lifecycle — the ONE pattern for mutations that ride
43
+ // whitelisted desk RPCs instead of the document CRUD path.
44
+ // ---------------------------------------------------------------------------
45
+ //
46
+ // Document CRUD (collection insert/update/delete) is already optimistic via
47
+ // the MutationEngine: local cache apply → journal Pending → server ack →
48
+ // temp-id → real-name remap, snapshot rollback on failure. Desk RPC
49
+ // mutations (add_comment, assign_to, tags, likes, shares…) bypass that
50
+ // engine, and every callsite used to hand-roll "await, then refetch" — the
51
+ // user saw nothing until the server round-trip completed.
52
+ //
53
+ // `runOptimisticRpc` gives those callsites the same lifecycle the engine
54
+ // gives documents, without per-callsite hacks:
55
+ //
56
+ // 1. APPLY — synchronous local apply BEFORE the request leaves;
57
+ // returns the undo used for rollback. The applied state
58
+ // should carry a pending marker so surfaces can render it
59
+ // visible-but-distinct (Axiom 6 HONEST STATE).
60
+ // 2. SEND — the RPC itself.
61
+ // 3. RECONCILE — on ack, converge the optimistic identity (the issued
62
+ // `tmp_*` id) to the server identity so the optimistic
63
+ // record and the server record become ONE record, never
64
+ // duplicates.
65
+ // 4. ROLLBACK — on failure, the apply-phase undo runs, then `onError`
66
+ // surfaces a visible error (house `notify()`), and the
67
+ // error rethrows for the caller.
68
+
69
+ /** Prefix shared with the MutationEngine's synthesized create identities. */
70
+ export const OPTIMISTIC_TEMP_PREFIX = "tmp_";
71
+
72
+ /** Issues a client-only identity for an optimistic record (`tmp_<uuid>`). */
73
+ export function newOptimisticTempId(): string {
74
+ const id =
75
+ typeof crypto !== "undefined" && "randomUUID" in crypto
76
+ ? crypto.randomUUID()
77
+ : Math.random().toString(36).slice(2);
78
+ return `${OPTIMISTIC_TEMP_PREFIX}${id}`;
79
+ }
80
+
81
+ /** True when a document name is a client-issued optimistic identity. */
82
+ export function isOptimisticTempId(name: unknown): name is string {
83
+ return typeof name === "string" && name.startsWith(OPTIMISTIC_TEMP_PREFIX);
84
+ }
85
+
86
+ export interface OptimisticRpcContext {
87
+ /** Client-only identity for the optimistic record (`tmp_<uuid>`). */
88
+ tempId: string;
89
+ }
90
+
91
+ export interface OptimisticRpcOptions<TAck> {
92
+ /**
93
+ * Apply the mutation locally, synchronously, before the request is sent.
94
+ * Returns the undo that restores the exact pre-apply state (rollback).
95
+ * The applied record should use `ctx.tempId` as its identity and carry a
96
+ * pending marker for the UI.
97
+ */
98
+ apply: (ctx: OptimisticRpcContext) => (() => void) | undefined | void;
99
+ /** The server RPC. */
100
+ send: (ctx: OptimisticRpcContext) => Promise<TAck>;
101
+ /**
102
+ * Converge on ack: replace the `ctx.tempId` record with the server's
103
+ * authoritative record (dedupe by server identity — a racing refetch may
104
+ * already have delivered it).
105
+ */
106
+ reconcile?: (ack: TAck, ctx: OptimisticRpcContext) => void;
107
+ /**
108
+ * Visible-error surface for the rollback path (house `notify()` or an
109
+ * inline error slot). Runs after the apply-phase undo.
110
+ */
111
+ onError?: (error: unknown, ctx: OptimisticRpcContext) => void;
112
+ }
113
+
114
+ /**
115
+ * Runs one mutation through the optimistic lifecycle:
116
+ * apply (local, immediate) → send → reconcile on ack, or undo + onError on
117
+ * failure (the error rethrows).
118
+ */
119
+ export async function runOptimisticRpc<TAck>(options: OptimisticRpcOptions<TAck>): Promise<TAck> {
120
+ const ctx: OptimisticRpcContext = { tempId: newOptimisticTempId() };
121
+ const undo = options.apply(ctx) ?? undefined;
122
+ try {
123
+ const ack = await options.send(ctx);
124
+ options.reconcile?.(ack, ctx);
125
+ return ack;
126
+ } catch (error) {
127
+ try {
128
+ undo?.();
129
+ } finally {
130
+ options.onError?.(error, ctx);
131
+ }
132
+ throw error;
133
+ }
134
+ }
package/src/pacer.ts CHANGED
@@ -6,6 +6,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
6
6
  import type { FrappeDoc, FrappeDocData } from "./common/types";
7
7
  import type { AuthConfig } from "./core/types";
8
8
  import { DbModule } from "./db";
9
+ import type { FixtureProvider } from "./fixtures";
9
10
  import { HttpClient } from "./core/http";
10
11
  import type { SyncModule } from "./sync";
11
12
  import { Operation } from "./sync/types";
@@ -62,6 +63,13 @@ export interface FrappeConnectionConfig {
62
63
  token?: string | (() => string | Promise<string>);
63
64
  type?: string;
64
65
  };
66
+ /**
67
+ * Fixture provider for story / test hosts. Consumers already forward it
68
+ * (frappe-ui's FrappeProvider builds this config from its own context), and
69
+ * `useDocTypeMeta` reads doctype metadata from it before reaching for the
70
+ * network.
71
+ */
72
+ fixtures?: FixtureProvider;
65
73
  }
66
74
 
67
75
  function createDbModule(config: FrappeConnectionConfig): DbModule {
@@ -783,6 +783,68 @@ describe("RealtimeModule", () => {
783
783
  rt.disconnect();
784
784
  });
785
785
 
786
+ it("uses an explicit siteName as the Socket.IO namespace (127.0.0.1 → localhost)", async () => {
787
+ // Frappe socketio maps Host localhost/127.0.0.1 to conf.default_site
788
+ // and rejects any other namespace. Connecting via the IPv4 literal
789
+ // (the serve_dev.py workaround for shcd on [::1]:8000) must still
790
+ // join /localhost, not /127.0.0.1.
791
+ const rt = new RealtimeModule({
792
+ baseURL: "http://127.0.0.1:8000",
793
+ socketPort: 9000,
794
+ siteName: "localhost",
795
+ });
796
+ await (rt as any).connect();
797
+
798
+ const calls = (io as unknown as ReturnType<typeof vi.fn>).mock.calls;
799
+ expect(calls[calls.length - 1]?.[0]).toBe("http://127.0.0.1:9000/localhost");
800
+ rt.disconnect();
801
+ });
802
+
803
+ it("dials socketBaseURL when set, joining the siteName namespace (same-origin dev)", async () => {
804
+ // Web dev: the dev server proxies /socket.io on the PAGE origin (e.g.
805
+ // :3998) to the bench socketio — the client must dial that origin, not
806
+ // baseURL+socketPort, so the polling handshake is same-origin.
807
+ const rt = new RealtimeModule({
808
+ baseURL: "http://127.0.0.1:8000",
809
+ socketBaseURL: "http://127.0.0.1:3998",
810
+ siteName: "localhost",
811
+ });
812
+ await (rt as any).connect();
813
+
814
+ const calls = (io as unknown as ReturnType<typeof vi.fn>).mock.calls;
815
+ expect(calls[calls.length - 1]?.[0]).toBe("http://127.0.0.1:3998/localhost");
816
+ rt.disconnect();
817
+ });
818
+
819
+ it("socketBaseURL wins over socketPort and trims a trailing slash", async () => {
820
+ const rt = new RealtimeModule({
821
+ baseURL: "http://127.0.0.1:8000",
822
+ socketPort: 9000,
823
+ socketBaseURL: "http://localhost:3998/",
824
+ siteName: "localhost",
825
+ });
826
+ await (rt as any).connect();
827
+
828
+ const calls = (io as unknown as ReturnType<typeof vi.fn>).mock.calls;
829
+ expect(calls[calls.length - 1]?.[0]).toBe("http://localhost:3998/localhost");
830
+ rt.disconnect();
831
+ });
832
+
833
+ it("falls back to the baseURL hostname namespace under socketBaseURL without siteName", async () => {
834
+ // Same fallback as the derived path: the namespace comes from the
835
+ // BENCH URL's hostname — never from socketBaseURL, whose hostname is
836
+ // the dev server, not a Frappe site.
837
+ const rt = new RealtimeModule({
838
+ baseURL: "http://bench.example.com:8000",
839
+ socketBaseURL: "http://localhost:3998",
840
+ });
841
+ await (rt as any).connect();
842
+
843
+ const calls = (io as unknown as ReturnType<typeof vi.fn>).mock.calls;
844
+ expect(calls[calls.length - 1]?.[0]).toBe("http://localhost:3998/bench.example.com");
845
+ rt.disconnect();
846
+ });
847
+
786
848
  it("uses the token scheme when auth type is token", async () => {
787
849
  const rt = new RealtimeModule({
788
850
  baseURL: "http://localhost:3000",
package/src/realtime.ts CHANGED
@@ -120,6 +120,23 @@ export interface RealtimeConfig {
120
120
  * `/socket.io` to the SocketIO backend.
121
121
  */
122
122
  socketPort?: number;
123
+ /**
124
+ * Dial the Socket.IO connection at this origin instead of deriving host and
125
+ * port from `baseURL` + `socketPort`.
126
+ *
127
+ * Built for dev servers that proxy `/socket.io` on their own origin (e.g.
128
+ * apps/one's `frappeSocketioDevProxy`): pointing the socket at the PAGE
129
+ * origin makes the handshake same-origin, so the browser stops blocking the
130
+ * cross-origin polling response (the reflected allow-origin is the proxy's
131
+ * injected bench origin, not the page's). Only the connection target
132
+ * changes — the namespace still comes from `siteName` (falling back to the
133
+ * `baseURL` hostname), because Frappe resolves the site server-side from
134
+ * the proxy-injected headers, not from where the client dialed.
135
+ *
136
+ * Wins over `socketPort` when both are set. Omit in production, where the
137
+ * reverse proxy already serves `/socket.io` on the `baseURL` origin.
138
+ */
139
+ socketBaseURL?: string;
123
140
  /**
124
141
  * Frappe site name, used as the Socket.IO namespace.
125
142
  *
@@ -223,6 +240,23 @@ export class RealtimeModule {
223
240
 
224
241
  private getSocketURL(): string {
225
242
  const base = this.config.baseURL.replace(/\/$/, "");
243
+ // Same-origin override: dial socketBaseURL directly (e.g. the page
244
+ // origin, whose dev server proxies /socket.io to the bench's socketio).
245
+ // The namespace is still siteName — falling back to the baseURL hostname
246
+ // exactly like the derived path below, NOT the socketBaseURL hostname,
247
+ // which would be the dev-server host and never a Frappe site.
248
+ if (this.config.socketBaseURL) {
249
+ const socketBase = this.config.socketBaseURL.replace(/\/$/, "");
250
+ let siteName = this.config.siteName;
251
+ if (!siteName) {
252
+ try {
253
+ siteName = new URL(base).hostname;
254
+ } catch {
255
+ siteName = base.replace(/^https?:\/\//, "").split(/[/:]/)[0];
256
+ }
257
+ }
258
+ return `${socketBase}/${siteName}`;
259
+ }
226
260
  try {
227
261
  const url = new URL(base);
228
262
  // Assemble origin from parts instead of mutating url.port and reading