@multiplatform.one/frappe 6.4.2 → 6.6.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 (194) hide show
  1. package/dist/cjs/auth.native.js.map +1 -1
  2. package/dist/cjs/call.native.js.map +1 -1
  3. package/dist/cjs/collection.cjs +25 -9
  4. package/dist/cjs/collection.native.js +25 -9
  5. package/dist/cjs/collection.native.js.map +1 -1
  6. package/dist/cjs/common/types.native.js.map +1 -1
  7. package/dist/cjs/core/http.native.js.map +1 -1
  8. package/dist/cjs/core/types.native.js.map +1 -1
  9. package/dist/cjs/core/wire.native.js.map +1 -1
  10. package/dist/cjs/db.cjs +13 -2
  11. package/dist/cjs/db.native.js +18 -2
  12. package/dist/cjs/db.native.js.map +1 -1
  13. package/dist/cjs/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  14. package/dist/cjs/devtools/index.native.js.map +1 -1
  15. package/dist/cjs/devtools/plugin.native.js.map +1 -1
  16. package/dist/cjs/devtools/theme.native.js.map +1 -1
  17. package/dist/cjs/doctype.native.js.map +1 -1
  18. package/dist/cjs/file.native.js.map +1 -1
  19. package/dist/cjs/fixtureDb.native.js.map +1 -1
  20. package/dist/cjs/fixtures.native.js.map +1 -1
  21. package/dist/cjs/frappeApp.native.js.map +1 -1
  22. package/dist/cjs/frappeReact.cjs +10 -3
  23. package/dist/cjs/frappeReact.native.js +11 -4
  24. package/dist/cjs/frappeReact.native.js.map +1 -1
  25. package/dist/cjs/index.native.js +2 -2
  26. package/dist/cjs/index.native.js.map +1 -1
  27. package/dist/cjs/optimistic.native.js.map +1 -1
  28. package/dist/cjs/pacer.native.js.map +1 -1
  29. package/dist/cjs/plugin/index.native.js.map +1 -1
  30. package/dist/cjs/realtime.native.js.map +1 -1
  31. package/dist/cjs/sync/backfill.native.js.map +1 -1
  32. package/dist/cjs/sync/filters.native.js.map +1 -1
  33. package/dist/cjs/sync/index.cjs +6 -1
  34. package/dist/cjs/sync/index.native.js +6 -1
  35. package/dist/cjs/sync/index.native.js.map +1 -1
  36. package/dist/cjs/sync/merge.native.js.map +1 -1
  37. package/dist/cjs/sync/mutationEngine.cjs +43 -19
  38. package/dist/cjs/sync/mutationEngine.native.js +51 -21
  39. package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
  40. package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
  41. package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
  42. package/dist/cjs/sync/persistence.native.js.map +1 -1
  43. package/dist/cjs/sync/realtime.native.js.map +1 -1
  44. package/dist/cjs/sync/rehydration.native.js.map +1 -1
  45. package/dist/cjs/sync/storage.native.js.map +1 -1
  46. package/dist/cjs/sync/subscriptionManager.cjs +4 -0
  47. package/dist/cjs/sync/subscriptionManager.native.js +4 -0
  48. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  49. package/dist/cjs/sync/types.native.js.map +1 -1
  50. package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
  51. package/dist/cjs/useLiveQuery.native.js.map +1 -1
  52. package/dist/cjs/util.native.js.map +1 -1
  53. package/dist/esm/auth.native.js.map +1 -1
  54. package/dist/esm/call.native.js.map +1 -1
  55. package/dist/esm/collection.mjs +24 -9
  56. package/dist/esm/collection.mjs.map +1 -1
  57. package/dist/esm/collection.native.js +25 -10
  58. package/dist/esm/collection.native.js.map +1 -1
  59. package/dist/esm/common/types.native.js.map +1 -1
  60. package/dist/esm/core/http.mjs.map +1 -1
  61. package/dist/esm/core/http.native.js +2 -2
  62. package/dist/esm/core/http.native.js.map +1 -1
  63. package/dist/esm/core/types.mjs.map +1 -1
  64. package/dist/esm/core/types.native.js.map +1 -1
  65. package/dist/esm/core/wire.native.js.map +1 -1
  66. package/dist/esm/db.mjs +13 -2
  67. package/dist/esm/db.mjs.map +1 -1
  68. package/dist/esm/db.native.js +19 -3
  69. package/dist/esm/db.native.js.map +1 -1
  70. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js +2 -2
  71. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  72. package/dist/esm/devtools/index.native.js +2 -2
  73. package/dist/esm/devtools/index.native.js.map +1 -1
  74. package/dist/esm/devtools/plugin.native.js +1 -1
  75. package/dist/esm/devtools/plugin.native.js.map +1 -1
  76. package/dist/esm/devtools/theme.native.js.map +1 -1
  77. package/dist/esm/doctype.native.js +1 -1
  78. package/dist/esm/doctype.native.js.map +1 -1
  79. package/dist/esm/file.native.js.map +1 -1
  80. package/dist/esm/fixtureDb.native.js +1 -1
  81. package/dist/esm/fixtureDb.native.js.map +1 -1
  82. package/dist/esm/fixtures.native.js.map +1 -1
  83. package/dist/esm/frappeApp.native.js +7 -7
  84. package/dist/esm/frappeApp.native.js.map +1 -1
  85. package/dist/esm/frappeReact.mjs +11 -4
  86. package/dist/esm/frappeReact.mjs.map +1 -1
  87. package/dist/esm/frappeReact.native.js +17 -10
  88. package/dist/esm/frappeReact.native.js.map +1 -1
  89. package/dist/esm/index.native.js +21 -21
  90. package/dist/esm/index.native.js.map +1 -1
  91. package/dist/esm/optimistic.native.js.map +1 -1
  92. package/dist/esm/pacer.native.js +4 -4
  93. package/dist/esm/pacer.native.js.map +1 -1
  94. package/dist/esm/plugin/index.mjs.map +1 -1
  95. package/dist/esm/plugin/index.native.js.map +1 -1
  96. package/dist/esm/realtime.mjs.map +1 -1
  97. package/dist/esm/realtime.native.js +1 -1
  98. package/dist/esm/realtime.native.js.map +1 -1
  99. package/dist/esm/sync/backfill.mjs.map +1 -1
  100. package/dist/esm/sync/backfill.native.js +1 -1
  101. package/dist/esm/sync/backfill.native.js.map +1 -1
  102. package/dist/esm/sync/filters.mjs.map +1 -1
  103. package/dist/esm/sync/filters.native.js.map +1 -1
  104. package/dist/esm/sync/index.mjs +6 -1
  105. package/dist/esm/sync/index.mjs.map +1 -1
  106. package/dist/esm/sync/index.native.js +14 -9
  107. package/dist/esm/sync/index.native.js.map +1 -1
  108. package/dist/esm/sync/merge.mjs.map +1 -1
  109. package/dist/esm/sync/merge.native.js.map +1 -1
  110. package/dist/esm/sync/mutationEngine.mjs +43 -19
  111. package/dist/esm/sync/mutationEngine.mjs.map +1 -1
  112. package/dist/esm/sync/mutationEngine.native.js +55 -25
  113. package/dist/esm/sync/mutationEngine.native.js.map +1 -1
  114. package/dist/esm/sync/mutationJournal.native.js +1 -1
  115. package/dist/esm/sync/mutationJournal.native.js.map +1 -1
  116. package/dist/esm/sync/normalizedCache.native.js.map +1 -1
  117. package/dist/esm/sync/persistence.native.js +2 -2
  118. package/dist/esm/sync/persistence.native.js.map +1 -1
  119. package/dist/esm/sync/realtime.native.js +1 -1
  120. package/dist/esm/sync/realtime.native.js.map +1 -1
  121. package/dist/esm/sync/rehydration.native.js +1 -1
  122. package/dist/esm/sync/rehydration.native.js.map +1 -1
  123. package/dist/esm/sync/storage.native.js.map +1 -1
  124. package/dist/esm/sync/subscriptionManager.mjs +4 -0
  125. package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
  126. package/dist/esm/sync/subscriptionManager.native.js +8 -4
  127. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  128. package/dist/esm/sync/types.mjs.map +1 -1
  129. package/dist/esm/sync/types.native.js.map +1 -1
  130. package/dist/esm/sync/virtualPageManager.mjs.map +1 -1
  131. package/dist/esm/sync/virtualPageManager.native.js +1 -1
  132. package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
  133. package/dist/esm/useLiveQuery.native.js.map +1 -1
  134. package/dist/esm/util.native.js.map +1 -1
  135. package/dist/jsx/collection.mjs +24 -9
  136. package/dist/jsx/collection.mjs.map +1 -1
  137. package/dist/jsx/collection.native.js +25 -9
  138. package/dist/jsx/collection.native.js.map +1 -1
  139. package/dist/jsx/core/http.mjs.map +1 -1
  140. package/dist/jsx/core/types.mjs.map +1 -1
  141. package/dist/jsx/db.mjs +13 -2
  142. package/dist/jsx/db.mjs.map +1 -1
  143. package/dist/jsx/db.native.js +18 -2
  144. package/dist/jsx/db.native.js.map +1 -1
  145. package/dist/jsx/frappeReact.mjs +11 -4
  146. package/dist/jsx/frappeReact.mjs.map +1 -1
  147. package/dist/jsx/frappeReact.native.js +10 -3
  148. package/dist/jsx/frappeReact.native.js.map +1 -1
  149. package/dist/jsx/plugin/index.mjs.map +1 -1
  150. package/dist/jsx/realtime.mjs.map +1 -1
  151. package/dist/jsx/realtime.native.js.map +1 -1
  152. package/dist/jsx/sync/backfill.mjs.map +1 -1
  153. package/dist/jsx/sync/filters.mjs.map +1 -1
  154. package/dist/jsx/sync/filters.native.js.map +1 -1
  155. package/dist/jsx/sync/index.mjs +6 -1
  156. package/dist/jsx/sync/index.mjs.map +1 -1
  157. package/dist/jsx/sync/index.native.js +6 -1
  158. package/dist/jsx/sync/index.native.js.map +1 -1
  159. package/dist/jsx/sync/merge.mjs.map +1 -1
  160. package/dist/jsx/sync/merge.native.js.map +1 -1
  161. package/dist/jsx/sync/mutationEngine.mjs +43 -19
  162. package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
  163. package/dist/jsx/sync/mutationEngine.native.js +51 -21
  164. package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
  165. package/dist/jsx/sync/subscriptionManager.mjs +4 -0
  166. package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
  167. package/dist/jsx/sync/subscriptionManager.native.js +4 -0
  168. package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
  169. package/dist/jsx/sync/types.mjs.map +1 -1
  170. package/dist/jsx/sync/types.native.js.map +1 -1
  171. package/dist/jsx/sync/virtualPageManager.mjs.map +1 -1
  172. package/package.json +6 -6
  173. package/src/collection.ts +53 -8
  174. package/src/db.spec.ts +70 -0
  175. package/src/db.ts +28 -2
  176. package/src/frappeReact.spec.ts +7 -1
  177. package/src/frappeReact.ts +30 -6
  178. package/src/frappeReactLiveQueryGc.spec.ts +44 -4
  179. package/src/sync/index.ts +10 -1
  180. package/src/sync/mutationEngine.ts +81 -20
  181. package/src/sync/optimisticCreateReconcile.spec.ts +372 -0
  182. package/src/sync/subscriptionManager.spec.ts +41 -0
  183. package/src/sync/subscriptionManager.ts +10 -0
  184. package/src/sync/subscriptions.spec.ts +4 -0
  185. package/src/sync/types.ts +9 -0
  186. package/types/collection.d.ts +12 -0
  187. package/types/collection.d.ts.map +1 -1
  188. package/types/db.d.ts.map +1 -1
  189. package/types/frappeReact.d.ts.map +1 -1
  190. package/types/sync/index.d.ts.map +1 -1
  191. package/types/sync/mutationEngine.d.ts.map +1 -1
  192. package/types/sync/subscriptionManager.d.ts.map +1 -1
  193. package/types/sync/types.d.ts +9 -0
  194. package/types/sync/types.d.ts.map +1 -1
