@docstack/react 0.0.9 → 0.1.1
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 +102 -24
- package/lib/hooks/class.d.ts +1 -1
- package/lib/hooks/class.js +76 -18
- package/lib/hooks/domain.d.ts +1 -2
- package/lib/hooks/domain.js +75 -18
- package/lib/hooks/index.d.ts +23 -5
- package/lib/hooks/index.js +128 -85
- 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 +2 -1
- package/package.json +2 -3
- 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 +153 -69
- 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/lib/hooks/index.js
CHANGED
|
@@ -8,81 +8,99 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
10
|
// src/hooks/useFind.js
|
|
11
|
-
import { useContext, useEffect, useRef, useState } from 'react';
|
|
11
|
+
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
12
12
|
import { DocStackContext } from '../components/StackProvider/index.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* @param stack - The name of the stack to query.
|
|
17
|
-
* @param sql - The SQL query string.
|
|
18
|
-
* @param params - Optional parameters for the SQL query.
|
|
19
|
-
* @returns Object containing the query result (rows and AST), loading state, and error.
|
|
20
|
-
*
|
|
21
|
-
* @example
|
|
22
|
-
* ```tsx
|
|
23
|
-
* const UserList = () => {
|
|
24
|
-
* const { result, loading } = useQuerySQL('my-stack', 'SELECT * FROM User WHERE age > ?', 18);
|
|
25
|
-
*
|
|
26
|
-
* if (loading) return <div>Loading...</div>;
|
|
27
|
-
*
|
|
28
|
-
* return (
|
|
29
|
-
* <ul>
|
|
30
|
-
* {result.rows.map(user => <li key={user._id}>{user.name}</li>)}
|
|
31
|
-
* </ul>
|
|
32
|
-
* );
|
|
33
|
-
* };
|
|
34
|
-
* ```
|
|
35
|
-
*/
|
|
36
|
-
export const useQuerySQL = (stack, sql, ...params) => {
|
|
13
|
+
import { collectQueryClasses } from '@docstack/client';
|
|
14
|
+
export const useQuerySQL = (stack, sql, params = [], options = {}) => {
|
|
15
|
+
const { live = true, coalesceMs = 150 } = options;
|
|
37
16
|
const docStack = useContext(DocStackContext);
|
|
38
17
|
const [result, setResult] = useState({ rows: [], ast: [] });
|
|
39
18
|
const [loading, setLoading] = useState(true);
|
|
40
19
|
const [error, setError] = useState(null);
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
20
|
+
// Which classes to watch. Held in state because it falls out of the first result and
|
|
21
|
+
// drives the subscription effect below.
|
|
22
|
+
const [watched, setWatched] = useState(undefined);
|
|
23
|
+
// Stable identities, so the effects key on the query rather than on the render count.
|
|
24
|
+
// The old `queryRef` latch was standing in for this: `params` arrived as a rest
|
|
25
|
+
// parameter, a fresh array every render, so the effect re-ran every render and the
|
|
26
|
+
// latch was the only thing preventing a query storm - at the cost of never re-running
|
|
27
|
+
// at all, including when `sql` changed. See ADR-0025.
|
|
28
|
+
const paramsKey = JSON.stringify(params);
|
|
29
|
+
const paramsRef = useRef(params);
|
|
30
|
+
paramsRef.current = params;
|
|
31
|
+
// Guards against a slow earlier run overwriting a fast later one.
|
|
32
|
+
const runId = useRef(0);
|
|
33
|
+
const runQuery = useCallback(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
34
|
+
const stackInstance = docStack === null || docStack === void 0 ? void 0 : docStack.getStack(stack);
|
|
35
|
+
if (!stackInstance)
|
|
49
36
|
return;
|
|
37
|
+
const id = ++runId.current;
|
|
38
|
+
try {
|
|
39
|
+
const queryResult = yield stackInstance.query(sql, ...paramsRef.current);
|
|
40
|
+
if (id !== runId.current)
|
|
41
|
+
return;
|
|
42
|
+
setResult(queryResult);
|
|
43
|
+
setWatched(collectQueryClasses(queryResult.ast));
|
|
44
|
+
setError(null);
|
|
50
45
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const stackInstance = docStack.getStack(stack);
|
|
54
|
-
if (stackInstance) {
|
|
55
|
-
// Run the initial query
|
|
56
|
-
console.log("Preparing to run query", { sql, params });
|
|
57
|
-
// debugger
|
|
58
|
-
const queryResult = yield stackInstance.query(sql, ...params);
|
|
59
|
-
setResult(queryResult);
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
console.log("Could not find corresponding stack", { stack });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
catch (err) {
|
|
66
|
-
console.log("Got error while running query", { error: err });
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (id === runId.current)
|
|
67
48
|
setError(err);
|
|
68
|
-
|
|
69
|
-
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
if (id === runId.current)
|
|
70
52
|
setLoading(false);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
if (!queryRef.current) {
|
|
74
|
-
queryRef.current = true;
|
|
75
|
-
setLoading(true);
|
|
76
|
-
runQuery();
|
|
77
53
|
}
|
|
78
|
-
|
|
79
|
-
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
}), [docStack, stack, sql, paramsKey]);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!docStack) {
|
|
58
|
+
// Null until the provider's `ready` event: startup, not a missing provider.
|
|
59
|
+
// See ADR-0022.
|
|
60
|
+
setLoading(true);
|
|
61
|
+
return;
|
|
80
62
|
}
|
|
63
|
+
setLoading(true);
|
|
64
|
+
runQuery();
|
|
65
|
+
}, [docStack, runQuery]);
|
|
66
|
+
const watchedKey = JSON.stringify(watched !== null && watched !== void 0 ? watched : null);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const stackInstance = docStack === null || docStack === void 0 ? void 0 : docStack.getStack(stack);
|
|
69
|
+
// `undefined` is "no result yet"; `null` is "the AST could not be accounted for".
|
|
70
|
+
if (!live || !stackInstance || watched === undefined)
|
|
71
|
+
return;
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
let timer;
|
|
74
|
+
let subscriptions = [];
|
|
75
|
+
const target = new EventTarget();
|
|
76
|
+
const onDoc = () => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
timer = setTimeout(runQuery, coalesceMs);
|
|
79
|
+
};
|
|
80
|
+
target.addEventListener("doc", onDoc);
|
|
81
|
+
// Fail open. Watching every class is wasteful; watching none is silently wrong,
|
|
82
|
+
// and silence is the failure this hook exists to end. Subscriptions share one
|
|
83
|
+
// database listener, so the wasteful branch costs little. See ADR-0025.
|
|
84
|
+
const resolveClasses = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
85
|
+
if (watched === null)
|
|
86
|
+
return stackInstance.getClassNames();
|
|
87
|
+
return watched;
|
|
88
|
+
});
|
|
89
|
+
void resolveClasses().then(classes => {
|
|
90
|
+
if (cancelled)
|
|
91
|
+
return;
|
|
92
|
+
subscriptions = classes.map(name => stackInstance.subscribeClassDocs(name, target));
|
|
93
|
+
});
|
|
81
94
|
return () => {
|
|
82
|
-
|
|
95
|
+
cancelled = true;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
target.removeEventListener("doc", onDoc);
|
|
98
|
+
for (const subscription of subscriptions)
|
|
99
|
+
stackInstance.releaseListener(subscription);
|
|
83
100
|
};
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, [docStack, stack, live, coalesceMs, watchedKey, runQuery]);
|
|
103
|
+
return { loading, result, error, refetch: runQuery };
|
|
86
104
|
};
|
|
87
105
|
/**
|
|
88
106
|
* Hook to find documents in a stack using a Mango selector.
|
|
@@ -119,52 +137,77 @@ export const useFind = (stack, query, sort, limit = 50) => {
|
|
|
119
137
|
const [docs, setDocs] = useState([]);
|
|
120
138
|
const [loading, setLoading] = useState(true);
|
|
121
139
|
const [error, setError] = useState(null);
|
|
140
|
+
// Guards against a slow earlier run overwriting a fast later one - the same
|
|
141
|
+
// discipline as useQuerySQL's runId above.
|
|
142
|
+
const runId = useRef(0);
|
|
122
143
|
useEffect(() => {
|
|
144
|
+
var _a;
|
|
123
145
|
// Check if the docStack instance is available
|
|
124
146
|
if (!docStack) {
|
|
125
147
|
// Handle the case where the provider is not yet initialized or missing
|
|
126
148
|
// You could throw an error or return an empty state.
|
|
127
|
-
|
|
128
|
-
|
|
149
|
+
// The provider publishes `null` into the context until its `ready`
|
|
150
|
+
// event fires, so this is the normal startup window, not a missing
|
|
151
|
+
// provider. Reporting it as one sends the reader hunting for a bug
|
|
152
|
+
// that is not there - and `setLoading(false)` was worse than the
|
|
153
|
+
// message: it tells a consumer "loaded, and empty" during startup,
|
|
154
|
+
// which is indistinguishable from a genuinely empty result. See
|
|
155
|
+
// ADR-0022.
|
|
156
|
+
setLoading(true);
|
|
129
157
|
return;
|
|
130
158
|
}
|
|
131
159
|
setLoading(true);
|
|
132
160
|
const runQuery = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
161
|
+
const id = ++runId.current;
|
|
133
162
|
try {
|
|
134
163
|
const stackInstance = docStack.getStack(stack);
|
|
135
164
|
if (stackInstance) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
165
|
+
const found = yield stackInstance.findDocuments(query.selector, query.fields);
|
|
166
|
+
// An empty result is a result. This setter used to be guarded on
|
|
167
|
+
// `.docs.length`, so a live list could gain rows but never lose its
|
|
168
|
+
// last one - a deleted document stayed on screen until a remount.
|
|
169
|
+
// The hazard that guard was standing in for is *staleness*, and the
|
|
170
|
+
// counter above owns that. See ADR-0035.
|
|
171
|
+
if (id === runId.current)
|
|
172
|
+
setDocs(found.docs);
|
|
142
173
|
}
|
|
143
174
|
}
|
|
144
175
|
catch (err) {
|
|
145
|
-
|
|
176
|
+
if (id === runId.current)
|
|
177
|
+
setError(err);
|
|
146
178
|
}
|
|
147
179
|
finally {
|
|
148
|
-
|
|
180
|
+
if (id === runId.current)
|
|
181
|
+
setLoading(false);
|
|
149
182
|
}
|
|
150
183
|
});
|
|
151
184
|
runQuery();
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
185
|
+
// A selector names its class directly, so there is no AST to consult - but it has
|
|
186
|
+
// to be subscribed the same way. This used to listen for `docStack`'s `change`,
|
|
187
|
+
// which is dispatched from the replication path and carries a `direction`: a
|
|
188
|
+
// document written locally never produces one, so an implementation built on it
|
|
189
|
+
// would appear to work while syncing and do nothing on the machine where the user
|
|
190
|
+
// is typing. See ADR-0025.
|
|
191
|
+
const className = (_a = query.selector) === null || _a === void 0 ? void 0 : _a["~class"];
|
|
192
|
+
const stackInstance = docStack.getStack(stack);
|
|
193
|
+
let timer;
|
|
194
|
+
let subscription;
|
|
195
|
+
const target = new EventTarget();
|
|
196
|
+
const onDoc = () => {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
timer = setTimeout(runQuery, 150);
|
|
160
199
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
200
|
+
if (stackInstance && typeof className === "string" && className) {
|
|
201
|
+
target.addEventListener("doc", onDoc);
|
|
202
|
+
subscription = stackInstance.subscribeClassDocs(className, target);
|
|
203
|
+
}
|
|
164
204
|
return () => {
|
|
165
|
-
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
target.removeEventListener("doc", onDoc);
|
|
207
|
+
if (stackInstance && subscription)
|
|
208
|
+
stackInstance.releaseListener(subscription);
|
|
166
209
|
};
|
|
167
|
-
}, [docStack, JSON.stringify(query)]); // Re-run if docStack or query changes
|
|
210
|
+
}, [docStack, stack, JSON.stringify(query)]); // Re-run if docStack or query changes
|
|
168
211
|
return { docs, loading, error };
|
|
169
212
|
};
|
|
170
213
|
export const useClassCreate = () => {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SyncStatus } from '@docstack/client';
|
|
2
|
+
/**
|
|
3
|
+
* Subscribes to replication state for one stack, or for all of them.
|
|
4
|
+
*
|
|
5
|
+
* Reads the state DocStack's sync layer keeps rather than tracking replication in the
|
|
6
|
+
* component: `lastConvergedAt` is the honest "last synced" value - the moment a cycle
|
|
7
|
+
* finished with nothing left to send - while `lastActiveAt` only says documents moved.
|
|
8
|
+
*
|
|
9
|
+
* The subscription is on the stacks, not on the replication handles, so it survives a
|
|
10
|
+
* {@link StackSyncHandle.restart} (a refreshed credential, say) and works whether it
|
|
11
|
+
* mounts before or after `sync()` was called.
|
|
12
|
+
*
|
|
13
|
+
* @param stackName - Narrow to a single stack. Omit for every open stack.
|
|
14
|
+
* @returns A map of stack name to {@link SyncStatus}; empty for stacks that have never
|
|
15
|
+
* synced.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const SyncBadge = ({ stack }: { stack: string }) => {
|
|
20
|
+
* const status = useSyncStatus(stack)[stack];
|
|
21
|
+
* if (!status) return <span>Not syncing</span>;
|
|
22
|
+
* if (status.state === 'error') return <span>Offline - retrying</span>;
|
|
23
|
+
* return <span>Synced {status.lastConvergedAt ? timeAgo(status.lastConvergedAt) : 'never'}</span>;
|
|
24
|
+
* };
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare const useSyncStatus: (stackName?: string) => Record<string, SyncStatus>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { useDocStack } from '../components/StackProvider/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Subscribes to replication state for one stack, or for all of them.
|
|
5
|
+
*
|
|
6
|
+
* Reads the state DocStack's sync layer keeps rather than tracking replication in the
|
|
7
|
+
* component: `lastConvergedAt` is the honest "last synced" value - the moment a cycle
|
|
8
|
+
* finished with nothing left to send - while `lastActiveAt` only says documents moved.
|
|
9
|
+
*
|
|
10
|
+
* The subscription is on the stacks, not on the replication handles, so it survives a
|
|
11
|
+
* {@link StackSyncHandle.restart} (a refreshed credential, say) and works whether it
|
|
12
|
+
* mounts before or after `sync()` was called.
|
|
13
|
+
*
|
|
14
|
+
* @param stackName - Narrow to a single stack. Omit for every open stack.
|
|
15
|
+
* @returns A map of stack name to {@link SyncStatus}; empty for stacks that have never
|
|
16
|
+
* synced.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const SyncBadge = ({ stack }: { stack: string }) => {
|
|
21
|
+
* const status = useSyncStatus(stack)[stack];
|
|
22
|
+
* if (!status) return <span>Not syncing</span>;
|
|
23
|
+
* if (status.state === 'error') return <span>Offline - retrying</span>;
|
|
24
|
+
* return <span>Synced {status.lastConvergedAt ? timeAgo(status.lastConvergedAt) : 'never'}</span>;
|
|
25
|
+
* };
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export const useSyncStatus = (stackName) => {
|
|
29
|
+
const docStack = useDocStack();
|
|
30
|
+
const [statuses, setStatuses] = useState({});
|
|
31
|
+
const collect = useCallback(() => {
|
|
32
|
+
if (!docStack)
|
|
33
|
+
return {};
|
|
34
|
+
const stacks = stackName
|
|
35
|
+
? [docStack.getStack(stackName)].filter(Boolean)
|
|
36
|
+
: docStack.getStacks();
|
|
37
|
+
const next = {};
|
|
38
|
+
for (const stack of stacks) {
|
|
39
|
+
const status = stack.getSyncStatus();
|
|
40
|
+
if (status)
|
|
41
|
+
next[stack.name] = status;
|
|
42
|
+
}
|
|
43
|
+
return next;
|
|
44
|
+
}, [docStack, stackName]);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!docStack)
|
|
47
|
+
return;
|
|
48
|
+
let subscribed = [];
|
|
49
|
+
const onStatus = () => setStatuses(collect());
|
|
50
|
+
const subscribe = () => {
|
|
51
|
+
for (const { target } of subscribed) {
|
|
52
|
+
target.removeEventListener('sync-status', onStatus);
|
|
53
|
+
}
|
|
54
|
+
const stacks = stackName
|
|
55
|
+
? [docStack.getStack(stackName)].filter(Boolean)
|
|
56
|
+
: docStack.getStacks();
|
|
57
|
+
subscribed = stacks.map(stack => ({ target: stack }));
|
|
58
|
+
for (const { target } of subscribed) {
|
|
59
|
+
target.addEventListener('sync-status', onStatus);
|
|
60
|
+
}
|
|
61
|
+
onStatus();
|
|
62
|
+
};
|
|
63
|
+
// The set of stacks is not fixed: one joined at runtime has to be picked up.
|
|
64
|
+
docStack.addEventListener('stack-added', subscribe);
|
|
65
|
+
docStack.addEventListener('stack-removed', subscribe);
|
|
66
|
+
subscribe();
|
|
67
|
+
return () => {
|
|
68
|
+
docStack.removeEventListener('stack-added', subscribe);
|
|
69
|
+
docStack.removeEventListener('stack-removed', subscribe);
|
|
70
|
+
for (const { target } of subscribed) {
|
|
71
|
+
target.removeEventListener('sync-status', onStatus);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}, [docStack, stackName, collect]);
|
|
75
|
+
return statuses;
|
|
76
|
+
};
|
package/lib/index.d.ts
CHANGED
|
@@ -2,7 +2,17 @@ import StackProvider, { DocStackContext, useDocStack } from "./components/StackP
|
|
|
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
|
export { StackProvider, DocStackContext, useDocStack };
|
|
6
|
-
export { useFind, useQuerySQL };
|
|
7
|
+
export { useFind, useQuerySQL, useSyncStatus };
|
|
7
8
|
export { useClassList, useClass, useClassDocs, useClassCreate };
|
|
8
9
|
export { useDomainList, useDomain, useDomainRelations, useDomainCreate };
|
|
10
|
+
/**
|
|
11
|
+
* Document-modelling types, re-exported from `@docstack/client`.
|
|
12
|
+
*
|
|
13
|
+
* Sourced from the client rather than `@docstack/shared` on purpose: the two packages
|
|
14
|
+
* would otherwise resolve their own copies of `@docstack/shared`, and a consumer using
|
|
15
|
+
* both could end up holding two structurally-identical-but-distinct `Patch` types.
|
|
16
|
+
* One source means one copy.
|
|
17
|
+
*/
|
|
18
|
+
export type { AttributeType, AttributeTypeConfig, AttributeModel, ClassModel, DomainModel, TriggerModel, Document, RelationDocument, Patch, SelectAST, UnionAST, ClientCredentials, DocstackReady, StackConfig, StackOptions, SyncDirection, SyncState, SyncStatus, StackSyncOptions, DocStackSyncOptions, RemoteResolver, InternalDocFilterOptions, } from "@docstack/client";
|
package/lib/index.js
CHANGED
|
@@ -2,7 +2,8 @@ import StackProvider, { DocStackContext, useDocStack } from "./components/StackP
|
|
|
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
|
export { StackProvider, DocStackContext, useDocStack };
|
|
6
|
-
export { useFind, useQuerySQL };
|
|
7
|
+
export { useFind, useQuerySQL, useSyncStatus };
|
|
7
8
|
export { useClassList, useClass, useClassDocs, useClassCreate };
|
|
8
9
|
export { useDomainList, useDomain, useDomainRelations, useDomainCreate };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@docstack/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "One does not simply stack documents.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "lib/index.js",
|
|
@@ -38,8 +38,7 @@
|
|
|
38
38
|
"react-dom": "^19.2.3"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@docstack/client": "^0.1.
|
|
42
|
-
"@docstack/shared": "^0.0.5",
|
|
41
|
+
"@docstack/client": "^0.1.5",
|
|
43
42
|
"react": "^19.2.3",
|
|
44
43
|
"react-dom": "^19.2.3"
|
|
45
44
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ReactNode, createContext, useContext, useRef, useCallback, useEffect, useState } from 'react';
|
|
1
|
+
import { ReactNode, createContext, useContext, useRef, useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
|
2
2
|
import {DocStack} from '@docstack/client'; // Import your DocStack class
|
|
3
|
-
import { ClientCredentials, StackConfig } from '@docstack/
|
|
3
|
+
import { ClientCredentials, StackConfig } from '@docstack/client';
|
|
4
4
|
|
|
5
5
|
// You can give it a default value, e.g., null, which can be checked later.
|
|
6
6
|
/**
|
|
@@ -11,16 +11,16 @@ export const DocStackContext = createContext<DocStack | null>(null);
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Hook to access the DocStack instance.
|
|
14
|
-
*
|
|
14
|
+
*
|
|
15
15
|
* @returns The current {@link DocStack} instance or null if not yet initialized.
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
17
|
* @example
|
|
18
18
|
* ```tsx
|
|
19
19
|
* const MyComponent = () => {
|
|
20
20
|
* const docStack = useDocStack();
|
|
21
|
-
*
|
|
21
|
+
*
|
|
22
22
|
* if (!docStack) return <div>Loading...</div>;
|
|
23
|
-
*
|
|
23
|
+
*
|
|
24
24
|
* return <div>Connected to {docStack.getStacks().length} stacks</div>;
|
|
25
25
|
* };
|
|
26
26
|
* ```
|
|
@@ -29,9 +29,41 @@ export const useDocStack = () => {
|
|
|
29
29
|
return useContext(DocStackContext);
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The name a configuration entry will end up carrying as a stack.
|
|
34
|
+
*
|
|
35
|
+
* Mirrors `DocStack.resolveStackConfig`: a string configuration *is* the name, an
|
|
36
|
+
* object's `name` wins, and a connection-only entry is identified by its connection.
|
|
37
|
+
*
|
|
38
|
+
* @param config - One stack configuration.
|
|
39
|
+
* @returns The identifier to reconcile on.
|
|
40
|
+
*/
|
|
41
|
+
const stackKey = (config: StackConfig): string => {
|
|
42
|
+
if (typeof config === 'string') return config;
|
|
43
|
+
return config.name || (config as { connection?: string }).connection || '';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Merges the `credentials` prop into the configurations it applies to.
|
|
48
|
+
*
|
|
49
|
+
* @param config - The configurations as given.
|
|
50
|
+
* @param credentials - One credential for every stack, or one per configuration entry.
|
|
51
|
+
* @returns Configurations with credentials folded in.
|
|
52
|
+
*/
|
|
53
|
+
const mergeCredentials = (
|
|
54
|
+
config: StackConfig[],
|
|
55
|
+
credentials?: ClientCredentials | ClientCredentials[]
|
|
56
|
+
): StackConfig[] => config.map((cfg, idx) => {
|
|
57
|
+
const cred = Array.isArray(credentials) ? credentials[idx] : credentials;
|
|
58
|
+
if (typeof cfg === 'string') {
|
|
59
|
+
return cred ? { connection: `db-${cfg}`, name: cfg, credentials: cred } : cfg;
|
|
60
|
+
}
|
|
61
|
+
return cred ? { ...cfg, credentials: cred } : cfg;
|
|
62
|
+
}) as StackConfig[];
|
|
63
|
+
|
|
32
64
|
/**
|
|
33
65
|
* Props for the {@link StackProvider} component.
|
|
34
|
-
*
|
|
66
|
+
*
|
|
35
67
|
* @example
|
|
36
68
|
* ```ts
|
|
37
69
|
* const props: DocStackProviderProps = {
|
|
@@ -45,6 +77,11 @@ export interface DocStackProviderProps {
|
|
|
45
77
|
config: StackConfig[];
|
|
46
78
|
/** Credentials for the stack(s). Can be a single object or an array matching the config. */
|
|
47
79
|
credentials?: ClientCredentials | ClientCredentials[];
|
|
80
|
+
/**
|
|
81
|
+
* Delete the underlying database when a stack drops out of `config`. Defaults to
|
|
82
|
+
* `false`: a workspace that disappears from the configuration is closed, not erased.
|
|
83
|
+
*/
|
|
84
|
+
destroyRemovedStacks?: boolean;
|
|
48
85
|
/** Child components. */
|
|
49
86
|
children?: ReactNode;
|
|
50
87
|
}
|
|
@@ -52,52 +89,99 @@ export interface DocStackProviderProps {
|
|
|
52
89
|
/**
|
|
53
90
|
* A provider component that initializes the DocStack client and makes it available
|
|
54
91
|
* to child components via the {@link useDocStack} hook.
|
|
55
|
-
*
|
|
56
|
-
*
|
|
92
|
+
*
|
|
93
|
+
* The `config` prop is reconciled rather than read once: a stack that appears in it is
|
|
94
|
+
* opened, a stack that disappears is closed, and the stacks either side of the change
|
|
95
|
+
* are left running. An application whose set of databases grows at runtime - one per
|
|
96
|
+
* workspace, say - therefore does not have to reload to pick up a new one, which
|
|
97
|
+
* matters once each stack also carries a live replication that a reload would drop.
|
|
98
|
+
*
|
|
57
99
|
* @example
|
|
58
100
|
* ```tsx
|
|
59
101
|
* import { StackProvider } from '@docstack/react';
|
|
60
|
-
*
|
|
61
|
-
* const App = () =>
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
102
|
+
*
|
|
103
|
+
* const App = () => {
|
|
104
|
+
* const workspaces = useWorkspaces();
|
|
105
|
+
* const config = useMemo(
|
|
106
|
+
* () => [{ name: 'app' }, ...workspaces.map(w => ({ name: `ws-${w.slug}` }))],
|
|
107
|
+
* [workspaces]
|
|
108
|
+
* );
|
|
109
|
+
* return (
|
|
110
|
+
* <StackProvider config={config}>
|
|
111
|
+
* <MyApp />
|
|
112
|
+
* </StackProvider>
|
|
113
|
+
* );
|
|
114
|
+
* };
|
|
66
115
|
* ```
|
|
67
116
|
*/
|
|
68
117
|
const StackProvider = (props: DocStackProviderProps) => {
|
|
69
|
-
const { config, children, credentials } = props;
|
|
118
|
+
const { config, children, credentials, destroyRemovedStacks } = props;
|
|
70
119
|
// Use a ref to store the DocStack instance
|
|
71
120
|
const docStackRef = useRef<DocStack | null>(null);
|
|
72
121
|
const [docStack, setDocStack] = useState<DocStack | null>(null);
|
|
122
|
+
// The DocStack instance is stable across reconciliations, so adding or removing a
|
|
123
|
+
// stack changes nothing React can see by itself.
|
|
124
|
+
const [, signalStacksChanged] = useReducer((count: number) => count + 1, 0);
|
|
125
|
+
// Reconciliations are serialized: opening a database is asynchronous and two
|
|
126
|
+
// overlapping passes would race to add the same stack twice.
|
|
127
|
+
const reconciling = useRef<Promise<unknown>>(Promise.resolve());
|
|
128
|
+
|
|
129
|
+
const mergedConfig = useMemo(
|
|
130
|
+
() => mergeCredentials(config, credentials),
|
|
131
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
|
+
[JSON.stringify(config), JSON.stringify(credentials)]
|
|
133
|
+
);
|
|
73
134
|
|
|
74
135
|
const setsDocStackWhenReady = useCallback(() => {
|
|
75
136
|
setDocStack(docStackRef.current)
|
|
76
137
|
},[]);
|
|
77
138
|
|
|
78
139
|
useEffect(() => {
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return cred ? { connection: cfg, credentials: cred } : cfg;
|
|
85
|
-
}
|
|
86
|
-
return cred ? { ...cfg, credentials: cred } : cfg;
|
|
87
|
-
});
|
|
88
|
-
const instance = new DocStack(...mergedConfig as StackConfig[]);
|
|
140
|
+
if (!mergedConfig.length) return;
|
|
141
|
+
|
|
142
|
+
if (docStackRef.current === null) {
|
|
143
|
+
console.log("DocStack provider - init instance", { config: mergedConfig });
|
|
144
|
+
const instance = new DocStack(...mergedConfig);
|
|
89
145
|
docStackRef.current = instance;
|
|
90
|
-
|
|
146
|
+
instance.addEventListener("ready", setsDocStackWhenReady);
|
|
147
|
+
instance.addEventListener("stack-added", signalStacksChanged);
|
|
148
|
+
instance.addEventListener("stack-removed", signalStacksChanged);
|
|
149
|
+
return;
|
|
91
150
|
}
|
|
92
151
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
152
|
+
const instance = docStackRef.current;
|
|
153
|
+
let cancelled = false;
|
|
154
|
+
|
|
155
|
+
const reconcile = async () => {
|
|
156
|
+
if (cancelled) return;
|
|
157
|
+
|
|
158
|
+
const wanted = new Map(mergedConfig.map(cfg => [stackKey(cfg), cfg]));
|
|
159
|
+
|
|
160
|
+
for (const stack of [...instance.getStacks()]) {
|
|
161
|
+
if (cancelled) return;
|
|
162
|
+
if (!wanted.has(stack.name)) {
|
|
163
|
+
console.log("DocStack provider - closing stack dropped from config", { name: stack.name });
|
|
164
|
+
await instance.removeStack(stack.name, { destroy: destroyRemovedStacks });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const [name, cfg] of wanted) {
|
|
169
|
+
if (cancelled) return;
|
|
170
|
+
if (!instance.getStack(name)) {
|
|
171
|
+
console.log("DocStack provider - opening stack added to config", { name });
|
|
172
|
+
await instance.addStack(cfg);
|
|
173
|
+
}
|
|
98
174
|
}
|
|
99
175
|
};
|
|
100
|
-
|
|
176
|
+
|
|
177
|
+
reconciling.current = reconciling.current.then(reconcile).catch(error => {
|
|
178
|
+
console.error("DocStack provider - failed to reconcile stacks", error);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return () => {
|
|
182
|
+
cancelled = true;
|
|
183
|
+
};
|
|
184
|
+
}, [mergedConfig, destroyRemovedStacks, setsDocStackWhenReady]);
|
|
101
185
|
|
|
102
186
|
return (
|
|
103
187
|
<DocStackContext.Provider value={docStack}>
|
|
@@ -106,4 +190,4 @@ const StackProvider = (props: DocStackProviderProps) => {
|
|
|
106
190
|
);
|
|
107
191
|
};
|
|
108
192
|
|
|
109
|
-
export default StackProvider;
|
|
193
|
+
export default StackProvider;
|