@docstack/react 0.0.9 → 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,6 +1,6 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { DocStack } from '@docstack/client';
3
- import { ClientCredentials, StackConfig } from '@docstack/shared';
3
+ import { ClientCredentials, StackConfig } from '@docstack/client';
4
4
  /**
5
5
  * Context object for the DocStack instance.
6
6
  * It provides the current DocStack instance or null if not initialized.
@@ -39,23 +39,40 @@ export interface DocStackProviderProps {
39
39
  config: StackConfig[];
40
40
  /** Credentials for the stack(s). Can be a single object or an array matching the config. */
41
41
  credentials?: ClientCredentials | ClientCredentials[];
42
+ /**
43
+ * Delete the underlying database when a stack drops out of `config`. Defaults to
44
+ * `false`: a workspace that disappears from the configuration is closed, not erased.
45
+ */
46
+ destroyRemovedStacks?: boolean;
42
47
  /** Child components. */
43
48
  children?: ReactNode;
44
49
  }
45
50
  /**
46
51
  * A provider component that initializes the DocStack client and makes it available
47
52
  * to child components via the {@link useDocStack} hook.
48
- * It handles the asynchronous initialization of the stack(s).
53
+ *
54
+ * The `config` prop is reconciled rather than read once: a stack that appears in it is
55
+ * opened, a stack that disappears is closed, and the stacks either side of the change
56
+ * are left running. An application whose set of databases grows at runtime - one per
57
+ * workspace, say - therefore does not have to reload to pick up a new one, which
58
+ * matters once each stack also carries a live replication that a reload would drop.
49
59
  *
50
60
  * @example
51
61
  * ```tsx
52
62
  * import { StackProvider } from '@docstack/react';
53
63
  *
54
- * const App = () => (
55
- * <StackProvider config={[{ name: 'my-db' }]}>
56
- * <MyApp />
57
- * </StackProvider>
58
- * );
64
+ * const App = () => {
65
+ * const workspaces = useWorkspaces();
66
+ * const config = useMemo(
67
+ * () => [{ name: 'app' }, ...workspaces.map(w => ({ name: `ws-${w.slug}` }))],
68
+ * [workspaces]
69
+ * );
70
+ * return (
71
+ * <StackProvider config={config}>
72
+ * <MyApp />
73
+ * </StackProvider>
74
+ * );
75
+ * };
59
76
  * ```
60
77
  */
61
78
  declare const StackProvider: (props: DocStackProviderProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,5 +1,14 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
1
10
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext, useRef, useCallback, useEffect, useState } from 'react';
11
+ import { createContext, useContext, useRef, useCallback, useEffect, useMemo, useReducer, useState } from 'react';
3
12
  import { DocStack } from '@docstack/client'; // Import your DocStack class
4
13
  // You can give it a default value, e.g., null, which can be checked later.
