@multiplatform.one/frappe 7.10.0 → 7.11.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/README.md +58 -0
- package/dist/cjs/frappeReact.cjs +54 -30
- package/dist/cjs/frappeReact.native.js +67 -32
- 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/sync/index.cjs +91 -14
- package/dist/cjs/sync/index.native.js +119 -14
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/types.native.js.map +1 -1
- package/dist/cjs/syncOptionsKey.cjs +59 -0
- package/dist/cjs/syncOptionsKey.native.js +82 -0
- package/dist/cjs/syncOptionsKey.native.js.map +1 -0
- package/dist/esm/frappeReact.mjs +55 -32
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +68 -34
- 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/sync/index.mjs +91 -14
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +119 -14
- package/dist/esm/sync/index.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/esm/syncOptionsKey.mjs +34 -0
- package/dist/esm/syncOptionsKey.mjs.map +1 -0
- package/dist/esm/syncOptionsKey.native.js +54 -0
- package/dist/esm/syncOptionsKey.native.js.map +1 -0
- package/dist/jsx/frappeReact.mjs +55 -32
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +67 -32
- 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/sync/index.mjs +91 -14
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +119 -14
- package/dist/jsx/sync/index.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/dist/jsx/syncOptionsKey.mjs +34 -0
- package/dist/jsx/syncOptionsKey.mjs.map +1 -0
- package/dist/jsx/syncOptionsKey.native.js +82 -0
- package/dist/jsx/syncOptionsKey.native.js.map +1 -0
- package/package.json +9 -9
- package/src/frappeReact.catchUp.spec.tsx +105 -0
- package/src/frappeReact.ts +62 -38
- package/src/index.ts +3 -0
- package/src/sync/catchUp.spec.ts +142 -0
- package/src/sync/index.ts +97 -9
- package/src/sync/types.ts +9 -1
- package/src/syncOptionsKey.spec.ts +46 -0
- package/src/syncOptionsKey.ts +45 -0
- package/types/frappeReact.d.ts +6 -1
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +3 -3
- package/types/index.d.ts.map +1 -1
- package/types/sync/index.d.ts +9 -1
- package/types/sync/index.d.ts.map +1 -1
- package/types/sync/types.d.ts +8 -1
- package/types/sync/types.d.ts.map +1 -1
- package/types/syncOptionsKey.d.ts +3 -0
- package/types/syncOptionsKey.d.ts.map +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all) __defProp(target, name, {
|
|
9
|
+
get: all[name],
|
|
10
|
+
enumerable: true
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
16
|
+
get: () => from[key],
|
|
17
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toCommonJS = mod => __copyProps(__defProp({}, "__esModule", {
|
|
23
|
+
value: true
|
|
24
|
+
}), mod);
|
|
25
|
+
var syncOptionsKey_exports = {};
|
|
26
|
+
__export(syncOptionsKey_exports, {
|
|
27
|
+
syncOptionsKey: () => syncOptionsKey
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(syncOptionsKey_exports);
|
|
30
|
+
function _type_of(obj) {
|
|
31
|
+
"@swc/helpers - typeof";
|
|
32
|
+
|
|
33
|
+
return obj && typeof Symbol !== "undefined" && obj.constructor === Symbol ? "symbol" : typeof obj;
|
|
34
|
+
}
|
|
35
|
+
var identities = /* @__PURE__ */new WeakMap();
|
|
36
|
+
var nextIdentity = 0;
|
|
37
|
+
function identity(value) {
|
|
38
|
+
var id = identities.get(value);
|
|
39
|
+
if (id === void 0) {
|
|
40
|
+
id = ++nextIdentity;
|
|
41
|
+
identities.set(value, id);
|
|
42
|
+
}
|
|
43
|
+
return ["reference", id];
|
|
44
|
+
}
|
|
45
|
+
function stableValue(value) {
|
|
46
|
+
var ancestors = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : /* @__PURE__ */new Set();
|
|
47
|
+
if (typeof value === "function") return identity(value);
|
|
48
|
+
if (value === null || (typeof value === "undefined" ? "undefined" : _type_of(value)) !== "object") return [typeof value === "undefined" ? "undefined" : _type_of(value), String(value)];
|
|
49
|
+
var prototype = Object.getPrototypeOf(value);
|
|
50
|
+
if (ancestors.has(value) || !Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
|
|
51
|
+
return identity(value);
|
|
52
|
+
}
|
|
53
|
+
var nested = new Set(ancestors).add(value);
|
|
54
|
+
if (Array.isArray(value)) return ["array", value.map(function (item) {
|
|
55
|
+
return stableValue(item, nested);
|
|
56
|
+
})];
|
|
57
|
+
return ["record", Object.entries(value).filter(function (param) {
|
|
58
|
+
var [, item] = param;
|
|
59
|
+
return item !== void 0;
|
|
60
|
+
}).sort(function (param, param1) {
|
|
61
|
+
var [left] = param,
|
|
62
|
+
[right] = param1;
|
|
63
|
+
return left.localeCompare(right);
|
|
64
|
+
}).map(function (param) {
|
|
65
|
+
var [key, item] = param;
|
|
66
|
+
return [key, stableValue(item, nested)];
|
|
67
|
+
})];
|
|
68
|
+
}
|
|
69
|
+
function syncOptionsKey() {
|
|
70
|
+
var options = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : {};
|
|
71
|
+
var _options_catchUpStrategy;
|
|
72
|
+
var {
|
|
73
|
+
storage,
|
|
74
|
+
...values
|
|
75
|
+
} = options;
|
|
76
|
+
return JSON.stringify([stableValue({
|
|
77
|
+
...values,
|
|
78
|
+
catchUpStrategy: (_options_catchUpStrategy = options.catchUpStrategy) !== null && _options_catchUpStrategy !== void 0 ? _options_catchUpStrategy : "cdc",
|
|
79
|
+
cdcInterval: options.cdcInterval && options.cdcInterval > 0 ? options.cdcInterval : 0
|
|
80
|
+
}), storage ? identity(storage) : null]);
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=syncOptionsKey.native.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["__defProp","Object","defineProperty","__getOwnPropDesc","getOwnPropertyDescriptor","__getOwnPropNames","getOwnPropertyNames","__hasOwnProp","prototype","hasOwnProperty","__export","target","all","name","get","enumerable","__copyProps","to","from","except","desc","key","call","__toCommonJS","mod","value","syncOptionsKey_exports","syncOptionsKey","module","exports","_type_of","obj","Symbol","constructor","identities","WeakMap","nextIdentity","identity","id","set"],"sources":["../../src/syncOptionsKey.ts"],"sourcesContent":[null],"mappings":"AAEA,YAAM;;AACN,IAAIA,SAAA,GAAAC,MAAe,CAAAC,cAAA;AACnB,IAAAC,gBAAkB,GAAAF,MAAe,CAAAG,wBAAA;AAC/B,IAAAC,iBAAS,GAAWJ,MAAI,CAAKK,mBAAA;AAC7B,IAAAC,YAAW,GAAAN,MAAW,CAAAO,SAAA,CAAAC,cAAA;AACpB,IAAAC,QAAO,GAAAA,CAAAC,MAAA,EAAAC,GAAA;EACP,SAAAC,IAAW,IAAID,GAAA,EACjBZ,SAAA,CAAAW,MAAA,EAAAE,IAAA;IAAAC,GAAA,EAAAF,GAAA,CAAAC,IAAA;IAAAE,UAAA;EAAA;AACA;AACF,IAAAC,WAAA,GAAAA,CAAAC,EAAA,EAAAC,IAAA,EAAAC,MAAA,EAAAC,IAAA;EAEA,IAAAF,IAAS,WAAYA,IAAA,KAAgB,YAAY,OAAAA,IAAA,eAA4B;IAC3E,KAAI,IAAOG,GAAA,IAAAhB,iBAAsB,CAAAa,IAAO,GACpC,KAAAX,YAAU,CAAQe,IAAA,CAAAL,EAAO,EAAAI,GAAA,KAAUA,GAAA,KAAAF,MAAU,EAC3CnB,SAAA,CAAAiB,EAAY,EAAAI,GAAA,EAAO;MAAAP,GAAA,EAAAA,CAAA,KAAAI,IAAe,CAAAG,GAAK;MAAAN,UAAA,IAAAK,IAAA,GAAAjB,gBAAA,CAAAe,IAAA,EAAAG,GAAA,MAAAD,IAAA,CAAAL;IAAA;EAC7C;EAIE,OAAAE,EAAO;AAAc;AAEvB,IAAAM,YAAM,GAASC,GAAI,IAAIR,WAAW,CAAIhB,SAAK;EAAAyB,KAAA;AAAA,IAAAD,GAAA;AAC3C,IAAAE,sBAAuB,GAAG;AAC1BhB,QAAA,CAAOgB,sBAAA;EAAAC,cACL,EAAAA,CAAA,KAAAA;AAAA;AAIwDC,MAC1D,CAAAC,OAAA,GAAAN,YAAA,CAAAG,sBAAA;AACF,SAAAI,SAAAC,GAAA;EAEO,uBAAwB;;EAC7B,OAAMA,GAAE,WAAYC,MAAO,KAAI,eAAAD,GAAA,CAAAE,WAAA,KAAAD,MAAA,qBAAAD,GAAA;AAC/B;AAAsB,IACpBG,UAAA,GAAY,mBAAAC,OAAA;AAAA,IAAAC,YACP;AAAA,SACHC,SAAAZ,KAAiB;EAA2B,IAC5Ca,EAAA,GAAAJ,UAAa,CAAApB,GAAA,CAAAW,KAAQ;EAA+D,IACrFa,EAAA;IACDA,EAAA,KAAAF,YAAmB;IACpBF,UAAA,CAAAK,GAAA,CAAAd,KAAA,EAAAa,EAAA;EACH","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/frappe",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.11.0",
|
|
4
4
|
"description": "frappe client for multiplatform.one ecosystem",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
@@ -60,15 +60,18 @@
|
|
|
60
60
|
"access": "public"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
+
"@multiplatform.one/store": "7.11.0",
|
|
63
64
|
"@tanstack/db": "0.6.5",
|
|
64
65
|
"@tanstack/pacer": "0.21.0",
|
|
65
66
|
"@tanstack/react-db": "0.1.83",
|
|
66
67
|
"@tanstack/react-pacer": "0.22.0",
|
|
67
68
|
"broadcast-channel": "^7.3.0",
|
|
68
|
-
"socket.io-client": "^4.8.3"
|
|
69
|
-
"@multiplatform.one/store": "7.10.0"
|
|
69
|
+
"socket.io-client": "^4.8.3"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
|
+
"@multiplatform.one/config": "7.11.0",
|
|
73
|
+
"@multiplatform.one/platform": "7.11.0",
|
|
74
|
+
"@multiplatform.one/test-utils": "7.11.0",
|
|
72
75
|
"@tamagui/build": "2.7.6",
|
|
73
76
|
"@testing-library/jest-dom": "^6.9.1",
|
|
74
77
|
"@testing-library/react": "^16.3.2",
|
|
@@ -78,14 +81,11 @@
|
|
|
78
81
|
"react": "19.2.5",
|
|
79
82
|
"tamagui": "2.7.6",
|
|
80
83
|
"typescript": "~5.9.3",
|
|
81
|
-
"vitest": "^4.1.5"
|
|
82
|
-
"@multiplatform.one/config": "7.10.0",
|
|
83
|
-
"@multiplatform.one/platform": "7.10.0",
|
|
84
|
-
"@multiplatform.one/test-utils": "7.10.0"
|
|
84
|
+
"vitest": "^4.1.5"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
|
-
"
|
|
88
|
-
"
|
|
87
|
+
"@multiplatform.one/platform": "^7.11.0",
|
|
88
|
+
"react": "^19.1.0"
|
|
89
89
|
},
|
|
90
90
|
"engines": {
|
|
91
91
|
"node": ">=6.0.0"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import { getFrappeSyncModule, useFrappeCatchUp, useFrappePendingMutations } from "./frappeReact";
|
|
4
|
+
import { Operation, Status } from "./sync/types";
|
|
5
|
+
import { InMemoryFixtureProvider } from "./fixtures";
|
|
6
|
+
import type { SyncModule } from "./sync";
|
|
7
|
+
import type { UseFrappeCollectionConfig } from "./frappeReact";
|
|
8
|
+
|
|
9
|
+
const modules = new Set<SyncModule>();
|
|
10
|
+
function moduleFor(config: UseFrappeCollectionConfig) {
|
|
11
|
+
const sync = getFrappeSyncModule(config)!;
|
|
12
|
+
expect(sync).toBeDefined();
|
|
13
|
+
modules.add(sync);
|
|
14
|
+
return sync;
|
|
15
|
+
}
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
for (const sync of modules) await sync.dispose();
|
|
18
|
+
modules.clear();
|
|
19
|
+
vi.unstubAllGlobals();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it.each([false, true])(
|
|
23
|
+
"separates effective options in the shared cache, fixtures=%s",
|
|
24
|
+
(fixture) => {
|
|
25
|
+
const config = fixture
|
|
26
|
+
? { fixtures: new InMemoryFixtureProvider({}) }
|
|
27
|
+
: { baseURL: "https://cache-options.test", realtime: false };
|
|
28
|
+
const defaultSync = moduleFor(config);
|
|
29
|
+
expect(moduleFor({ ...config, syncOptions: { catchUpStrategy: "cdc", cdcInterval: 0 } })).toBe(
|
|
30
|
+
defaultSync,
|
|
31
|
+
);
|
|
32
|
+
const snapshot = moduleFor({ ...config, syncOptions: { catchUpStrategy: "snapshot" } });
|
|
33
|
+
expect(snapshot).not.toBe(defaultSync);
|
|
34
|
+
expect(moduleFor({ ...config, syncOptions: { catchUpStrategy: "snapshot" } })).toBe(snapshot);
|
|
35
|
+
expect(moduleFor({ ...config, syncOptions: { cdcInterval: 1000 } })).not.toBe(defaultSync);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
it("follows the selected module, retains failure state during retry and does not retry on render", async () => {
|
|
40
|
+
const transport = vi.fn(async () => Response.json({ data: [{ name: "a", doctype: "Task" }] }));
|
|
41
|
+
vi.stubGlobal("fetch", transport);
|
|
42
|
+
const cdc = { baseURL: "https://hook-options.test", realtime: false };
|
|
43
|
+
const snapshot = { ...cdc, syncOptions: { catchUpStrategy: "snapshot" as const } };
|
|
44
|
+
const first = moduleFor(cdc);
|
|
45
|
+
const second = moduleFor(snapshot);
|
|
46
|
+
await first.subscribe({ doctype: "Task" });
|
|
47
|
+
await second.subscribe({ doctype: "Task" });
|
|
48
|
+
const view = renderHook(({ config }) => useFrappeCatchUp(config), {
|
|
49
|
+
initialProps: { config: cdc as UseFrappeCollectionConfig | undefined },
|
|
50
|
+
});
|
|
51
|
+
transport.mockImplementation(async () =>
|
|
52
|
+
Response.json({ message: "unavailable" }, { status: 404 }),
|
|
53
|
+
);
|
|
54
|
+
await act(async () => {
|
|
55
|
+
await view.result.current.retry();
|
|
56
|
+
});
|
|
57
|
+
expect(view.result.current).toMatchObject({
|
|
58
|
+
refreshing: false,
|
|
59
|
+
stale: true,
|
|
60
|
+
error: { status: 404 },
|
|
61
|
+
});
|
|
62
|
+
const calls = transport.mock.calls.length;
|
|
63
|
+
view.rerender({ config: { ...cdc } });
|
|
64
|
+
expect(transport).toHaveBeenCalledTimes(calls);
|
|
65
|
+
view.rerender({ config: snapshot });
|
|
66
|
+
await waitFor(() => expect(view.result.current.stale).toBe(false));
|
|
67
|
+
transport.mockImplementation(async () => Response.json({ data: [] }));
|
|
68
|
+
await act(async () => {
|
|
69
|
+
await view.result.current.retry();
|
|
70
|
+
});
|
|
71
|
+
expect(view.result.current.error).toBeNull();
|
|
72
|
+
expect(second.__debug().subscriptions[0].docCount).toBe(0);
|
|
73
|
+
expect(first.__debug().subscriptions[0].docCount).toBe(1);
|
|
74
|
+
view.rerender({ config: undefined });
|
|
75
|
+
expect(await view.result.current.retry()).toEqual({
|
|
76
|
+
refreshing: false,
|
|
77
|
+
stale: false,
|
|
78
|
+
error: null,
|
|
79
|
+
});
|
|
80
|
+
view.unmount();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("drops the previous module's mutation notice when changed options select an empty ledger", () => {
|
|
84
|
+
const config = { fixtures: new InMemoryFixtureProvider({}) };
|
|
85
|
+
const next = { ...config, syncOptions: { catchUpStrategy: "snapshot" as const } };
|
|
86
|
+
vi.spyOn(moduleFor(config), "getMutationEntries").mockReturnValue([
|
|
87
|
+
{
|
|
88
|
+
id: "old",
|
|
89
|
+
operation: Operation.Update,
|
|
90
|
+
doctype: "Task",
|
|
91
|
+
status: Status.Failed,
|
|
92
|
+
createdAt: 1,
|
|
93
|
+
retryCount: 0,
|
|
94
|
+
error: "old module failure",
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
moduleFor(next);
|
|
98
|
+
const view = renderHook(({ connection }) => useFrappePendingMutations(connection), {
|
|
99
|
+
initialProps: { connection: config as UseFrappeCollectionConfig },
|
|
100
|
+
});
|
|
101
|
+
expect(view.result.current.failed.map((entry) => entry.id)).toEqual(["old"]);
|
|
102
|
+
view.rerender({ connection: next });
|
|
103
|
+
expect(view.result.current.entries).toEqual([]);
|
|
104
|
+
view.unmount();
|
|
105
|
+
});
|
package/src/frappeReact.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isWindowDefined } from "@multiplatform.one/platform";
|
|
2
2
|
import type { Collection } from "@tanstack/db";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import {
|
|
5
5
|
createFrappeCollection,
|
|
6
6
|
releaseFrappeCollectionSync,
|
|
@@ -13,7 +13,8 @@ import { FixtureDbModule } from "./fixtureDb";
|
|
|
13
13
|
import { RealtimeModule } from "./realtime";
|
|
14
14
|
import { SyncModule } from "./sync";
|
|
15
15
|
import { Status } from "./sync/types";
|
|
16
|
-
import type { DebugMutationEntry } from "./sync/types";
|
|
16
|
+
import type { DebugMutationEntry, SyncCatchUpState } from "./sync/types";
|
|
17
|
+
import { syncOptionsKey } from "./syncOptionsKey";
|
|
17
18
|
import { isBrowser } from "./util";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -72,7 +73,10 @@ export interface UseFrappeCollectionConfig {
|
|
|
72
73
|
const syncModuleCache = new Map<string, SyncModule>();
|
|
73
74
|
// Fixture-mode SyncModules keyed by the FixtureProvider instance so each unique
|
|
74
75
|
// fixture set gets its own SyncModule (prevents cross-test cache pollution).
|
|
75
|
-
const fixtureSyncModuleCache = new WeakMap<
|
|
76
|
+
const fixtureSyncModuleCache = new WeakMap<
|
|
77
|
+
import("./fixtures").FixtureProvider,
|
|
78
|
+
Map<string, SyncModule>
|
|
79
|
+
>();
|
|
76
80
|
|
|
77
81
|
/**
|
|
78
82
|
* Returns the first active SyncModule, or undefined if none exist.
|
|
@@ -115,13 +119,19 @@ export function getFrappeSyncModule(
|
|
|
115
119
|
*/
|
|
116
120
|
function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefined {
|
|
117
121
|
if (!isBrowser()) return undefined;
|
|
122
|
+
const optionsKey = syncOptionsKey(config.syncOptions);
|
|
118
123
|
|
|
119
124
|
if (config.fixtures) {
|
|
120
|
-
let
|
|
125
|
+
let cache = fixtureSyncModuleCache.get(config.fixtures);
|
|
126
|
+
if (!cache) {
|
|
127
|
+
cache = new Map();
|
|
128
|
+
fixtureSyncModuleCache.set(config.fixtures, cache);
|
|
129
|
+
}
|
|
130
|
+
let sync = cache.get(optionsKey);
|
|
121
131
|
if (!sync) {
|
|
122
132
|
const db = new FixtureDbModule(config.fixtures) as unknown as DbModule;
|
|
123
133
|
sync = new SyncModule(db, undefined, config.syncOptions);
|
|
124
|
-
|
|
134
|
+
cache.set(optionsKey, sync);
|
|
125
135
|
}
|
|
126
136
|
return sync;
|
|
127
137
|
}
|
|
@@ -143,17 +153,10 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
|
|
|
143
153
|
config.socketPort ?? "",
|
|
144
154
|
config.socketBaseURL ?? "",
|
|
145
155
|
config.siteName ?? "",
|
|
156
|
+
optionsKey,
|
|
146
157
|
].join("|");
|
|
147
158
|
let sync = syncModuleCache.get(key);
|
|
148
159
|
if (!sync) {
|
|
149
|
-
// The FIRST config for a baseURL wins for every later consumer (the
|
|
150
|
-
// module is shared) — surface what it was constructed with so a
|
|
151
|
-
// missing persistKey/cdcInterval is traceable to the winning mount.
|
|
152
|
-
console.debug(
|
|
153
|
-
`[frappe-sync] SyncModule created for ${key} ` +
|
|
154
|
-
`(persistKey=${config.syncOptions?.persistKey ?? "none"}, ` +
|
|
155
|
-
`cdcInterval=${config.syncOptions?.cdcInterval ?? "off"})`,
|
|
156
|
-
);
|
|
157
160
|
const authConfig: AuthConfig | undefined = config.auth
|
|
158
161
|
? {
|
|
159
162
|
useToken: config.auth.useToken,
|
|
@@ -580,6 +583,31 @@ export function useFrappeCollection<TDoc extends object & { name: string; doctyp
|
|
|
580
583
|
// Connection / realtime hooks
|
|
581
584
|
// ---------------------------------------------------------------------------
|
|
582
585
|
|
|
586
|
+
export interface UseFrappeCatchUpResult extends SyncCatchUpState {
|
|
587
|
+
retry: () => Promise<SyncCatchUpState>;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const idleCatchUpState: SyncCatchUpState = Object.freeze({
|
|
591
|
+
refreshing: false,
|
|
592
|
+
stale: false,
|
|
593
|
+
error: null,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
/** Connection-level refresh qualification. Retry resolves with state, including failures. */
|
|
597
|
+
export function useFrappeCatchUp(
|
|
598
|
+
config: UseFrappeCollectionConfig | undefined,
|
|
599
|
+
): UseFrappeCatchUpResult {
|
|
600
|
+
const sync = getFrappeSyncModule(config);
|
|
601
|
+
const subscribe = useCallback(
|
|
602
|
+
(listener: () => void) => sync?.subscribeStore(listener) ?? (() => {}),
|
|
603
|
+
[sync],
|
|
604
|
+
);
|
|
605
|
+
const snapshot = useCallback(() => sync?.getCatchUpState() ?? idleCatchUpState, [sync]);
|
|
606
|
+
const state = useSyncExternalStore(subscribe, snapshot, () => idleCatchUpState);
|
|
607
|
+
const retry = useCallback(() => sync?.catchUp() ?? Promise.resolve(idleCatchUpState), [sync]);
|
|
608
|
+
return useMemo(() => ({ ...state, retry }), [state, retry]);
|
|
609
|
+
}
|
|
610
|
+
|
|
583
611
|
/** Snapshot returned by {@link useFrappeConnection}. */
|
|
584
612
|
export interface FrappeConnectionState {
|
|
585
613
|
/** Socket connected right now. */
|
|
@@ -605,14 +633,14 @@ export function useFrappeConnection(
|
|
|
605
633
|
config: UseFrappeCollectionConfig | undefined,
|
|
606
634
|
): FrappeConnectionState {
|
|
607
635
|
const [state, setState] = useState<FrappeConnectionState>(DISCONNECTED_STATE);
|
|
636
|
+
const syncModule =
|
|
637
|
+
config && !config.fixtures && config.realtime !== false ? getSyncModule(config) : undefined;
|
|
608
638
|
|
|
609
639
|
useEffect(() => {
|
|
610
|
-
if (!
|
|
640
|
+
if (!syncModule) {
|
|
611
641
|
setState(DISCONNECTED_STATE);
|
|
612
642
|
return;
|
|
613
643
|
}
|
|
614
|
-
const syncModule = getSyncModule(config);
|
|
615
|
-
if (!syncModule) return;
|
|
616
644
|
|
|
617
645
|
const read = () => {
|
|
618
646
|
const next = syncModule.getConnectionState();
|
|
@@ -625,7 +653,7 @@ export function useFrappeConnection(
|
|
|
625
653
|
read();
|
|
626
654
|
return syncModule.subscribeStore(read);
|
|
627
655
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
628
|
-
}, [
|
|
656
|
+
}, [syncModule]);
|
|
629
657
|
|
|
630
658
|
return config?.realtime === false ? DISCONNECTED_STATE : state;
|
|
631
659
|
}
|
|
@@ -646,18 +674,12 @@ export function useFrappeDoctypeEvents(
|
|
|
646
674
|
): void {
|
|
647
675
|
const handlerRef = useRef(onEvent);
|
|
648
676
|
handlerRef.current = onEvent;
|
|
677
|
+
const syncModule =
|
|
678
|
+
enabled && config && !config.fixtures && config.realtime !== false && doctype
|
|
679
|
+
? getSyncModule(config)
|
|
680
|
+
: undefined;
|
|
649
681
|
|
|
650
682
|
useEffect(() => {
|
|
651
|
-
if (
|
|
652
|
-
!enabled ||
|
|
653
|
-
!config ||
|
|
654
|
-
config.fixtures ||
|
|
655
|
-
config.realtime === false ||
|
|
656
|
-
!doctype ||
|
|
657
|
-
!isBrowser()
|
|
658
|
-
)
|
|
659
|
-
return;
|
|
660
|
-
const syncModule = getSyncModule(config);
|
|
661
683
|
if (!syncModule) return;
|
|
662
684
|
|
|
663
685
|
syncModule.watchDoctype(doctype);
|
|
@@ -672,7 +694,7 @@ export function useFrappeDoctypeEvents(
|
|
|
672
694
|
syncModule.unwatchDoctype(doctype);
|
|
673
695
|
};
|
|
674
696
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
675
|
-
}, [
|
|
697
|
+
}, [syncModule, doctype]);
|
|
676
698
|
}
|
|
677
699
|
|
|
678
700
|
// ---------------------------------------------------------------------------
|
|
@@ -712,17 +734,16 @@ export function useFrappePendingMutations(
|
|
|
712
734
|
): UseFrappePendingMutationsResult {
|
|
713
735
|
const [entries, setEntries] = useState<DebugMutationEntry[]>(EMPTY_LEDGER);
|
|
714
736
|
const syncModuleRef = useRef<SyncModule | undefined>(undefined);
|
|
737
|
+
const syncModule = getFrappeSyncModule(config);
|
|
715
738
|
|
|
716
739
|
useEffect(() => {
|
|
717
|
-
if (!
|
|
740
|
+
if (!syncModule) {
|
|
718
741
|
setEntries(EMPTY_LEDGER);
|
|
719
742
|
return;
|
|
720
743
|
}
|
|
721
|
-
const syncModule = getSyncModule(config);
|
|
722
|
-
if (!syncModule) return;
|
|
723
744
|
syncModuleRef.current = syncModule;
|
|
724
745
|
|
|
725
|
-
let lastSignature
|
|
746
|
+
let lastSignature: string | undefined;
|
|
726
747
|
const read = () => {
|
|
727
748
|
const next = syncModule.getMutationEntries(doctype);
|
|
728
749
|
const signature = next.map((e) => `${e.id}:${e.status}:${e.retryCount}`).join("|");
|
|
@@ -731,9 +752,13 @@ export function useFrappePendingMutations(
|
|
|
731
752
|
setEntries(next.length === 0 ? EMPTY_LEDGER : next);
|
|
732
753
|
};
|
|
733
754
|
read();
|
|
734
|
-
|
|
755
|
+
const unsubscribe = syncModule.subscribeStore(read);
|
|
756
|
+
return () => {
|
|
757
|
+
unsubscribe();
|
|
758
|
+
syncModuleRef.current = undefined;
|
|
759
|
+
};
|
|
735
760
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
736
|
-
}, [
|
|
761
|
+
}, [syncModule, doctype]);
|
|
737
762
|
|
|
738
763
|
const retry = useCallback(async () => {
|
|
739
764
|
await syncModuleRef.current?.replayPending();
|
|
@@ -1039,12 +1064,10 @@ export function useFrappeInfiniteList<TDoc extends object & { name: string; doct
|
|
|
1039
1064
|
|
|
1040
1065
|
// Stable serialised key for filters so we can depend on it in useEffect
|
|
1041
1066
|
const filtersKey = JSON.stringify(filters);
|
|
1067
|
+
const syncModule = getFrappeSyncModule(config);
|
|
1042
1068
|
|
|
1043
1069
|
// Subscribe via SyncModule with progressive: true
|
|
1044
1070
|
useEffect(() => {
|
|
1045
|
-
if (!config || !isBrowser()) return;
|
|
1046
|
-
|
|
1047
|
-
const syncModule = getSyncModule(config);
|
|
1048
1071
|
if (!syncModule) return;
|
|
1049
1072
|
syncModuleRef.current = syncModule;
|
|
1050
1073
|
|
|
@@ -1100,9 +1123,10 @@ export function useFrappeInfiniteList<TDoc extends object & { name: string; doct
|
|
|
1100
1123
|
syncModule.unsubscribe(subscriptionIdRef.current);
|
|
1101
1124
|
subscriptionIdRef.current = undefined;
|
|
1102
1125
|
}
|
|
1126
|
+
syncModuleRef.current = undefined;
|
|
1103
1127
|
};
|
|
1104
1128
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1105
|
-
}, [
|
|
1129
|
+
}, [syncModule, config?.auth?.token, doctype, filtersKey, pageSize]);
|
|
1106
1130
|
|
|
1107
1131
|
const loadMore = useCallback(async () => {
|
|
1108
1132
|
const syncModule = syncModuleRef.current;
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ export type { OptimisticRpcContext, OptimisticRpcOptions } from "./optimistic";
|
|
|
47
47
|
// React hooks for Frappe + TanStack DB
|
|
48
48
|
export {
|
|
49
49
|
useFrappeCollection,
|
|
50
|
+
useFrappeCatchUp,
|
|
50
51
|
useFrappeConnection,
|
|
51
52
|
useFrappeDoctypeEvents,
|
|
52
53
|
useFrappePendingMutations,
|
|
@@ -57,6 +58,7 @@ export {
|
|
|
57
58
|
} from "./frappeReact";
|
|
58
59
|
export type {
|
|
59
60
|
FrappeConnectionState,
|
|
61
|
+
UseFrappeCatchUpResult,
|
|
60
62
|
UseFrappeCollectionConfig,
|
|
61
63
|
UseFrappePendingMutationsResult,
|
|
62
64
|
UseInfiniteScrollConfig,
|
|
@@ -191,6 +193,7 @@ export type { FrappeDocValue, FrappeDocData, FrappeDoc, ChildTableRow } from "./
|
|
|
191
193
|
// Sync types with advanced features
|
|
192
194
|
export type {
|
|
193
195
|
SyncOptions,
|
|
196
|
+
SyncCatchUpState,
|
|
194
197
|
PageBoundaries,
|
|
195
198
|
RehydrationStrategy,
|
|
196
199
|
Mutation,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { HttpClient } from "../core/http";
|
|
3
|
+
import { DbModule } from "../db";
|
|
4
|
+
import { SyncModule } from "./index";
|
|
5
|
+
import { BackfillScope, type SyncOptions } from "./types";
|
|
6
|
+
|
|
7
|
+
const modules: SyncModule[] = [];
|
|
8
|
+
const row = (name: string) => ({ name, doctype: "Task", modified: "2026-09-09T00:00:00Z" });
|
|
9
|
+
function setup(options: SyncOptions = {}) {
|
|
10
|
+
const transport = vi.fn(async (_url: string) => Response.json({ data: [row("a")] }));
|
|
11
|
+
vi.stubGlobal("fetch", transport);
|
|
12
|
+
const sync = new SyncModule(
|
|
13
|
+
new DbModule(new HttpClient("https://catch-up.test", undefined, { maxRetries: 0 }), {
|
|
14
|
+
appName: "live.live",
|
|
15
|
+
}),
|
|
16
|
+
undefined,
|
|
17
|
+
options,
|
|
18
|
+
);
|
|
19
|
+
modules.push(sync);
|
|
20
|
+
return { sync, transport };
|
|
21
|
+
}
|
|
22
|
+
function deferred<T>() {
|
|
23
|
+
let resolve!: (value: T) => void;
|
|
24
|
+
const promise = new Promise<T>((done) => {
|
|
25
|
+
resolve = done;
|
|
26
|
+
});
|
|
27
|
+
return { promise, resolve };
|
|
28
|
+
}
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
for (const sync of modules.splice(0)) await sync.dispose();
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("refreshes resource snapshots through focus, online and the optional interval", async () => {
|
|
36
|
+
vi.useFakeTimers();
|
|
37
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot", cdcInterval: 1000 });
|
|
38
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Task" });
|
|
39
|
+
expect(sync.getCatchUpState()).toEqual({ refreshing: false, stale: false, error: null });
|
|
40
|
+
for (const trigger of ["focus", "online", "interval"]) {
|
|
41
|
+
transport.mockResolvedValue(Response.json({ data: [row(trigger)] }));
|
|
42
|
+
if (trigger === "interval") await vi.advanceTimersByTimeAsync(1000);
|
|
43
|
+
else window.dispatchEvent(new Event(trigger));
|
|
44
|
+
await vi.waitFor(() =>
|
|
45
|
+
expect(sync.getDocsForSubscription(subscriptionId)[0]?.name).toBe(trigger),
|
|
46
|
+
);
|
|
47
|
+
await vi.waitFor(() => expect(sync.getCatchUpState().refreshing).toBe(false));
|
|
48
|
+
}
|
|
49
|
+
expect(
|
|
50
|
+
transport.mock.calls.every(([url]) => new URL(url).pathname === "/api/resource/Task"),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
expect(transport).toHaveBeenCalledTimes(4);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("retains rows and stale/error qualification through a deferred retry", async () => {
|
|
56
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
|
|
57
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Task" });
|
|
58
|
+
transport.mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
|
|
59
|
+
const failed = await sync.catchUp();
|
|
60
|
+
expect(failed).toMatchObject({ refreshing: false, stale: true });
|
|
61
|
+
expect(failed.error).toBeInstanceOf(AggregateError);
|
|
62
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([row("a")]);
|
|
63
|
+
const read = deferred<Response>();
|
|
64
|
+
transport.mockImplementation(() => read.promise);
|
|
65
|
+
const retry = sync.catchUp();
|
|
66
|
+
expect(sync.getCatchUpState()).toMatchObject({
|
|
67
|
+
refreshing: true,
|
|
68
|
+
stale: true,
|
|
69
|
+
error: failed.error,
|
|
70
|
+
});
|
|
71
|
+
read.resolve(Response.json({ data: [] }));
|
|
72
|
+
expect(await retry).toEqual({ refreshing: false, stale: false, error: null });
|
|
73
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps CDC as default, observes its error and preserves explicit backfill rejection", async () => {
|
|
77
|
+
const { sync, transport } = setup();
|
|
78
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Task" });
|
|
79
|
+
transport.mockImplementation(async () => Response.json({ message: "no CDC" }, { status: 404 }));
|
|
80
|
+
const result = await sync.catchUp();
|
|
81
|
+
expect(result).toMatchObject({ stale: true, error: { status: 404 } });
|
|
82
|
+
expect(new URL(transport.mock.calls[1][0]).pathname).toBe("/api/method/live.live.api.backfill");
|
|
83
|
+
await expect(sync.backfill({ scope: BackfillScope.All })).rejects.toMatchObject({ status: 404 });
|
|
84
|
+
transport.mockImplementation(async () =>
|
|
85
|
+
Response.json({
|
|
86
|
+
message: {
|
|
87
|
+
rows: [row("b")],
|
|
88
|
+
deleted: [{ name: "a" }],
|
|
89
|
+
checkpoint: "next",
|
|
90
|
+
has_more: false,
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
expect(await sync.catchUp()).toEqual({ refreshing: false, stale: false, error: null });
|
|
95
|
+
expect(sync.getDocsForSubscription(subscriptionId).map((doc) => doc.name)).toEqual(["b"]);
|
|
96
|
+
expect(sync.getSubscription(subscriptionId)?.checkpoint).toBe("next");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects progressive snapshot subscriptions before any fetch", async () => {
|
|
100
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
|
|
101
|
+
await expect(sync.subscribe({ doctype: "Task", progressive: true })).rejects.toThrow(
|
|
102
|
+
/progressive/,
|
|
103
|
+
);
|
|
104
|
+
expect(transport).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("coalesces overlap into at most one trailing request and settles despite more events", async () => {
|
|
108
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
|
|
109
|
+
await sync.subscribe({ doctype: "Task" });
|
|
110
|
+
const first = deferred<Response>();
|
|
111
|
+
const last = deferred<Response>();
|
|
112
|
+
transport.mockImplementationOnce(() => first.promise).mockImplementationOnce(() => last.promise);
|
|
113
|
+
const one = sync.catchUp();
|
|
114
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
|
|
115
|
+
expect(sync.catchUp()).toBe(one);
|
|
116
|
+
expect(sync.catchUp()).toBe(one);
|
|
117
|
+
first.resolve(Response.json({ data: [row("first")] }));
|
|
118
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(3));
|
|
119
|
+
for (let i = 0; i < 20; i++) expect(sync.catchUp()).toBe(one);
|
|
120
|
+
last.resolve(Response.json({ data: [row("last")] }));
|
|
121
|
+
expect(await one).toEqual({ refreshing: false, stale: false, error: null });
|
|
122
|
+
expect(transport).toHaveBeenCalledTimes(3);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it.each(["reset", "dispose"])("does not publish a late failed catch-up after %s", async (end) => {
|
|
126
|
+
const { sync, transport } = setup({ catchUpStrategy: "snapshot" });
|
|
127
|
+
await sync.subscribe({ doctype: "Task" });
|
|
128
|
+
const read = deferred<Response>();
|
|
129
|
+
transport.mockImplementation(() => read.promise);
|
|
130
|
+
const pending = sync.catchUp();
|
|
131
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
|
|
132
|
+
if (end === "reset") sync.resetCache();
|
|
133
|
+
else await sync.dispose();
|
|
134
|
+
const afterReset = sync.getCatchUpState();
|
|
135
|
+
const listener = vi.fn();
|
|
136
|
+
const unsubscribe = sync.subscribeStore(listener);
|
|
137
|
+
read.resolve(Response.json({ message: "obsolete" }, { status: 503 }));
|
|
138
|
+
await pending;
|
|
139
|
+
expect(sync.getCatchUpState()).toBe(afterReset);
|
|
140
|
+
expect(listener).not.toHaveBeenCalled();
|
|
141
|
+
unsubscribe();
|
|
142
|
+
});
|