@multiplatform.one/frappe 6.4.3 → 6.7.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 (125) hide show
  1. package/dist/cjs/collection.cjs +25 -9
  2. package/dist/cjs/collection.native.js +25 -9
  3. package/dist/cjs/collection.native.js.map +1 -1
  4. package/dist/cjs/db.cjs +13 -2
  5. package/dist/cjs/db.native.js +18 -2
  6. package/dist/cjs/db.native.js.map +1 -1
  7. package/dist/cjs/frappeReact.cjs +10 -3
  8. package/dist/cjs/frappeReact.native.js +10 -3
  9. package/dist/cjs/frappeReact.native.js.map +1 -1
  10. package/dist/cjs/index.cjs +1 -0
  11. package/dist/cjs/index.native.js +1 -0
  12. package/dist/cjs/index.native.js.map +1 -1
  13. package/dist/cjs/realtime.cjs +222 -11
  14. package/dist/cjs/realtime.native.js +352 -14
  15. package/dist/cjs/realtime.native.js.map +1 -1
  16. package/dist/cjs/sync/index.cjs +6 -1
  17. package/dist/cjs/sync/index.native.js +6 -1
  18. package/dist/cjs/sync/index.native.js.map +1 -1
  19. package/dist/cjs/sync/mutationEngine.cjs +43 -19
  20. package/dist/cjs/sync/mutationEngine.native.js +51 -21
  21. package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
  22. package/dist/cjs/sync/subscriptionManager.cjs +4 -0
  23. package/dist/cjs/sync/subscriptionManager.native.js +4 -0
  24. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  25. package/dist/cjs/sync/types.native.js.map +1 -1
  26. package/dist/esm/collection.mjs +24 -9
  27. package/dist/esm/collection.mjs.map +1 -1
  28. package/dist/esm/collection.native.js +24 -9
  29. package/dist/esm/collection.native.js.map +1 -1
  30. package/dist/esm/db.mjs +13 -2
  31. package/dist/esm/db.mjs.map +1 -1
  32. package/dist/esm/db.native.js +18 -2
  33. package/dist/esm/db.native.js.map +1 -1
  34. package/dist/esm/frappeReact.mjs +11 -4
  35. package/dist/esm/frappeReact.mjs.map +1 -1
  36. package/dist/esm/frappeReact.native.js +11 -4
  37. package/dist/esm/frappeReact.native.js.map +1 -1
  38. package/dist/esm/index.mjs +2 -2
  39. package/dist/esm/index.mjs.map +1 -1
  40. package/dist/esm/index.native.js +2 -2
  41. package/dist/esm/index.native.js.map +1 -1
  42. package/dist/esm/realtime.mjs +222 -11
  43. package/dist/esm/realtime.mjs.map +1 -1
  44. package/dist/esm/realtime.native.js +352 -14
  45. package/dist/esm/realtime.native.js.map +1 -1
  46. package/dist/esm/sync/index.mjs +6 -1
  47. package/dist/esm/sync/index.mjs.map +1 -1
  48. package/dist/esm/sync/index.native.js +6 -1
  49. package/dist/esm/sync/index.native.js.map +1 -1
  50. package/dist/esm/sync/mutationEngine.mjs +43 -19
  51. package/dist/esm/sync/mutationEngine.mjs.map +1 -1
  52. package/dist/esm/sync/mutationEngine.native.js +51 -21
  53. package/dist/esm/sync/mutationEngine.native.js.map +1 -1
  54. package/dist/esm/sync/subscriptionManager.mjs +4 -0
  55. package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
  56. package/dist/esm/sync/subscriptionManager.native.js +4 -0
  57. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  58. package/dist/esm/sync/types.mjs.map +1 -1
  59. package/dist/esm/sync/types.native.js.map +1 -1
  60. package/dist/jsx/collection.mjs +24 -9
  61. package/dist/jsx/collection.mjs.map +1 -1
  62. package/dist/jsx/collection.native.js +25 -9
  63. package/dist/jsx/collection.native.js.map +1 -1
  64. package/dist/jsx/db.mjs +13 -2
  65. package/dist/jsx/db.mjs.map +1 -1
  66. package/dist/jsx/db.native.js +18 -2
  67. package/dist/jsx/db.native.js.map +1 -1
  68. package/dist/jsx/frappeReact.mjs +11 -4
  69. package/dist/jsx/frappeReact.mjs.map +1 -1
  70. package/dist/jsx/frappeReact.native.js +10 -3
  71. package/dist/jsx/frappeReact.native.js.map +1 -1
  72. package/dist/jsx/index.js +2 -2
  73. package/dist/jsx/index.js.map +1 -1
  74. package/dist/jsx/index.mjs +2 -2
  75. package/dist/jsx/index.mjs.map +1 -1
  76. package/dist/jsx/index.native.js +1 -0
  77. package/dist/jsx/index.native.js.map +1 -1
  78. package/dist/jsx/realtime.mjs +222 -11
  79. package/dist/jsx/realtime.mjs.map +1 -1
  80. package/dist/jsx/realtime.native.js +352 -14
  81. package/dist/jsx/realtime.native.js.map +1 -1
  82. package/dist/jsx/sync/index.mjs +6 -1
  83. package/dist/jsx/sync/index.mjs.map +1 -1
  84. package/dist/jsx/sync/index.native.js +6 -1
  85. package/dist/jsx/sync/index.native.js.map +1 -1
  86. package/dist/jsx/sync/mutationEngine.mjs +43 -19
  87. package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
  88. package/dist/jsx/sync/mutationEngine.native.js +51 -21
  89. package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
  90. package/dist/jsx/sync/subscriptionManager.mjs +4 -0
  91. package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
  92. package/dist/jsx/sync/subscriptionManager.native.js +4 -0
  93. package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
  94. package/dist/jsx/sync/types.mjs.map +1 -1
  95. package/dist/jsx/sync/types.native.js.map +1 -1
  96. package/package.json +7 -7
  97. package/src/collection.ts +53 -8
  98. package/src/db.spec.ts +70 -0
  99. package/src/db.ts +28 -2
  100. package/src/frappeReact.spec.ts +7 -1
  101. package/src/frappeReact.ts +30 -6
  102. package/src/frappeReactLiveQueryGc.spec.ts +44 -4
  103. package/src/index.ts +13 -1
  104. package/src/realtime.ts +406 -14
  105. package/src/realtimeSse.spec.ts +547 -0
  106. package/src/sync/index.ts +10 -1
  107. package/src/sync/mutationEngine.ts +81 -20
  108. package/src/sync/optimisticCreateReconcile.spec.ts +372 -0
  109. package/src/sync/subscriptionManager.spec.ts +41 -0
  110. package/src/sync/subscriptionManager.ts +10 -0
  111. package/src/sync/subscriptions.spec.ts +4 -0
  112. package/src/sync/types.ts +9 -0
  113. package/types/collection.d.ts +12 -0
  114. package/types/collection.d.ts.map +1 -1
  115. package/types/db.d.ts.map +1 -1
  116. package/types/frappeReact.d.ts.map +1 -1
  117. package/types/index.d.ts +2 -1
  118. package/types/index.d.ts.map +1 -1
  119. package/types/realtime.d.ts +166 -1
  120. package/types/realtime.d.ts.map +1 -1
  121. package/types/sync/index.d.ts.map +1 -1
  122. package/types/sync/mutationEngine.d.ts.map +1 -1
  123. package/types/sync/subscriptionManager.d.ts.map +1 -1
  124. package/types/sync/types.d.ts +9 -0
  125. package/types/sync/types.d.ts.map +1 -1