package/src/collection.ts CHANGED
@@ -34,6 +34,40 @@ export interface FrappeCollectionOptionsConfig<
34
34
  onSyncError?: (error: unknown) => void;
35
35
  }
36
36
 
37
+ // ---------------------------------------------------------------------------
38
+ // External sync release
39
+ // ---------------------------------------------------------------------------
40
+ //
41
+ // Registered per collection while a Frappe sync session is active. Lets the
42
+ // shared-collection GC in frappeReact free the REAL resources (SyncModule
43
+ // subscription, store listener, realtime room) of a collection that leaked
44
+ // live queries still pin, WITHOUT calling collection.cleanup(): TanStack DB
45
+ // transitions every attached live query to a permanent error state (plus a
46
+ // console.error per query — "Source collection … was manually cleaned up
47
+ // while live query … depends on it") when a depended-on collection is
48
+ // cleaned up. Releasing externally leaves the collection object alive but
49
+ // inert: attached queries keep their last data, no error, no leak beyond
50
+ // the plain object graph the leaked query itself already holds.
51
+ const externalSyncReleases = new WeakMap<object, () => void>();
52
+
53
+ /**
54
+ * Releases the Frappe-side sync resources (SyncModule subscription + store
55
+ * listener → realtime room) of a collection created by
56
+ * {@link createFrappeCollection}, without touching TanStack DB's lifecycle.
57
+ * Idempotent; also runs automatically when TanStack stops the sync itself.
58
+ *
59
+ * @returns true when an active sync session was released, false when the
60
+ * collection has no registered session (sync never started, already
61
+ * stopped, or not a Frappe collection) — callers may fall back to
62
+ * `collection.cleanup()` in that case.
63
+ */
64
+ export function releaseFrappeCollectionSync(collection: object): boolean {
65
+ const release = externalSyncReleases.get(collection);
66
+ if (!release) return false;
67
+ release();
68
+ return true;
69
+ }
70
+
37
71
  /**
38
72
  * Creates TanStack DB collection options wired to Frappe's SyncModule.
39
73
  *
@@ -74,6 +108,22 @@ export function frappeCollectionOptions<TDoc extends object & { name: string; do
74
108
  // (invalid "cleaned-up" → "ready" status transition).
75
109
  let cancelled = false;
76
110
 
111
+ // Idempotent resource release shared by TanStack's own sync teardown
112
+ // and the external escape hatch (releaseFrappeCollectionSync).
113
+ const releaseResources = () => {
114
+ cancelled = true;
115
+ if (storeUnsubscribe) {
116
+ storeUnsubscribe();
117
+ storeUnsubscribe = undefined;
118
+ }
119
+ if (subscriptionId) {
120
+ syncModule.unsubscribe(subscriptionId);
121
+ subscriptionId = undefined;
122
+ }
123
+ };
124
+ const syncedCollection = (params as { collection?: object }).collection;
125
+ if (syncedCollection) externalSyncReleases.set(syncedCollection, releaseResources);
126
+
77
127
  async function initSync() {
78
128
  try {
79
129
  // Subscribe to the SyncModule for this doctype
@@ -147,15 +197,10 @@ export function frappeCollectionOptions<TDoc extends object & { name: string; do
147
197
 
148
198
  // Return cleanup function
149
199
  return () => {
150
- cancelled = true;
151
- if (storeUnsubscribe) {
152
- storeUnsubscribe();
153
- storeUnsubscribe = undefined;
154
- }
155
- if (subscriptionId) {
156
- syncModule.unsubscribe(subscriptionId);
157
- subscriptionId = undefined;
200
+ if (syncedCollection && externalSyncReleases.get(syncedCollection) === releaseResources) {
201
+ externalSyncReleases.delete(syncedCollection);
158
202
  }
203
+ releaseResources();
159
204
  };
160
205
  };
161
206
 
package/src/db.spec.ts CHANGED
@@ -511,6 +511,54 @@ describe("DbModule", () => {
511
511
  expect.any(Object),
512
512
  );
513
513
  });
514
+
515
+ it("should unwrap the live server's {message} envelope so the ack surfaces the real name", async () => {
516
+ // Regression: whitelisted method returns arrive as {message: {...doc}}.
517
+ // Returning the envelope made created.name undefined, so the mutation
518
+ // engine's temporary→real reconciliation never ran (optimistic rows
519
+ // lingered next to their realtime server rows until reload).
520
+ const createdDoc: TestDoc = {
521
+ doctype: "TestDocType",
522
+ name: "SERVER-NAME-001",
523
+ title: "New Document",
524
+ };
525
+
526
+ (mockHttpClient.post as any).mockResolvedValue({
527
+ payload: { message: createdDoc },
528
+ headers: new Headers(),
529
+ status: 200,
530
+ });
531
+
532
+ const result = await db.createDoc<TestDoc>({
533
+ doctype: "TestDocType",
534
+ name: "new-todo-placeholder",
535
+ title: "New Document",
536
+ });
537
+
538
+ expect(result).toEqual(createdDoc);
539
+ expect(result.name).toBe("SERVER-NAME-001");
540
+ });
541
+
542
+ it("should not unwrap a document that itself has a message field", async () => {
543
+ // A doc with a scalar `message` field (e.g. Email Template) must pass
544
+ // through untouched — only the envelope shape (object message, no
545
+ // top-level name) unwraps.
546
+ const docWithMessageField = {
547
+ doctype: "TestDocType",
548
+ name: "TEST-004",
549
+ message: "I am a doc field, not an envelope",
550
+ };
551
+
552
+ (mockHttpClient.post as any).mockResolvedValue({
553
+ payload: docWithMessageField,
554
+ headers: new Headers(),
555
+ status: 200,
556
+ });
557
+
558
+ const result = await db.createDoc(docWithMessageField as any);
559
+
560
+ expect(result).toEqual(docWithMessageField);
561
+ });
514
562
  });
515
563
 
516
564
  describe("updateDoc", () => {
@@ -664,6 +712,28 @@ describe("DbModule", () => {
664
712
  expect.any(Object),
665
713
  );
666
714
  });
715
+
716
+ it("should unwrap the live server's {message} envelope", async () => {
717
+ const updatedDoc: TestDoc = {
718
+ doctype: "TestDocType",
719
+ name: "TEST-001",
720
+ title: "Updated Title",
721
+ };
722
+
723
+ (mockHttpClient.post as any).mockResolvedValue({
724
+ payload: { message: updatedDoc },
725
+ headers: new Headers(),
726
+ status: 200,
727
+ });
728
+
729
+ const result = await db.updateDoc<TestDoc>({
730
+ doctype: "TestDocType",
731
+ name: "TEST-001",
732
+ title: "Updated Title",
733
+ });
734
+
735
+ expect(result).toEqual(updatedDoc);
736
+ });
667
737
  });
668
738
 
669
739
  describe("deleteDoc", () => {
package/src/db.ts CHANGED
@@ -142,13 +142,14 @@ export class DbModule {
142
142
  if (client_mutation_id) {
143
143
  headers["Idempotency-Key"] = client_mutation_id;
144
144
  }
145
- return (
145
+ const raw = (
146
146
  await this.http.post<FrappeDoc<Data>>(
147
147
  `/api/method/${this.endpoints.createDoc}`,
148
148
  cleanDoc,
149
149
  headers,
150
150
  )
151
151
  ).payload;
152
+ return unwrapMethodDoc(raw);
152
153
  }
153
154
 
154
155
  async updateDoc<Data extends FrappeDocData>(
@@ -165,13 +166,14 @@ export class DbModule {
165
166
  if (base_modified) {
166
167
  headers["If-Match"] = base_modified;
167
168
  }
168
- return (
169
+ const raw = (
169
170
  await this.http.post<FrappeDoc<Data>>(
170
171
  `/api/method/${this.endpoints.updateDoc}`,
171
172
  { doctype, name, data },
172
173
  headers,
173
174
  )
174
175
  ).payload;
176
+ return unwrapMethodDoc(raw);
175
177
  }
176
178
 
177
179
  async deleteDoc<Data extends FrappeDocData>(
@@ -291,6 +293,30 @@ function normalizeFilters(
291
293
  return Object.entries(filters).map(([k, v]) => [k, "=", v]);
292
294
  }
293
295
 
296
+ /**
297
+ * Unwraps Frappe's whitelisted-method response envelope.
298
+ *
299
+ * The live server returns the document wrapped as `{ message: {...doc} }`
300
+ * (every `@frappe.whitelist` return is enveloped). The old code returned the
301
+ * envelope itself, so `created.name` was always undefined and the mutation
302
+ * engine's temporary→real name reconciliation was dead code on the live wire
303
+ * (optimistic rows lingered next to their realtime server rows).
304
+ *
305
+ * Mocks and custom endpoints may return the document directly, so only
306
+ * unwrap when the payload actually looks like an envelope: a `message`
307
+ * object (a doc's own scalar `message` field or a child-table `message`
308
+ * array never matches) on a payload that has no `name` of its own.
309
+ */
310
+ function unwrapMethodDoc<Doc>(raw: Doc): Doc {
311
+ if (raw && typeof raw === "object" && !("name" in raw)) {
312
+ const message = (raw as { message?: unknown }).message;
313
+ if (message && typeof message === "object" && !Array.isArray(message)) {
314
+ return message as Doc;
315
+ }
316
+ }
317
+ return raw;
318
+ }
319
+
294
320
  export interface BackfillArgs {
295
321
  method?: string;
296
322
  doctype: string;
@@ -44,7 +44,13 @@ const { mockCreateFrappeCollection, mockIsBrowser } = vi.hoisted(() => ({
44
44
  mockIsBrowser: vi.fn(() => true),
45
45
  }));
46
46
 
47
- vi.mock("./collection", () => ({ createFrappeCollection: mockCreateFrappeCollection }));
47
+ vi.mock("./collection", () => ({
48
+ createFrappeCollection: mockCreateFrappeCollection,
49
+ // Mocked collections never register a sync session, so the registry's
50
+ // force-GC falls back to collection.cleanup() (the pre-existing contract
51
+ // these specs assert).
52
+ releaseFrappeCollectionSync: () => false,
53
+ }));
48
54
  vi.mock("./util", () => ({ isBrowser: mockIsBrowser }));
49
55
  vi.mock("./fixtureDb", () => ({ FixtureDbModule: class {} }));
50
56
 
@@ -1,7 +1,11 @@
1
1
  import { isWindowDefined } from "@multiplatform.one/platform";
2
2
  import type { Collection } from "@tanstack/db";
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
- import { createFrappeCollection, type FrappeCollectionOptionsConfig } from "./collection";
4
+ import {
5
+ createFrappeCollection,
6
+ releaseFrappeCollectionSync,
7
+ type FrappeCollectionOptionsConfig,
8
+ } from "./collection";
5
9
  import { HttpClient } from "./core/http";
6
10
  import type { AuthConfig } from "./core/types";
7
11
  import { DbModule } from "./db";
@@ -233,8 +237,20 @@ function scheduleEntryGc(
233
237
  // Past the deadline: a leaked query (created by a render that never
234
238
  // committed — TanStack DB never GCs a live query that never had a
235
239
  // subscriber) is pinning the sync subscription + realtime room.
236
- // Force-clean; any consumer that appears later gets a fresh instance
237
- // via the stale-entry paths in getOrCreateSharedCollection / the hook.
240
+ // Calling cleanup() here would transition EVERY attached live query
241
+ // to a permanent error state with a console.error per query ("Source
242
+ // collection 'X' was manually cleaned up while live query … depends
243
+ // on it" — the Workspace console-sweep bug), so release the
244
+ // Frappe-side resources directly instead and leave the collection
245
+ // object alive but inert. The entry is still evicted: any consumer
246
+ // that appears later gets a fresh instance via the stale-entry paths
247
+ // in getOrCreateSharedCollection / the hook.
248
+ entry.cleaned = true;
249
+ entry.gcForceDeadline = undefined;
250
+ if (cache.get(key) === entry) cache.delete(key);
251
+ if (releaseFrappeCollectionSync(entry.collection)) return;
252
+ // No active sync session registered (non-frappe collection or sync
253
+ // never started) — fall through to the plain cleanup below.
238
254
  }
239
255
  entry.cleaned = true;
240
256
  entry.gcForceDeadline = undefined;
@@ -242,6 +258,7 @@ function scheduleEntryGc(
242
258
  // Best-effort: Collection.cleanup() stops the sync (unsubscribes from the
243
259
  // SyncModule, leaving the realtime room). A consumer that raced eviction
244
260
  // still works — TanStack DB restarts a cleaned-up collection on next use.
261
+ // Safe here: subscriberCount is 0, so no live query depends on it.
245
262
  Promise.resolve(entry.collection.cleanup?.()).catch(() => {});
246
263
  }, SHARED_COLLECTION_GC_MS);
