@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.
- package/dist/cjs/collection.cjs +25 -9
- package/dist/cjs/collection.native.js +25 -9
- package/dist/cjs/collection.native.js.map +1 -1
- package/dist/cjs/db.cjs +13 -2
- package/dist/cjs/db.native.js +18 -2
- package/dist/cjs/db.native.js.map +1 -1
- package/dist/cjs/frappeReact.cjs +10 -3
- package/dist/cjs/frappeReact.native.js +10 -3
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/realtime.cjs +222 -11
- package/dist/cjs/realtime.native.js +352 -14
- package/dist/cjs/realtime.native.js.map +1 -1
- package/dist/cjs/sync/index.cjs +6 -1
- package/dist/cjs/sync/index.native.js +6 -1
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/mutationEngine.cjs +43 -19
- package/dist/cjs/sync/mutationEngine.native.js +51 -21
- package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
- package/dist/cjs/sync/subscriptionManager.cjs +4 -0
- package/dist/cjs/sync/subscriptionManager.native.js +4 -0
- package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
- package/dist/cjs/sync/types.native.js.map +1 -1
- package/dist/esm/collection.mjs +24 -9
- package/dist/esm/collection.mjs.map +1 -1
- package/dist/esm/collection.native.js +24 -9
- package/dist/esm/collection.native.js.map +1 -1
- package/dist/esm/db.mjs +13 -2
- package/dist/esm/db.mjs.map +1 -1
- package/dist/esm/db.native.js +18 -2
- package/dist/esm/db.native.js.map +1 -1
- package/dist/esm/frappeReact.mjs +11 -4
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +11 -4
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.mjs +2 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/realtime.mjs +222 -11
- package/dist/esm/realtime.mjs.map +1 -1
- package/dist/esm/realtime.native.js +352 -14
- package/dist/esm/realtime.native.js.map +1 -1
- package/dist/esm/sync/index.mjs +6 -1
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +6 -1
- package/dist/esm/sync/index.native.js.map +1 -1
- package/dist/esm/sync/mutationEngine.mjs +43 -19
- package/dist/esm/sync/mutationEngine.mjs.map +1 -1
- package/dist/esm/sync/mutationEngine.native.js +51 -21
- package/dist/esm/sync/mutationEngine.native.js.map +1 -1
- package/dist/esm/sync/subscriptionManager.mjs +4 -0
- package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
- package/dist/esm/sync/subscriptionManager.native.js +4 -0
- package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
- package/dist/esm/sync/types.mjs.map +1 -1
- package/dist/esm/sync/types.native.js.map +1 -1
- package/dist/jsx/collection.mjs +24 -9
- package/dist/jsx/collection.mjs.map +1 -1
- package/dist/jsx/collection.native.js +25 -9
- package/dist/jsx/collection.native.js.map +1 -1
- package/dist/jsx/db.mjs +13 -2
- package/dist/jsx/db.mjs.map +1 -1
- package/dist/jsx/db.native.js +18 -2
- package/dist/jsx/db.native.js.map +1 -1
- package/dist/jsx/frappeReact.mjs +11 -4
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +10 -3
- package/dist/jsx/frappeReact.native.js.map +1 -1
- package/dist/jsx/index.js +2 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +2 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +1 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/realtime.mjs +222 -11
- package/dist/jsx/realtime.mjs.map +1 -1
- package/dist/jsx/realtime.native.js +352 -14
- package/dist/jsx/realtime.native.js.map +1 -1
- package/dist/jsx/sync/index.mjs +6 -1
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +6 -1
- package/dist/jsx/sync/index.native.js.map +1 -1
- package/dist/jsx/sync/mutationEngine.mjs +43 -19
- package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
- package/dist/jsx/sync/mutationEngine.native.js +51 -21
- package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
- package/dist/jsx/sync/subscriptionManager.mjs +4 -0
- package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
- package/dist/jsx/sync/subscriptionManager.native.js +4 -0
- package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
- package/dist/jsx/sync/types.mjs.map +1 -1
- package/dist/jsx/sync/types.native.js.map +1 -1
- package/package.json +7 -7
- package/src/collection.ts +53 -8
- package/src/db.spec.ts +70 -0
- package/src/db.ts +28 -2
- package/src/frappeReact.spec.ts +7 -1
- package/src/frappeReact.ts +30 -6
- package/src/frappeReactLiveQueryGc.spec.ts +44 -4
- package/src/index.ts +13 -1
- package/src/realtime.ts +406 -14
- package/src/realtimeSse.spec.ts +547 -0
- package/src/sync/index.ts +10 -1
- package/src/sync/mutationEngine.ts +81 -20
- package/src/sync/optimisticCreateReconcile.spec.ts +372 -0
- package/src/sync/subscriptionManager.spec.ts +41 -0
- package/src/sync/subscriptionManager.ts +10 -0
- package/src/sync/subscriptions.spec.ts +4 -0
- package/src/sync/types.ts +9 -0
- package/types/collection.d.ts +12 -0
- package/types/collection.d.ts.map +1 -1
- package/types/db.d.ts.map +1 -1
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +2 -1
- package/types/index.d.ts.map +1 -1
- package/types/realtime.d.ts +166 -1
- package/types/realtime.d.ts.map +1 -1
- package/types/sync/index.d.ts.map +1 -1
- package/types/sync/mutationEngine.d.ts.map +1 -1
- package/types/sync/subscriptionManager.d.ts.map +1 -1
- package/types/sync/types.d.ts +9 -0
- 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
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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> {
|
package/types/collection.d.ts
CHANGED
|
@@ -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
|
*
|