@multiplatform.one/frappe 6.4.3 → 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 (175) 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.native.js +2 -2
  61. package/dist/esm/core/http.native.js.map +1 -1
  62. package/dist/esm/core/types.native.js.map +1 -1
  63. package/dist/esm/core/wire.native.js.map +1 -1
  64. package/dist/esm/db.mjs +13 -2
  65. package/dist/esm/db.mjs.map +1 -1
  66. package/dist/esm/db.native.js +19 -3
  67. package/dist/esm/db.native.js.map +1 -1
  68. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js +2 -2
  69. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  70. package/dist/esm/devtools/index.native.js +2 -2
  71. package/dist/esm/devtools/index.native.js.map +1 -1
  72. package/dist/esm/devtools/plugin.native.js +1 -1
  73. package/dist/esm/devtools/plugin.native.js.map +1 -1
  74. package/dist/esm/devtools/theme.native.js.map +1 -1
  75. package/dist/esm/doctype.native.js +1 -1
  76. package/dist/esm/doctype.native.js.map +1 -1
  77. package/dist/esm/file.native.js.map +1 -1
  78. package/dist/esm/fixtureDb.native.js +1 -1
  79. package/dist/esm/fixtureDb.native.js.map +1 -1
  80. package/dist/esm/fixtures.native.js.map +1 -1
  81. package/dist/esm/frappeApp.native.js +7 -7
  82. package/dist/esm/frappeApp.native.js.map +1 -1
  83. package/dist/esm/frappeReact.mjs +11 -4
  84. package/dist/esm/frappeReact.mjs.map +1 -1
  85. package/dist/esm/frappeReact.native.js +17 -10
  86. package/dist/esm/frappeReact.native.js.map +1 -1
  87. package/dist/esm/index.native.js +21 -21
  88. package/dist/esm/index.native.js.map +1 -1
  89. package/dist/esm/optimistic.native.js.map +1 -1
  90. package/dist/esm/pacer.native.js +4 -4
  91. package/dist/esm/pacer.native.js.map +1 -1
  92. package/dist/esm/plugin/index.native.js.map +1 -1
  93. package/dist/esm/realtime.native.js +1 -1
  94. package/dist/esm/realtime.native.js.map +1 -1
  95. package/dist/esm/sync/backfill.native.js +1 -1
  96. package/dist/esm/sync/backfill.native.js.map +1 -1
  97. package/dist/esm/sync/filters.native.js.map +1 -1
  98. package/dist/esm/sync/index.mjs +6 -1
  99. package/dist/esm/sync/index.mjs.map +1 -1
  100. package/dist/esm/sync/index.native.js +14 -9
  101. package/dist/esm/sync/index.native.js.map +1 -1
  102. package/dist/esm/sync/merge.native.js.map +1 -1
  103. package/dist/esm/sync/mutationEngine.mjs +43 -19
  104. package/dist/esm/sync/mutationEngine.mjs.map +1 -1
  105. package/dist/esm/sync/mutationEngine.native.js +55 -25
  106. package/dist/esm/sync/mutationEngine.native.js.map +1 -1
  107. package/dist/esm/sync/mutationJournal.native.js +1 -1
  108. package/dist/esm/sync/mutationJournal.native.js.map +1 -1
  109. package/dist/esm/sync/normalizedCache.native.js.map +1 -1
  110. package/dist/esm/sync/persistence.native.js +2 -2
  111. package/dist/esm/sync/persistence.native.js.map +1 -1
  112. package/dist/esm/sync/realtime.native.js +1 -1
  113. package/dist/esm/sync/realtime.native.js.map +1 -1
  114. package/dist/esm/sync/rehydration.native.js +1 -1
  115. package/dist/esm/sync/rehydration.native.js.map +1 -1
  116. package/dist/esm/sync/storage.native.js.map +1 -1
  117. package/dist/esm/sync/subscriptionManager.mjs +4 -0
  118. package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
  119. package/dist/esm/sync/subscriptionManager.native.js +8 -4
  120. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  121. package/dist/esm/sync/types.mjs.map +1 -1
  122. package/dist/esm/sync/types.native.js.map +1 -1
  123. package/dist/esm/sync/virtualPageManager.native.js +1 -1
  124. package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
  125. package/dist/esm/useLiveQuery.native.js.map +1 -1
  126. package/dist/esm/util.native.js.map +1 -1
  127. package/dist/jsx/collection.mjs +24 -9
  128. package/dist/jsx/collection.mjs.map +1 -1
  129. package/dist/jsx/collection.native.js +25 -9
  130. package/dist/jsx/collection.native.js.map +1 -1
  131. package/dist/jsx/db.mjs +13 -2
  132. package/dist/jsx/db.mjs.map +1 -1
  133. package/dist/jsx/db.native.js +18 -2
  134. package/dist/jsx/db.native.js.map +1 -1
  135. package/dist/jsx/frappeReact.mjs +11 -4
  136. package/dist/jsx/frappeReact.mjs.map +1 -1
  137. package/dist/jsx/frappeReact.native.js +10 -3
  138. package/dist/jsx/frappeReact.native.js.map +1 -1
  139. package/dist/jsx/sync/index.mjs +6 -1
  140. package/dist/jsx/sync/index.mjs.map +1 -1
  141. package/dist/jsx/sync/index.native.js +6 -1
  142. package/dist/jsx/sync/index.native.js.map +1 -1
  143. package/dist/jsx/sync/mutationEngine.mjs +43 -19
  144. package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
  145. package/dist/jsx/sync/mutationEngine.native.js +51 -21
  146. package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
  147. package/dist/jsx/sync/subscriptionManager.mjs +4 -0
  148. package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
  149. package/dist/jsx/sync/subscriptionManager.native.js +4 -0
  150. package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
  151. package/dist/jsx/sync/types.mjs.map +1 -1
  152. package/dist/jsx/sync/types.native.js.map +1 -1
  153. package/package.json +6 -6
  154. package/src/collection.ts +53 -8
  155. package/src/db.spec.ts +70 -0
  156. package/src/db.ts +28 -2
  157. package/src/frappeReact.spec.ts +7 -1
  158. package/src/frappeReact.ts +30 -6
  159. package/src/frappeReactLiveQueryGc.spec.ts +44 -4
  160. package/src/sync/index.ts +10 -1
  161. package/src/sync/mutationEngine.ts +81 -20
  162. package/src/sync/optimisticCreateReconcile.spec.ts +372 -0
  163. package/src/sync/subscriptionManager.spec.ts +41 -0
  164. package/src/sync/subscriptionManager.ts +10 -0
  165. package/src/sync/subscriptions.spec.ts +4 -0
  166. package/src/sync/types.ts +9 -0
  167. package/types/collection.d.ts +12 -0
  168. package/types/collection.d.ts.map +1 -1
  169. package/types/db.d.ts.map +1 -1
  170. package/types/frappeReact.d.ts.map +1 -1
  171. package/types/sync/index.d.ts.map +1 -1
  172. package/types/sync/mutationEngine.d.ts.map +1 -1
  173. package/types/sync/subscriptionManager.d.ts.map +1 -1
  174. package/types/sync/types.d.ts +9 -0
  175. package/types/sync/types.d.ts.map +1 -1
