@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.
@@ -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
@@ -1,3 +1,9 @@
1
- /*! For license information please see index.js.LICENSE.txt */
2
- import{createContext as e,useCallback as n,useContext as t,useEffect as r,useRef as o,useState as c}from"react";import{Class as i,DocStack as s}from"@docstack/client";import{Domain as l}from"@docstack/shared";var d={698(e,n){var t=Symbol.for("react.transitional.element");Symbol.for("react.fragment"),n.jsx=function(e,n,r){var o=null;if(void 0!==r&&(o=""+r),void 0!==n.key&&(o=""+n.key),"key"in n)for(var c in r={},n)"key"!==c&&(r[c]=n[c]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:o,ref:void 0!==n?n:null,props:r}}},848(e,n,t){e.exports=t(698)}},a={};function u(e){var n=a[e];if(void 0!==n)return n.exports;var t=a[e]={exports:{}};return d[e](t,t.exports,u),t.exports}u.d=(e,n)=>{for(var t in n)u.o(n,t)&&!u.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},u.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n);var f=u(848);const v=e(null),g=()=>t(v),y=e=>{const{config:t,children:i,credentials:l}=e,d=o(null),[a,u]=c(null),g=n(()=>{u(d.current)},[]);return r(()=>{if(null===d.current&&t.length){console.log("DocStack provider - init instance",{config:t});const e=t.map((e,n)=>{const t=Array.isArray(l)?l[n]:l;return"string"==typeof e?t?{connection:e,credentials:t}:e:t?Object.assign(Object.assign({},e),{credentials:t}):e}),n=new s(...e);d.current=n,d.current.addEventListener("ready",g)}return()=>{d.current}},[t,l,g]),(0,f.jsx)(v.Provider,{value:a,children:i})};var h=function(e,n,t,r){return new(t||(t=Promise))(function(o,c){function i(e){try{l(r.next(e))}catch(e){c(e)}}function s(e){try{l(r.throw(e))}catch(e){c(e)}}function l(e){var n;e.done?o(e.value):(n=e.value,n instanceof t?n:new t(function(e){e(n)})).then(i,s)}l((r=r.apply(e,n||[])).next())})};const m=(e,n,...i)=>{const s=t(v),[l,d]=c({rows:[],ast:[]}),[a,u]=c(!0),[f,g]=c(null),y=o(!1);return r(()=>s?(y.current?console.log("Already performing query"):(y.current=!0,u(!0),h(void 0,void 0,void 0,function*(){try{const t=s.getStack(e);if(t){console.log("Preparing to run query",{sql:n,params:i});const e=yield t.query(n,...i);d(e)}else console.log("Could not find corresponding stack",{stack:e})}catch(e){console.log("Got error while running query",{error:e}),g(e)}finally{u(!1)}})),()=>{}):(console.error("useClassList must be used within a DocStackProvider."),void u(!1)),[s,e,i]),{loading:a,result:l,error:f}},p=(e,n,o,i=50)=>{const s=t(v),[l,d]=c([]),[a,u]=c(!0),[f,g]=c(null);return r(()=>{if(!s)return console.error("useFind must be used within a DocStackProvider."),void u(!1);u(!0),h(void 0,void 0,void 0,function*(){try{const t=s.getStack(e);if(t){const e=yield t.findDocuments(n.selector,n.fields);if(e.docs.length){let n=e.docs;d(n)}}}catch(e){g(e)}finally{u(!1)}});const t=e=>{};return s.addEventListener("change",t),()=>{s.removeEventListener("change",t)}},[s,JSON.stringify(n)]),{docs:l,loading:a,error:f}};var k=function(e,n,t,r){return new(t||(t=Promise))(function(o,c){function i(e){try{l(r.next(e))}catch(e){c(e)}}function s(e){try{l(r.throw(e))}catch(e){c(e)}}function l(e){var n;e.done?o(e.value):(n=e.value,n instanceof t?n:new t(function(e){e(n)})).then(i,s)}l((r=r.apply(e,n||[])).next())})};const S=e=>{const r=t(v);return n((n,t)=>k(void 0,void 0,void 0,function*(){try{if(!r)return console.error("useClassCreate must be used within a DocStackProvider."),Promise.resolve(null);const o=r.getStack(e);if(o){const e=yield i.create(o,n,"class",t);return yield o.addClass(e),e}return null}catch(e){return console.error(e),null}}),[r,e])},w=(e,n)=>{const s=t(v),[l,d]=c(),[a,u]=c([]),f=o([]),[g,y]=c(!0),[h,m]=c(null);return r(()=>{if(s)return k(void 0,void 0,void 0,function*(){y(!0),m(null);try{const n=s.getStack(e);if(n){const e=yield n.getClass("class");e&&d(e)}}catch(e){m(e),y(!1)}}),()=>{};y(!1)},[s,e]),r(()=>{l&&k(void 0,void 0,void 0,function*(){y(!0);try{const t=yield l.getCards(n),r=[],o=s.getStack(e);for(const e of t){const n=yield i.buildFromModel(o,e);r.push(n)}f.current=r,u(f.current)}catch(e){m(e)}finally{y(!1)}const t=e=>{const n=e.detail.doc;if(console.log("useClassDocs - detail",{detail:e.detail}),n.active){const e=f.current.findIndex(e=>e.id==n._id);-1!=e?f.current=[...f.current.slice(0,e),n,...f.current.slice(e+1,f.current.length)]:f.current.push(n)}else{console.log("useClassDocs - a doc was deleted",{doc:n});const e=f.current.findIndex(e=>e.id==n._id);-1!=e&&(f.current=[...f.current.slice(0,e),...f.current.slice(e+1,f.current.length)])}u([...f.current])};return l.addEventListener("doc",t),()=>{l.removeEventListener("doc",t)}})},[l,JSON.stringify(n)]),{classList:a,loading:g,error:h}},C=(e,n)=>{const i=t(v),[s,l]=c(!1),[d,a]=c(),[u,f]=c(),g=o(!1);return r(()=>i?(g.current||(g.current=!0,l(!0),k(void 0,void 0,void 0,function*(){try{const t=i.getStack(e);if(t){const e=yield t.getClass(n);e&&f(e)}}catch(e){a(e)}finally{l(!1)}})),()=>{}):(console.error("useClass must be used within a DocStackProvider."),void l(!1)),[i,e,n]),{loading:s,error:d,classObj:u}},x=(e,n,i={})=>{const s=t(v),[l,d]=c(),[a,u]=c([]),f=o([]),[g,y]=c(!0),[h,m]=c(null);return r(()=>{if(s&&n)return k(void 0,void 0,void 0,function*(){y(!0),m(null);try{const t=s.getStack(e);if(t){const e=yield t.getClass(n);e&&d(e)}}catch(e){m(e),y(!1)}}),()=>{};y(!1)},[s,e,n]),r(()=>{l&&k(void 0,void 0,void 0,function*(){y(!0);try{const e=yield l.getCards(i);f.current=e,u(f.current)}catch(e){m(e)}finally{y(!1)}const e=e=>{const n=e.detail.doc;if(console.log("useClassDocs - detail",{detail:e.detail}),n.active){console.log("useClassDocs - a doc was changed or added",{doc:n});const e=f.current.findIndex(e=>e._id==n._id);-1!=e?(console.log("useClassDocs - a doc was changed",{doc:n}),f.current=[...f.current.slice(0,e),n,...f.current.slice(e+1,f.current.length)]):(console.log("useClassDocs - a doc was added",{doc:n}),f.current.push(n))}else{console.log("useClassDocs - a doc was deleted",{doc:n});const e=f.current.findIndex(e=>e._id==n._id);-1!=e&&(f.current=[...f.current.slice(0,e),...f.current.slice(e+1,f.current.length)])}u([...f.current])};return l.addEventListener("doc",e),()=>{l.removeEventListener("doc",e)}})},[l,JSON.stringify(i)]),{docs:a,loading:g,error:h}};var D=function(e,n,t,r){return new(t||(t=Promise))(function(o,c){function i(e){try{l(r.next(e))}catch(e){c(e)}}function s(e){try{l(r.throw(e))}catch(e){c(e)}}function l(e){var n;e.done?o(e.value):(n=e.value,n instanceof t?n:new t(function(e){e(n)})).then(i,s)}l((r=r.apply(e,n||[])).next())})};const b=e=>{const r=t(v);return n((n,t,o,c,i)=>D(void 0,void 0,void 0,function*(){try{if(!r)return console.error("useDomainCreate must be used within a DocStackProvider."),Promise.resolve(null);const s=r.getStack(e);return s?yield l.create(s,null,n,"domain",t,o,c,i):null}catch(e){return console.error(e),null}}),[r,e])},P=(e,n)=>{const i=t(v),[s,d]=c(),[a,u]=c([]),f=o([]),[g,y]=c(!0),[h,m]=c(null);return r(()=>{if(i)return D(void 0,void 0,void 0,function*(){y(!0),m(null);try{const n=i.getStack(e);if(n){const e=yield n.getClass("domain");e&&d(e)}}catch(e){m(e),y(!1)}}),()=>{};y(!1)},[i,e]),r(()=>{s&&D(void 0,void 0,void 0,function*(){y(!0);try{const t=i.getStack(e),r=yield s.getCards(n),o=yield Promise.all(r.map(e=>D(void 0,void 0,void 0,function*(){return yield l.buildFromModel(t,e)})));f.current=o,u(f.current)}catch(e){m(e)}finally{y(!1)}const t=e=>{const n=e.detail.doc;if(n.active){const e=f.current.findIndex(e=>e.id==n._id);-1!=e?f.current=[...f.current.slice(0,e),n,...f.current.slice(e+1,f.current.length)]:f.current.push(n)}else{const e=f.current.findIndex(e=>e.id==n._id);-1!=e&&(f.current=[...f.current.slice(0,e),...f.current.slice(e+1,f.current.length)])}u([...f.current])};return s.addEventListener("doc",t),()=>{s.removeEventListener("doc",t)}})},[s,JSON.stringify(n)]),{domainList:a,loading:g,error:h}},L=(e,n)=>{const i=t(v),[s,l]=c(!1),[d,a]=c(),[u,f]=c(),g=o(!1);return r(()=>i?(g.current||(g.current=!0,l(!0),D(void 0,void 0,void 0,function*(){try{const t=i.getStack(e);if(t){const e=yield t.getDomain(n);e&&f(e)}}catch(e){a(e)}finally{l(!1)}})),()=>{}):(console.error("useDomain must be used within a DocStackProvider."),void l(!1)),[i,e,n]),{loading:s,error:d,domain:u}},E=(e,n,i={})=>{const s=t(v),[l,d]=c(),[a,u]=c([]),f=o([]),[g,y]=c(!0),[h,m]=c(null);return r(()=>{if(s&&n)return D(void 0,void 0,void 0,function*(){y(!0),m(null);try{const t=s.getStack(e);if(t){const e=yield t.getDomain(n);e&&d(e)}}catch(e){m(e),y(!1)}}),()=>{};y(!1)},[s,e,n]),r(()=>{l&&D(void 0,void 0,void 0,function*(){y(!0);try{const e=yield l.getRelations(i);f.current=e,u(f.current)}catch(e){m(e)}finally{y(!1)}const e=e=>{const n=e.detail.doc;if(n.active){console.log("useDomainRelations - a doc was changed or added",{doc:n});const e=f.current.findIndex(e=>e._id==n._id);-1!=e?(console.log("useDomainRelations - a doc was changed",{doc:n}),f.current=[...f.current.slice(0,e),n,...f.current.slice(e+1,f.current.length)]):(console.log("useDomainRelations - a doc was added",{doc:n}),f.current.push(n))}else{const e=f.current.findIndex(e=>e._id==n._id);-1!=e&&(f.current=[...f.current.slice(0,e),...f.current.slice(e+1,f.current.length)])}u([...f.current])};return l.addEventListener("doc",e),()=>{l.removeEventListener("doc",e)}})},[l,JSON.stringify(i)]),{docs:a,loading:g,error:h}};export{v as DocStackContext,y as StackProvider,C as useClass,S as useClassCreate,x as useClassDocs,w as useClassList,g as useDocStack,L as useDomain,b as useDomainCreate,P as useDomainList,E as useDomainRelations,p as useFind,m as useQuerySQL};
3
- //# sourceMappingURL=index.js.map
1
+ import StackProvider, { DocStackContext, useDocStack } from "./components/StackProvider/index.js";
2
+ import { useFind, useQuerySQL } from "./hooks/index.js";
3
+ import { useClass, useClassList, useClassDocs, useClassCreate } from "./hooks/class.js";
4
+ import { useDomainList, useDomain, useDomainRelations, useDomainCreate } from "./hooks/domain.js";
5
+ import { useSyncStatus } from "./hooks/sync.js";
6
+ export { StackProvider, DocStackContext, useDocStack };
7
+ export { useFind, useQuerySQL, useSyncStatus };
8
+ export { useClassList, useClass, useClassDocs, useClassCreate };
9
+ export { useDomainList, useDomain, useDomainRelations, useDomainCreate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docstack/react",
3
- "version": "0.0.7",
3
+ "version": "0.1.0",
4
4
  "description": "One does not simply stack documents.",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/index.js",
@@ -10,7 +10,7 @@
10
10
  "access": "public"
11
11
  },