247
264
  }
@@ -366,9 +383,16 @@ function cleanupEntryCollectionWhenIdle(entry: SharedCollectionEntry): void {
366
383
  const tick = () => {
367
384
  entry.gcTimer = undefined;
368
385
  const subscriberCount = (entry.collection as { subscriberCount?: number }).subscriberCount ?? 0;
369
- if (subscriberCount > 0 && Date.now() < deadline) {
370
- entry.gcTimer = setTimeout(tick, SHARED_COLLECTION_GC_MS);
371
- return;
386
+ if (subscriberCount > 0) {
387
+ if (Date.now() < deadline) {
388
+ entry.gcTimer = setTimeout(tick, SHARED_COLLECTION_GC_MS);
389
+ return;
390
+ }
391
+ // Queries are still attached past the force window (leaked queries
392
+ // from never-committed renders). cleanup() would permanently error
393
+ // each of them with a console.error — release the sync resources
394
+ // externally instead; the collection object stays alive but inert.
395
+ if (releaseFrappeCollectionSync(entry.collection)) return;
372
396
  }
373
397
  Promise.resolve(entry.collection.cleanup?.()).catch(() => {});
374
398
  };
@@ -281,7 +281,7 @@ describe("shared collection GC vs real live queries", () => {
281
281
  expect(liveQueryErrors()).toHaveLength(0);
282
282
  });
283
283
 
284
- it("force window: a leaked query cannot pin the collection forever, and a fresh mount recovers", async () => {
284
+ it("force window: a leaked query releases the sync WITHOUT erroring, and a fresh mount recovers", async () => {
285
285
  const config = configFor("leak");
286
286
  const hook = renderHook(() => useFrappeCollection<WorkspaceDoc>(config, "Workspace"));
287
287
  await flushMicrotasks();
@@ -290,18 +290,29 @@ describe("shared collection GC vs real live queries", () => {
290
290
  // A leaked query: created (sync started, source subscribed) but never
291
291
  // given a subscriber — the shape a discarded render leaves behind.
292
292
  // TanStack DB never arms its GC for it, so only the force window frees
293
- // the source collection.
294
- createLiveQueryCollection({
293
+ // the sync resources.
294
+ const leakedQuery = createLiveQueryCollection({
295
295
  query: (q) => q.from({ workspace: collection }),
296
296
  startSync: true,
297
297
  gcTime: 300_000,
298
298
  });
299
299
  await flushMicrotasks();
300
300
  expect(collection.subscriberCount).toBeGreaterThan(0);
301
+ const unsubscribesBefore = mockUnsubscribe.mock.calls.length;
301
302
 
302
303
  hook.unmount();
303
304
  await advanceTime(FORCE_WAIT_MS);
304
- expect(collection.status).toBe("cleaned-up");
305
+ // The regression this pins: the force window used to call cleanup() on
306
+ // the pinned collection, permanently erroring every attached query with
307
+ // "[Live Query Error] Source collection 'Workspace' was manually cleaned
308
+ // up while live query … depends on it" (7× in the console sweep).
309
+ // Now the SyncModule subscription is released externally instead: the
310
+ // collection is never transitioned to cleaned-up, the leaked query
311
+ // never errors, and no console error is emitted.
312
+ expect(mockUnsubscribe.mock.calls.length).toBeGreaterThan(unsubscribesBefore);
313
+ expect(collection.status).not.toBe("cleaned-up");
314
+ expect(leakedQuery.status).not.toBe("error");
315
+ expect(liveQueryErrors()).toHaveLength(0);
305
316
 
306
317
  // A consumer mounting afterwards gets a FRESH working collection — a new
307
318
  // live query over it loads and reaches ready.
@@ -316,5 +327,34 @@ describe("shared collection GC vs real live queries", () => {
316
327
  expect(fresh.result.current!.status).toBe("ready");
317
328
  expect(freshQuery.status).toBe("ready");
318
329
  expect(freshQuery.toArray).toHaveLength(2);
330
+ expect(liveQueryErrors()).toHaveLength(0);
331
+ });
332
+
333
+ it("errored entry: idle cleanup with a leaked query attached releases sync without erroring", async () => {
334
+ // The production path from the console sweep: initial sync fails (401),
335
+ // consumers unmount (error screen), the errored entry is evicted through
336
+ // cleanupEntryCollectionWhenIdle — which, past the force window, used to
337
+ // cleanup() the collection while leaked queries were still attached.
338
+ const config = configFor("error");
339
+ mockSubscribe.mockRejectedValueOnce(new Error("AuthenticationError"));
340
+
341
+ const hook = renderHook(() => useFrappeCollection<WorkspaceDoc>(config, "Workspace"));
342
+ await flushMicrotasks();
343
+ const collection = hook.result.current!;
344
+
345
+ const leakedQuery = createLiveQueryCollection({
346
+ query: (q) => q.from({ workspace: collection }),
347
+ startSync: true,
348
+ gcTime: 300_000,
349
+ });
350
+ await flushMicrotasks();
351
+ expect(collection.subscriberCount).toBeGreaterThan(0);
352
+
353
+ hook.unmount();
354
+ await advanceTime(FORCE_WAIT_MS);
355
+
356
+ expect(collection.status).not.toBe("cleaned-up");
357
+ expect(leakedQuery.status).not.toBe("error");
358
+ expect(liveQueryErrors()).toHaveLength(0);
319
359
  });
320
360
  });
package/src/sync/index.ts CHANGED
@@ -226,7 +226,16 @@ export class SyncModule {
226
226
  }
227
227
 
228
228
  async mutate(mutation: Mutation<FrappeDocData>) {
229
- await this.mutationEngine.mutate(mutation);
229
+ try {
230
+ await this.mutationEngine.mutate(mutation);
231
+ } catch (err) {
232
+ // The failure path rolled back optimistic state (e.g. a rejected
233
+ // create's temp row was evicted) — store listeners must see that
234
+ // rollback too, or consumers that mirrored the optimistic membership
235
+ // keep rendering a ghost row the engine already dropped.
236
+ this.emit();
237
+ throw err;
238
+ }
230
239
  // Broadcast mutation to other tabs for cross-tab sync
231
240
  this.persistenceManager.broadcastMutation(mutation);
232
241
  this.emit();
@@ -147,10 +147,22 @@ export class MutationEngine {
147
147
  if (result.data) {
148
148
  this.normalizedCache.upsert(result.data);
149
149
 
150
- // Handle temp ID mapping for creates
151
- if (mutation.operation === Operation.Create && mutation.doc.name !== result.data.name) {
152
- this.temporaryIdToRealNameMap.set(mutation.doc.name!, result.data.name);
153
- this.rehydrationManager.registerTempIdMapping(mutation.doc.name!, result.data.name);
150
+ // Reconcile optimistic identity for creates — the full remap
151
+ // (subscription membership + temp cache row eviction), not just
152
+ // the id maps, or the optimistic row lingers beside the server
153
+ // row exactly like the non-batched path used to.
154
+ if (mutation.operation === Operation.Create) {
155
+ const temporaryName = mutation.temporaryName ?? mutation.doc.name;
156
+ if (temporaryName && result.data.name && temporaryName !== result.data.name) {
157
+ this.handleTemporaryToRealMapping(
158
+ mutation.doc.doctype,
159
+ temporaryName,
160
+ result.data.name,
161
+ );
162
+ this.rehydrationManager.registerTempIdMapping(temporaryName, result.data.name);
163
+ } else if (temporaryName) {
164
+ this.globalOptimisticDocIds.get(mutation.doc.doctype)?.delete(temporaryName);
165
+ }
154
166
  }
155
167
  }
156
168
 
@@ -244,7 +256,11 @@ export class MutationEngine {
244
256
  : mutation.doc;
245
257
  this.mutationJournal.add(mutation.mutationId, {
246
258
  doctype: doc.doctype,
247
- docname: doc.name,
259
+ // Creates journal the optimistic identity (consumer placeholder or
260
+ // engine-generated temp id) so page refreshes keep the pending row
261
+ // and rollback can evict it even when doc.name was never set.
262
+ docname:
263
+ mutation.operation === Operation.Create ? (mutation.temporaryName ?? doc.name) : doc.name,
248
264
  values,
249
265
  operation: mutation.operation,
250
266
  version,
@@ -318,6 +334,11 @@ export class MutationEngine {
318
334
 
319
335
  private async handleOptimisticCreate<Data extends FrappeDocData>(mutation: CreateMutation<Data>) {
320
336
  const temporaryName = mutation.doc.name || `tmp_${cryptoRandomId()}`;
337
+ // Record the optimistic identity on the mutation itself: the server-ack
338
+ // remap, rollback, and journal paths must all resolve the SAME name.
339
+ // For engine-generated names, mutation.doc.name stays unset so the
340
+ // synthetic id is never sent to the server.
341
+ mutation.temporaryName = temporaryName;
321
342
  const optimisticDoc = {
322
343
  ...mutation.doc,
323
344
  name: temporaryName,
@@ -370,11 +391,27 @@ export class MutationEngine {
370
391
  }
371
392
 
372
393
  private async handleServerCreate<Data extends FrappeDocData>(mutation: CreateMutation<Data>) {
373
- const created = await this.db.createDoc<FrappeDoc<Data>>(mutation.doc);
394
+ // Idempotency-Key rides the first attempt (not just replays) so an
395
+ // HTTP-level retry after a lost response replays the recorded outcome
396
+ // instead of double-inserting (live-protocol FRAPPE-EXT-001).
397
+ const created = await this.db.createDoc<FrappeDoc<Data>>({
398
+ ...mutation.doc,
399
+ client_mutation_id: mutation.mutationId,
400
+ });
374
401
  // Authoritative name assigned by the backend (may equal the provided temp name)
375
402
  const realName = created?.name;
376
403
  // The client-synthesized or user-provided temporary name used during optimism
377
- const temporaryName = mutation.doc.name;
404
+ const temporaryName = mutation.temporaryName ?? mutation.doc.name;
405
+ // The ack payload IS the authoritative post-insert document (same user
406
+ // context as any refetch) — seed the cache unconditionally so remapped
407
+ // membership resolves immediately instead of waiting for the realtime
408
+ // echo (which used to leave the row blank until the socket delivered).
409
+ if (realName) {
410
+ this.normalizedCache.upsert({
411
+ ...created,
412
+ doctype: created.doctype || mutation.doc.doctype,
413
+ });
414
+ }
378
415
  // If backend assigned a different name, remap temporary → real across subscriptions.
379
416
  if (realName && temporaryName && realName !== temporaryName) {
380
417
  this.handleTemporaryToRealMapping(mutation.doc.doctype, temporaryName, realName);
@@ -387,10 +424,6 @@ export class MutationEngine {
387
424
  globalDocs.delete(temporaryName);
388
425
  }
389
426
  }
390
- // When realtime is unhealthy, seed cache with authoritative document.
391
- if (!this.realtime?.isHealthy()) {
392
- this.normalizedCache.upsert(created);
393
- }
394
427
  }
395
428
 
396
429
  private async handleServerUpdate<Data extends FrappeDocData>(
@@ -433,10 +466,16 @@ export class MutationEngine {
433
466
  globalDocs.delete(temporaryName);
434
467
  // Real name is no longer optimistic, so we don't add it back
435
468
  }
436
- // replace in subscriptions
469
+ // Replace in subscriptions. When the realtime event / CDC backfill /
470
+ // page-refresh reconcile already delivered the server row (the ack lost
471
+ // the race), a plain map would leave the same name twice — drop the
472
+ // temporary id instead so exactly one row remains.
437
473
  for (const subscription of this.subscriptionManager.getAllSubscriptions().values()) {
438
474
  if (subscription.query.doctype !== doctype) continue;
439
- subscription.docIds = subscription.docIds.map((x) => (x === temporaryName ? realName : x));
475
+ if (!subscription.docIds.includes(temporaryName)) continue;
476
+ subscription.docIds = subscription.docIds.includes(realName)
477
+ ? subscription.docIds.filter((id) => id !== temporaryName)
478
+ : subscription.docIds.map((id) => (id === temporaryName ? realName : id));
440
479
  }
441
480
  // Remove the temporary cached row now that the authoritative name exists
442
481
  this.normalizedCache.remove({ doctype, name: temporaryName });
@@ -450,7 +489,12 @@ export class MutationEngine {
450
489
  const realName = (docname && this.temporaryIdToRealNameMap.get(docname)) || docname;
451
490
  // A deleted doc no longer exists server-side — refetching it would 404
452
491
  // and route a server-confirmed delete into the error/rollback path.
453
- if (realName && mutation.operation !== Operation.Delete) {
492
+ // A created doc is the insert-side sibling of the same bug: its cache
493
+ // copy was already seeded from the authoritative ack response, and
494
+ // refetching the pre-remap temporary name is a guaranteed 404 that used
495
+ // to roll back a server-confirmed create. Only updates refetch the
496
+ // main document here.
497
+ if (realName && mutation.operation === Operation.Update) {
454
498
  targets.push({ doctype, name: realName });
455
499
  }
456
500
  if (mutation.affected?.length) targets.push(...mutation.affected);
@@ -503,11 +547,9 @@ export class MutationEngine {
503
547
  switch (mutation.operation) {
504
548
  case Operation.Create:
505
549
  // Remove temporary optimistic document from cache and subscriptions
550
+ // (handleFailedCreate resolves the optimistic identity — consumer
551
+ // placeholder or engine-generated temp id — and evicts both).
506
552
  await this.handleFailedCreate(mutation);
507
- // Also remove from cache
508
- if (docname) {
509
- this.normalizedCache.remove({ doctype, name: docname });
510
- }
511
553
  break;
512
554
 
513
555
  case Operation.Update:
@@ -541,7 +583,10 @@ export class MutationEngine {
541
583
  }
542
584
 
543
585
  private async handleFailedCreate<Data extends FrappeDocData>(mutation: Mutation<Data>) {
544
- const temporaryName = mutation.doc.name;
586
+ const temporaryName =
587
+ mutation.operation === Operation.Create
588
+ ? (mutation.temporaryName ?? mutation.doc.name)
589
+ : mutation.doc.name;
545
590
  const doctype = mutation.doc.doctype;
546
591
 
547
592
  if (temporaryName) {
@@ -555,6 +600,9 @@ export class MutationEngine {
555
600
  if (subscription.query.doctype !== doctype) continue;
556
601
  subscription.docIds = subscription.docIds.filter((id) => id !== temporaryName);
557
602
  }
603
+ // Evict the optimistic cache row (generated temp ids never lived in
604
+ // mutation.doc.name, so keying off the resolved name matters here).
605
+ this.normalizedCache.remove({ doctype, name: temporaryName });
558
606
  }
559
607
  }
560
608
 
@@ -788,12 +836,25 @@ export class MutationEngine {
788
836
  }
789
837
  try {
790
838
  if (entry.operation === Operation.Create) {
791
- await this.db.createDoc({
839
+ const created = await this.db.createDoc({
792
840
  ...entry.values,
793
841
  client_mutation_id: id,
794
842
  doctype: entry.doctype,
795
843
  name: entry.docname || cryptoRandomId(),
796
844
  });
845
+ // A replayed create acks with the authoritative document (possibly
846
+ // an idempotent replay of an earlier success) — reconcile the
847
+ // optimistic identity exactly like the first attempt.
848
+ if (created?.name) {
849
+ this.normalizedCache.upsert({
850
+ ...created,
851
+ doctype: created.doctype || entry.doctype,
852
+ });
853
+ if (entry.docname && created.name !== entry.docname) {
854
+ this.handleTemporaryToRealMapping(entry.doctype, entry.docname, created.name);
855
+ this.rehydrationManager.registerTempIdMapping(entry.docname, created.name);
856
+ }
857
+ }
797
858
  } else if (entry.operation === Operation.Update && entry.docname) {
798
859
  await this.db.updateDoc({
799
860
  ...entry.values,