@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.
@@ -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
- console.error('useDomainCreate must be used within a DocStackProvider.');
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
- setLoading(false);
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
- originClass.addEventListener('doc', changeListener as EventListener);
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
- console.error('useDomain must be used within a DocStackProvider.');
223
- setLoading(false);
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 || !domainName) {
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
- domain.addEventListener('doc', changeListener as EventListener);
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 };
@@ -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/shared';
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 - Optional parameters for the SQL query.
12
- * @returns Object containing the query result (rows and AST), loading state, and error.
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 const useQuerySQL = (stack: string, sql: string, ...params: any[]) => {
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
- // [TODO] Solve bounce of component because of StrictMode or other reasons
35
- const queryRef = useRef(false);
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
- useEffect( () => {
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
- // Handle the case where the provider is not yet initialized or missing
40
- // You could throw an error or return an empty state.
41
- console.error('useClassList must be used within a DocStackProvider.');
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
- const runQuery = async () => {
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
- } else {
57
- console.log("Could not find corresponding stack", {stack})
58
- }
59
- } catch (err: any) {
60
- console.log("Got error while running query", {error: err})
61
- setError(err);
62
- } finally {
63
- setLoading(false);
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
- if (!queryRef.current) {
68
- queryRef.current = true;
69
- setLoading(true);
70
- runQuery();
71
- } else {
72
- console.log("Already performing query");
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
- return () => {
77
- //
78
- }
131
+ void resolveClasses().then(classes => {
132
+ if (cancelled) return;
133
+ subscriptions = classes.map(name => stackInstance.subscribeClassDocs(name, target));
134
+ });
79
135
 
80
- }, [docStack, stack, params]);
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
- console.error('useFind must be used within a DocStackProvider.');
130
- setLoading(false);
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
- // Set up the listener for changes
158
- const changeListener = (change: any) => {
159
- // Logic to handle the change and update the docs state
160
- // This part is crucial for real-time updates.
161
- // You'll need to re-run the query or intelligently update the docs array
162
- // based on the change object (add, update, delete).
163
- // A simple way is to re-run the query.
164
- // runQuery();
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
- // [TODO] Implement events
168
- docStack.addEventListener('change', changeListener);
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
- docStack.removeEventListener('change', changeListener);
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";
@@ -1,9 +0,0 @@
1
- /**
2
- * @license React
3
- * react-jsx-runtime.production.js
4
- *
5
- * Copyright (c) Meta Platforms, Inc. and affiliates.
6
- *
7
- * This source code is licensed under the MIT license found in the
8
- * LICENSE file in the root directory of this source tree.
9
- */