12
12
  "scripts": {
13
- "build": "npm run build:prod",
13
+ "build": "npx tsc -build --force ./tsconfig.json",
14
14
  "build:dev": "webpack --node-env=development",
15
15
  "build:prod": "webpack --node-env=production"
16
16
  },
@@ -38,8 +38,7 @@
38
38
  "react-dom": "^19.2.3"
39
39
  },
40
40
  "dependencies": {
41
- "@docstack/client": "^0.1.3",
42
- "@docstack/shared": "^0.0.4",
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/shared';
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
- * It handles the asynchronous initialization of the stack(s).
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
- * <StackProvider config={[{ name: 'my-db' }]}>
63
- * <MyApp />
64
- * </StackProvider>
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 (docStackRef.current === null && config.length) {
80
- console.log("DocStack provider - init instance", {config});
81
- const mergedConfig = config.map((cfg, idx) => {
82
- const cred = Array.isArray(credentials) ? credentials[idx] : credentials;
83
- if (typeof cfg === "string") {
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
- docStackRef.current.addEventListener("ready", setsDocStackWhenReady);
146
+ instance.addEventListener("ready", setsDocStackWhenReady);
147
+ instance.addEventListener("stack-added", signalStacksChanged);
148
+ instance.addEventListener("stack-removed", signalStacksChanged);
149
+ return;
91
150
  }
92
151
 
93
- // Optional: Cleanup function to remove listeners
94
- return () => {
95
- if (docStackRef.current) {
96
- // docStackRef.current.removeEventListener("ready", setsDocStackWhenReady);
97
- // docStackRef.current.getStore().removeAllListeners();
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
- }, [config, credentials, setsDocStackWhenReady]);
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;
@@ -1,7 +1,7 @@
1
1
  import { useContext, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { DocStackContext } from "../components/StackProvider/index.js";
3
3
  import { Class } from "@docstack/client";
4
- import {ClassModel, Document} from "@docstack/shared";
4
+ import {ClassModel, Document} from "@docstack/client";
5
5
 
6
6
  /**
7
7
  * Hook to create a new Class in a specific stack.
@@ -34,7 +34,9 @@ export const useClassCreate = (stack: string) => {
34
34
  if (!docStack) {
35
35
  // Handle the case where the provider is not yet initialized or missing
36
36
  // You could throw an error or return an empty state.
37
- console.error('useClassCreate must be used within a DocStackProvider.');
37
+ // Null until the provider's `ready` event; that is startup,
38
+ // not a missing provider. See ADR-0022.
39
+ console.warn('useClassCreate - stack not ready yet; the call was ignored.');
38
40
  // setLoading(false);
39
41
  return Promise.resolve(null);
40
42
  }
@@ -94,7 +96,10 @@ export const useClassList = (stack: string, selector: {[key: string]: any}) => {
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
 
@@ -128,6 +133,9 @@ export const useClassList = (stack: string, selector: {[key: string]: any}) => {
128
133
  return;
129
134
  }
130
135
 
136
+ let cancelled = false;
137
+ let attached: EventListener | null = null;
138
+
131
139
  const runQueryAndListen = async () => {
132
140
  setLoading(true);
133
141
  try {
@@ -138,13 +146,20 @@ export const useClassList = (stack: string, selector: {[key: string]: any}) => {
138
146
  const classInstance = await Class.buildFromModel(stackInstance!, cls);
139
147
  initialClassList.push(classInstance);
140
148
  }
149
+ if (cancelled) {
150
+ // The effect was torn down mid-query; these were built anyway, and
151
+ // each one holds a live subscription until it is closed.
152
+ for (const classInstance of initialClassList) classInstance.close();
153
+ return;
154
+ }
141
155
  classListRef.current = initialClassList;
142
156
  setClassList(classListRef.current);
143
157
  } catch (err: any) {
144
- setError(err);
158
+ if (!cancelled) setError(err);
145
159
  } finally {
146
- setLoading(false);
160
+ if (!cancelled) setLoading(false);
147
161
  }
162
+ if (cancelled) return;
148
163
 
149
164
  const changeListener = (change: CustomEvent) => {
150
165
  const doc = change.detail.doc;
@@ -177,14 +192,23 @@ export const useClassList = (stack: string, selector: {[key: string]: any}) => {
177
192
  setClassList([...classListRef.current])
178
193
  };
179
194
 
180
- originClass.addEventListener('doc', changeListener as EventListener);
181
-
182
- return () => {
183
- originClass.removeEventListener('doc', changeListener as EventListener);
184
- };
195
+ attached = changeListener as EventListener;
196
+ originClass.addEventListener('doc', attached);
185
197
  };
186
198
 
187
199
  runQueryAndListen();
200
+
201
+ // The cleanup used to be returned from `runQueryAndListen`, where React never saw
202
+ // it: the listener stayed attached and the built classes stayed subscribed for
203
+ // every render that changed the selector.
204
+ return () => {
205
+ cancelled = true;
206
+ if (attached) 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) classInstance?.close?.();
210
+ classListRef.current = [];
211
+ };
188
212
  }, [originClass, JSON.stringify(selector)]); // Dependency on classObj and query
189
213
 
190
214
  return { classList, loading, error };
@@ -220,8 +244,14 @@ export const useClass = (stack: string, className: string) => {
220
244
  if (!docStack) {
221
245
  // Handle the case where the provider is not yet initialized or missing
222
246
  // You could throw an error or return an empty state.
223
- console.error('useClass must be used within a DocStackProvider.');
224
- setLoading(false);
247
+ // The provider publishes `null` into the context until its `ready`
248
+ // event fires, so this is the normal startup window, not a missing
249
+ // provider. Reporting it as one sends the reader hunting for a bug
250
+ // that is not there - and `setLoading(false)` was worse than the
251
+ // message: it tells a consumer "loaded, and empty" during startup,
252
+ // which is indistinguishable from a genuinely empty result. See
253
+ // ADR-0022.
254
+ setLoading(true);
225
255
  return;
226
256
  }
227
257
 
@@ -298,7 +328,16 @@ export const useClassDocs = (stack: string, className: string, query = {}) => {
298
328
 
299
329
  useEffect(() => {
300
330
  // Only run if the docStack is available and a className is provided
301
- if (!docStack || !className) {
331
+ if (!docStack) {
332
+ // Null until the provider's `ready` event: startup, not a missing
333
+ // provider. Reporting "loaded" here is indistinguishable from a genuinely
334
+ // empty result. See ADR-0022.
335
+ setLoading(true);
336
+ return;
337
+ }
338
+ if (!className) {
339
+ // A genuinely absent className is "nothing to load", which is a settled state -
340
+ // unlike the pre-ready window above.
302
341
  setLoading(false);
303
342
  return;
304
343
  }
@@ -333,18 +372,23 @@ export const useClassDocs = (stack: string, className: string, query = {}) => {
333
372
  return;
334
373
  }
335
374
 
375
+ let cancelled = false;
376
+ let attached: EventListener | null = null;
377
+
336
378
  const runQueryAndListen = async () => {
337
379
  setLoading(true);
338
380
  try {
339
- debugger;
381
+ // debugger;
340
382
  const initialDocs = await classObj.getCards(query) as Document[];
383
+ if (cancelled) return;
341
384
  docsRef.current = initialDocs;
342
385
  setDocs(docsRef.current);
343
386
  } catch (err: any) {
344
- setError(err);
387
+ if (!cancelled) setError(err);
345
388
  } finally {
346
- setLoading(false);
389
+ if (!cancelled) setLoading(false);
347
390
  }
391
+ if (cancelled) return;
348
392
 
349
393
  const changeListener = (change: CustomEvent) => {
350
394
  const doc = change.detail.doc;
@@ -380,14 +424,18 @@ export const useClassDocs = (stack: string, className: string, query = {}) => {
380
424
  setDocs([...docsRef.current])
381
425
  };
382
426
 
383
- classObj.addEventListener('doc', changeListener as EventListener);
384
-
385
- return () => {
386
- classObj.removeEventListener('doc', changeListener as EventListener);
387
- };
427
+ attached = changeListener as EventListener;
428
+ classObj.addEventListener('doc', attached);
388
429
  };
389
430
 
390
431
  runQueryAndListen();
432
+
433
+ // The cleanup used to be returned from `runQueryAndListen`, so React never
434
+ // received it and each query change left another listener on the class.
435
+ return () => {
436
+ cancelled = true;
437
+ if (attached) classObj.removeEventListener('doc', attached);
438
+ };
391
439
  }, [classObj, JSON.stringify(query)]); // Dependency on classObj and query
392
440
 
393
441
  return { docs, loading, error };