5
14
  /**
@@ -26,52 +35,121 @@ export const DocStackContext = createContext(null);
26
35
  export const useDocStack = () => {
27
36
  return useContext(DocStackContext);
28
37
  };
38
+ /**
39
+ * The name a configuration entry will end up carrying as a stack.
40
+ *
41
+ * Mirrors `DocStack.resolveStackConfig`: a string configuration *is* the name, an
42
+ * object's `name` wins, and a connection-only entry is identified by its connection.
43
+ *
44
+ * @param config - One stack configuration.
45
+ * @returns The identifier to reconcile on.
46
+ */
47
+ const stackKey = (config) => {
48
+ if (typeof config === 'string')
49
+ return config;
50
+ return config.name || config.connection || '';
51
+ };
52
+ /**
53
+ * Merges the `credentials` prop into the configurations it applies to.
54
+ *
55
+ * @param config - The configurations as given.
56
+ * @param credentials - One credential for every stack, or one per configuration entry.
57
+ * @returns Configurations with credentials folded in.
58
+ */
59
+ const mergeCredentials = (config, credentials) => config.map((cfg, idx) => {
60
+ const cred = Array.isArray(credentials) ? credentials[idx] : credentials;
61
+ if (typeof cfg === 'string') {
62
+ return cred ? { connection: `db-${cfg}`, name: cfg, credentials: cred } : cfg;
63
+ }
64
+ return cred ? Object.assign(Object.assign({}, cfg), { credentials: cred }) : cfg;
65
+ });
29
66
  /**
30
67
  * A provider component that initializes the DocStack client and makes it available
31
68
  * to child components via the {@link useDocStack} hook.
32
- * It handles the asynchronous initialization of the stack(s).
69
+ *
70
+ * The `config` prop is reconciled rather than read once: a stack that appears in it is
71
+ * opened, a stack that disappears is closed, and the stacks either side of the change
72
+ * are left running. An application whose set of databases grows at runtime - one per
73
+ * workspace, say - therefore does not have to reload to pick up a new one, which
74
+ * matters once each stack also carries a live replication that a reload would drop.
33
75
  *
34
76
  * @example
35
77
  * ```tsx
36
78
  * import { StackProvider } from '@docstack/react';
37
79
  *
38
- * const App = () => (
39
- * <StackProvider config={[{ name: 'my-db' }]}>
40
- * <MyApp />
41
- * </StackProvider>
42
- * );
80
+ * const App = () => {
81
+ * const workspaces = useWorkspaces();
82
+ * const config = useMemo(
83
+ * () => [{ name: 'app' }, ...workspaces.map(w => ({ name: `ws-${w.slug}` }))],
84
+ * [workspaces]
85
+ * );
86
+ * return (
87
+ * <StackProvider config={config}>
88
+ * <MyApp />
89
+ * </StackProvider>
90
+ * );
91
+ * };
43
92
  * ```
44
93
  */