@@ -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
+ });
@@ -128,6 +128,46 @@ describe("SubscriptionManager", () => {
128
128
  fields: ["title", "description", "modified", "name"],
129
129
  filters: { status: "Active" },
130
130
  orderBy: { field: "modified", order: Order.Desc },
131
+ limit: 20,
132
+ });
133
+ });
134
+
135
+ it("passes an explicit limit (and limitStart) through to the initial fetch", async () => {
136
+ // Regression: the initial fetch used to drop query.limit entirely, so
137
+ // the request carried no limit_page_length and the bench clamped every
138
+ // collection to its default page length (20) — `useFrappeCollection`
139
+ // consumers with `limit: N > 20` silently lost rows (pokemon moves
140
+ // accordion truncation).
141
+ const query: SubscriptionQuery = {
142
+ doctype: "TestDoc",
143
+ fields: ["title"],
144
+ limit: 500,
145
+ limitStart: 40,
146
+ };
147
+
148
+ await subscriptionManager.subscribe(query);
149
+
150
+ expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
151
+ fields: ["title", "modified", "name"],
152
+ filters: undefined,
153
+ orderBy: undefined,
154
+ limit: 500,
155
+ limitStart: 40,
156
+ });
157
+ });
158
+
159
+ it("omits limit from the initial fetch when the query has none", async () => {
160
+ const query: SubscriptionQuery = {
161
+ doctype: "TestDoc",
162
+ fields: ["title"],
163
+ };
164
+
165
+ await subscriptionManager.subscribe(query);
166
+
167
+ expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
168
+ fields: ["title", "modified", "name"],
169
+ filters: undefined,
170
+ orderBy: undefined,
131
171
  });
