@docstack/react 0.0.7 → 0.1.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/lib/components/StackProvider/index.d.ts +24 -7
- package/lib/components/StackProvider/index.js +155 -0
- package/lib/components/index.js +1 -0
- package/lib/hooks/class.d.ts +1 -1
- package/lib/hooks/class.js +428 -0
- package/lib/hooks/domain.d.ts +1 -2
- package/lib/hooks/domain.js +423 -0
- package/lib/hooks/index.d.ts +23 -5
- package/lib/hooks/index.js +206 -0
- package/lib/hooks/sync.d.ts +27 -0
- package/lib/hooks/sync.js +76 -0
- package/lib/index.d.ts +11 -1
- package/lib/index.js +9 -3
- package/package.json +3 -4
- package/src/components/StackProvider/index.tsx +118 -34
- package/src/hooks/class.ts +69 -21
- package/src/hooks/domain.ts +68 -22
- package/src/hooks/index.ts +138 -59
- package/src/hooks/sync.ts +84 -0
- package/src/index.ts +36 -2
- package/lib/index.js.LICENSE.txt +0 -9
- package/lib/index.js.map +0 -1
package/src/hooks/domain.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useContext, useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { DocStackContext } from "../components/StackProvider/index.js";
|
|
3
|
-
import { Class } from "@docstack/client";
|
|
4
|
-
import {Document, Domain, DomainModel, RelationDocument, SelectAST, UnionAST} from "@docstack/shared";
|
|
3
|
+
import { Class, Domain, Document, DomainModel, RelationDocument, SelectAST, UnionAST } from "@docstack/client";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Hook to create a new Domain in a specific stack.
|
|
@@ -38,7 +37,9 @@ export const useDomainCreate = (stack: string) => {
|
|
|
38
37
|
if (!docStack) {
|
|
39
38
|
// Handle the case where the provider is not yet initialized or missing
|
|
40
39
|
// You could throw an error or return an empty state.
|
|
41
|
-
|
|
40
|
+
// Null until the provider's `ready` event; that is startup,
|
|
41
|
+
// not a missing provider. See ADR-0022.
|
|
42
|
+
console.warn('useDomainCreate - stack not ready yet; the call was ignored.');
|
|
42
43
|
// setLoading(false);
|
|
43
44
|
return Promise.resolve(null);
|
|
44
45
|
}
|
|
@@ -98,7 +99,10 @@ export const useDomainList = (stack: string, selector: {[key: string]: any}) =>
|
|
|
98
99
|
useEffect(() => {
|
|
99
100
|
// Only run if the docStack is available and a className is provided
|
|
100
101
|
if (!docStack) {
|
|
101
|
-
|
|
102
|
+
// Null until the provider's `ready` event: startup, not a missing
|
|
103
|
+
// provider. Reporting "loaded" here is indistinguishable from a genuinely
|
|
104
|
+
// empty result. See ADR-0022.
|
|
105
|
+
setLoading(true);
|
|
102
106
|
return;
|
|
103
107
|
}
|
|
104
108
|
|
|
@@ -132,20 +136,30 @@ export const useDomainList = (stack: string, selector: {[key: string]: any}) =>
|
|
|
132
136
|
return;
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
let cancelled = false;
|
|
140
|
+
let attached: EventListener | null = null;
|
|
141
|
+
|
|
135
142
|
const runQueryAndListen = async () => {
|
|
136
143
|
setLoading(true);
|
|
137
144
|
try {
|
|
138
145
|
const stackInstance = docStack!.getStack(stack)!;
|
|
139
146
|
const initialDomainModelList = await originClass.getCards(selector) as DomainModel[];
|
|
140
147
|
const initDomainList = await Promise.all(initialDomainModelList.map(async (dm) => await Domain.buildFromModel(stackInstance, dm)));
|
|
141
|
-
|
|
148
|
+
|
|
149
|
+
if (cancelled) {
|
|
150
|
+
// Torn down mid-query; these were built anyway and each holds a live
|
|
151
|
+
// subscription until it is closed.
|
|
152
|
+
for (const domain of initDomainList) domain.close();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
142
155
|
domainListRef.current = initDomainList;
|
|
143
156
|
setDomainList(domainListRef.current);
|
|
144
157
|
} catch (err: any) {
|
|
145
|
-
setError(err);
|
|
158
|
+
if (!cancelled) setError(err);
|
|
146
159
|
} finally {
|
|
147
|
-
setLoading(false);
|
|
160
|
+
if (!cancelled) setLoading(false);
|
|
148
161
|
}
|
|
162
|
+
if (cancelled) return;
|
|
149
163
|
|
|
150
164
|
const changeListener = (change: CustomEvent) => {
|
|
151
165
|
const doc = change.detail.doc;
|
|
@@ -176,14 +190,22 @@ export const useDomainList = (stack: string, selector: {[key: string]: any}) =>
|
|
|
176
190
|
setDomainList([...domainListRef.current])
|
|
177
191
|
};
|
|
178
192
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return () => {
|
|
182
|
-
originClass.removeEventListener('doc', changeListener as EventListener);
|
|
183
|
-
};
|
|
193
|
+
attached = changeListener as EventListener;
|
|
194
|
+
originClass.addEventListener('doc', attached);
|
|
184
195
|
};
|
|
185
196
|
|
|
186
197
|
runQueryAndListen();
|
|
198
|
+
|
|
199
|
+
// The cleanup used to be returned from `runQueryAndListen`, where React never saw
|
|
200
|
+
// it: the listener stayed attached and the built domains stayed subscribed.
|
|
201
|
+
return () => {
|
|
202
|
+
cancelled = true;
|
|
203
|
+
if (attached) originClass.removeEventListener('doc', attached);
|
|
204
|
+
// Guarded: the change handler above pushes the raw document for a domain it
|
|
205
|
+
// has not seen before, so the list is not uniformly Domain instances.
|
|
206
|
+
for (const domain of domainListRef.current) domain?.close?.();
|
|
207
|
+
domainListRef.current = [];
|
|
208
|
+
};
|
|
187
209
|
}, [originClass, JSON.stringify(selector)]); // Dependency on classObj and query
|
|
188
210
|
|
|
189
211
|
return { domainList, loading, error };
|
|
@@ -219,8 +241,14 @@ export const useDomain = (stack: string, domainName: string) => {
|
|
|
219
241
|
if (!docStack) {
|
|
220
242
|
// Handle the case where the provider is not yet initialized or missing
|
|
221
243
|
// You could throw an error or return an empty state.
|
|
222
|
-
|
|
223
|
-
|
|
244
|
+
// The provider publishes `null` into the context until its `ready`
|
|
245
|
+
// event fires, so this is the normal startup window, not a missing
|
|
246
|
+
// provider. Reporting it as one sends the reader hunting for a bug
|
|
247
|
+
// that is not there - and `setLoading(false)` was worse than the
|
|
248
|
+
// message: it tells a consumer "loaded, and empty" during startup,
|
|
249
|
+
// which is indistinguishable from a genuinely empty result. See
|
|
250
|
+
// ADR-0022.
|
|
251
|
+
setLoading(true);
|
|
224
252
|
return;
|
|
225
253
|
}
|
|
226
254
|
|
|
@@ -299,7 +327,16 @@ export const useDomainRelations = (stack: string, domainName: string, query = {}
|
|
|
299
327
|
|
|
300
328
|
useEffect(() => {
|
|
301
329
|
// Only run if the docStack is available and a className is provided
|
|
302
|
-
if (!docStack
|
|
330
|
+
if (!docStack) {
|
|
331
|
+
// Null until the provider's `ready` event: startup, not a missing
|
|
332
|
+
// provider. Reporting "loaded" here is indistinguishable from a genuinely
|
|
333
|
+
// empty result. See ADR-0022.
|
|
334
|
+
setLoading(true);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (!domainName) {
|
|
338
|
+
// A genuinely absent domainName is "nothing to load", which is a settled state -
|
|
339
|
+
// unlike the pre-ready window above.
|
|
303
340
|
setLoading(false);
|
|
304
341
|
return;
|
|
305
342
|
}
|
|
@@ -334,17 +371,22 @@ export const useDomainRelations = (stack: string, domainName: string, query = {}
|
|
|
334
371
|
return;
|
|
335
372
|
}
|
|
336
373
|
|
|
374
|
+
let cancelled = false;
|
|
375
|
+
let attached: EventListener | null = null;
|
|
376
|
+
|
|
337
377
|
const runQueryAndListen = async () => {
|
|
338
378
|
setLoading(true);
|
|
339
379
|
try {
|
|
340
380
|
const initialDocs = await domain.getRelations(query);
|
|
381
|
+
if (cancelled) return;
|
|
341
382
|
docsRef.current = initialDocs;
|
|
342
383
|
setDocs(docsRef.current);
|
|
343
384
|
} catch (err: any) {
|
|
344
|
-
setError(err);
|
|
385
|
+
if (!cancelled) setError(err);
|
|
345
386
|
} finally {
|
|
346
|
-
setLoading(false);
|
|
387
|
+
if (!cancelled) setLoading(false);
|
|
347
388
|
}
|
|
389
|
+
if (cancelled) return;
|
|
348
390
|
|
|
349
391
|
const changeListener = (change: CustomEvent) => {
|
|
350
392
|
const doc = change.detail.doc;
|
|
@@ -378,14 +420,18 @@ export const useDomainRelations = (stack: string, domainName: string, query = {}
|
|
|
378
420
|
setDocs([...docsRef.current])
|
|
379
421
|
};
|
|
380
422
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return () => {
|
|
384
|
-
domain.removeEventListener('doc', changeListener as EventListener);
|
|
385
|
-
};
|
|
423
|
+
attached = changeListener as EventListener;
|
|
424
|
+
domain.addEventListener('doc', attached);
|
|
386
425
|
};
|
|
387
426
|
|
|
388
427
|
runQueryAndListen();
|
|
428
|
+
|
|
429
|
+
// The cleanup used to be returned from `runQueryAndListen`, so React never
|
|
430
|
+
// received it and each query change left another listener on the domain.
|
|
431
|
+
return () => {
|
|
432
|
+
cancelled = true;
|
|
433
|
+
if (attached) domain.removeEventListener('doc', attached);
|
|
434
|
+
};
|
|
389
435
|
}, [domain, JSON.stringify(query)]); // Dependency on classObj and query
|
|
390
436
|
|
|
391
437
|
return { docs, loading, error };
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
// src/hooks/useFind.js
|
|
2
|
-
import { useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { DocStackContext } from '../components/StackProvider/index.js';
|
|
4
|
-
import { Document, SelectAST, UnionAST } from '@docstack/
|
|
4
|
+
import { Document, SelectAST, UnionAST, collectQueryClasses } from '@docstack/client';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Hook to execute a SQL query against a specific stack.
|
|
8
8
|
*
|
|
9
|
+
* Live by default: the query re-runs when a document changes in a class it actually
|
|
10
|
+
* reads, derived from the `ast` the query itself returns. It used to run once and never
|
|
11
|
+
* again, which was invisible beside `useClassDocs` in the same import - one list
|
|
12
|
+
* refreshing next to one that did not. See ADR-0025.
|
|
13
|
+
*
|
|
9
14
|
* @param stack - The name of the stack to query.
|
|
10
15
|
* @param sql - The SQL query string.
|
|
11
|
-
* @param params -
|
|
12
|
-
* @
|
|
16
|
+
* @param params - Values for the query's `?` placeholders.
|
|
17
|
+
* @param options - See {@link QuerySQLOptions}; `{ live: false }` for a snapshot.
|
|
18
|
+
* @returns The query result (rows and AST), loading state, error, and `refetch`.
|
|
13
19
|
*
|
|
14
20
|
* @example
|
|
15
21
|
* ```tsx
|
|
@@ -26,60 +32,117 @@ import { Document, SelectAST, UnionAST } from '@docstack/shared';
|
|
|
26
32
|
* };
|
|
27
33
|
* ```
|
|
28
34
|
*/
|
|
29
|
-
export
|
|
35
|
+
export type QuerySQLOptions = {
|
|
36
|
+
/**
|
|
37
|
+
* Re-run when a document changes in a class this query reads. Defaults to `true`.
|
|
38
|
+
*
|
|
39
|
+
* Pass `false` for a deliberate snapshot - and say so at the call site, which the
|
|
40
|
+
* previous behaviour never did.
|
|
41
|
+
*/
|
|
42
|
+
live?: boolean;
|
|
43
|
+
/** Coalesce a burst of changes into one re-run, in milliseconds. Defaults to 150. */
|
|
44
|
+
coalesceMs?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const useQuerySQL = (
|
|
48
|
+
stack: string,
|
|
49
|
+
sql: string,
|
|
50
|
+
params: any[] = [],
|
|
51
|
+
options: QuerySQLOptions = {},
|
|
52
|
+
) => {
|
|
53
|
+
const { live = true, coalesceMs = 150 } = options;
|
|
30
54
|
const docStack = useContext(DocStackContext);
|
|
31
55
|
const [result, setResult] = useState<{ rows: any[]; ast: (SelectAST | UnionAST)[] | null; }>({ rows: [], ast: [] });
|
|
32
56
|
const [loading, setLoading] = useState(true);
|
|
33
|
-
const [error, setError] = useState(null);
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
const [error, setError] = useState<any>(null);
|
|
58
|
+
|
|
59
|
+
// Which classes to watch. Held in state because it falls out of the first result and
|
|
60
|
+
// drives the subscription effect below.
|
|
61
|
+
const [watched, setWatched] = useState<string[] | null | undefined>(undefined);
|
|
62
|
+
|
|
63
|
+
// Stable identities, so the effects key on the query rather than on the render count.
|
|
64
|
+
// The old `queryRef` latch was standing in for this: `params` arrived as a rest
|
|
65
|
+
// parameter, a fresh array every render, so the effect re-ran every render and the
|
|
66
|
+
// latch was the only thing preventing a query storm - at the cost of never re-running
|
|
67
|
+
// at all, including when `sql` changed. See ADR-0025.
|
|
68
|
+
const paramsKey = JSON.stringify(params);
|
|
69
|
+
const paramsRef = useRef(params);
|
|
70
|
+
paramsRef.current = params;
|
|
36
71
|
|
|
37
|
-
|
|
72
|
+
// Guards against a slow earlier run overwriting a fast later one.
|
|
73
|
+
const runId = useRef(0);
|
|
74
|
+
|
|
75
|
+
const runQuery = useCallback(async () => {
|
|
76
|
+
const stackInstance = docStack?.getStack(stack);
|
|
77
|
+
if (!stackInstance) return;
|
|
78
|
+
|
|
79
|
+
const id = ++runId.current;
|
|
80
|
+
try {
|
|
81
|
+
const queryResult = await stackInstance.query(sql, ...paramsRef.current);
|
|
82
|
+
if (id !== runId.current) return;
|
|
83
|
+
setResult(queryResult);
|
|
84
|
+
setWatched(collectQueryClasses(queryResult.ast));
|
|
85
|
+
setError(null);
|
|
86
|
+
} catch (err: any) {
|
|
87
|
+
if (id === runId.current) setError(err);
|
|
88
|
+
} finally {
|
|
89
|
+
if (id === runId.current) setLoading(false);
|
|
90
|
+
}
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, [docStack, stack, sql, paramsKey]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
38
95
|
if (!docStack) {
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
setLoading(false);
|
|
96
|
+
// Null until the provider's `ready` event: startup, not a missing provider.
|
|
97
|
+
// See ADR-0022.
|
|
98
|
+
setLoading(true);
|
|
43
99
|
return;
|
|
44
100
|
}
|
|
101
|
+
setLoading(true);
|
|
102
|
+
runQuery();
|
|
103
|
+
}, [docStack, runQuery]);
|
|
45
104
|
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const stackInstance = docStack.getStack(stack);
|
|
49
|
-
if (stackInstance) {
|
|
50
|
-
// Run the initial query
|
|
51
|
-
console.log("Preparing to run query", {sql, params})
|
|
52
|
-
// debugger
|
|
53
|
-
const queryResult = await stackInstance.query(sql, ...params);
|
|
54
|
-
setResult(queryResult);
|
|
105
|
+
const watchedKey = JSON.stringify(watched ?? null);
|
|
55
106
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const stackInstance = docStack?.getStack(stack);
|
|
109
|
+
// `undefined` is "no result yet"; `null` is "the AST could not be accounted for".
|
|
110
|
+
if (!live || !stackInstance || watched === undefined) return;
|
|
111
|
+
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
114
|
+
let subscriptions: any[] = [];
|
|
115
|
+
const target = new EventTarget();
|
|
116
|
+
|
|
117
|
+
const onDoc = () => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
timer = setTimeout(runQuery, coalesceMs);
|
|
65
120
|
};
|
|
121
|
+
target.addEventListener("doc", onDoc);
|
|
66
122
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
123
|
+
// Fail open. Watching every class is wasteful; watching none is silently wrong,
|
|
124
|
+
// and silence is the failure this hook exists to end. Subscriptions share one
|
|
125
|
+
// database listener, so the wasteful branch costs little. See ADR-0025.
|
|
126
|
+
const resolveClasses = async (): Promise<string[]> => {
|
|
127
|
+
if (watched === null) return stackInstance.getClassNames();
|
|
128
|
+
return watched;
|
|
129
|
+
};
|
|
75
130
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
131
|
+
void resolveClasses().then(classes => {
|
|
132
|
+
if (cancelled) return;
|
|
133
|
+
subscriptions = classes.map(name => stackInstance.subscribeClassDocs(name, target));
|
|
134
|
+
});
|
|
79
135
|
|
|
80
|
-
|
|
136
|
+
return () => {
|
|
137
|
+
cancelled = true;
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
target.removeEventListener("doc", onDoc);
|
|
140
|
+
for (const subscription of subscriptions) stackInstance.releaseListener(subscription);
|
|
141
|
+
};
|
|
142
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
143
|
+
}, [docStack, stack, live, coalesceMs, watchedKey, runQuery]);
|
|
81
144
|
|
|
82
|
-
return { loading, result, error };
|
|
145
|
+
return { loading, result, error, refetch: runQuery };
|
|
83
146
|
}
|
|
84
147
|
|
|
85
148
|
/**
|
|
@@ -126,8 +189,14 @@ export const useFind = (stack: string, query: {
|
|
|
126
189
|
if (!docStack) {
|
|
127
190
|
// Handle the case where the provider is not yet initialized or missing
|
|
128
191
|
// You could throw an error or return an empty state.
|
|
129
|
-
|
|
130
|
-
|
|
192
|
+
// The provider publishes `null` into the context until its `ready`
|
|
193
|
+
// event fires, so this is the normal startup window, not a missing
|
|
194
|
+
// provider. Reporting it as one sends the reader hunting for a bug
|
|
195
|
+
// that is not there - and `setLoading(false)` was worse than the
|
|
196
|
+
// message: it tells a consumer "loaded, and empty" during startup,
|
|
197
|
+
// which is indistinguishable from a genuinely empty result. See
|
|
198
|
+
// ADR-0022.
|
|
199
|
+
setLoading(true);
|
|
131
200
|
return;
|
|
132
201
|
}
|
|
133
202
|
|
|
@@ -154,25 +223,35 @@ export const useFind = (stack: string, query: {
|
|
|
154
223
|
|
|
155
224
|
runQuery();
|
|
156
225
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
226
|
+
// A selector names its class directly, so there is no AST to consult - but it has
|
|
227
|
+
// to be subscribed the same way. This used to listen for `docStack`'s `change`,
|
|
228
|
+
// which is dispatched from the replication path and carries a `direction`: a
|
|
229
|
+
// document written locally never produces one, so an implementation built on it
|
|
230
|
+
// would appear to work while syncing and do nothing on the machine where the user
|
|
231
|
+
// is typing. See ADR-0025.
|
|
232
|
+
const className = (query.selector as { [key: string]: any })?.["~class"];
|
|
233
|
+
const stackInstance = docStack.getStack(stack);
|
|
234
|
+
|
|
235
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
236
|
+
let subscription: any;
|
|
237
|
+
const target = new EventTarget();
|
|
238
|
+
const onDoc = () => {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
timer = setTimeout(runQuery, 150);
|
|
165
241
|
};
|
|
166
242
|
|
|
167
|
-
|
|
168
|
-
|
|
243
|
+
if (stackInstance && typeof className === "string" && className) {
|
|
244
|
+
target.addEventListener("doc", onDoc);
|
|
245
|
+
subscription = stackInstance.subscribeClassDocs(className, target);
|
|
246
|
+
}
|
|
169
247
|
|
|
170
|
-
// Cleanup function: remove the listener when the component unmounts
|
|
171
248
|
return () => {
|
|
172
|
-
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
target.removeEventListener("doc", onDoc);
|
|
251
|
+
if (stackInstance && subscription) stackInstance.releaseListener(subscription);
|
|
173
252
|
};
|
|
174
253
|
|
|
175
|
-
}, [docStack, JSON.stringify(query)]); // Re-run if docStack or query changes
|
|
254
|
+
}, [docStack, stack, JSON.stringify(query)]); // Re-run if docStack or query changes
|
|
176
255
|
|
|
177
256
|
return { docs, loading, error };
|
|
178
257
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { useDocStack } from '../components/StackProvider/index.js';
|
|
3
|
+
import type { SyncStatus } from '@docstack/client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Subscribes to replication state for one stack, or for all of them.
|
|
7
|
+
*
|
|
8
|
+
* Reads the state DocStack's sync layer keeps rather than tracking replication in the
|
|
9
|
+
* component: `lastConvergedAt` is the honest "last synced" value - the moment a cycle
|
|
10
|
+
* finished with nothing left to send - while `lastActiveAt` only says documents moved.
|
|
11
|
+
*
|
|
12
|
+
* The subscription is on the stacks, not on the replication handles, so it survives a
|
|
13
|
+
* {@link StackSyncHandle.restart} (a refreshed credential, say) and works whether it
|
|
14
|
+
* mounts before or after `sync()` was called.
|
|
15
|
+
*
|
|
16
|
+
* @param stackName - Narrow to a single stack. Omit for every open stack.
|
|
17
|
+
* @returns A map of stack name to {@link SyncStatus}; empty for stacks that have never
|
|
18
|
+
* synced.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const SyncBadge = ({ stack }: { stack: string }) => {
|
|
23
|
+
* const status = useSyncStatus(stack)[stack];
|
|
24
|
+
* if (!status) return <span>Not syncing</span>;
|
|
25
|
+
* if (status.state === 'error') return <span>Offline - retrying</span>;
|
|
26
|
+
* return <span>Synced {status.lastConvergedAt ? timeAgo(status.lastConvergedAt) : 'never'}</span>;
|
|
27
|
+
* };
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export const useSyncStatus = (stackName?: string): Record<string, SyncStatus> => {
|
|
31
|
+
const docStack = useDocStack();
|
|
32
|
+
const [statuses, setStatuses] = useState<Record<string, SyncStatus>>({});
|
|
33
|
+
|
|
34
|
+
const collect = useCallback((): Record<string, SyncStatus> => {
|
|
35
|
+
if (!docStack) return {};
|
|
36
|
+
const stacks = stackName
|
|
37
|
+
? [docStack.getStack(stackName)].filter(Boolean)
|
|
38
|
+
: docStack.getStacks();
|
|
39
|
+
|
|
40
|
+
const next: Record<string, SyncStatus> = {};
|
|
41
|
+
for (const stack of stacks) {
|
|
42
|
+
const status = stack!.getSyncStatus();
|
|
43
|
+
if (status) next[stack!.name] = status;
|
|
44
|
+
}
|
|
45
|
+
return next;
|
|
46
|
+
}, [docStack, stackName]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!docStack) return;
|
|
50
|
+
|
|
51
|
+
let subscribed: { target: EventTarget; }[] = [];
|
|
52
|
+
|
|
53
|
+
const onStatus = () => setStatuses(collect());
|
|
54
|
+
|
|
55
|
+
const subscribe = () => {
|
|
56
|
+
for (const { target } of subscribed) {
|
|
57
|
+
target.removeEventListener('sync-status', onStatus);
|
|
58
|
+
}
|
|
59
|
+
const stacks = stackName
|
|
60
|
+
? [docStack.getStack(stackName)].filter(Boolean)
|
|
61
|
+
: docStack.getStacks();
|
|
62
|
+
subscribed = stacks.map(stack => ({ target: stack as unknown as EventTarget }));
|
|
63
|
+
for (const { target } of subscribed) {
|
|
64
|
+
target.addEventListener('sync-status', onStatus);
|
|
65
|
+
}
|
|
66
|
+
onStatus();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// The set of stacks is not fixed: one joined at runtime has to be picked up.
|
|
70
|
+
docStack.addEventListener('stack-added', subscribe);
|
|
71
|
+
docStack.addEventListener('stack-removed', subscribe);
|
|
72
|
+
subscribe();
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
docStack.removeEventListener('stack-added', subscribe);
|
|
76
|
+
docStack.removeEventListener('stack-removed', subscribe);
|
|
77
|
+
for (const { target } of subscribed) {
|
|
78
|
+
target.removeEventListener('sync-status', onStatus);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}, [docStack, stackName, collect]);
|
|
82
|
+
|
|
83
|
+
return statuses;
|
|
84
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -2,9 +2,43 @@ import StackProvider, {DocStackContext, useDocStack} from "./components/StackPro
|
|
|
2
2
|
import { useFind, useQuerySQL } from "./hooks/index.js";
|
|
3
3
|
import { useClass, useClassList, useClassDocs, useClassCreate } from "./hooks/class.js";
|
|
4
4
|
import { useDomainList, useDomain, useDomainRelations, useDomainCreate } from "./hooks/domain.js";
|
|
5
|
+
import { useSyncStatus } from "./hooks/sync.js";
|
|
5
6
|
|
|
6
7
|
export { StackProvider, DocStackContext, useDocStack };
|
|
7
|
-
export { useFind, useQuerySQL };
|
|
8
|
+
export { useFind, useQuerySQL, useSyncStatus };
|
|
8
9
|
|
|
9
10
|
export { useClassList, useClass, useClassDocs, useClassCreate };
|
|
10
|
-
export { useDomainList, useDomain, useDomainRelations, useDomainCreate };
|
|
11
|
+
export { useDomainList, useDomain, useDomainRelations, useDomainCreate };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Document-modelling types, re-exported from `@docstack/client`.
|
|
15
|
+
*
|
|
16
|
+
* Sourced from the client rather than `@docstack/shared` on purpose: the two packages
|
|
17
|
+
* would otherwise resolve their own copies of `@docstack/shared`, and a consumer using
|
|
18
|
+
* both could end up holding two structurally-identical-but-distinct `Patch` types.
|
|
19
|
+
* One source means one copy.
|
|
20
|
+
*/
|
|
21
|
+
export type {
|
|
22
|
+
AttributeType,
|
|
23
|
+
AttributeTypeConfig,
|
|
24
|
+
AttributeModel,
|
|
25
|
+
ClassModel,
|
|
26
|
+
DomainModel,
|
|
27
|
+
TriggerModel,
|
|
28
|
+
Document,
|
|
29
|
+
RelationDocument,
|
|
30
|
+
Patch,
|
|
31
|
+
SelectAST,
|
|
32
|
+
UnionAST,
|
|
33
|
+
ClientCredentials,
|
|
34
|
+
DocstackReady,
|
|
35
|
+
StackConfig,
|
|
36
|
+
StackOptions,
|
|
37
|
+
SyncDirection,
|
|
38
|
+
SyncState,
|
|
39
|
+
SyncStatus,
|
|
40
|
+
StackSyncOptions,
|
|
41
|
+
DocStackSyncOptions,
|
|
42
|
+
RemoteResolver,
|
|
43
|
+
InternalDocFilterOptions,
|
|
44
|
+
} from "@docstack/client";
|
package/lib/index.js.LICENSE.txt
DELETED