45
94
  const StackProvider = (props) => {
46
- const { config, children, credentials } = props;
95
+ const { config, children, credentials, destroyRemovedStacks } = props;
47
96
  // Use a ref to store the DocStack instance
48
97
  const docStackRef = useRef(null);
49
98
  const [docStack, setDocStack] = useState(null);
99
+ // The DocStack instance is stable across reconciliations, so adding or removing a
100
+ // stack changes nothing React can see by itself.
101
+ const [, signalStacksChanged] = useReducer((count) => count + 1, 0);
102
+ // Reconciliations are serialized: opening a database is asynchronous and two
103
+ // overlapping passes would race to add the same stack twice.
104
+ const reconciling = useRef(Promise.resolve());
105
+ const mergedConfig = useMemo(() => mergeCredentials(config, credentials),
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ [JSON.stringify(config), JSON.stringify(credentials)]);
50
108
  const setsDocStackWhenReady = useCallback(() => {
51
109
  setDocStack(docStackRef.current);
52
110
  }, []);
53
111
  useEffect(() => {
54
- if (docStackRef.current === null && config.length) {
55
- console.log("DocStack provider - init instance", { config });
56
- const mergedConfig = config.map((cfg, idx) => {
57
- const cred = Array.isArray(credentials) ? credentials[idx] : credentials;
58
- if (typeof cfg === "string") {
59
- return cred ? { connection: cfg, credentials: cred } : cfg;
60
- }
61
- return cred ? Object.assign(Object.assign({}, cfg), { credentials: cred }) : cfg;
62
- });
112
+ if (!mergedConfig.length)
113
+ return;
114
+ if (docStackRef.current === null) {
115
+ console.log("DocStack provider - init instance", { config: mergedConfig });
63
116
  const instance = new DocStack(...mergedConfig);
64
117
  docStackRef.current = instance;
65
- docStackRef.current.addEventListener("ready", setsDocStackWhenReady);
118
+ instance.addEventListener("ready", setsDocStackWhenReady);
119
+ instance.addEventListener("stack-added", signalStacksChanged);
120
+ instance.addEventListener("stack-removed", signalStacksChanged);
121
+ return;
66
122
  }
67
- // Optional: Cleanup function to remove listeners
68
- return () => {
69
- if (docStackRef.current) {
70
- // docStackRef.current.removeEventListener("ready", setsDocStackWhenReady);
71
- // docStackRef.current.getStore().removeAllListeners();
123
+ const instance = docStackRef.current;
124
+ let cancelled = false;
125
+ const reconcile = () => __awaiter(void 0, void 0, void 0, function* () {
126
+ if (cancelled)
127
+ return;
128
+ const wanted = new Map(mergedConfig.map(cfg => [stackKey(cfg), cfg]));
129
+ for (const stack of [...instance.getStacks()]) {
130
+ if (cancelled)
131
+ return;
132
+ if (!wanted.has(stack.name)) {
133
+ console.log("DocStack provider - closing stack dropped from config", { name: stack.name });
134
+ yield instance.removeStack(stack.name, { destroy: destroyRemovedStacks });
135
+ }
136
+ }
137
+ for (const [name, cfg] of wanted) {
138
+ if (cancelled)
139
+ return;
140
+ if (!instance.getStack(name)) {
141
+ console.log("DocStack provider - opening stack added to config", { name });
142
+ yield instance.addStack(cfg);
143
+ }
72
144
  }
145
+ });
146
+ reconciling.current = reconciling.current.then(reconcile).catch(error => {
147
+ console.error("DocStack provider - failed to reconcile stacks", error);
148
+ });
149
+ return () => {
150
+ cancelled = true;
73
151
  };
74
- }, [config, credentials, setsDocStackWhenReady]);
152
+ }, [mergedConfig, destroyRemovedStacks, setsDocStackWhenReady]);
75
153
  return (_jsx(DocStackContext.Provider, { value: docStack, children: children }));
76
154
  };
77
155
  export default StackProvider;
@@ -1,5 +1,5 @@
1
1
  import { Class } from "@docstack/client";
2
- import { Document } from "@docstack/shared";
2
+ import { Document } from "@docstack/client";
3
3
  /**
4
4
  * Hook to create a new Class in a specific stack.
5
5
  *
@@ -39,7 +39,9 @@ export const useClassCreate = (stack) => {
39
39
  if (!docStack) {
40
40
  // Handle the case where the provider is not yet initialized or missing
41
41
  // You could throw an error or return an empty state.
42
- console.error('useClassCreate must be used within a DocStackProvider.');
42
+ // Null until the provider's `ready` event; that is startup,
43
+ // not a missing provider. See ADR-0022.
44
+ console.warn('useClassCreate - stack not ready yet; the call was ignored.');
43
45
  // setLoading(false);
44
46
  return Promise.resolve(null);
45
47
  }
@@ -94,7 +96,10 @@ export const useClassList = (stack, selector) => {
94
96
  useEffect(() => {
95
97
  // Only run if the docStack is available and a className is provided
96
98
  if (!docStack) {
97
- setLoading(false);
99
+ // Null until the provider's `ready` event: startup, not a missing
100
+ // provider. Reporting "loaded" here is indistinguishable from a genuinely
101
+ // empty result. See ADR-0022.
102
+ setLoading(true);
98
103
  return;
99
104
  }
100
105
  const fetchClass = () => __awaiter(void 0, void 0, void 0, function* () {
@@ -123,6 +128,8 @@ export const useClassList = (stack, selector) => {
123
128
  if (!originClass) {
124
129
  return;
125
130
  }
131
+ let cancelled = false;
132
+ let attached = null;
126
133
  const runQueryAndListen = () => __awaiter(void 0, void 0, void 0, function* () {
127
134
  setLoading(true);
128
135
  try {
@@ -133,15 +140,26 @@ export const useClassList = (stack, selector) => {
133
140
  const classInstance = yield Class.buildFromModel(stackInstance, cls);
134
141
  initialClassList.push(classInstance);
135
142
  }
143
+ if (cancelled) {
144
+ // The effect was torn down mid-query; these were built anyway, and
145
+ // each one holds a live subscription until it is closed.
146
+ for (const classInstance of initialClassList)
147
+ classInstance.close();
148
+ return;
149
+ }
136
150
  classListRef.current = initialClassList;
137
151
  setClassList(classListRef.current);
138
152
  }
139
153
  catch (err) {
140
- setError(err);
154
+ if (!cancelled)
155
+ setError(err);
141
156
  }
142
157
  finally {
143
- setLoading(false);
158
+ if (!cancelled)
159
+ setLoading(false);
144
160
  }
161
+ if (cancelled)
162
+ return;
145
163
  const changeListener = (change) => {
146
164
  const doc = change.detail.doc;
147
165
  console.log("useClassDocs - detail", { detail: change.detail });
@@ -174,12 +192,24 @@ export const useClassList = (stack, selector) => {
174
192
  }
175
193
  setClassList([...classListRef.current]);
176
194
  };
177
- originClass.addEventListener('doc', changeListener);
178
- return () => {
179
- originClass.removeEventListener('doc', changeListener);
180
- };
195
+ attached = changeListener;
196
+ originClass.addEventListener('doc', attached);
181
197
  });
182
198
  runQueryAndListen();
199
+ // The cleanup used to be returned from `runQueryAndListen`, where React never saw
200
+ // it: the listener stayed attached and the built classes stayed subscribed for
201
+ // every render that changed the selector.
202
+ return () => {
203
+ var _a;
204
+ cancelled = true;
205
+ if (attached)
206
+ originClass.removeEventListener('doc', attached);
207
+ // Guarded: the change handler above pushes the raw document for a class it
208
+ // has not seen before, so the list is not uniformly Class instances.
209
+ for (const classInstance of classListRef.current)
210
+ (_a = classInstance === null || classInstance === void 0 ? void 0 : classInstance.close) === null || _a === void 0 ? void 0 : _a.call(classInstance);
211
+ classListRef.current = [];
212
+ };
183
213
  }, [originClass, JSON.stringify(selector)]); // Dependency on classObj and query
184
214
  return { classList, loading, error };
185
215
  };
@@ -212,8 +242,14 @@ export const useClass = (stack, className) => {
212
242
  if (!docStack) {
213
243
  // Handle the case where the provider is not yet initialized or missing
214
244
  // You could throw an error or return an empty state.
215
- console.error('useClass must be used within a DocStackProvider.');
216
- setLoading(false);
245
+ // The provider publishes `null` into the context until its `ready`
246
+ // event fires, so this is the normal startup window, not a missing
247
+ // provider. Reporting it as one sends the reader hunting for a bug
248
+ // that is not there - and `setLoading(false)` was worse than the
249
+ // message: it tells a consumer "loaded, and empty" during startup,
250
+ // which is indistinguishable from a genuinely empty result. See
251
+ // ADR-0022.
252
+ setLoading(true);
217
253
  return;
218
254
  }
219
255
  const fetchClass = () => __awaiter(void 0, void 0, void 0, function* () {
@@ -280,7 +316,16 @@ export const useClassDocs = (stack, className, query = {}) => {
280
316
  const [error, setError] = useState(null);
281
317
  useEffect(() => {
282
318
  // Only run if the docStack is available and a className is provided
283
- if (!docStack || !className) {
319
+ if (!docStack) {
320
+ // Null until the provider's `ready` event: startup, not a missing
321
+ // provider. Reporting "loaded" here is indistinguishable from a genuinely
322
+ // empty result. See ADR-0022.
323
+ setLoading(true);
324
+ return;
325
+ }
326
+ if (!className) {
327
+ // A genuinely absent className is "nothing to load", which is a settled state -
328
+ // unlike the pre-ready window above.
284
329
  setLoading(false);
285
330
  return;
286
331
  }
@@ -310,20 +355,28 @@ export const useClassDocs = (stack, className, query = {}) => {
310
355
  if (!classObj) {
311
356
  return;
312
357
  }
358
+ let cancelled = false;
359
+ let attached = null;
313
360
  const runQueryAndListen = () => __awaiter(void 0, void 0, void 0, function* () {
314
361
  setLoading(true);
315
362
  try {
316
- debugger;
363
+ // debugger;
317
364
  const initialDocs = yield classObj.getCards(query);
365
+ if (cancelled)
366
+ return;
318
367
  docsRef.current = initialDocs;
319
368
  setDocs(docsRef.current);
320
369
  }
321
370
  catch (err) {
322
- setError(err);
371
+ if (!cancelled)
372
+ setError(err);
323
373
  }
324
374
  finally {
325
- setLoading(false);
375
+ if (!cancelled)
376
+ setLoading(false);
326
377
  }
378
+ if (cancelled)
379
+ return;
327
380
  const changeListener = (change) => {
328
381
  const doc = change.detail.doc;
329
382
  console.log("useClassDocs - detail", { detail: change.detail });
@@ -359,12 +412,17 @@ export const useClassDocs = (stack, className, query = {}) => {
359
412
  }
360
413
  setDocs([...docsRef.current]);
361
414
  };
362
- classObj.addEventListener('doc', changeListener);
363
- return () => {
364
- classObj.removeEventListener('doc', changeListener);
365
- };
415
+ attached = changeListener;
416
+ classObj.addEventListener('doc', attached);
366
417
  });
367
418
  runQueryAndListen();
419
+ // The cleanup used to be returned from `runQueryAndListen`, so React never
420
+ // received it and each query change left another listener on the class.
421
+ return () => {
422
+ cancelled = true;
423
+ if (attached)
424
+ classObj.removeEventListener('doc', attached);
425
+ };
368
426
  }, [classObj, JSON.stringify(query)]); // Dependency on classObj and query
369
427
  return { docs, loading, error };
370
428
  };
@@ -1,5 +1,4 @@
1
- import { Class } from "@docstack/client";
2
- import { Domain, RelationDocument } from "@docstack/shared";
1
+ import { Class, Domain, RelationDocument } from "@docstack/client";
3
2
  /**
4
3
  * Hook to create a new Domain in a specific stack.
5
4
  *
@@ -9,7 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import { useContext, useCallback, useEffect, useRef, useState } from "react";
11
11
  import { DocStackContext } from "../components/StackProvider/index.js";
12
- import { Domain } from "@docstack/shared";
12
+ import { Domain } from "@docstack/client";
13
13
  /**
14
14
  * Hook to create a new Domain in a specific stack.
15
15
  *
@@ -43,7 +43,9 @@ export const useDomainCreate = (stack) => {
43
43
  if (!docStack) {
44
44
  // Handle the case where the provider is not yet initialized or missing
45
45
  // You could throw an error or return an empty state.
46
- console.error('useDomainCreate must be used within a DocStackProvider.');
46
+ // Null until the provider's `ready` event; that is startup,
47
+ // not a missing provider. See ADR-0022.
48
+ console.warn('useDomainCreate - stack not ready yet; the call was ignored.');
47
49
  // setLoading(false);
48
50
  return Promise.resolve(null);
49
51
  }
@@ -97,7 +99,10 @@ export const useDomainList = (stack, selector) => {
97
99
  useEffect(() => {
98
100
  // Only run if the docStack is available and a className is provided
99
101
  if (!docStack) {
100
- 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);
101
106
  return;
102
107
  }
103
108
  const fetchClass = () => __awaiter(void 0, void 0, void 0, function* () {
@@ -126,21 +131,34 @@ export const useDomainList = (stack, selector) => {
126
131
  if (!originClass) {
127
132
  return;
128
133
  }
134
+ let cancelled = false;
135
+ let attached = null;
129
136
  const runQueryAndListen = () => __awaiter(void 0, void 0, void 0, function* () {
130
137
  setLoading(true);
131
138
  try {
132
139
  const stackInstance = docStack.getStack(stack);
133
140
  const initialDomainModelList = yield originClass.getCards(selector);
134
141
  const initDomainList = yield Promise.all(initialDomainModelList.map((dm) => __awaiter(void 0, void 0, void 0, function* () { return yield Domain.buildFromModel(stackInstance, dm); })));
142
+ if (cancelled) {
143
+ // Torn down mid-query; these were built anyway and each holds a live
144
+ // subscription until it is closed.
145
+ for (const domain of initDomainList)
146
+ domain.close();
147
+ return;
148
+ }
135
149
  domainListRef.current = initDomainList;
136
150
  setDomainList(domainListRef.current);
137
151
  }
138
152
  catch (err) {
139
- setError(err);
153
+ if (!cancelled)
154
+ setError(err);
140
155
  }
141
156
  finally {
142
- setLoading(false);
157
+ if (!cancelled)
158
+ setLoading(false);
143
159
  }
160
+ if (cancelled)
161
+ return;
144
162
  const changeListener = (change) => {
145
163
  const doc = change.detail.doc;
146
164
  if (!doc.active) {
@@ -171,12 +189,23 @@ export const useDomainList = (stack, selector) => {
171
189
  }
172
190
  setDomainList([...domainListRef.current]);
173
191
  };
174
- originClass.addEventListener('doc', changeListener);
175
- return () => {
176
- originClass.removeEventListener('doc', changeListener);
177
- };
192
+ attached = changeListener;
193
+ originClass.addEventListener('doc', attached);
178
194
  });
179
195
  runQueryAndListen();
196
+ // The cleanup used to be returned from `runQueryAndListen`, where React never saw
197
+ // it: the listener stayed attached and the built domains stayed subscribed.
198
+ return () => {
199
+ var _a;
200
+ cancelled = true;
201
+ if (attached)
202
+ originClass.removeEventListener('doc', attached);
203
+ // Guarded: the change handler above pushes the raw document for a domain it
204
+ // has not seen before, so the list is not uniformly Domain instances.
205
+ for (const domain of domainListRef.current)
206
+ (_a = domain === null || domain === void 0 ? void 0 : domain.close) === null || _a === void 0 ? void 0 : _a.call(domain);
207
+ domainListRef.current = [];
208
+ };
180
209
  }, [originClass, JSON.stringify(selector)]); // Dependency on classObj and query
181
210
  return { domainList, loading, error };
182
211
  };
@@ -209,8 +238,14 @@ export const useDomain = (stack, domainName) => {
209
238
  if (!docStack) {
210
239
  // Handle the case where the provider is not yet initialized or missing
211
240
  // You could throw an error or return an empty state.
212
- console.error('useDomain must be used within a DocStackProvider.');
213
- setLoading(false);
241
+ // The provider publishes `null` into the context until its `ready`
242
+ // event fires, so this is the normal startup window, not a missing
243
+ // provider. Reporting it as one sends the reader hunting for a bug
244
+ // that is not there - and `setLoading(false)` was worse than the
245
+ // message: it tells a consumer "loaded, and empty" during startup,
246
+ // which is indistinguishable from a genuinely empty result. See
247
+ // ADR-0022.
248
+ setLoading(true);
214
249
  return;
215
250
  }
216
251
  const fetchClass = () => __awaiter(void 0, void 0, void 0, function* () {
@@ -279,7 +314,16 @@ export const useDomainRelations = (stack, domainName, query = {}) => {
279
314
  const [error, setError] = useState(null);
280
315
  useEffect(() => {
281
316
  // Only run if the docStack is available and a className is provided
282
- if (!docStack || !domainName) {
317
+ if (!docStack) {
318
+ // Null until the provider's `ready` event: startup, not a missing
319
+ // provider. Reporting "loaded" here is indistinguishable from a genuinely
320
+ // empty result. See ADR-0022.
321
+ setLoading(true);
322
+ return;
323
+ }
324
+ if (!domainName) {
325
+ // A genuinely absent domainName is "nothing to load", which is a settled state -
326
+ // unlike the pre-ready window above.
283
327
  setLoading(false);
284
328
  return;
285
329
  }
@@ -309,19 +353,27 @@ export const useDomainRelations = (stack, domainName, query = {}) => {
309
353
  if (!domain) {
310
354
  return;
311
355
  }
356
+ let cancelled = false;
357
+ let attached = null;
312
358
  const runQueryAndListen = () => __awaiter(void 0, void 0, void 0, function* () {
313
359
  setLoading(true);
314
360
  try {
315
361
  const initialDocs = yield domain.getRelations(query);
362
+ if (cancelled)
363
+ return;
316
364
  docsRef.current = initialDocs;
317
365
  setDocs(docsRef.current);
318
366
  }
319
367
  catch (err) {
320
- setError(err);
368
+ if (!cancelled)
369
+ setError(err);
321
370
  }
322
371
  finally {
323
- setLoading(false);
372
+ if (!cancelled)
373
+ setLoading(false);
324
374
  }
375
+ if (cancelled)
376
+ return;
325
377
  const changeListener = (change) => {
326
378
  const doc = change.detail.doc;
327
379
  if (!doc.active) {
@@ -355,12 +407,17 @@ export const useDomainRelations = (stack, domainName, query = {}) => {
355
407
  }
356
408
  setDocs([...docsRef.current]);
357
409
  };
358
- domain.addEventListener('doc', changeListener);
359
- return () => {
360
- domain.removeEventListener('doc', changeListener);
361
- };
410
+ attached = changeListener;
411
+ domain.addEventListener('doc', attached);
362
412
  });
363
413
  runQueryAndListen();
414
+ // The cleanup used to be returned from `runQueryAndListen`, so React never
415
+ // received it and each query change left another listener on the domain.
416
+ return () => {
417
+ cancelled = true;
418
+ if (attached)
419
+ domain.removeEventListener('doc', attached);
420
+ };
364
421
  }, [domain, JSON.stringify(query)]); // Dependency on classObj and query
365
422
  return { docs, loading, error };
366
423
  };
@@ -1,11 +1,17 @@
1
- import { Document, SelectAST, UnionAST } from '@docstack/shared';
1
+ import { Document, SelectAST, UnionAST } from '@docstack/client';
2
2
  /**
3
3
  * Hook to execute a SQL query against a specific stack.
4
4
  *
5
+ * Live by default: the query re-runs when a document changes in a class it actually
6
+ * reads, derived from the `ast` the query itself returns. It used to run once and never
7
+ * again, which was invisible beside `useClassDocs` in the same import - one list
8
+ * refreshing next to one that did not. See ADR-0025.
9
+ *
5
10
  * @param stack - The name of the stack to query.
6
11
  * @param sql - The SQL query string.
7
- * @param params - Optional parameters for the SQL query.
8
- * @returns Object containing the query result (rows and AST), loading state, and error.
12
+ * @param params - Values for the query's `?` placeholders.
13
+ * @param options - See {@link QuerySQLOptions}; `{ live: false }` for a snapshot.
14
+ * @returns The query result (rows and AST), loading state, error, and `refetch`.
9
15
  *
10
16
  * @example
11
17
  * ```tsx
@@ -22,13 +28,25 @@ import { Document, SelectAST, UnionAST } from '@docstack/shared';
22
28
  * };
23
29
  * ```
24
30
  */
25
- export declare const useQuerySQL: (stack: string, sql: string, ...params: any[]) => {
31
+ export type QuerySQLOptions = {
32
+ /**
33
+ * Re-run when a document changes in a class this query reads. Defaults to `true`.
34
+ *
35
+ * Pass `false` for a deliberate snapshot - and say so at the call site, which the
36
+ * previous behaviour never did.
37
+ */
38
+ live?: boolean;
39
+ /** Coalesce a burst of changes into one re-run, in milliseconds. Defaults to 150. */
40
+ coalesceMs?: number;
41
+ };
42
+ export declare const useQuerySQL: (stack: string, sql: string, params?: any[], options?: QuerySQLOptions) => {
26
43
  loading: boolean;
27
44
  result: {
28
45
  rows: any[];
29
46
  ast: (SelectAST | UnionAST)[] | null;
30
47
  };
31
- error: null;
48
+ error: any;
49
+ refetch: () => Promise<void>;
32
50
  };
33
51
  /**
34
52
  * Hook to find documents in a stack using a Mango selector.