@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,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.ts
CHANGED
|
@@ -20,8 +20,12 @@ 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;
|
|
25
29
|
/** False keeps HTTP sync without constructing a realtime transport. Defaults to true. */
|
|
26
30
|
realtime?: boolean;
|
|
27
31
|
/**
|
|
@@ -122,6 +126,11 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
|
|
|
122
126
|
return sync;
|
|
123
127
|
}
|
|
124
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
|
+
|
|
125
134
|
// Key on the full socket target, not just baseURL: socketPort /
|
|
126
135
|
// socketBaseURL / siteName all change where (or how) the realtime socket
|
|
127
136
|
// dials, so configs that differ in them must not share a SyncModule —
|
package/src/index.ts
CHANGED
|
@@ -64,7 +64,11 @@ export type {
|
|
|
64
64
|
UseFrappeInfiniteListConfig,
|
|
65
65
|
UseFrappeInfiniteListResult,
|
|
66
66
|
} from "./frappeReact";
|
|
67
|
-
export type {
|
|
67
|
+
export type {
|
|
68
|
+
SyncRealtimeEvent,
|
|
69
|
+
SubscriptionRefreshOptions,
|
|
70
|
+
SubscriptionRefreshResult,
|
|
71
|
+
} from "./sync";
|
|
68
72
|
|
|
69
73
|
// TanStack Pacer utilities for Frappe
|
|
70
74
|
export { useDebouncedSearch, useThrottledPagination, createRateLimitedBatch } from "./pacer";
|
|
@@ -201,7 +205,7 @@ export type {
|
|
|
201
205
|
DebugMutationEntry,
|
|
202
206
|
PageIsland,
|
|
203
207
|
} from "./sync/types";
|
|
204
|
-
export { Order, Operation } from "./sync/types";
|
|
208
|
+
export { defaultPageLength, Order, Operation } from "./sync/types";
|
|
205
209
|
export { MergeStrategy, FieldMergeStrategy } from "./sync/merge";
|
|
206
210
|
export type { FieldMergeStrategyOrFunction, MergeConfig, MergeResult } from "./sync/merge";
|
|
207
211
|
|
package/src/pacer.ts
CHANGED
|
@@ -53,8 +53,12 @@ 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;
|
|
58
62
|
/** False disables realtime when this connection is used by collection hooks. */
|
|
59
63
|
realtime?: boolean;
|
|
60
64
|
/** Override port for Socket.IO realtime connection (e.g. 9000 in dev). */
|
|
@@ -74,7 +78,8 @@ export interface FrappeConnectionConfig {
|
|
|
74
78
|
fixtures?: FixtureProvider;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
function createDbModule(config: FrappeConnectionConfig): DbModule {
|
|
81
|
+
function createDbModule(config: FrappeConnectionConfig): DbModule | undefined {
|
|
82
|
+
if (!config.baseURL) return undefined;
|
|
78
83
|
const authConfig: AuthConfig | undefined = config.auth
|
|
79
84
|
? {
|
|
80
85
|
useToken: config.auth.useToken,
|
package/src/sync/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { NormalizedCache } from "./normalizedCache";
|
|
|
8
8
|
import { PersistenceManager } from "./persistence";
|
|
9
9
|
import { RealtimeHandler } from "./realtime";
|
|
10
10
|
import { RehydrationManager } from "./rehydration";
|
|
11
|
+
import type { SubscriptionRefreshOptions, SubscriptionRefreshResult } from "./subscriptionManager";
|
|
12
|
+
export type { SubscriptionRefreshOptions, SubscriptionRefreshResult } from "./subscriptionManager";
|
|
11
13
|
import { SubscriptionManager } from "./subscriptionManager";
|
|
12
14
|
import { BackfillScope, Status } from "./types";
|
|
13
15
|
import type {
|
|
@@ -261,6 +263,16 @@ export class SyncModule {
|
|
|
261
263
|
this.emit();
|
|
262
264
|
}
|
|
263
265
|
|
|
266
|
+
/** Refresh active query snapshots in place. Failures retain the last committed data. */
|
|
267
|
+
async refreshSubscriptions(
|
|
268
|
+
options?: SubscriptionRefreshOptions,
|
|
269
|
+
): Promise<SubscriptionRefreshResult[]> {
|
|
270
|
+
await this.persistenceManager.whenReady();
|
|
271
|
+
const results = await this.subscriptionManager.refreshSubscriptions(options);
|
|
272
|
+
this.emit();
|
|
273
|
+
return results;
|
|
274
|
+
}
|
|
275
|
+
|
|
264
276
|
/**
|
|
265
277
|
* Reconciles local state by fetching changes since stored cursors.
|
|
266
278
|
*/
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { NormalizedCache } from "./normalizedCache";
|
|
3
|
+
import { SubscriptionManager } from "./subscriptionManager";
|
|
4
|
+
import type { SyncOptions } from "./types";
|
|
5
|
+
|
|
6
|
+
function deferred<T>() {
|
|
7
|
+
let resolve!: (value: T) => void;
|
|
8
|
+
let reject!: (error: unknown) => void;
|
|
9
|
+
const promise = new Promise<T>((yes, no) => {
|
|
10
|
+
resolve = yes;
|
|
11
|
+
reject = no;
|
|
12
|
+
});
|
|
13
|
+
return { promise, resolve, reject };
|
|
14
|
+
}
|
|
15
|
+
const row = (name: string, title = name) => ({
|
|
16
|
+
name,
|
|
17
|
+
title,
|
|
18
|
+
doctype: "Item",
|
|
19
|
+
modified: "2026-09-09T01:00:00Z",
|
|
20
|
+
});
|
|
21
|
+
const query = { doctype: "Item" };
|
|
22
|
+
async function setup(options: SyncOptions = {}) {
|
|
23
|
+
const db = { getDocList: vi.fn().mockResolvedValue([row("a")]) };
|
|
24
|
+
const cache = new NormalizedCache();
|
|
25
|
+
const manager = new SubscriptionManager(db as never, cache, options);
|
|
26
|
+
const { subscriptionId: id } = await manager.subscribe(query);
|
|
27
|
+
return { db, cache, manager, id };
|
|
28
|
+
}
|
|
29
|
+
function pending(db: Awaited<ReturnType<typeof setup>>["db"]) {
|
|
30
|
+
const response = deferred<ReturnType<typeof row>[]>();
|
|
31
|
+
const started = deferred<void>();
|
|
32
|
+
db.getDocList.mockImplementationOnce(() => {
|
|
33
|
+
started.resolve();
|
|
34
|
+
return response.promise;
|
|
35
|
+
});
|
|
36
|
+
return { ...response, started: started.promise };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("snapshot refresh lifecycle", () => {
|
|
40
|
+
it.each(["resolve", "reject"] as const)(
|
|
41
|
+
"invalidates pre-detach refresh that later %ss after re-adoption",
|
|
42
|
+
async (outcome) => {
|
|
43
|
+
const { manager, db, cache, id } = await setup({ persistKey: "lifecycle" });
|
|
44
|
+
const old = pending(db);
|
|
45
|
+
const oldResult = manager.refreshSubscriptions();
|
|
46
|
+
await old.started;
|
|
47
|
+
manager.unsubscribe(id);
|
|
48
|
+
const fresh = pending(db);
|
|
49
|
+
const adopted = await manager.subscribe(query);
|
|
50
|
+
expect(adopted.subscriptionId).toBe(id);
|
|
51
|
+
expect(adopted.rows).toEqual([row("a")]);
|
|
52
|
+
await fresh.started;
|
|
53
|
+
fresh.resolve([row("a", "new-adoption")]);
|
|
54
|
+
expect(await adopted.refresh).toMatchObject({ ok: true });
|
|
55
|
+
if (outcome === "resolve") old.resolve([row("a", "obsolete-before-detach"), row("obsolete")]);
|
|
56
|
+
else old.reject(new Error("old failure"));
|
|
57
|
+
expect((await oldResult)[0].ok).toBe(false);
|
|
58
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("a", "new-adoption")]);
|
|
59
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
60
|
+
expect(manager.getSubscription(id)?.stale).toBe(false);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
it("does not let an old finally erase the new adoption operation", async () => {
|
|
65
|
+
const { manager, db, cache, id } = await setup({ persistKey: "lifecycle" });
|
|
66
|
+
const old = pending(db);
|
|
67
|
+
const oldResult = manager.refreshSubscriptions();
|
|
68
|
+
await old.started;
|
|
69
|
+
manager.unsubscribe(id);
|
|
70
|
+
const adoption = pending(db);
|
|
71
|
+
const adopted = await manager.subscribe(query);
|
|
72
|
+
await adoption.started;
|
|
73
|
+
old.resolve([row("obsolete")]);
|
|
74
|
+
expect((await oldResult)[0].ok).toBe(false);
|
|
75
|
+
const trailing = pending(db);
|
|
76
|
+
const explicit = manager.refreshSubscriptions();
|
|
77
|
+
expect(db.getDocList).toHaveBeenCalledTimes(3);
|
|
78
|
+
adoption.resolve([row("discarded-adoption")]);
|
|
79
|
+
await trailing.started;
|
|
80
|
+
expect(cache.get({ doctype: "Item", name: "discarded-adoption" })).toBeUndefined();
|
|
81
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("a")]);
|
|
82
|
+
trailing.resolve([row("final")]);
|
|
83
|
+
expect(await adopted.refresh).toEqual((await explicit)[0]);
|
|
84
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
85
|
+
expect(db.getDocList).toHaveBeenCalledTimes(4);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it.each([false, true])(
|
|
89
|
+
"coalesces requests and retains the committed snapshot when trailing fails=%s",
|
|
90
|
+
async (fails) => {
|
|
91
|
+
const { manager, db, cache, id } = await setup();
|
|
92
|
+
const before = structuredClone(manager.getSubscription(id));
|
|
93
|
+
const first = pending(db);
|
|
94
|
+
const one = manager.refreshSubscriptions();
|
|
95
|
+
await first.started;
|
|
96
|
+
const last = pending(db);
|
|
97
|
+
const two = manager.refreshSubscriptions();
|
|
98
|
+
const three = manager.refreshSubscriptions();
|
|
99
|
+
first.resolve([row("a", "discarded"), row("obsolete")]);
|
|
100
|
+
await last.started;
|
|
101
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
102
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("a")]);
|
|
103
|
+
const error = new Error("trailing failed");
|
|
104
|
+
if (fails) last.reject(error);
|
|
105
|
+
else last.resolve([row("final")]);
|
|
106
|
+
const results = await Promise.all([one, two, three]);
|
|
107
|
+
expect(results).toEqual(
|
|
108
|
+
Array(3).fill([{ subscriptionId: id, ok: !fails, ...(fails ? { error } : {}) }]),
|
|
109
|
+
);
|
|
110
|
+
if (fails) for (const result of results) expect(result[0].error).toBe(error);
|
|
111
|
+
expect(db.getDocList).toHaveBeenCalledTimes(3);
|
|
112
|
+
if (fails) expect(manager.getSubscription(id)).toEqual({ ...before, stale: true });
|
|
113
|
+
else expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
114
|
+
await manager.refreshSubscriptions();
|
|
115
|
+
expect(db.getDocList).toHaveBeenCalledTimes(4);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
it("trails a failed attempt only when another caller requested it", async () => {
|
|
120
|
+
const { manager, db, id } = await setup();
|
|
121
|
+
const first = pending(db);
|
|
122
|
+
const one = manager.refreshSubscriptions();
|
|
123
|
+
await first.started;
|
|
124
|
+
const last = pending(db);
|
|
125
|
+
const two = manager.refreshSubscriptions();
|
|
126
|
+
first.reject(new Error("superseded failure"));
|
|
127
|
+
await last.started;
|
|
128
|
+
last.resolve([row("final")]);
|
|
129
|
+
expect(await one).toEqual(await two);
|
|
130
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
131
|
+
expect(db.getDocList).toHaveBeenCalledTimes(3);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("joins an adoption already fetching and discards its superseded rows", async () => {
|
|
135
|
+
const { manager, db, cache, id } = await setup({ persistKey: "lifecycle" });
|
|
136
|
+
manager.unsubscribe(id);
|
|
137
|
+
const adoption = pending(db);
|
|
138
|
+
const adopted = await manager.subscribe(query);
|
|
139
|
+
await adoption.started;
|
|
140
|
+
const last = pending(db);
|
|
141
|
+
const explicit = manager.refreshSubscriptions();
|
|
142
|
+
adoption.resolve([row("obsolete")]);
|
|
143
|
+
await last.started;
|
|
144
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
145
|
+
last.resolve([row("final")]);
|
|
146
|
+
expect(await adopted.refresh).toEqual((await explicit)[0]);
|
|
147
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("invalidates the last release without corrupting cached documents", async () => {
|
|
151
|
+
const { manager, db, cache, id } = await setup();
|
|
152
|
+
const read = pending(db);
|
|
153
|
+
const result = manager.refreshSubscriptions();
|
|
154
|
+
await read.started;
|
|
155
|
+
manager.unsubscribe(id);
|
|
156
|
+
read.resolve([row("a", "obsolete"), row("new")]);
|
|
157
|
+
expect((await result)[0].ok).toBe(false);
|
|
158
|
+
expect(manager.getSubscription(id)).toBeUndefined();
|
|
159
|
+
expect(cache.get({ doctype: "Item", name: "a" })).toEqual(row("a"));
|
|
160
|
+
expect(cache.get({ doctype: "Item", name: "new" })).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("keeps shared refreshes active until the last consumer releases", async () => {
|
|
164
|
+
const { manager, db, id } = await setup();
|
|
165
|
+
expect((await manager.subscribe(query)).subscriptionId).toBe(id);
|
|
166
|
+
const first = pending(db);
|
|
167
|
+
const one = manager.refreshSubscriptions();
|
|
168
|
+
await first.started;
|
|
169
|
+
manager.unsubscribe(id);
|
|
170
|
+
first.resolve([row("updated")]);
|
|
171
|
+
expect(await one).toEqual([{ subscriptionId: id, ok: true }]);
|
|
172
|
+
const last = pending(db);
|
|
173
|
+
const two = manager.refreshSubscriptions();
|
|
174
|
+
await last.started;
|
|
175
|
+
manager.unsubscribe(id);
|
|
176
|
+
last.resolve([row("obsolete")]);
|
|
177
|
+
expect((await two)[0].ok).toBe(false);
|
|
178
|
+
expect(manager.getSubscription(id)).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("invalidates clear and allows a subsequent activation", async () => {
|
|
182
|
+
const { manager, db, cache, id } = await setup();
|
|
183
|
+
const old = pending(db);
|
|
184
|
+
const result = manager.refreshSubscriptions();
|
|
185
|
+
await old.started;
|
|
186
|
+
manager.clear();
|
|
187
|
+
cache.clear();
|
|
188
|
+
const fresh = await manager.subscribe(query);
|
|
189
|
+
old.resolve([row("a", "obsolete"), row("obsolete")]);
|
|
190
|
+
expect((await result)[0].ok).toBe(false);
|
|
191
|
+
expect(manager.getSubscription(id)).toBeUndefined();
|
|
192
|
+
expect(manager.getDocsForSubscription(fresh.subscriptionId)).toEqual([row("a")]);
|
|
193
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does not seed in the microtask between HTTP completion and acceptance", async () => {
|
|
197
|
+
const { manager, db, cache, id } = await setup();
|
|
198
|
+
const first = pending(db);
|
|
199
|
+
const one = manager.refreshSubscriptions();
|
|
200
|
+
await first.started;
|
|
201
|
+
const last = pending(db);
|
|
202
|
+
first.resolve([row("obsolete")]);
|
|
203
|
+
const two = Promise.resolve().then(() => manager.refreshSubscriptions());
|
|
204
|
+
await last.started;
|
|
205
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
206
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("a")]);
|
|
207
|
+
last.resolve([row("final")]);
|
|
208
|
+
expect(await one).toEqual(await two);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("allows different subscriptions to finish independently", async () => {
|
|
212
|
+
const { manager, db, id } = await setup();
|
|
213
|
+
const second = await manager.subscribe({ doctype: "Other" });
|
|
214
|
+
const slow = pending(db);
|
|
215
|
+
const fast = pending(db);
|
|
216
|
+
const result = manager.refreshSubscriptions();
|
|
217
|
+
await Promise.all([slow.started, fast.started]);
|
|
218
|
+
fast.resolve([row("fast")]);
|
|
219
|
+
await Promise.resolve();
|
|
220
|
+
await Promise.resolve();
|
|
221
|
+
expect(manager.getSubscription(second.subscriptionId)?.docIds).toEqual(["fast"]);
|
|
222
|
+
expect(manager.getSubscription(id)?.docIds).toEqual(["a"]);
|
|
223
|
+
slow.resolve([row("slow")]);
|
|
224
|
+
expect((await result).every((entry) => entry.ok)).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("excludes dormant restored subscriptions and selects each shared id once", async () => {
|
|
229
|
+
const { manager, db, id } = await setup();
|
|
230
|
+
await manager.subscribe(query);
|
|
231
|
+
manager.restoreSubscription({ id: "dormant", query: { doctype: "Dormant" }, docIds: ["cached"] });
|
|
232
|
+
expect(await manager.refreshSubscriptions({ doctypes: ["Dormant"] })).toEqual([]);
|
|
233
|
+
expect(await manager.refreshSubscriptions({ doctypes: [] })).toEqual([]);
|
|
234
|
+
expect(db.getDocList).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(await manager.refreshSubscriptions()).toEqual([{ subscriptionId: id, ok: true }]);
|
|
236
|
+
expect(db.getDocList).toHaveBeenCalledTimes(2);
|
|
237
|
+
manager.unsubscribe(id);
|
|
238
|
+
manager.unsubscribe(id);
|
|
239
|
+
expect(manager.getSubscription(id)).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("retains cached projections and documents outside the refreshed membership", async () => {
|
|
243
|
+
const { manager, db, cache, id } = await setup();
|
|
244
|
+
cache.upsert({ ...row("a"), detail: "other view" });
|
|
245
|
+
db.getDocList.mockResolvedValueOnce([row("b")]);
|
|
246
|
+
const other = await manager.subscribe({ doctype: "Item", filters: { enabled: 1 } });
|
|
247
|
+
db.getDocList
|
|
248
|
+
.mockResolvedValueOnce([{ name: "a", title: "projected" }])
|
|
249
|
+
.mockResolvedValueOnce([]);
|
|
250
|
+
await manager.refreshSubscriptions();
|
|
251
|
+
expect(manager.getDocsForSubscription(id)).toEqual([
|
|
252
|
+
{ ...row("a", "projected"), detail: "other view" },
|
|
253
|
+
]);
|
|
254
|
+
expect(manager.getDocsForSubscription(other.subscriptionId)).toEqual([]);
|
|
255
|
+
expect(cache.get({ doctype: "Item", name: "b" })).toEqual(row("b"));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("shares the first read when callers arrive before it starts", async () => {
|
|
259
|
+
const { manager, db, id } = await setup();
|
|
260
|
+
const first = pending(db);
|
|
261
|
+
const one = manager.refreshSubscriptions();
|
|
262
|
+
const two = manager.refreshSubscriptions();
|
|
263
|
+
await first.started;
|
|
264
|
+
first.resolve([row("final")]);
|
|
265
|
+
expect(await one).toEqual(await two);
|
|
266
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
267
|
+
expect(db.getDocList).toHaveBeenCalledTimes(2);
|
|
268
|
+
});
|