132
172
  });
133
173
 
@@ -183,6 +223,7 @@ describe("SubscriptionManager", () => {
183
223
 
184
224
  expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
185
225
  fields: ["*", "modified", "name"],
226
+ limit: 20,
186
227
  cursor: undefined,
187
228
  });
188
229
  expect(result.rows).toEqual(mockDocs);
@@ -168,6 +168,16 @@ export class SubscriptionManager {
168
168
  filters: query.filters,
169
169
  orderBy: query.orderBy,
170
170
  };
171
+ // Honor an explicit subscription limit on the INITIAL fetch. Without
172
+ // this the request carries no limit_page_length and the bench clamps
173
+ // to its default page length (20) — collections created with
174
+ // `limit: N` silently truncated to 20 rows (surfaced by the pokemon
175
+ // moves accordion once rosters grew past 20 junction rows). No
176
+ // explicit limit keeps the previous no-param behavior.
177
+ if (query.limit != null) {
178
+ listOptions.limit = query.limit;
179
+ if (query.limitStart != null) listOptions.limitStart = query.limitStart;
180
+ }
171
181
  if (this.options.useCursorPagination) listOptions.cursor = undefined;
172
182
  const response = await this.db.getDocList<Data>(query.doctype, listOptions);
173
183
 
@@ -134,6 +134,9 @@ describe("SubscriptionManager", () => {
134
134
  fields: ["title", "description", "modified", "name"],
135
135
  filters: { status: "Active" },
136
136
  orderBy: { field: "modified", order: Order.Desc },
137
+ // The initial fetch honors the subscription limit (it used to drop
138
+ // it, so the bench clamped every list to its default page length).
139
+ limit: 20,
137
140
  });
138
141
  });
139
142
 
@@ -189,6 +192,7 @@ describe("SubscriptionManager", () => {
189
192
 
190
193
  expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
191
194
  fields: ["*", "modified", "name"],
195
+ limit: 20,
192
196
  cursor: undefined,
193
197
  });
194
198
  expect(result.rows).toEqual(mockDocs);
package/src/sync/types.ts CHANGED
@@ -106,6 +106,15 @@ export interface CreateMutation<D extends FrappeDocData> {
106
106
  doc: FrappeDoc<D>;
107
107
  version?: string;
108
108
  affected?: { doctype: string; name: string }[];
109
+ /**
110
+ * Client-side temporary identity the optimistic row was inserted under.
111
+ * Set by the engine when the mutation is applied: equals `doc.name` when
112
+ * the consumer provided a placeholder, otherwise an engine-generated
113
+ * `tmp_*` id (which is never sent to the server). The server ack remap,
114
+ * rollback, and journal paths all resolve the optimistic row through
115
+ * this single field.
116
+ */
117
+ temporaryName?: string;
109
118
  }
110
119
 
111
120
  export interface UpdateMutation<Data extends FrappeDocData> {
@@ -30,6 +30,18 @@ export interface FrappeCollectionOptionsConfig<_TDoc extends object & {
30
30
  */
31
31
  onSyncError?: (error: unknown) => void;
32
32
  }
33
+ /**
34
+ * Releases the Frappe-side sync resources (SyncModule subscription + store
35
+ * listener → realtime room) of a collection created by
36
+ * {@link createFrappeCollection}, without touching TanStack DB's lifecycle.
37
+ * Idempotent; also runs automatically when TanStack stops the sync itself.
38
+ *
39
+ * @returns true when an active sync session was released, false when the
40
+ * collection has no registered session (sync never started, already
41
+ * stopped, or not a Frappe collection) — callers may fall back to
42
+ * `collection.cleanup()` in that case.
43
+ */
44
+ export declare function releaseFrappeCollectionSync(collection: object): boolean;
33
45
  /**
34
46
  * Creates TanStack DB collection options wired to Frappe's SyncModule.
35
47
  *