@multiplatform.one/frappe 7.7.4 → 7.8.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 +4 -1
- package/dist/cjs/collection.native.js +4 -1
- package/dist/cjs/collection.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/frappeReact.cjs +13 -9
- package/dist/cjs/frappeReact.native.js +13 -9
- 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/pacer.cjs +1 -0
- package/dist/cjs/pacer.native.js +1 -0
- package/dist/cjs/pacer.native.js.map +1 -1
- package/dist/cjs/sync/index.cjs +10 -1
- package/dist/cjs/sync/index.native.js +10 -1
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/subscriptionManager.cjs +2 -4
- package/dist/cjs/sync/subscriptionManager.native.js +3 -4
- 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/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/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/frappeReact.mjs +13 -9
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +13 -9
- 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/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/sync/index.mjs +10 -1
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +10 -1
- package/dist/esm/sync/index.native.js.map +1 -1
- package/dist/esm/sync/subscriptionManager.mjs +3 -5
- package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
- package/dist/esm/sync/subscriptionManager.native.js +4 -5
- 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/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 +13 -9
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +13 -9
- 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 +10 -1
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +10 -1
- package/dist/jsx/sync/index.native.js.map +1 -1
- package/dist/jsx/sync/subscriptionManager.mjs +3 -5
- package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
- package/dist/jsx/sync/subscriptionManager.native.js +3 -4
- 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.spec.ts +15 -0
- package/src/frappeReact.ts +37 -10
- package/src/index.ts +1 -1
- package/src/pacer.ts +10 -3
- package/src/sync/index.ts +14 -1
- package/src/sync/subscriptionManager.spec.ts +41 -2
- package/src/sync/subscriptionManager.ts +8 -11
- package/src/sync/syncModule.spec.ts +78 -3
- 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 +8 -2
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +1 -1
- package/types/index.d.ts.map +1 -1
- package/types/pacer.d.ts +8 -2
- package/types/pacer.d.ts.map +1 -1
- package/types/sync/index.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
package/src/collection.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createCollection } from "@tanstack/db";
|
|
|
3
3
|
import type { FrappeDocData } from "./common/types";
|
|
4
4
|
import type { FixtureProvider } from "./fixtures";
|
|
5
5
|
import type { SyncModule } from "./sync";
|
|
6
|
-
import { Operation } from "./sync/types";
|
|
6
|
+
import { defaultPageLength, Operation } from "./sync/types";
|
|
7
7
|
import type { SubscriptionQuery } from "./sync/types";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -24,7 +24,11 @@ export interface FrappeCollectionOptionsConfig<
|
|
|
24
24
|
id?: string;
|
|
25
25
|
/** Order-by configuration */
|
|
26
26
|
orderBy?: SubscriptionQuery["orderBy"];
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Rows to read. Omitting it reads {@link defaultPageLength} (20) rows in
|
|
29
|
+
* BOTH fixture mode and against a live bench. Pass `limit: 0` for every
|
|
30
|
+
* row, which also means the same on both.
|
|
31
|
+
*/
|
|
28
32
|
limit?: number;
|
|
29
33
|
/**
|
|
30
34
|
* Called when the initial subscription fetch fails (network down,
|
|
@@ -74,6 +78,13 @@ export function releaseFrappeCollectionSync(collection: object): boolean {
|
|
|
74
78
|
* This follows TanStack DB's collection options creator pattern (Pattern B),
|
|
75
79
|
* where mutation handlers are built-in and delegate to SyncModule.
|
|
76
80
|
*
|
|
81
|
+
* ROW LIMIT. A collection created without `limit` reads
|
|
82
|
+
* {@link defaultPageLength} (20) rows, and reads the same 20 in fixture mode
|
|
83
|
+
* as it does against a live bench. Frappe clamps a request with no
|
|
84
|
+
* `limit_page_length` to 20 while a fixture provider returns every row, so
|
|
85
|
+
* the limit is now always sent rather than left off. Pass `limit: 0` for
|
|
86
|
+
* every row.
|
|
87
|
+
*
|
|
77
88
|
* @typeParam TDoc - The Frappe document type
|
|
78
89
|
* @param doctype - The Frappe doctype name (e.g. "ToDo", "Pokemon")
|
|
79
90
|
* @param config - Configuration for the collection
|
|
@@ -249,6 +260,8 @@ export function frappeCollectionOptions<TDoc extends object & { name: string; do
|
|
|
249
260
|
* Creates a TanStack DB Collection backed by a Frappe doctype via SyncModule.
|
|
250
261
|
*
|
|
251
262
|
* Convenience wrapper that calls `createCollection(frappeCollectionOptions(...))`.
|
|
263
|
+
* Without `limit` it reads {@link defaultPageLength} (20) rows in fixture mode
|
|
264
|
+
* and against a live bench alike; `limit: 0` reads every row.
|
|
252
265
|
*
|
|
253
266
|
* @typeParam TDoc - The Frappe document type
|
|
254
267
|
* @param doctype - The Frappe doctype name
|
|
@@ -295,7 +308,10 @@ export interface FixtureCollectionOptionsConfig<
|
|
|
295
308
|
id?: string;
|
|
296
309
|
/** Order-by configuration */
|
|
297
310
|
orderBy?: SubscriptionQuery["orderBy"];
|
|
298
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* Rows to read. Omitting it reads {@link defaultPageLength} (20) rows, the
|
|
313
|
+
* same as against a live bench. Pass `limit: 0` for every row.
|
|
314
|
+
*/
|
|
299
315
|
limit?: number;
|
|
300
316
|
/** Offset for pagination */
|
|
301
317
|
limitStart?: number;
|
|
@@ -304,6 +320,10 @@ export interface FixtureCollectionOptionsConfig<
|
|
|
304
320
|
/**
|
|
305
321
|
* Creates TanStack DB collection options wired to a FixtureProvider.
|
|
306
322
|
* Used when config.fixtures is set (e.g. in tests or Storybook).
|
|
323
|
+
*
|
|
324
|
+
* ROW LIMIT. Without `limit` this reads {@link defaultPageLength} (20) rows,
|
|
325
|
+
* the same count a live bench returns for a request with no
|
|
326
|
+
* `limit_page_length`. Pass `limit: 0` for every row.
|
|
307
327
|
*/
|
|
308
328
|
export function fixtureCollectionOptions<TDoc extends object & { name: string; doctype: string }>(
|
|
309
329
|
doctype: string,
|
|
@@ -334,7 +354,10 @@ export function fixtureCollectionOptions<TDoc extends object & { name: string; d
|
|
|
334
354
|
filters: filters as Partial<TDoc> | Array<[string, string, any]> | undefined,
|
|
335
355
|
orderBy: orderByForFixture,
|
|
336
356
|
limitStart,
|
|
337
|
-
|
|
357
|
+
// Same page length the bench applies to a request that carries no
|
|
358
|
+
// limit_page_length, so a no-limit collection reads the same rows
|
|
359
|
+
// here as it does in production.
|
|
360
|
+
limit: limit ?? defaultPageLength,
|
|
338
361
|
});
|
|
339
362
|
|
|
340
363
|
if (cancelled) return;
|
|
@@ -393,6 +416,8 @@ export function fixtureCollectionOptions<TDoc extends object & { name: string; d
|
|
|
393
416
|
/**
|
|
394
417
|
* Creates a TanStack DB Collection backed by a FixtureProvider.
|
|
395
418
|
* Used when config.fixtures is set (e.g. in tests or Storybook).
|
|
419
|
+
* Without `limit` it reads {@link defaultPageLength} (20) rows, matching a
|
|
420
|
+
* live bench; `limit: 0` reads every row.
|
|
396
421
|
*/
|
|
397
422
|
export function createFixtureCollection<TDoc extends object & { name: string; doctype: string }>(
|
|
398
423
|
doctype: string,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A collection created with no `limit` must return the same rows in fixture
|
|
3
|
+
* mode as it does against a live bench (MPO-238). Frappe clamps a request
|
|
4
|
+
* that carries no `limit_page_length` to 20 rows while a fixture provider
|
|
5
|
+
* slices only when asked, so development read every row, production read 20,
|
|
6
|
+
* and neither one errored to say so.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { fixtureCollectionOptions } from "./collection";
|
|
10
|
+
import type { DbModule } from "./db";
|
|
11
|
+
import { FixtureDbModule } from "./fixtureDb";
|
|
12
|
+
import { InMemoryFixtureProvider } from "./fixtures";
|
|
13
|
+
import { NormalizedCache } from "./sync/normalizedCache";
|
|
14
|
+
import { SubscriptionManager } from "./sync/subscriptionManager";
|
|
15
|
+
import { defaultPageLength } from "./sync/types";
|
|
16
|
+
import type { SubscriptionQuery } from "./sync/types";
|
|
17
|
+
|
|
18
|
+
const rowCount = 50;
|
|
19
|
+
|
|
20
|
+
interface Todo {
|
|
21
|
+
name: string;
|
|
22
|
+
doctype: string;
|
|
23
|
+
description: string;
|
|
24
|
+
modified: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function todos(): Todo[] {
|
|
28
|
+
return Array.from({ length: rowCount }, (_, index) => ({
|
|
29
|
+
name: `TODO-${String(index + 1).padStart(4, "0")}`,
|
|
30
|
+
doctype: "ToDo",
|
|
31
|
+
description: `row ${index + 1}`,
|
|
32
|
+
modified: `2026-01-01T00:${String(index).padStart(2, "0")}:00`,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stands in for a live bench: /api/resource pages a request with no
|
|
38
|
+
* `limit_page_length` at 20 rows and reads `limit_page_length=0` as every
|
|
39
|
+
* row. Nothing else about the response differs from fixture mode, so any
|
|
40
|
+
* divergence these specs catch is the page length alone.
|
|
41
|
+
*/
|
|
42
|
+
function benchDb(): DbModule {
|
|
43
|
+
const rows = todos();
|
|
44
|
+
return {
|
|
45
|
+
getDoc: async (_doctype: string, name: string) => rows.find((row) => row.name === name) ?? null,
|
|
46
|
+
getDocList: async (_doctype: string, options: { limit?: number; limitStart?: number } = {}) => {
|
|
47
|
+
const start = options.limitStart ?? 0;
|
|
48
|
+
const pageLength = options.limit ?? 20;
|
|
49
|
+
return rows.slice(start, pageLength === 0 ? undefined : start + pageLength);
|
|
50
|
+
},
|
|
51
|
+
} as unknown as DbModule;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fixtureDb(): DbModule {
|
|
55
|
+
return new FixtureDbModule(new InMemoryFixtureProvider({ ToDo: todos() })) as unknown as DbModule;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function subscribedNames(db: DbModule, query: SubscriptionQuery): Promise<string[]> {
|
|
59
|
+
const manager = new SubscriptionManager(db, new NormalizedCache());
|
|
60
|
+
const result = await manager.subscribe(query);
|
|
61
|
+
return result.rows.map((row) => String(row.name));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function fixtureCollectionNames(limit?: number): Promise<string[]> {
|
|
65
|
+
const options = fixtureCollectionOptions<Todo>("ToDo", {
|
|
66
|
+
fixtures: new InMemoryFixtureProvider({ ToDo: todos() }),
|
|
67
|
+
...(limit === undefined ? {} : { limit }),
|
|
68
|
+
});
|
|
69
|
+
const written: string[] = [];
|
|
70
|
+
const cleanup = options.sync!.sync({
|
|
71
|
+
begin: vi.fn(),
|
|
72
|
+
write: (message: any) => {
|
|
73
|
+
written.push(message.value.name);
|
|
74
|
+
},
|
|
75
|
+
commit: vi.fn(),
|
|
76
|
+
markReady: vi.fn(),
|
|
77
|
+
truncate: vi.fn(),
|
|
78
|
+
collection: {} as any,
|
|
79
|
+
} as any);
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
81
|
+
if (typeof cleanup === "function") cleanup();
|
|
82
|
+
return written;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("collection row limit parity", () => {
|
|
86
|
+
it("reads the default page length with no limit, on both paths", async () => {
|
|
87
|
+
const query: SubscriptionQuery = { doctype: "ToDo" };
|
|
88
|
+
|
|
89
|
+
const live = await subscribedNames(benchDb(), query);
|
|
90
|
+
const fixture = await subscribedNames(fixtureDb(), query);
|
|
91
|
+
|
|
92
|
+
expect(fixture).toEqual(live);
|
|
93
|
+
expect(fixture).toHaveLength(defaultPageLength);
|
|
94
|
+
expect(fixture.at(-1)).toBe("TODO-0020");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("reads every row with limit 0, on both paths", async () => {
|
|
98
|
+
const query: SubscriptionQuery = { doctype: "ToDo", limit: 0 };
|
|
99
|
+
|
|
100
|
+
const live = await subscribedNames(benchDb(), query);
|
|
101
|
+
const fixture = await subscribedNames(fixtureDb(), query);
|
|
102
|
+
|
|
103
|
+
expect(fixture).toEqual(live);
|
|
104
|
+
expect(fixture).toHaveLength(rowCount);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reads an explicit limit past the bench default, on both paths", async () => {
|
|
108
|
+
const query: SubscriptionQuery = { doctype: "ToDo", limit: 35 };
|
|
109
|
+
|
|
110
|
+
const live = await subscribedNames(benchDb(), query);
|
|
111
|
+
const fixture = await subscribedNames(fixtureDb(), query);
|
|
112
|
+
|
|
113
|
+
expect(fixture).toEqual(live);
|
|
114
|
+
expect(fixture).toHaveLength(35);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("offsets by limitStart with no limit, on both paths", async () => {
|
|
118
|
+
const query: SubscriptionQuery = { doctype: "ToDo", limitStart: 20 };
|
|
119
|
+
|
|
120
|
+
const live = await subscribedNames(benchDb(), query);
|
|
121
|
+
const fixture = await subscribedNames(fixtureDb(), query);
|
|
122
|
+
|
|
123
|
+
expect(fixture).toEqual(live);
|
|
124
|
+
expect(fixture[0]).toBe("TODO-0021");
|
|
125
|
+
expect(fixture).toHaveLength(defaultPageLength);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("gives the direct fixture collection the same default page length", async () => {
|
|
129
|
+
expect(await fixtureCollectionNames()).toHaveLength(defaultPageLength);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("gives the direct fixture collection every row with limit 0", async () => {
|
|
133
|
+
expect(await fixtureCollectionNames(0)).toHaveLength(rowCount);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -131,7 +131,9 @@ describe("FrappeDevtoolsPanel reads __debug()", () => {
|
|
|
131
131
|
);
|
|
132
132
|
expect(stub.module.__debug).toHaveBeenCalled();
|
|
133
133
|
expect(container.textContent).toContain("ToDo");
|
|
134
|
+
expect(container.textContent).toContain("8 docs");
|
|
134
135
|
expect(container.textContent).toContain("Note");
|
|
136
|
+
expect(container.textContent).toContain("3 docs");
|
|
135
137
|
});
|
|
136
138
|
|
|
137
139
|
it("an empty snapshot still paints the shell, not a blank div", () => {
|
|
@@ -262,8 +264,8 @@ describe("FrappeDevtoolsPanel theme prop", () => {
|
|
|
262
264
|
);
|
|
263
265
|
const darkBg = (dark.container.firstElementChild as HTMLElement).style.backgroundColor;
|
|
264
266
|
expect(lightBg).not.toBe(darkBg);
|
|
265
|
-
expect(lightBg).
|
|
266
|
-
expect(darkBg).
|
|
267
|
+
expect(lightBg).toBe(themes.light.bg);
|
|
268
|
+
expect(darkBg).toBe(themes.dark.bg);
|
|
267
269
|
// The panel owns its own palette (devtools/theme.ts); it never reads the app knobs.
|
|
268
270
|
expect(Object.keys(themes)).toEqual(["light", "dark"]);
|
|
269
271
|
});
|
package/src/doctype.ts
CHANGED
|
@@ -372,7 +372,8 @@ export function parseFormLayout(fields: DocTypeField[]): FormSection[] {
|
|
|
372
372
|
// Internal helpers
|
|
373
373
|
// ---------------------------------------------------------------------------
|
|
374
374
|
|
|
375
|
-
function createHttpClient(config: FrappeConnectionConfig): HttpClient {
|
|
375
|
+
function createHttpClient(config: FrappeConnectionConfig): HttpClient | undefined {
|
|
376
|
+
if (!config.baseURL) return undefined;
|
|
376
377
|
const authConfig: AuthConfig | undefined = config.auth
|
|
377
378
|
? {
|
|
378
379
|
useToken: config.auth.useToken,
|
|
@@ -474,6 +475,27 @@ function parseRawDocTypeMeta(raw: RawDocTypeResponse): DocTypeMeta {
|
|
|
474
475
|
};
|
|
475
476
|
}
|
|
476
477
|
|
|
478
|
+
/**
|
|
479
|
+
* Cache identity for a connection. A fixtures-only connection has no
|
|
480
|
+
* baseURL, so it is keyed by its FixtureProvider instance instead — two
|
|
481
|
+
* corpora must not share one cached DocType (the cross-test pollution the
|
|
482
|
+
* SyncModule cache already avoids the same way).
|
|
483
|
+
*/
|
|
484
|
+
const fixtureConnectionIds = new WeakMap<object, string>();
|
|
485
|
+
let nextFixtureConnectionId = 0;
|
|
486
|
+
|
|
487
|
+
function connectionKey(config: FrappeConnectionConfig): string {
|
|
488
|
+
if (config.baseURL) return config.baseURL;
|
|
489
|
+
const fixtures = config.fixtures;
|
|
490
|
+
if (!fixtures) return "";
|
|
491
|
+
let id = fixtureConnectionIds.get(fixtures);
|
|
492
|
+
if (!id) {
|
|
493
|
+
id = `fixtures#${++nextFixtureConnectionId}`;
|
|
494
|
+
fixtureConnectionIds.set(fixtures, id);
|
|
495
|
+
}
|
|
496
|
+
return id;
|
|
497
|
+
}
|
|
498
|
+
|
|
477
499
|
/**
|
|
478
500
|
* Setup pattern for a stable HttpClient ref, following the same pattern
|
|
479
501
|
* used in `useDebouncedSearch` for DbModule.
|
|
@@ -538,7 +560,7 @@ export function useDocTypeMeta(
|
|
|
538
560
|
error: Error | null;
|
|
539
561
|
refetch: () => void;
|
|
540
562
|
} {
|
|
541
|
-
const cacheKey = config ? `${config
|
|
563
|
+
const cacheKey = config ? `${connectionKey(config)}:${doctype}` : "";
|
|
542
564
|
|
|
543
565
|
const [meta, setMeta] = useState<DocTypeMeta | undefined>(() =>
|
|
544
566
|
cacheKey ? metaCache.get(cacheKey) : undefined,
|
|
@@ -571,12 +593,14 @@ export function useDocTypeMeta(
|
|
|
571
593
|
return;
|
|
572
594
|
}
|
|
573
595
|
|
|
574
|
-
const key = `${config
|
|
596
|
+
const key = `${connectionKey(config)}:${doctype}`;
|
|
575
597
|
// Already have cached data (synchronized during render)
|
|
576
598
|
if (metaCache.has(key)) return;
|
|
577
599
|
|
|
578
600
|
const http = httpRef.current;
|
|
579
|
-
|
|
601
|
+
// No client AND no corpus: nothing can answer, so park rather than
|
|
602
|
+
// dial `undefined` (a fixtures-only host has no baseURL by design).
|
|
603
|
+
if (!http && !config.fixtures) return;
|
|
580
604
|
|
|
581
605
|
let cancelled = false;
|
|
582
606
|
setIsLoading(true);
|
|
@@ -596,6 +620,14 @@ export function useDocTypeMeta(
|
|
|
596
620
|
let raw = fromFixtures?.fields ? fromFixtures : undefined;
|
|
597
621
|
|
|
598
622
|
if (!raw) {
|
|
623
|
+
if (!http) {
|
|
624
|
+
// Fixtures-only host: there is no server behind the corpus, so a
|
|
625
|
+
// missing DocType entry is the honest end of the road.
|
|
626
|
+
throw new Error(
|
|
627
|
+
`DocType "${doctype}" is not in the fixture corpus and no baseURL is configured`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
599
631
|
// `getdoctype` is the whitelisted RPC the Desk itself loads form
|
|
600
632
|
// metadata with, and every logged-in user may call it. Reading
|
|
601
633
|
// `/api/resource/DocType/<name>` instead asks for the DocType
|
|
@@ -612,20 +644,20 @@ export function useDocTypeMeta(
|
|
|
612
644
|
// docs[0] is the requested doctype; docs[1..] are its child tables.
|
|
613
645
|
const docs = response.payload?.docs;
|
|
614
646
|
raw = docs?.find((doc) => doc?.name === doctype) ?? docs?.[0];
|
|
615
|
-
}
|
|
616
647
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
648
|
+
if (!raw) {
|
|
649
|
+
// Sites that expose the REST API without the desk leave the RPC
|
|
650
|
+
// above unregistered, so it answers with no `docs`. The document
|
|
651
|
+
// route still serves meta there, and such a site's API clients are
|
|
652
|
+
// typically System Managers, who may read it.
|
|
653
|
+
const rest = await http.get<{ data?: RawDocTypeResponse }>(
|
|
654
|
+
`/api/resource/DocType/${encodeURIComponent(doctype)}`,
|
|
655
|
+
);
|
|
625
656
|
|
|
626
|
-
|
|
657
|
+
if (cancelled) return;
|
|
627
658
|
|
|
628
|
-
|
|
659
|
+
raw = rest.payload?.data;
|
|
660
|
+
}
|
|
629
661
|
}
|
|
630
662
|
|
|
631
663
|
if (!raw) {
|
package/src/frappeReact.spec.ts
CHANGED
|
@@ -1073,3 +1073,18 @@ describe("useInfiniteScroll", () => {
|
|
|
1073
1073
|
expect(ioInstances[0].disconnect).toHaveBeenCalled();
|
|
1074
1074
|
});
|
|
1075
1075
|
});
|
|
1076
|
+
|
|
1077
|
+
describe("explicit HTTP-only realtime mode", () => {
|
|
1078
|
+
it("isolates the disabled engine while default and true share realtime", async () => {
|
|
1079
|
+
const { getFrappeSyncModule } = await import("./frappeReact");
|
|
1080
|
+
mockRealtimeCtor.mockClear();
|
|
1081
|
+
const baseURL = "https://realtime-mode-cache.test";
|
|
1082
|
+
const http = getFrappeSyncModule({ baseURL, realtime: false });
|
|
1083
|
+
expect(mockRealtimeCtor).not.toHaveBeenCalled();
|
|
1084
|
+
const enabled = getFrappeSyncModule({ baseURL });
|
|
1085
|
+
expect(mockRealtimeCtor).toHaveBeenCalledTimes(1);
|
|
1086
|
+
expect(getFrappeSyncModule({ baseURL, realtime: true })).toBe(enabled);
|
|
1087
|
+
expect(getFrappeSyncModule({ baseURL, realtime: false })).toBe(http);
|
|
1088
|
+
expect(http).not.toBe(enabled);
|
|
1089
|
+
});
|
|
1090
|
+
});
|
package/src/frappeReact.ts
CHANGED
|
@@ -20,8 +20,14 @@ import { isBrowser } from "./util";
|
|
|
20
20
|
* Configuration for creating a Frappe collection with connection details.
|
|
21
21
|
*/
|
|
22
22
|
export interface UseFrappeCollectionConfig {
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The base URL of the Frappe server. Required unless `fixtures` is set —
|
|
25
|
+
* a fixture corpus is the whole data source and nothing is ever dialled,
|
|
26
|
+
* so a fixtures-only host legitimately has no server to name.
|
|
27
|
+
*/
|
|
28
|
+
baseURL?: string;
|
|
29
|
+
/** False keeps HTTP sync without constructing a realtime transport. Defaults to true. */
|
|
30
|
+
realtime?: boolean;
|
|
25
31
|
/**
|
|
26
32
|
* Override the port used for the Socket.IO realtime connection.
|
|
27
33
|
* In dev, set to `FRAPPE_SOCKETIO_PORT` (e.g. 9000). Omit in production
|
|
@@ -120,6 +126,11 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
|
|
|
120
126
|
return sync;
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
// No fixtures and no baseURL: there is nothing to dial. Same contract as
|
|
130
|
+
// SSR above — consumers render their loading/empty state instead of
|
|
131
|
+
// constructing an HttpClient against `undefined`.
|
|
132
|
+
if (!config.baseURL) return undefined;
|
|
133
|
+
|
|
123
134
|
// Key on the full socket target, not just baseURL: socketPort /
|
|
124
135
|
// socketBaseURL / siteName all change where (or how) the realtime socket
|
|
125
136
|
// dials, so configs that differ in them must not share a SyncModule —
|
|
@@ -128,6 +139,7 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
|
|
|
128
139
|
// (identical fields → identical key).
|
|
129
140
|
const key = [
|
|
130
141
|
config.baseURL,
|
|
142
|
+
config.realtime === false ? "http" : "realtime",
|
|
131
143
|
config.socketPort ?? "",
|
|
132
144
|
config.socketBaseURL ?? "",
|
|
133
145
|
config.siteName ?? "",
|
|
@@ -164,7 +176,11 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
|
|
|
164
176
|
auth: authConfig,
|
|
165
177
|
socketOptions: config.socketOptions,
|
|
166
178
|
};
|
|
167
|
-
sync = new SyncModule(
|
|
179
|
+
sync = new SyncModule(
|
|
180
|
+
db,
|
|
181
|
+
config.realtime === false ? undefined : new RealtimeModule(realtimeConfig),
|
|
182
|
+
config.syncOptions,
|
|
183
|
+
);
|
|
168
184
|
syncModuleCache.set(key, sync);
|
|
169
185
|
}
|
|
170
186
|
return sync;
|
|
@@ -591,7 +607,10 @@ export function useFrappeConnection(
|
|
|
591
607
|
const [state, setState] = useState<FrappeConnectionState>(DISCONNECTED_STATE);
|
|
592
608
|
|
|
593
609
|
useEffect(() => {
|
|
594
|
-
if (!config || config.fixtures || !isBrowser())
|
|
610
|
+
if (!config || config.fixtures || config.realtime === false || !isBrowser()) {
|
|
611
|
+
setState(DISCONNECTED_STATE);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
595
614
|
const syncModule = getSyncModule(config);
|
|
596
615
|
if (!syncModule) return;
|
|
597
616
|
|
|
@@ -606,9 +625,9 @@ export function useFrappeConnection(
|
|
|
606
625
|
read();
|
|
607
626
|
return syncModule.subscribeStore(read);
|
|
608
627
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
609
|
-
}, [config?.baseURL, !!config?.fixtures]);
|
|
628
|
+
}, [config?.baseURL, config?.realtime, !!config?.fixtures]);
|
|
610
629
|
|
|
611
|
-
return state;
|
|
630
|
+
return config?.realtime === false ? DISCONNECTED_STATE : state;
|
|
612
631
|
}
|
|
613
632
|
|
|
614
633
|
/**
|
|
@@ -629,7 +648,15 @@ export function useFrappeDoctypeEvents(
|
|
|
629
648
|
handlerRef.current = onEvent;
|
|
630
649
|
|
|
631
650
|
useEffect(() => {
|
|
632
|
-
if (
|
|
651
|
+
if (
|
|
652
|
+
!enabled ||
|
|
653
|
+
!config ||
|
|
654
|
+
config.fixtures ||
|
|
655
|
+
config.realtime === false ||
|
|
656
|
+
!doctype ||
|
|
657
|
+
!isBrowser()
|
|
658
|
+
)
|
|
659
|
+
return;
|
|
633
660
|
const syncModule = getSyncModule(config);
|
|
634
661
|
if (!syncModule) return;
|
|
635
662
|
|
|
@@ -645,7 +672,7 @@ export function useFrappeDoctypeEvents(
|
|
|
645
672
|
syncModule.unwatchDoctype(doctype);
|
|
646
673
|
};
|
|
647
674
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
648
|
-
}, [enabled, config?.baseURL, !!config?.fixtures, doctype]);
|
|
675
|
+
}, [enabled, config?.baseURL, config?.realtime, !!config?.fixtures, doctype]);
|
|
649
676
|
}
|
|
650
677
|
|
|
651
678
|
// ---------------------------------------------------------------------------
|
|
@@ -706,7 +733,7 @@ export function useFrappePendingMutations(
|
|
|
706
733
|
read();
|
|
707
734
|
return syncModule.subscribeStore(read);
|
|
708
735
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
709
|
-
}, [config?.baseURL, !!config?.fixtures, doctype]);
|
|
736
|
+
}, [config?.baseURL, config?.realtime, !!config?.fixtures, doctype]);
|
|
710
737
|
|
|
711
738
|
const retry = useCallback(async () => {
|
|
712
739
|
await syncModuleRef.current?.replayPending();
|
|
@@ -1075,7 +1102,7 @@ export function useFrappeInfiniteList<TDoc extends object & { name: string; doct
|
|
|
1075
1102
|
}
|
|
1076
1103
|
};
|
|
1077
1104
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1078
|
-
}, [config?.baseURL, config?.auth?.token, doctype, filtersKey, pageSize]);
|
|
1105
|
+
}, [config?.baseURL, config?.realtime, config?.auth?.token, doctype, filtersKey, pageSize]);
|
|
1079
1106
|
|
|
1080
1107
|
const loadMore = useCallback(async () => {
|
|
1081
1108
|
const syncModule = syncModuleRef.current;
|
package/src/index.ts
CHANGED
|
@@ -201,7 +201,7 @@ export type {
|
|
|
201
201
|
DebugMutationEntry,
|
|
202
202
|
PageIsland,
|
|
203
203
|
} from "./sync/types";
|
|
204
|
-
export { Order, Operation } from "./sync/types";
|
|
204
|
+
export { defaultPageLength, Order, Operation } from "./sync/types";
|
|
205
205
|
export { MergeStrategy, FieldMergeStrategy } from "./sync/merge";
|
|
206
206
|
export type { FieldMergeStrategyOrFunction, MergeConfig, MergeResult } from "./sync/merge";
|
|
207
207
|
|
package/src/pacer.ts
CHANGED
|
@@ -53,8 +53,14 @@ export interface DebouncedSearchResult<TDoc extends FrappeDocData> {
|
|
|
53
53
|
* Connection configuration for hooks that need a Frappe backend.
|
|
54
54
|
*/
|
|
55
55
|
export interface FrappeConnectionConfig {
|
|
56
|
-
/**
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* The base URL of the Frappe server. Required unless `fixtures` is set:
|
|
58
|
+
* a fixtures-only host has no server, and the hooks here read the corpus
|
|
59
|
+
* (or park) instead of dialling one.
|
|
60
|
+
*/
|
|
61
|
+
baseURL?: string;
|
|
62
|
+
/** False disables realtime when this connection is used by collection hooks. */
|
|
63
|
+
realtime?: boolean;
|
|
58
64
|
/** Override port for Socket.IO realtime connection (e.g. 9000 in dev). */
|
|
59
65
|
socketPort?: number;
|
|
60
66
|
/** Authentication configuration */
|
|
@@ -72,7 +78,8 @@ export interface FrappeConnectionConfig {
|
|
|
72
78
|
fixtures?: FixtureProvider;
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
function createDbModule(config: FrappeConnectionConfig): DbModule {
|
|
81
|
+
function createDbModule(config: FrappeConnectionConfig): DbModule | undefined {
|
|
82
|
+
if (!config.baseURL) return undefined;
|
|
76
83
|
const authConfig: AuthConfig | undefined = config.auth
|
|
77
84
|
? {
|
|
78
85
|
useToken: config.auth.useToken,
|
package/src/sync/index.ts
CHANGED
|
@@ -27,6 +27,19 @@ export interface SyncRealtimeEvent {
|
|
|
27
27
|
payload?: unknown;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function getBrowserJournalStorage(): Storage | undefined {
|
|
31
|
+
if (typeof window === "undefined" || typeof document === "undefined") return undefined;
|
|
32
|
+
try {
|
|
33
|
+
const storage = window.localStorage;
|
|
34
|
+
return typeof storage?.getItem === "function" && typeof storage.setItem === "function"
|
|
35
|
+
? storage
|
|
36
|
+
: undefined;
|
|
37
|
+
} catch {
|
|
38
|
+
// Browsers can deny access; Node's Web Storage is not browser-scoped.
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
/**
|
|
31
44
|
* Synchronization engine that provides Zero-style UX on top of Frappe's REST API.
|
|
32
45
|
*
|
|
@@ -110,7 +123,7 @@ export class SyncModule {
|
|
|
110
123
|
this.mutationJournal = new MutationJournal({
|
|
111
124
|
ttl: this.options.mutationTTL,
|
|
112
125
|
enableAutoCleanup: true,
|
|
113
|
-
storage:
|
|
126
|
+
storage: getBrowserJournalStorage(),
|
|
114
127
|
});
|
|
115
128
|
this.subscriptionManager = new SubscriptionManager(this.db, this.normalizedCache, this.options);
|
|
116
129
|
|
|
@@ -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,
|
|
@@ -168,16 +168,13 @@ export class SubscriptionManager {
|
|
|
168
168
|
filters: query.filters,
|
|
169
169
|
orderBy: query.orderBy,
|
|
170
170
|
};
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
if (query.
|
|
178
|
-
listOptions.limit = query.limit;
|
|
179
|
-
if (query.limitStart != null) listOptions.limitStart = query.limitStart;
|
|
180
|
-
}
|
|
171
|
+
// Always send a page length on the INITIAL fetch. A request with no
|
|
172
|
+
// limit_page_length is clamped to 20 rows by the bench while a fixture
|
|
173
|
+
// provider returns every row, so an absent limit made development and
|
|
174
|
+
// production disagree with nothing on screen to say so. `limit: 0`
|
|
175
|
+
// (every row) passes through untouched.
|
|
176
|
+
listOptions.limit = query.limit ?? defaultPageLength;
|
|
177
|
+
if (query.limitStart != null) listOptions.limitStart = query.limitStart;
|
|
181
178
|
if (this.options.useCursorPagination) listOptions.cursor = undefined;
|
|
182
179
|
const response = await this.db.getDocList<Data>(query.doctype, listOptions);
|
|
183
180
|
|