@multiplatform.one/frappe 7.7.6 → 7.10.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 +31 -1
- package/dist/cjs/auth.native.js.map +1 -1
- package/dist/cjs/call.native.js.map +1 -1
- package/dist/cjs/collection.cjs +4 -1
- package/dist/cjs/collection.native.js +4 -1
- package/dist/cjs/collection.native.js.map +1 -1
- package/dist/cjs/common/types.native.js.map +1 -1
- package/dist/cjs/core/http.native.js.map +1 -1
- package/dist/cjs/core/types.native.js.map +1 -1
- package/dist/cjs/core/wire.native.js.map +1 -1
- package/dist/cjs/db.native.js.map +1 -1
- package/dist/cjs/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
- package/dist/cjs/devtools/index.native.js.map +1 -1
- package/dist/cjs/devtools/plugin.native.js.map +1 -1
- package/dist/cjs/devtools/theme.native.js.map +1 -1
- package/dist/cjs/doctype.cjs +25 -8
- package/dist/cjs/doctype.native.js +26 -9
- package/dist/cjs/doctype.native.js.map +1 -1
- package/dist/cjs/file.native.js.map +1 -1
- package/dist/cjs/fixtureDb.native.js.map +1 -1
- package/dist/cjs/fixtures.native.js.map +1 -1
- package/dist/cjs/frappeApp.native.js.map +1 -1
- package/dist/cjs/frappeReact.cjs +1 -0
- package/dist/cjs/frappeReact.native.js +2 -1
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +3 -2
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/optimistic.native.js.map +1 -1
- package/dist/cjs/pacer.cjs +1 -0
- package/dist/cjs/pacer.native.js +1 -0
- package/dist/cjs/pacer.native.js.map +1 -1
- package/dist/cjs/plugin/index.native.js.map +1 -1
- package/dist/cjs/realtime.native.js.map +1 -1
- package/dist/cjs/sync/backfill.native.js.map +1 -1
- package/dist/cjs/sync/filters.native.js.map +1 -1
- package/dist/cjs/sync/index.cjs +7 -0
- package/dist/cjs/sync/index.native.js +9 -0
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/merge.native.js.map +1 -1
- package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
- package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
- package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
- package/dist/cjs/sync/persistence.native.js.map +1 -1
- package/dist/cjs/sync/realtime.native.js.map +1 -1
- package/dist/cjs/sync/rehydration.native.js.map +1 -1
- package/dist/cjs/sync/storage.native.js.map +1 -1
- package/dist/cjs/sync/subscriptionManager.cjs +96 -54
- package/dist/cjs/sync/subscriptionManager.native.js +136 -77
- package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
- package/dist/cjs/sync/types.cjs +3 -1
- package/dist/cjs/sync/types.native.js +3 -1
- package/dist/cjs/sync/types.native.js.map +1 -1
- package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
- package/dist/cjs/useLiveQuery.native.js.map +1 -1
- package/dist/cjs/util.native.js.map +1 -1
- package/dist/esm/auth.native.js.map +1 -1
- package/dist/esm/call.native.js.map +1 -1
- package/dist/esm/collection.mjs +5 -2
- package/dist/esm/collection.mjs.map +1 -1
- package/dist/esm/collection.native.js +5 -2
- package/dist/esm/collection.native.js.map +1 -1
- package/dist/esm/common/types.native.js.map +1 -1
- package/dist/esm/core/http.native.js.map +1 -1
- package/dist/esm/core/types.native.js.map +1 -1
- package/dist/esm/core/wire.native.js.map +1 -1
- package/dist/esm/db.native.js.map +1 -1
- package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
- package/dist/esm/devtools/index.native.js.map +1 -1
- package/dist/esm/devtools/plugin.native.js.map +1 -1
- package/dist/esm/devtools/theme.native.js.map +1 -1
- package/dist/esm/doctype.mjs +25 -8
- package/dist/esm/doctype.mjs.map +1 -1
- package/dist/esm/doctype.native.js +26 -9
- package/dist/esm/doctype.native.js.map +1 -1
- package/dist/esm/file.native.js.map +1 -1
- package/dist/esm/fixtureDb.native.js.map +1 -1
- package/dist/esm/fixtures.native.js.map +1 -1
- package/dist/esm/frappeApp.native.js.map +1 -1
- package/dist/esm/frappeReact.mjs +1 -0
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +1 -0
- 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/optimistic.native.js.map +1 -1
- package/dist/esm/pacer.mjs +1 -0
- package/dist/esm/pacer.mjs.map +1 -1
- package/dist/esm/pacer.native.js +1 -0
- package/dist/esm/pacer.native.js.map +1 -1
- package/dist/esm/plugin/index.native.js.map +1 -1
- package/dist/esm/realtime.native.js.map +1 -1
- package/dist/esm/sync/backfill.native.js.map +1 -1
- package/dist/esm/sync/filters.native.js.map +1 -1
- package/dist/esm/sync/index.mjs +7 -0
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +9 -0
- package/dist/esm/sync/index.native.js.map +1 -1
- package/dist/esm/sync/merge.native.js.map +1 -1
- package/dist/esm/sync/mutationEngine.native.js.map +1 -1
- package/dist/esm/sync/mutationJournal.native.js.map +1 -1
- package/dist/esm/sync/normalizedCache.native.js.map +1 -1
- package/dist/esm/sync/persistence.native.js.map +1 -1
- package/dist/esm/sync/realtime.native.js.map +1 -1
- package/dist/esm/sync/rehydration.native.js.map +1 -1
- package/dist/esm/sync/storage.native.js.map +1 -1
- package/dist/esm/sync/subscriptionManager.mjs +97 -55
- package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
- package/dist/esm/sync/subscriptionManager.native.js +137 -78
- package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
- package/dist/esm/sync/types.mjs +2 -1
- package/dist/esm/sync/types.mjs.map +1 -1
- package/dist/esm/sync/types.native.js +2 -1
- package/dist/esm/sync/types.native.js.map +1 -1
- package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
- package/dist/esm/useLiveQuery.native.js.map +1 -1
- package/dist/esm/util.native.js.map +1 -1
- package/dist/jsx/collection.mjs +5 -2
- package/dist/jsx/collection.mjs.map +1 -1
- package/dist/jsx/collection.native.js +4 -1
- package/dist/jsx/collection.native.js.map +1 -1
- package/dist/jsx/doctype.mjs +25 -8
- package/dist/jsx/doctype.mjs.map +1 -1
- package/dist/jsx/doctype.native.js +26 -9
- package/dist/jsx/doctype.native.js.map +1 -1
- package/dist/jsx/frappeReact.mjs +1 -0
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +1 -0
- 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/pacer.mjs +1 -0
- package/dist/jsx/pacer.mjs.map +1 -1
- package/dist/jsx/pacer.native.js +1 -0
- package/dist/jsx/pacer.native.js.map +1 -1
- package/dist/jsx/sync/index.mjs +7 -0
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +9 -0
- package/dist/jsx/sync/index.native.js.map +1 -1
- package/dist/jsx/sync/subscriptionManager.mjs +97 -55
- package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
- package/dist/jsx/sync/subscriptionManager.native.js +136 -77
- package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
- package/dist/jsx/sync/types.mjs +2 -1
- package/dist/jsx/sync/types.mjs.map +1 -1
- package/dist/jsx/sync/types.native.js +3 -1
- package/dist/jsx/sync/types.native.js.map +1 -1
- package/package.json +6 -6
- package/src/collection.ts +29 -4
- package/src/collectionLimitParity.spec.ts +135 -0
- package/src/devtools/FrappeDevtoolsPanel.spec.tsx +4 -2
- package/src/doctype.ts +47 -15
- package/src/frappeReact.ts +11 -2
- package/src/index.ts +6 -2
- package/src/pacer.ts +8 -3
- package/src/sync/index.ts +12 -0
- package/src/sync/refreshSubscriptions.lifecycle.spec.ts +268 -0
- package/src/sync/refreshSubscriptions.spec.ts +295 -0
- package/src/sync/subscriptionManager.spec.ts +41 -2
- package/src/sync/subscriptionManager.ts +115 -55
- package/src/sync/types.ts +10 -0
- package/types/collection.d.ts +24 -2
- package/types/collection.d.ts.map +1 -1
- package/types/doctype.d.ts.map +1 -1
- package/types/frappeReact.d.ts +6 -2
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +2 -2
- package/types/index.d.ts.map +1 -1
- package/types/pacer.d.ts +6 -2
- package/types/pacer.d.ts.map +1 -1
- package/types/sync/index.d.ts +4 -0
- package/types/sync/index.d.ts.map +1 -1
- package/types/sync/subscriptionManager.d.ts +13 -10
- 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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { createFrappeCollection } from "../collection";
|
|
3
|
+
import { SyncModule as PublicSyncModule } from "../index";
|
|
4
|
+
import { SyncModule } from "./index";
|
|
5
|
+
import { Order, defaultPageLength } from "./types";
|
|
6
|
+
import type { SubscriptionQuery } from "./types";
|
|
7
|
+
|
|
8
|
+
vi.mock("@multiplatform.one/platform", () => ({
|
|
9
|
+
isNative: false,
|
|
10
|
+
isServer: true,
|
|
11
|
+
isWindowDefined: false,
|
|
12
|
+
onAppFocus: () => () => {},
|
|
13
|
+
onOnline: () => () => {},
|
|
14
|
+
onVisibilityChange: () => () => {},
|
|
15
|
+
}));
|
|
16
|
+
const instances: SyncModule[] = [];
|
|
17
|
+
const cleanups: (() => unknown)[] = [];
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
try {
|
|
20
|
+
for (const cleanup of cleanups.splice(0)) await cleanup();
|
|
21
|
+
} finally {
|
|
22
|
+
for (const sync of instances.splice(0)) await sync.dispose();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
const row = (name: string, title = name) => ({
|
|
26
|
+
name,
|
|
27
|
+
title,
|
|
28
|
+
doctype: "Item",
|
|
29
|
+
modified: "2026-09-09T01:00:00Z",
|
|
30
|
+
});
|
|
31
|
+
function setup() {
|
|
32
|
+
const db = { getDocList: vi.fn().mockResolvedValue([row("a"), row("b")]) };
|
|
33
|
+
const sync = new SyncModule(db as never, undefined);
|
|
34
|
+
instances.push(sync);
|
|
35
|
+
return { db, sync };
|
|
36
|
+
}
|
|
37
|
+
function deferred<T>() {
|
|
38
|
+
let resolve!: (value: T) => void;
|
|
39
|
+
const promise = new Promise<T>((done) => {
|
|
40
|
+
resolve = done;
|
|
41
|
+
});
|
|
42
|
+
return { promise, resolve };
|
|
43
|
+
}
|
|
44
|
+
it("replaces mounted collection membership and clears authoritative empty snapshots", async () => {
|
|
45
|
+
const { db, sync } = setup();
|
|
46
|
+
const subscribe = vi.spyOn(sync, "subscribe");
|
|
47
|
+
const collection = createFrappeCollection<ReturnType<typeof row>>("Item", { sync });
|
|
48
|
+
cleanups.push(() => collection.cleanup());
|
|
49
|
+
await collection.preload();
|
|
50
|
+
const originalCollection = collection;
|
|
51
|
+
const id = (await subscribe.mock.results[0].value).subscriptionId;
|
|
52
|
+
const observed: string[][] = [];
|
|
53
|
+
cleanups.push(
|
|
54
|
+
sync.subscribeStore(() => {
|
|
55
|
+
observed.push(sync.getDocsForSubscription(id!).map((doc) => doc.name));
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
db.getDocList.mockResolvedValue({
|
|
59
|
+
data: [row("b", "updated"), row("c")],
|
|
60
|
+
cursor: "replacement-cursor",
|
|
61
|
+
});
|
|
62
|
+
const [result] = await sync.refreshSubscriptions();
|
|
63
|
+
expect(result.ok).toBe(true);
|
|
64
|
+
expect(result.subscriptionId).toBe(id);
|
|
65
|
+
expect(collection).toBe(originalCollection);
|
|
66
|
+
expect(
|
|
67
|
+
[...collection.state.values()]
|
|
68
|
+
.map(({ name, title }) => ({ name, title }))
|
|
69
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
70
|
+
).toEqual([
|
|
71
|
+
{ name: "b", title: "updated" },
|
|
72
|
+
{ name: "c", title: "c" },
|
|
73
|
+
]);
|
|
74
|
+
expect(observed).toEqual([["b", "c"]]);
|
|
75
|
+
expect(sync.getSubscription(id)?.cursor).toBe("replacement-cursor");
|
|
76
|
+
expect(sync.getDocFromCache("Item", "a")).toEqual(row("a"));
|
|
77
|
+
db.getDocList.mockResolvedValue([]);
|
|
78
|
+
expect(await sync.refreshSubscriptions()).toEqual([result]);
|
|
79
|
+
expect([...collection.state.values()]).toEqual([]);
|
|
80
|
+
expect(sync.getSubscription(result.subscriptionId)?.boundaries).toBeUndefined();
|
|
81
|
+
expect(collection).toBe(originalCollection);
|
|
82
|
+
expect(observed).toEqual([["b", "c"], []]);
|
|
83
|
+
expect(subscribe).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(sync.getSubscription(id)).toMatchObject({
|
|
85
|
+
id,
|
|
86
|
+
docIds: [],
|
|
87
|
+
metadata: { itemsBeforePage: 0, itemsAfterPage: 0 },
|
|
88
|
+
});
|
|
89
|
+
expect(sync.getSubscription(id)?.cursor).toBeUndefined();
|
|
90
|
+
expect(sync.getSubscription(id)?.checkpoint).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
it("retains data and checkpoint on failure and preserves the query", async () => {
|
|
93
|
+
const { db, sync } = setup();
|
|
94
|
+
db.getDocList.mockResolvedValue({ data: [row("a"), row("b")], cursor: "committed-cursor" });
|
|
95
|
+
const query = {
|
|
96
|
+
doctype: "Item",
|
|
97
|
+
fields: ["title"],
|
|
98
|
+
filters: { enabled: 1 },
|
|
99
|
+
orderBy: [{ field: "title", order: Order.Desc }],
|
|
100
|
+
limit: 3,
|
|
101
|
+
limitStart: 6,
|
|
102
|
+
};
|
|
103
|
+
const { subscriptionId } = await sync.subscribe(query);
|
|
104
|
+
const before = structuredClone(sync.getSubscription(subscriptionId));
|
|
105
|
+
const error = new Error("offline");
|
|
106
|
+
db.getDocList.mockRejectedValue(error);
|
|
107
|
+
const results = await sync.refreshSubscriptions();
|
|
108
|
+
expect(results).toEqual([{ subscriptionId, ok: false, error }]);
|
|
109
|
+
expect(results[0].error).toBe(error);
|
|
110
|
+
expect(sync.getSubscription(subscriptionId)).toEqual({ ...before, stale: true });
|
|
111
|
+
expect(db.getDocList.mock.calls).toEqual(
|
|
112
|
+
Array(2).fill([
|
|
113
|
+
"Item",
|
|
114
|
+
{
|
|
115
|
+
fields: ["title", "modified", "name"],
|
|
116
|
+
filters: { enabled: 1 },
|
|
117
|
+
orderBy: [{ field: "title", order: Order.Desc }],
|
|
118
|
+
limit: 3,
|
|
119
|
+
limitStart: 6,
|
|
120
|
+
},
|
|
121
|
+
]),
|
|
122
|
+
);
|
|
123
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([row("a"), row("b")]);
|
|
124
|
+
});
|
|
125
|
+
it("coalesces overlapping requests into a trailing authoritative fetch", async () => {
|
|
126
|
+
const { db, sync } = setup();
|
|
127
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Item" });
|
|
128
|
+
const first = deferred<ReturnType<typeof row>[]>();
|
|
129
|
+
const last = deferred<ReturnType<typeof row>[]>();
|
|
130
|
+
db.getDocList.mockReturnValueOnce(first.promise).mockReturnValueOnce(last.promise);
|
|
131
|
+
const one = sync.refreshSubscriptions();
|
|
132
|
+
await vi.waitFor(() => expect(db.getDocList).toHaveBeenCalledTimes(2));
|
|
133
|
+
const two = sync.refreshSubscriptions();
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
first.resolve([row("old")]);
|
|
136
|
+
await vi.waitFor(() => expect(db.getDocList).toHaveBeenCalledTimes(3));
|
|
137
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([row("a"), row("b")]);
|
|
138
|
+
last.resolve([row("new")]);
|
|
139
|
+
expect(await one).toEqual(await two);
|
|
140
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([row("new")]);
|
|
141
|
+
});
|
|
142
|
+
it("does not resurrect an unsubscribed request or modify its cached rows", async () => {
|
|
143
|
+
const { db, sync } = setup();
|
|
144
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Item" });
|
|
145
|
+
const pending = deferred<ReturnType<typeof row>[]>();
|
|
146
|
+
db.getDocList.mockReturnValueOnce(pending.promise);
|
|
147
|
+
const refresh = sync.refreshSubscriptions();
|
|
148
|
+
await vi.waitFor(() => expect(db.getDocList).toHaveBeenCalledTimes(2));
|
|
149
|
+
sync.unsubscribe(subscriptionId);
|
|
150
|
+
pending.resolve([row("a", "obsolete")]);
|
|
151
|
+
expect((await refresh)[0].ok).toBe(false);
|
|
152
|
+
expect(sync.getSubscription(subscriptionId)).toBeUndefined();
|
|
153
|
+
expect(sync.getDocFromCache("Item", "a")).toEqual(row("a"));
|
|
154
|
+
});
|
|
155
|
+
it("filters active doctypes and explicitly declines progressive refresh", async () => {
|
|
156
|
+
const { db, sync } = setup();
|
|
157
|
+
await sync.subscribe({ doctype: "Item" });
|
|
158
|
+
const progressive = await sync.subscribe({ doctype: "Paged", progressive: true });
|
|
159
|
+
const before = structuredClone(sync.getSubscription(progressive.subscriptionId));
|
|
160
|
+
const pageBefore = structuredClone(sync.getPageInfo(progressive.subscriptionId));
|
|
161
|
+
expect(db.getDocList).toHaveBeenCalledTimes(2);
|
|
162
|
+
db.getDocList.mockClear();
|
|
163
|
+
expect(await sync.refreshSubscriptions({ doctypes: [] })).toEqual([]);
|
|
164
|
+
expect(await sync.refreshSubscriptions({ doctypes: ["Unknown"] })).toEqual([]);
|
|
165
|
+
expect(db.getDocList).not.toHaveBeenCalled();
|
|
166
|
+
expect((await sync.refreshSubscriptions({ doctypes: ["Item"] }))[0].ok).toBe(true);
|
|
167
|
+
expect(db.getDocList).toHaveBeenCalledTimes(1);
|
|
168
|
+
const result = await sync.refreshSubscriptions({ doctypes: ["Paged"] });
|
|
169
|
+
expect(sync.getSubscription(progressive.subscriptionId)).toEqual(before);
|
|
170
|
+
expect(sync.getPageInfo(progressive.subscriptionId)).toEqual(pageBefore);
|
|
171
|
+
expect(result).toEqual([
|
|
172
|
+
{
|
|
173
|
+
subscriptionId: progressive.subscriptionId,
|
|
174
|
+
ok: false,
|
|
175
|
+
error: new Error("Snapshot refresh does not support progressive subscriptions"),
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
expect(db.getDocList).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("exports the public runtime constructor", () => {
|
|
182
|
+
expect(PublicSyncModule).toBe(SyncModule);
|
|
183
|
+
expect(PublicSyncModule.prototype.refreshSubscriptions).toBeTypeOf("function");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it.each([
|
|
187
|
+
[undefined, undefined, defaultPageLength],
|
|
188
|
+
[undefined, 8, defaultPageLength],
|
|
189
|
+
[null, 4, defaultPageLength],
|
|
190
|
+
[0, 0, 0],
|
|
191
|
+
[7, 2, 7],
|
|
192
|
+
])("preserves query window limit=%s offset=%s", async (limit, limitStart, expectedLimit) => {
|
|
193
|
+
const { sync, db } = setup();
|
|
194
|
+
const query = {
|
|
195
|
+
doctype: "Item",
|
|
196
|
+
fields: ["title", "name", "title"],
|
|
197
|
+
filters: { enabled: 1 },
|
|
198
|
+
orderBy: [{ field: "title", order: Order.Desc }],
|
|
199
|
+
...(limit === undefined ? {} : { limit }),
|
|
200
|
+
...(limitStart === undefined ? {} : { limitStart }),
|
|
201
|
+
} as SubscriptionQuery;
|
|
202
|
+
const { subscriptionId } = await sync.subscribe(query);
|
|
203
|
+
expect(sync.getSubscription(subscriptionId)?.boundaries?.pageSize).toBe(expectedLimit);
|
|
204
|
+
db.getDocList.mockResolvedValue({
|
|
205
|
+
data: [row("z"), { ...row("b"), modified: "2026-09-09T02:00:00Z" }],
|
|
206
|
+
cursor: "new-cursor",
|
|
207
|
+
});
|
|
208
|
+
await sync.refreshSubscriptions();
|
|
209
|
+
expect(db.getDocList.mock.calls).toEqual(
|
|
210
|
+
Array(2).fill([
|
|
211
|
+
"Item",
|
|
212
|
+
{
|
|
213
|
+
fields: ["title", "name", "modified"],
|
|
214
|
+
filters: query.filters,
|
|
215
|
+
orderBy: query.orderBy,
|
|
216
|
+
limit: expectedLimit,
|
|
217
|
+
...(limitStart === undefined ? {} : { limitStart }),
|
|
218
|
+
},
|
|
219
|
+
]),
|
|
220
|
+
);
|
|
221
|
+
expect(sync.getSubscription(subscriptionId)).toMatchObject({
|
|
222
|
+
docIds: ["z", "b"],
|
|
223
|
+
cursor: "new-cursor",
|
|
224
|
+
checkpoint: "2026-09-09T02:00:00Z",
|
|
225
|
+
boundaries: { pageSize: expectedLimit, offset: limitStart ?? 0 },
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it.each([404, 403])("handles single-name getDoc status %s", async (status) => {
|
|
230
|
+
const db = { getDoc: vi.fn().mockResolvedValue(row("a")), getDocList: vi.fn() };
|
|
231
|
+
const sync = new SyncModule(db as never, undefined);
|
|
232
|
+
instances.push(sync);
|
|
233
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Item", filters: { name: "a" } });
|
|
234
|
+
const before = structuredClone(sync.getSubscription(subscriptionId));
|
|
235
|
+
const error = Object.assign(new Error("getDoc failed"), { status });
|
|
236
|
+
db.getDoc.mockRejectedValue(error);
|
|
237
|
+
expect(await sync.refreshSubscriptions()).toEqual([
|
|
238
|
+
{ subscriptionId, ok: status === 404, ...(status === 404 ? {} : { error }) },
|
|
239
|
+
]);
|
|
240
|
+
expect(db.getDoc.mock.calls).toEqual([
|
|
241
|
+
["Item", "a"],
|
|
242
|
+
["Item", "a"],
|
|
243
|
+
]);
|
|
244
|
+
expect(db.getDocList).not.toHaveBeenCalled();
|
|
245
|
+
if (status === 404) {
|
|
246
|
+
expect(sync.getDocsForSubscription(subscriptionId)).toEqual([]);
|
|
247
|
+
expect(sync.getSubscription(subscriptionId)?.boundaries).toBeUndefined();
|
|
248
|
+
} else expect(sync.getSubscription(subscriptionId)).toEqual({ ...before, stale: true });
|
|
249
|
+
expect(sync.getDocFromCache("Item", "a")).toEqual(row("a"));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("returns independent success, failure and progressive refusal", async () => {
|
|
253
|
+
const { sync, db } = setup();
|
|
254
|
+
const item = await sync.subscribe({ doctype: "Item" });
|
|
255
|
+
const other = await sync.subscribe({ doctype: "Other" });
|
|
256
|
+
const progressive = await sync.subscribe({ doctype: "Paged", progressive: true });
|
|
257
|
+
const before = structuredClone(sync.getSubscription(progressive.subscriptionId));
|
|
258
|
+
const pageBefore = structuredClone(sync.getPageInfo(progressive.subscriptionId));
|
|
259
|
+
const error = new Error("offline");
|
|
260
|
+
db.getDocList.mockRejectedValueOnce(error).mockResolvedValueOnce([row("success")]);
|
|
261
|
+
expect(await sync.refreshSubscriptions()).toEqual([
|
|
262
|
+
{ subscriptionId: item.subscriptionId, ok: false, error },
|
|
263
|
+
{ subscriptionId: other.subscriptionId, ok: true },
|
|
264
|
+
{
|
|
265
|
+
subscriptionId: progressive.subscriptionId,
|
|
266
|
+
ok: false,
|
|
267
|
+
error: new Error("Snapshot refresh does not support progressive subscriptions"),
|
|
268
|
+
},
|
|
269
|
+
]);
|
|
270
|
+
expect(sync.getSubscription(progressive.subscriptionId)).toEqual(before);
|
|
271
|
+
expect(sync.getPageInfo(progressive.subscriptionId)).toEqual(pageBefore);
|
|
272
|
+
expect(db.getDocList).toHaveBeenCalledTimes(5);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it.each(["resetCache", "dispose"] as const)(
|
|
276
|
+
"invalidates a pending refresh on %s",
|
|
277
|
+
async (action) => {
|
|
278
|
+
const { sync, db } = setup();
|
|
279
|
+
const { subscriptionId } = await sync.subscribe({ doctype: "Item" });
|
|
280
|
+
const pending = deferred<ReturnType<typeof row>[]>();
|
|
281
|
+
const started = deferred<void>();
|
|
282
|
+
db.getDocList.mockImplementationOnce(() => {
|
|
283
|
+
started.resolve();
|
|
284
|
+
return pending.promise;
|
|
285
|
+
});
|
|
286
|
+
const result = sync.refreshSubscriptions();
|
|
287
|
+
await started.promise;
|
|
288
|
+
await sync[action]();
|
|
289
|
+
pending.resolve([row("a", "obsolete"), row("obsolete")]);
|
|
290
|
+
expect((await result)[0].ok).toBe(false);
|
|
291
|
+
expect(sync.getSubscription(subscriptionId)).toBeUndefined();
|
|
292
|
+
expect(sync.getDocFromCache("Item", "a")).toBeUndefined();
|
|
293
|
+
expect(sync.getDocFromCache("Item", "obsolete")).toBeUndefined();
|
|
294
|
+
},
|
|
295
|
+
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import type { FrappeDoc, FrappeDocData } from "../common/types";
|
|
3
3
|
import { SubscriptionManager } from "./subscriptionManager";
|
|
4
|
-
import { BackfillScope } from "./types";
|
|
4
|
+
import { BackfillScope, defaultPageLength } from "./types";
|
|
5
5
|
import { Order } from "./types";
|
|
6
6
|
import type { SubscriptionQuery, SyncOptions } from "./types";
|
|
7
7
|
|
|
@@ -156,7 +156,10 @@ describe("SubscriptionManager", () => {
|
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
it("
|
|
159
|
+
it("sends the default page length when the query has no limit", async () => {
|
|
160
|
+
// MPO-238: leaving limit_page_length off let the bench clamp to 20
|
|
161
|
+
// while fixture mode returned every row, so a no-limit collection read
|
|
162
|
+
// one count in development and another in production.
|
|
160
163
|
const query: SubscriptionQuery = {
|
|
161
164
|
doctype: "TestDoc",
|
|
162
165
|
fields: ["title"],
|
|
@@ -168,6 +171,42 @@ describe("SubscriptionManager", () => {
|
|
|
168
171
|
fields: ["title", "modified", "name"],
|
|
169
172
|
filters: undefined,
|
|
170
173
|
orderBy: undefined,
|
|
174
|
+
limit: defaultPageLength,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("passes limit 0 through as Frappe's every-row page length", async () => {
|
|
179
|
+
const query: SubscriptionQuery = {
|
|
180
|
+
doctype: "TestDoc",
|
|
181
|
+
fields: ["title"],
|
|
182
|
+
limit: 0,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
await subscriptionManager.subscribe(query);
|
|
186
|
+
|
|
187
|
+
expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
|
|
188
|
+
fields: ["title", "modified", "name"],
|
|
189
|
+
filters: undefined,
|
|
190
|
+
orderBy: undefined,
|
|
191
|
+
limit: 0,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("applies limitStart without an explicit limit", async () => {
|
|
196
|
+
const query: SubscriptionQuery = {
|
|
197
|
+
doctype: "TestDoc",
|
|
198
|
+
fields: ["title"],
|
|
199
|
+
limitStart: 20,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
await subscriptionManager.subscribe(query);
|
|
203
|
+
|
|
204
|
+
expect(mockDb.getDocList).toHaveBeenCalledWith("TestDoc", {
|
|
205
|
+
fields: ["title", "modified", "name"],
|
|
206
|
+
filters: undefined,
|
|
207
|
+
orderBy: undefined,
|
|
208
|
+
limit: defaultPageLength,
|
|
209
|
+
limitStart: 20,
|
|
171
210
|
});
|
|
172
211
|
});
|
|
173
212
|
|
|
@@ -4,7 +4,7 @@ import type { DbModule } from "../db";
|
|
|
4
4
|
import { BackfillManager } from "./backfill";
|
|
5
5
|
import { extractSingleName, matchesFilters } from "./filters";
|
|
6
6
|
import type { NormalizedCache } from "./normalizedCache";
|
|
7
|
-
import { BackfillScope, Order } from "./types";
|
|
7
|
+
import { BackfillScope, defaultPageLength, Order } from "./types";
|
|
8
8
|
import type {
|
|
9
9
|
BackfillArgs,
|
|
10
10
|
OrderByField,
|
|
@@ -30,7 +30,24 @@ export interface SubscribeResult<Data extends FrappeDocData> {
|
|
|
30
30
|
refresh?: Promise<{ ok: boolean; error?: unknown }>;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface SubscriptionRefreshOptions {
|
|
34
|
+
doctypes?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SubscriptionRefreshResult {
|
|
38
|
+
subscriptionId: string;
|
|
39
|
+
ok: boolean;
|
|
40
|
+
error?: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
export class SubscriptionManager {
|
|
44
|
+
private refreshes = new Map<
|
|
45
|
+
string,
|
|
46
|
+
{
|
|
47
|
+
requested: boolean;
|
|
48
|
+
promise: Promise<SubscriptionRefreshResult>;
|
|
49
|
+
}
|
|
50
|
+
>();
|
|
34
51
|
private subscriptions = new Map<string, Subscription>();
|
|
35
52
|
private virtualPageManager: VirtualPageManager;
|
|
36
53
|
private backfillManager: BackfillManager;
|
|
@@ -85,6 +102,7 @@ export class SubscriptionManager {
|
|
|
85
102
|
|
|
86
103
|
const subscriptionId = cryptoRandomId();
|
|
87
104
|
const { rows, cursor, checkpoint } = await this.fetchInitialRows<Data>(query);
|
|
105
|
+
this.seedRows(query, rows);
|
|
88
106
|
// Materialize the in-memory subscription record with initial membership
|
|
89
107
|
const subscription: Subscription = {
|
|
90
108
|
id: subscriptionId,
|
|
@@ -100,8 +118,8 @@ export class SubscriptionManager {
|
|
|
100
118
|
subscription.boundaries = {
|
|
101
119
|
firstItem: rows[0] as unknown as FrappeDoc<Data>,
|
|
102
120
|
lastItem: rows[rows.length - 1] as unknown as FrappeDoc<Data>,
|
|
103
|
-
offset: query.limitStart
|
|
104
|
-
pageSize: query.limit
|
|
121
|
+
offset: query.limitStart ?? 0,
|
|
122
|
+
pageSize: query.limit ?? defaultPageLength,
|
|
105
123
|
};
|
|
106
124
|
}
|
|
107
125
|
|
|
@@ -130,9 +148,85 @@ export class SubscriptionManager {
|
|
|
130
148
|
return { subscriptionId: subscriptionId, rows, checkpoint };
|
|
131
149
|
}
|
|
132
150
|
|
|
151
|
+
async refreshSubscriptions(
|
|
152
|
+
options: SubscriptionRefreshOptions = {},
|
|
153
|
+
): Promise<SubscriptionRefreshResult[]> {
|
|
154
|
+
const subscriptions = [...this.subscriptions.values()].filter(
|
|
155
|
+
(subscription) =>
|
|
156
|
+
(this.subRefCounts.get(subscription.id) ?? 0) > 0 &&
|
|
157
|
+
(!options.doctypes || options.doctypes.includes(subscription.query.doctype)),
|
|
158
|
+
);
|
|
159
|
+
return Promise.all(subscriptions.map((subscription) => this.refreshSubscription(subscription)));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private refreshSubscription(subscription: Subscription): Promise<SubscriptionRefreshResult> {
|
|
163
|
+
const subscriptionId = subscription.id;
|
|
164
|
+
if (subscription.query.progressive) {
|
|
165
|
+
return Promise.resolve({
|
|
166
|
+
subscriptionId,
|
|
167
|
+
ok: false,
|
|
168
|
+
error: new Error("Snapshot refresh does not support progressive subscriptions"),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const existing = this.refreshes.get(subscriptionId);
|
|
172
|
+
if (existing) {
|
|
173
|
+
existing.requested = true;
|
|
174
|
+
return existing.promise;
|
|
175
|
+
}
|
|
176
|
+
const state = {
|
|
177
|
+
requested: false,
|
|
178
|
+
promise: Promise.resolve<SubscriptionRefreshResult>({ subscriptionId, ok: false }),
|
|
179
|
+
};
|
|
180
|
+
const active = () =>
|
|
181
|
+
this.refreshes.get(subscriptionId) === state &&
|
|
182
|
+
this.subscriptions.get(subscriptionId) === subscription &&
|
|
183
|
+
(this.subRefCounts.get(subscriptionId) ?? 0) > 0;
|
|
184
|
+
state.promise = Promise.resolve().then(async () => {
|
|
185
|
+
try {
|
|
186
|
+
while (true) {
|
|
187
|
+
state.requested = false;
|
|
188
|
+
try {
|
|
189
|
+
const { rows, cursor, checkpoint } = await this.fetchInitialRows(subscription.query);
|
|
190
|
+
if (!active()) {
|
|
191
|
+
return {
|
|
192
|
+
subscriptionId,
|
|
193
|
+
ok: false,
|
|
194
|
+
error: new Error("Subscription is no longer active"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (state.requested) continue;
|
|
198
|
+
this.seedRows(subscription.query, rows);
|
|
199
|
+
subscription.docIds = rows.map((row) => row.name);
|
|
200
|
+
subscription.cursor = cursor;
|
|
201
|
+
subscription.checkpoint = checkpoint;
|
|
202
|
+
subscription.boundaries = rows.length
|
|
203
|
+
? {
|
|
204
|
+
firstItem: rows[0],
|
|
205
|
+
lastItem: rows[rows.length - 1],
|
|
206
|
+
offset: subscription.query.limitStart ?? 0,
|
|
207
|
+
pageSize: subscription.query.limit ?? defaultPageLength,
|
|
208
|
+
}
|
|
209
|
+
: undefined;
|
|
210
|
+
subscription.metadata = { itemsBeforePage: 0, itemsAfterPage: 0 };
|
|
211
|
+
subscription.stale = false;
|
|
212
|
+
return { subscriptionId, ok: true };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (active() && state.requested) continue;
|
|
215
|
+
if (active()) subscription.stale = true;
|
|
216
|
+
return { subscriptionId, ok: false, error };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
if (this.refreshes.get(subscriptionId) === state) this.refreshes.delete(subscriptionId);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
this.refreshes.set(subscriptionId, state);
|
|
224
|
+
return state.promise;
|
|
225
|
+
}
|
|
226
|
+
|
|
133
227
|
/**
|
|
134
228
|
* Performs the initial subscription fetch (single doc via getDoc, lists via
|
|
135
|
-
* getDocList)
|
|
229
|
+
* getDocList) and computes the CDC checkpoint
|
|
136
230
|
* as the max `modified` across returned rows. Shared by fresh subscribes
|
|
137
231
|
* and adopted-subscription refreshes so both have identical semantics.
|
|
138
232
|
*/
|
|
@@ -168,16 +262,13 @@ export class SubscriptionManager {
|
|
|
168
262
|
filters: query.filters,
|
|
169
263
|
orderBy: query.orderBy,
|
|
170
264
|
};
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
if (query.
|
|
178
|
-
listOptions.limit = query.limit;
|
|
179
|
-
if (query.limitStart != null) listOptions.limitStart = query.limitStart;
|
|
180
|
-
}
|
|
265
|
+
// Always send a page length on the INITIAL fetch. A request with no
|
|
266
|
+
// limit_page_length is clamped to 20 rows by the bench while a fixture
|
|
267
|
+
// provider returns every row, so an absent limit made development and
|
|
268
|
+
// production disagree with nothing on screen to say so. `limit: 0`
|
|
269
|
+
// (every row) passes through untouched.
|
|
270
|
+
listOptions.limit = query.limit ?? defaultPageLength;
|
|
271
|
+
if (query.limitStart != null) listOptions.limitStart = query.limitStart;
|
|
181
272
|
if (this.options.useCursorPagination) listOptions.cursor = undefined;
|
|
182
273
|
const response = await this.db.getDocList<Data>(query.doctype, listOptions);
|
|
183
274
|
|
|
@@ -189,13 +280,6 @@ export class SubscriptionManager {
|
|
|
189
280
|
rows = response;
|
|
190
281
|
}
|
|
191
282
|
}
|
|
192
|
-
// Seed the normalized cache with every returned row for instant local reads.
|
|
193
|
-
// Frappe's standard get_list API omits `doctype` from each row, so inject
|
|
194
|
-
// it from the subscription query to ensure the cache keys correctly.
|
|
195
|
-
for (const row of rows) {
|
|
196
|
-
if (!row.doctype) row.doctype = query.doctype;
|
|
197
|
-
this.normalizedCache.upsert(row);
|
|
198
|
-
}
|
|
199
283
|
// Compute checkpoint for CDC as the max `modified` across returned rows (robust to server ordering)
|
|
200
284
|
let checkpoint: string | undefined = undefined;
|
|
201
285
|
for (const row of rows) {
|
|
@@ -205,6 +289,13 @@ export class SubscriptionManager {
|
|
|
205
289
|
return { rows, cursor, checkpoint };
|
|
206
290
|
}
|
|
207
291
|
|
|
292
|
+
private seedRows<Data extends FrappeDocData>(query: SubscriptionQuery, rows: FrappeDoc<Data>[]) {
|
|
293
|
+
for (const row of rows) {
|
|
294
|
+
if (!row.doctype) row.doctype = query.doctype;
|
|
295
|
+
this.normalizedCache.upsert(row);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
208
299
|
/**
|
|
209
300
|
* Re-adopts a subscription restored from a persisted snapshot when an
|
|
210
301
|
* identical query subscribes: the cached membership renders immediately and
|
|
@@ -228,44 +319,11 @@ export class SubscriptionManager {
|
|
|
228
319
|
this.queryKeyToSubId.set(queryKey, restoredId);
|
|
229
320
|
this.subRefCounts.set(restoredId, 1);
|
|
230
321
|
|
|
231
|
-
const refresh = this.
|
|
322
|
+
const refresh = this.refreshSubscription(subscription);
|
|
232
323
|
const rows = this.getDocsForSubscription<Data>(restoredId);
|
|
233
324
|
return { subscriptionId: restoredId, rows, checkpoint: subscription.checkpoint, refresh };
|
|
234
325
|
}
|
|
235
326
|
|
|
236
|
-
/**
|
|
237
|
-
* Network refresh for an adopted subscription with fresh-subscribe
|
|
238
|
-
* semantics: an authoritative page fetch replaces membership and recomputes
|
|
239
|
-
* the checkpoint (identical to what a brand-new subscribe would produce).
|
|
240
|
-
* On failure (offline) the cached membership and the restored CDC
|
|
241
|
-
* checkpoint are kept and the subscription is marked stale — the next
|
|
242
|
-
* backfill trigger drains changes from that checkpoint. Never rejects.
|
|
243
|
-
*/
|
|
244
|
-
private async refreshAdoptedSubscription<Data extends FrappeDocData>(
|
|
245
|
-
subscription: Subscription,
|
|
246
|
-
): Promise<{ ok: boolean; error?: unknown }> {
|
|
247
|
-
try {
|
|
248
|
-
const { rows, cursor, checkpoint } = await this.fetchInitialRows<Data>(subscription.query);
|
|
249
|
-
subscription.checkpoint = checkpoint;
|
|
250
|
-
subscription.cursor = cursor;
|
|
251
|
-
subscription.docIds = rows.map((row) => row.name);
|
|
252
|
-
if (rows.length > 0) {
|
|
253
|
-
subscription.boundaries = {
|
|
254
|
-
firstItem: rows[0] as unknown as FrappeDoc<FrappeDocData>,
|
|
255
|
-
lastItem: rows[rows.length - 1] as unknown as FrappeDoc<FrappeDocData>,
|
|
256
|
-
offset: subscription.query.limitStart || 0,
|
|
257
|
-
pageSize: subscription.query.limit || 20,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
subscription.metadata = { itemsBeforePage: 0, itemsAfterPage: 0 };
|
|
261
|
-
subscription.stale = false;
|
|
262
|
-
return { ok: true };
|
|
263
|
-
} catch (error) {
|
|
264
|
-
subscription.stale = true;
|
|
265
|
-
return { ok: false, error };
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
327
|
/**
|
|
270
328
|
* Registers a subscription restored from a persisted snapshot. Restored
|
|
271
329
|
* subscriptions are dormant: they hold the persisted query, CDC checkpoint,
|
|
@@ -346,6 +404,7 @@ export class SubscriptionManager {
|
|
|
346
404
|
this.subRefCounts.set(subscriptionId, refCount);
|
|
347
405
|
return;
|
|
348
406
|
}
|
|
407
|
+
this.refreshes.delete(subscriptionId);
|
|
349
408
|
// Last consumer released — clean up
|
|
350
409
|
this.subRefCounts.delete(subscriptionId);
|
|
351
410
|
let queryKey: string | undefined;
|
|
@@ -756,6 +815,7 @@ export class SubscriptionManager {
|
|
|
756
815
|
}
|
|
757
816
|
|
|
758
817
|
clear() {
|
|
818
|
+
this.refreshes.clear();
|
|
759
819
|
this.subscriptions.clear();
|
|
760
820
|
this.queryKeyToSubId.clear();
|
|
761
821
|
this.subRefCounts.clear();
|
package/src/sync/types.ts
CHANGED
|
@@ -13,10 +13,20 @@ export interface OrderByField {
|
|
|
13
13
|
|
|
14
14
|
export type OrderBy = OrderByField | OrderByField[];
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Page length used when a subscription (and so a collection) carries no
|
|
18
|
+
* explicit `limit`. Frappe's /api/resource clamps a request with no
|
|
19
|
+
* `limit_page_length` to 20 rows while a fixture provider slices only when
|
|
20
|
+
* asked, so both paths send this value explicitly and return the same rows.
|
|
21
|
+
* `limit: 0` is Frappe's "every row" and means the same in fixture mode.
|
|
22
|
+
*/
|
|
23
|
+
export const defaultPageLength = 20;
|
|
24
|
+
|
|
16
25
|
export interface SubscriptionQuery {
|
|
17
26
|
doctype: string;
|
|
18
27
|
fields?: readonly string[];
|
|
19
28
|
filters?: Array<[string, string, unknown]> | Record<string, unknown>;
|
|
29
|
+
/** Rows per page. Absent means {@link defaultPageLength}; `0` means every row. */
|
|
20
30
|
limit?: number;
|
|
21
31
|
limitStart?: number;
|
|
22
32
|
orderBy?: OrderBy;
|