@@ -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,
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Optimistic create → server row identity reconciliation.
3
+ *
4
+ * Regression suite for the "optimistic insert row lingers beside the realtime
5
+ * server row" bug (p-results/fix-backoffice-resilience.md gap 3 known limit):
6
+ * the live wire wraps whitelisted method returns as `{message: {...doc}}`, so
7
+ * `createDoc().name` was undefined and the temporary→real remap never ran —
8
+ * the temp row stayed in membership forever, the realtime row was inserted
9
+ * alongside (2 UI rows / 1 server doc), the temp row's mutations 404'd, and
10
+ * with realtime unhealthy the post-create refetch of the temp name 404'd and
11
+ * rolled back a server-confirmed create (the insert-side sibling of the
12
+ * delete-refetch rollback bug).
13
+ *
14
+ * These specs run the REAL engine stack — SubscriptionManager,
15
+ * NormalizedCache, MutationJournal, RehydrationManager, RealtimeHandler,
16
+ * MutationEngine — over a scripted DbModule, so the ack/realtime race is
17
+ * exercised end-to-end in both orders (subscriptionManager.spec.ts pattern:
18
+ * real internals, mocked db).
19
+ */
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
+ import type { FrappeDoc, FrappeDocData } from "../common/types";
22
+ import type { DbModule } from "../db";
23
+ import type { RealtimeModule } from "../realtime";
24
+ import { MutationEngine } from "./mutationEngine";
25
+ import { MutationJournal } from "./mutationJournal";
26
+ import { NormalizedCache } from "./normalizedCache";
27
+ import { RealtimeHandler } from "./realtime";
28
+ import { RehydrationManager } from "./rehydration";
29
+ import { SubscriptionManager } from "./subscriptionManager";
30
+ import { Operation } from "./types";
31
+ import type { CreateMutation } from "./types";
32
+
33
+ type Doc = FrappeDoc<FrappeDocData>;
34
+
35
+ interface Harness {
36
+ engine: MutationEngine;
37
+ subscriptionManager: SubscriptionManager;
38
+ cache: NormalizedCache;
39
+ journal: MutationJournal;
40
+ db: {
41
+ getDoc: ReturnType<typeof vi.fn>;
42
+ getDocList: ReturnType<typeof vi.fn>;
43
+ createDoc: ReturnType<typeof vi.fn>;
44
+ updateDoc: ReturnType<typeof vi.fn>;
45
+ deleteDoc: ReturnType<typeof vi.fn>;
46
+ backfill: ReturnType<typeof vi.fn>;
47
+ };
48
+ /** Server-side rows the mocked db serves (list + getDoc). */
49
+ serverRows: Doc[];
50
+ /** Fires a realtime list_update the way the socket would. */
51
+ emitListUpdate: (name: string) => Promise<void>;
52
+ realtimeHealthy: { value: boolean };
53
+ dispose: () => void;
54
+ }
55
+
56
+ function createHarness(): Harness {
57
+ const serverRows: Doc[] = [];
58
+ const realtimeHealthy = { value: true };
59
+
60
+ const db = {
61
+ getDoc: vi.fn(async (doctype: string, name: string) => {
62
+ const row = serverRows.find((doc) => doc.doctype === doctype && doc.name === name);
63
+ if (!row) {
64
+ const error = new Error(`${doctype}/${name} not found`) as Error & { status: number };
65
+ error.status = 404;
66
+ throw error;
67
+ }
68
+ return { ...row };
69
+ }),
70
+ getDocList: vi.fn(async (doctype: string) =>
71
+ serverRows.filter((doc) => doc.doctype === doctype).map((doc) => ({ ...doc })),
72
+ ),
73
+ createDoc: vi.fn(),
74
+ updateDoc: vi.fn(),
75
+ deleteDoc: vi.fn(),
76
+ backfill: vi.fn(async () => ({ rows: [], checkpoint: undefined })),
77
+ };
78
+
79
+ const realtimeHandlers = new Map<string, (payload: unknown) => Promise<void> | void>();
80
+ const realtime = {
81
+ isHealthy: () => realtimeHealthy.value,
82
+ on: (event: string, handler: (payload: unknown) => Promise<void> | void) => {
83
+ realtimeHandlers.set(event, handler);
84
+ },
85
+ subscribeList: vi.fn(),
86
+ unsubscribeList: vi.fn(),
87
+ disconnect: vi.fn(),
88
+ } as unknown as RealtimeModule;
89
+
90
+ const cache = new NormalizedCache({});
91
+ const journal = new MutationJournal({ enableAutoCleanup: false });
92
+ const subscriptionManager = new SubscriptionManager(db as unknown as DbModule, cache, {});
93
+ const rehydrationManager = new RehydrationManager(
94
+ db as unknown as DbModule,
95
+ cache,
96
+ subscriptionManager,
97
+ journal,
98
+ );
99
+ const engine = new MutationEngine(
100
+ db as unknown as DbModule,
101
+ realtime,
102
+ cache,
103
+ journal,
104
+ subscriptionManager,
105
+ rehydrationManager,
106
+ {},
107
+ );
108
+ const realtimeHandler = new RealtimeHandler(
109
+ db as unknown as DbModule,
110
+ realtime,
111
+ cache,
112
+ subscriptionManager,
113
+ rehydrationManager,
114
+ {},
115
+ );
116
+
117
+ return {
118
+ engine,
119
+ subscriptionManager,
120
+ cache,
121
+ journal,
122
+ db,
123
+ serverRows,
124
+ emitListUpdate: async (name: string) => {
125
+ await realtimeHandlers.get("list_update")?.({ doctype: "TestDoc", name });
126
+ },
127
+ realtimeHealthy,
128
+ dispose: () => {
129
+ engine.dispose();
130
+ realtimeHandler.dispose();
131
+ rehydrationManager.clear();
132
+ journal.dispose();
133
+ },
134
+ };
135
+ }
136
+
137
+ const serverDoc = (name: string): Doc => ({
138
+ doctype: "TestDoc",
139
+ name,
140
+ title: "Created",
141
+ modified: "2024-01-01T10:00:00",
142
+ });
143
+
144
+ const createMutation = (
145
+ doc: Partial<Doc>,
146
+ mutationId = "create-1",
147
+ ): CreateMutation<FrappeDocData> => ({
148
+ mutationId,
149
+ operation: Operation.Create,
150
+ doc: { doctype: "TestDoc", ...doc } as Doc,
151
+ affected: [],
152
+ });
153
+
154
+ describe("optimistic create reconciliation (engine-level)", () => {
155
+ let harness: Harness;
156
+ let subscriptionId: string;
157
+
158
+ const rowNames = () =>
159
+ harness.subscriptionManager.getDocsForSubscription(subscriptionId).map((doc) => doc.name);
160
+ const docIds = () => [
161
+ ...(harness.subscriptionManager.getSubscription(subscriptionId)?.docIds ?? []),
162
+ ];
163
+
164
+ beforeEach(async () => {
165
+ vi.useFakeTimers();
166
+ harness = createHarness();
167
+ const result = await harness.subscriptionManager.subscribe({
168
+ doctype: "TestDoc",
169
+ fields: ["name", "title", "modified"],
170
+ });
171
+ subscriptionId = result.subscriptionId;
172
+ });
173
+
174
+ afterEach(() => {
175
+ harness.dispose();
176
+ vi.useRealTimers();
177
+ vi.restoreAllMocks();
178
+ });
179
+
180
+ it("server ack reconciles the optimistic row to a single server-named row", async () => {
181
+ harness.db.createDoc.mockImplementation(async () => {
182
+ harness.serverRows.push(serverDoc("real-1"));
183
+ return serverDoc("real-1");
184
+ });
185
+
186
+ const mutation = createMutation({ name: "new-todo-placeholder" });
187
+ const mutatePromise = harness.engine.mutate(mutation);
188
+
189
+ // Optimistic state: the placeholder row is live immediately.
190
+ expect(docIds()).toEqual(["new-todo-placeholder"]);
191
+ expect(rowNames()).toEqual(["new-todo-placeholder"]);
192
+
193
+ await mutatePromise;
194
+
195
+ // Ack reconciled identity: exactly one row, under the server name, and
196
+ // the row resolves from the cache (the ack payload seeded it — no wait
197
+ // for the realtime echo).
198
+ expect(docIds()).toEqual(["real-1"]);
199
+ expect(rowNames()).toEqual(["real-1"]);
200
+ expect(harness.cache.get({ doctype: "TestDoc", name: "new-todo-placeholder" })).toBeUndefined();
201
+ expect(harness.engine.getTemporaryIdToRealNameMap().get("new-todo-placeholder")).toBe("real-1");
202
+
203
+ // The idempotency machinery rides the first attempt.
204
+ expect(harness.db.createDoc).toHaveBeenCalledWith(
205
+ expect.objectContaining({ client_mutation_id: "create-1" }),
206
+ );
207
+ });
208
+
209
+ it("reconciles when the realtime insert arrives BEFORE the REST ack", async () => {
210
+ let resolveAck: (doc: Doc) => void = () => {};
211
+ harness.db.createDoc.mockImplementation(() => {
212
+ // Server commits immediately (realtime will observe the row); only the
213
+ // HTTP response is still in flight.
214
+ harness.serverRows.push(serverDoc("real-2"));
215
+ return new Promise<Doc>((resolve) => {
216
+ resolveAck = resolve;
217
+ });
218
+ });
219
+
220
+ const mutation = createMutation({ name: "new-todo-racer" });
221
+ const mutatePromise = harness.engine.mutate(mutation);
222
+ await vi.advanceTimersByTimeAsync(0);
223
+ expect(docIds()).toEqual(["new-todo-racer"]);
224
+
225
+ // Realtime beats the ack: the doctype-room event lands and the page
226
+ // refresh it schedules runs while the create is still pending.
227
+ await harness.emitListUpdate("real-2");
228
+ await vi.advanceTimersByTimeAsync(600);
229
+
230
+ // Pre-ack the engine honestly shows both: the unmatched server row and
231
+ // the still-pending optimistic row (they cannot be correlated until the
232
+ // ack names the create).
233
+ expect([...docIds()].sort()).toEqual(["new-todo-racer", "real-2"]);
234
+
235
+ resolveAck(serverDoc("real-2"));
236
+ await mutatePromise;
237
+
238
+ // Ack lands: the temp row is dropped rather than remapped onto the
239
+ // already-present server row — exactly one row, no duplicates.
240
+ expect(docIds()).toEqual(["real-2"]);
241
+ expect(rowNames()).toEqual(["real-2"]);
242
+ expect(harness.cache.get({ doctype: "TestDoc", name: "new-todo-racer" })).toBeUndefined();
243
+
244
+ // Late rehydrations stay idempotent.
245
+ await vi.advanceTimersByTimeAsync(1000);
246
+ expect(docIds()).toEqual(["real-2"]);
247
+ });
248
+
249
+ it("reconciles when the REST ack lands BEFORE the realtime insert", async () => {
250
+ harness.db.createDoc.mockImplementation(async () => {
251
+ harness.serverRows.push(serverDoc("real-3"));
252
+ return serverDoc("real-3");
253
+ });
254
+
255
+ await harness.engine.mutate(createMutation({ name: "new-todo-early-ack" }));
256
+ expect(docIds()).toEqual(["real-3"]);
257
+
258
+ // The realtime echo for the same doc arrives afterwards — membership
259
+ // already contains the server name, so nothing duplicates.
260
+ await harness.emitListUpdate("real-3");
261
+ await vi.advanceTimersByTimeAsync(1000);
262
+
263
+ expect(docIds()).toEqual(["real-3"]);
264
+ expect(rowNames()).toEqual(["real-3"]);
265
+ });
266
+
267
+ it("acks a create with an engine-generated temp name without sending the temp name", async () => {
268
+ harness.db.createDoc.mockImplementation(async () => {
269
+ harness.serverRows.push(serverDoc("real-4"));
270
+ return serverDoc("real-4");
271
+ });
272
+
273
+ const mutation = createMutation({});
274
+ const mutatePromise = harness.engine.mutate(mutation);
275
+ const optimisticIds = docIds();
276
+ expect(optimisticIds).toHaveLength(1);
277
+ expect(optimisticIds[0]).toMatch(/^tmp_/);
278
+
279
+ await mutatePromise;
280
+
281
+ // The generated temp id was never sent to the server…
282
+ expect(harness.db.createDoc.mock.calls[0][0].name).toBeUndefined();
283
+ // …and the ack still reconciled it away.
284
+ expect(docIds()).toEqual(["real-4"]);
285
+ expect(harness.cache.get({ doctype: "TestDoc", name: optimisticIds[0] })).toBeUndefined();
286
+ });
287
+
288
+ it("does not roll back a server-confirmed create when realtime is unhealthy", async () => {
289
+ // The insert-side sibling of the delete-refetch rollback bug: with the
290
+ // socket down, the post-mutation refetch used to target the PRE-remap
291
+ // temporary name → guaranteed 404 → journal.fail + rollback of a create
292
+ // the server had already committed.
293
+ harness.realtimeHealthy.value = false;
294
+ harness.db.createDoc.mockImplementation(async () => {
295
+ harness.serverRows.push(serverDoc("real-5"));
296
+ return serverDoc("real-5");
297
+ });
298
+
299
+ await expect(
300
+ harness.engine.mutate(createMutation({ name: "new-todo-offline-socket" })),
301
+ ).resolves.not.toThrow();
302
+
303
+ expect(docIds()).toEqual(["real-5"]);
304
+ expect(rowNames()).toEqual(["real-5"]);
305
+ // The temp name was never refetched (it would 404).
306
+ expect(harness.db.getDoc).not.toHaveBeenCalledWith("TestDoc", "new-todo-offline-socket");
307
+ expect(harness.journal.get("create-1")).toBeUndefined(); // confirmed → removed
308
+ });
309
+
310
+ it("confirms (not rolls back) a create whose ack carries no document name", async () => {
311
+ // Custom endpoints (and the pre-fix live envelope) can yield an ack
312
+ // without a usable name. The engine then cannot remap — but it must NOT
313
+ // refetch the temporary name (guaranteed 404) and roll back a create
314
+ // the server just confirmed. The optimistic row stays until realtime /
315
+ // backfill reconciles it.
316
+ harness.realtimeHealthy.value = false;
317
+ harness.db.createDoc.mockResolvedValue({} as Doc);
318
+
319
+ await expect(
320
+ harness.engine.mutate(createMutation({ name: "new-todo-nameless-ack" })),
321
+ ).resolves.not.toThrow();
322
+
323
+ expect(harness.db.getDoc).not.toHaveBeenCalled();
324
+ expect(harness.journal.get("create-1")).toBeUndefined(); // confirmed → removed
325
+ expect(docIds()).toEqual(["new-todo-nameless-ack"]);
326
+ expect(rowNames()).toEqual(["new-todo-nameless-ack"]);
327
+ });
328
+
329
+ it("rolls back a rejected create (temp row evicted from membership and cache)", async () => {
330
+ harness.db.createDoc.mockRejectedValue(
331
+ Object.assign(new Error("Validation failed"), {
332
+ status: 417,
333
+ }),
334
+ );
335
+
336
+ await expect(
337
+ harness.engine.mutate(createMutation({ name: "new-todo-rejected" })),
338
+ ).rejects.toThrow("Validation failed");
339
+
340
+ expect(docIds()).toEqual([]);
341
+ expect(rowNames()).toEqual([]);
342
+ expect(harness.cache.get({ doctype: "TestDoc", name: "new-todo-rejected" })).toBeUndefined();
343
+ });
344
+
345
+ it("rolls back a rejected create even when the realtime row already landed", async () => {
346
+ // Rejection while a foreign-looking server row is present (e.g. another
347
+ // user's concurrent insert): only the optimistic temp row is rolled
348
+ // back — the authoritative server row stays.
349
+ harness.serverRows.push(serverDoc("real-other"));
350
+ let rejectAck: (err: unknown) => void = () => {};
351
+ harness.db.createDoc.mockImplementation(
352
+ () =>
353
+ new Promise((_resolve, reject) => {
354
+ rejectAck = reject;
355
+ }),
356
+ );
357
+
358
+ const mutatePromise = harness.engine.mutate(createMutation({ name: "new-todo-doomed" }));
359
+ const settled = mutatePromise.catch((err) => err);
360
+ await vi.advanceTimersByTimeAsync(0);
361
+ await harness.emitListUpdate("real-other");
362
+ await vi.advanceTimersByTimeAsync(600);
363
+ expect([...docIds()].sort()).toEqual(["new-todo-doomed", "real-other"]);
364
+
365
+ rejectAck(Object.assign(new Error("Server said no"), { status: 417 }));
366
+ expect(await settled).toBeInstanceOf(Error);
367
+
368
+ expect(docIds()).toEqual(["real-other"]);
369
+ expect(rowNames()).toEqual(["real-other"]);
370
+ expect(harness.cache.get({ doctype: "TestDoc", name: "new-todo-doomed" })).toBeUndefined();
371
+ });
372
+ });