@docstack/react 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,187 +1,230 @@
1
+ [![npm](https://img.shields.io/npm/v/@docstack/react)](https://www.npmjs.com/package/@docstack/react)
2
+ [![Docs](https://img.shields.io/badge/docs-onyx--og.github.io-blue)](https://onyx-og.github.io/docstack/)
3
+ [![License](https://img.shields.io/badge/license-CC--BY--SA--4.0-lightgrey)](https://github.com/onyx-og/docstack/blob/main/LICENSE.md)
4
+
1
5
  # @docstack/react
2
6
 
3
- React bindings for **DocStack Client**, enabling seamless integration of the intelligent, offline-first database engine into React applications.
7
+ **React bindings for [DocStack](https://github.com/onyx-og/docstack) an offline-first embedded database with schemas, SQL, access scopes and encryption.**
8
+
9
+ A provider that owns the database lifecycle, and hooks that are **live by default**. Every hook subscribes to the local database, so when a document changes — because the user edited it, because a background job wrote it, or because it arrived over sync — the components reading it re-render. There is no fetching layer, no cache to invalidate, and no staleness to reason about.
4
10
 
5
- This package provides a context provider and hooks to initialize the database, subscribe to data changes, and perform write operations.
11
+ ---
6
12
 
7
13
  ## 📦 Installation
8
14
 
9
15
  ```bash
10
- npm install @docstack/react @docstack/client
16
+ npm install @docstack/react @docstack/client pouchdb-browser pouchdb-find
11
17
  ```
12
18
 
13
- ## 🚀 Usage
19
+ React 19 is a peer dependency. `pouchdb-browser` and `pouchdb-find` are peers of `@docstack/client`.
14
20
 
15
- ### 1. Setup the Provider
21
+ ## 🚀 Why hooks instead of a data layer
16
22
 
17
- Wrap your application root with `<StackProvider>`. This component initializes the DocStack engine and applies any schema patches provided in the configuration.
23
+ In a typical React app, "data" means a server, a client cache, and the machinery between them query keys, invalidation, refetch intervals, optimistic updates and rollback.
18
24
 
19
- ```tsx
20
- import { StackProvider } from "@docstack/react";
25
+ DocStack removes the server from that path. The database is in the browser, so:
26
+
27
+ * **A query is a subscription.** `useClassDocs`, `useQuerySQL`, `useFind` and the domain hooks all re-run when the data they read changes. Nothing to invalidate.
28
+ * **Writes are immediate and already true.** `classObj.add(...)` returns after the document has landed locally. There is no optimistic state to reconcile, because the write is not a request.
29
+ * **`useQuerySQL` watches the right things.** It derives which classes to subscribe to from the query's own AST, so a `JOIN` across three classes re-runs when any of the three changes — and bursts coalesce into one re-run (150 ms by default).
30
+ * **Offline is not a state you handle.** Components render from local storage. Sync status is something you *display* (`useSyncStatus`), not something a read has to survive.
21
31
 
22
- const DB_NAME = 'my-app-db';
32
+ ## Quick start
23
33
 
24
- // Define schema patches (versioned changes to your data model)
25
- const PATCHES = [
26
- {
27
- "~class": "patch",
28
- version: "1.0.0",
34
+ ### 1. Mount the provider
35
+
36
+ `StackProvider` builds the DocStack instance, opens each configured database, and applies the schema patches it is given. It reconciles when `config` changes: a stack added to the array is opened, one removed is closed.
37
+
38
+ ```tsx
39
+ import { StackProvider } from '@docstack/react';
40
+
41
+ const PATCHES = [{
42
+ '~class': 'patch',
43
+ _id: 'my-app-0.1.0',
44
+ version: '0.1.0',
45
+ target: 'my-app',
46
+ changelog: 'Add the Todo class.',
29
47
  active: true,
30
- docs: [
31
- {
32
- _id: "Todo",
33
- "~class": "class",
34
- name: "Todo",
48
+ docs: [{
49
+ '~class': 'class',
50
+ _id: 'Todo',
51
+ name: 'Todo',
35
52
  schema: {
36
- title: { name: "title", type: "string", config: { mandatory: true } },
37
- completed: { name: "completed", type: "boolean", config: { defaultValue: false } }
38
- }
39
- }
40
- ]
41
- }
42
- ];
43
-
44
- function App() {
45
- return (
46
- <StackProvider config={[{ name: DB_NAME, patches: PATCHES }]}>
47
- <MyComponent />
53
+ title: { name: 'title', type: 'string', config: { mandatory: true } },
54
+ completed: { name: 'completed', type: 'boolean', config: { defaultValue: false } },
55
+ },
56
+ }],
57
+ }];
58
+
59
+ const App = () => (
60
+ <StackProvider config={[{ name: 'my-app', patches: PATCHES }]}>
61
+ <TodoList />
48
62
  </StackProvider>
49
- );
50
- }
63
+ );
51
64
  ```
52
65
 
53
- ### 2. Read Data (Reactive)
66
+ Each entry accepts everything `ClientStack` accepts — `patches`, `documentKey`, `transactions`, `logLevel`, `plugins`, `credentials`.
54
67
 
55
- Use the `useClassDocs` hook to fetch documents for a specific class. This hook automatically subscribes to changes, so your component re-renders whenever the underlying data changes.
68
+ ### 2. Read and stay live
56
69
 
57
70
  ```tsx
58
- import { useClassDocs } from "@docstack/react";
59
-
60
- function TodoList() {
61
- // Fetch all documents of class 'Todo' from 'my-app-db'
62
- const { docs: todos, loading } = useClassDocs('my-app-db', 'Todo');
63
-
64
- if (loading) return <p>Loading...</p>;
65
-
66
- return (
67
- <ul>
68
- {todos.map(todo => (
69
- <li key={todo._id}>
70
- {todo.title} {todo.completed ? '✅' : '⭕'}
71
- </li>
72
- ))}
73
- </ul>
74
- );
75
- }
71
+ import { useClassDocs } from '@docstack/react';
72
+
73
+ const TodoList = () => {
74
+ const { docs, loading } = useClassDocs('my-app', 'Todo');
75
+
76
+ if (loading) return <p>Loading…</p>;
77
+
78
+ return (
79
+ <ul>
80
+ {docs.map(todo => (
81
+ <li key={todo._id}>{todo.title} {todo.completed ? '✅' : '⭕'}</li>
82
+ ))}
83
+ </ul>
84
+ );
85
+ };
76
86
  ```
77
87
 
78
- ### 3. Write Data
88
+ Pass a Mango selector as the third argument to narrow it: `useClassDocs('my-app', 'Todo', { completed: { $eq: false } })`.
79
89
 
80
- Use the `useDocStack` hook to access the underlying DocStack client instance for creating, updating, or deleting data.
90
+ ### 3. Write
81
91
 
82
92
  ```tsx
83
93
  import { useState } from 'react';
84
- import { useClass } from "@docstack/react";
85
-
86
- function AddTodo() {
87
- const { classObj: todoClass } = useClass('my-app-db', 'Todo');
88
- const [title, setTitle] = useState("");
89
-
90
- const handleAdd = async () => {
91
- if (!todoClass || !title) return;
92
-
93
- // Add the new document
94
- await todoClass.add({ title, completed: false });
95
-
96
- setTitle("");
97
- };
98
-
99
- return (
100
- <div>
101
- <input value={title} onChange={e => setTitle(e.target.value)} />
102
- <button onClick={handleAdd}>Add Task</button>
103
- </div>
104
- );
105
- }
94
+ import { useClass } from '@docstack/react';
95
+
96
+ const AddTodo = () => {
97
+ const { classObj: todoClass } = useClass('my-app', 'Todo');
98
+ const [title, setTitle] = useState('');
99
+
100
+ const handleAdd = async () => {
101
+ if (!todoClass || !title) return;
102
+ await todoClass.add({ title, completed: false });
103
+ setTitle(''); // the list above updates itself
104
+ };
105
+
106
+ return (
107
+ <>
108
+ <input value={title} onChange={e => setTitle(e.target.value)} />
109
+ <button onClick={handleAdd}>Add</button>
110
+ </>
111
+ );
112
+ };
106
113
  ```
107
114
 
108
- ## 📚 API Reference
115
+ ### 4. SQL, live
109
116
 
110
- ### `<StackProvider />`
117
+ ```tsx
118
+ import { useQuerySQL } from '@docstack/react';
119
+
120
+ const Overdue = ({ today }: { today: string }) => {
121
+ const { result, loading, error } = useQuerySQL(
122
+ 'my-app',
123
+ `SELECT t.title, p.name AS project
124
+ FROM Todo AS t
125
+ JOIN Project AS p ON p._id = t.projectId
126
+ WHERE t.completed = false AND t.dueDate < ?
127
+ ORDER BY t.dueDate`,
128
+ [today],
129
+ );
130
+
131
+ if (loading) return <p>Loading…</p>;
132
+ if (error) return <p>Query failed</p>;
133
+
134
+ return <ul>{result.rows.map(r => <li key={r.title}>{r.title} — {r.project}</li>)}</ul>;
135
+ };
136
+ ```
111
137
 
112
- The context provider that manages the lifecycle of the DocStack client.
138
+ **Params are an array**, and the query is live. For a deliberate one-shot read, say so at the call site: `useQuerySQL(stack, sql, [today], { live: false })`.
113
139
 
114
- | Prop | Type | Description |
115
- |------|------|-------------|
116
- | `config` | `Array<{ name: string, patches: any[] }>` | Configuration for initializing stacks, including database names and schema patches. |
140
+ ### 5. Show sync state honestly
117
141
 
118
- ### `useClassDocs(dbName: string, className: string)`
142
+ ```tsx
143
+ import { useSyncStatus } from '@docstack/react';
144
+
145
+ const SyncBadge = ({ stack }: { stack: string }) => {
146
+ const status = useSyncStatus(stack)[stack];
147
+
148
+ if (!status) return <span>Not syncing</span>;
149
+ if (status.state === 'error') return <span>Offline — retrying</span>;
150
+ return <span>Synced {status.lastConvergedAt ? timeAgo(status.lastConvergedAt) : 'never'}</span>;
151
+ };
152
+ ```
153
+
154
+ `lastConvergedAt` is the value to render as "last synced" — it marks a cycle that finished with nothing left to send. `lastActiveAt` only says documents moved, which is not the same promise.
155
+
156
+ The subscription is on the stacks rather than on the replication handles, so it survives a `handle.restart()` (a refreshed credential, say) and works whether it mounts before or after `sync()` was called.
157
+
158
+ ## 📚 API reference
119
159
 
120
- A hook that subscribes to a specific class in the database.
160
+ ### `<StackProvider />`
121
161
 
122
- - **Arguments**:
123
- - `dbName`: The name of the database stack.
124
- - `className`: The name of the class to query.
125
- - **Returns**:
126
- - `docs`: An array of documents belonging to the class.
127
- - `loading`: A boolean indicating if the initial data load is in progress.
162
+ | Prop | Type | Description |
163
+ |---|---|---|
164
+ | `config` | `StackConfig[]` | Stacks to open. A string is the database name; an object accepts every `ClientStack` option (`name`, `patches`, `documentKey`, `transactions`, `logLevel`, `plugins`, `credentials`). Reconciled on change. |
165
+ | `credentials` | `ClientCredentials \| ClientCredentials[]` | One credential for every stack, or one per config entry. Merged into the configs it applies to. |
166
+ | `destroyRemovedStacks` | `boolean` | Delete the underlying database when a stack drops out of `config`. Defaults to `false` — a workspace that disappears from the configuration is closed, not erased. |
128
167
 
129
- ### `useClass(dbName: string, className: string)`
168
+ The context publishes `null` until the instance is ready. That window is startup, not a missing provider — every hook handles it, and `useDocStack()` returning `null` is the signal to render a splash rather than an error.
130
169
 
131
- A hook that retrieves the class definition for writing data.
170
+ ### Hooks
132
171
 
133
- - **Arguments**:
134
- - `dbName`: The name of the database stack.
135
- - `className`: The name of the class.
136
- - **Returns**:
137
- - `classObj`: The class instance used for operations like `add` or `updateCard`.
138
- - `loading`: Boolean indicating if the class is loading.
172
+ | Hook | Signature | Returns |
173
+ |---|---|---|
174
+ | `useDocStack` | `()` | The `DocStack` instance, or `null` during startup |
175
+ | `useClassDocs` | `(stack, className, query?)` | `{ docs, loading, error }` — live documents of a class, optionally filtered by a Mango selector |
176
+ | `useClass` | `(stack, className)` | `{ classObj, loading, error }` the `Class` instance, for `add` / `updateCard` / `addTrigger` |
177
+ | `useClassList` | `(stack, selector)` | `{ classList, loading, error }` the classes defined in the database |
178
+ | `useClassCreate` | `(stack)` | `(className, description?) => Promise<Class>` |
179
+ | `useQuerySQL` | `(stack, sql, params?, options?)` | `{ result, loading, error, refetch }` — `result.rows` and `result.ast`. Live unless `{ live: false }`; `{ coalesceMs }` defaults to 150 |
180
+ | `useFind` | `(stack, { selector, fields? }, sort?, limit?)` | `{ docs, loading, error }` — Mango query; `limit` defaults to 50 |
181
+ | `useSyncStatus` | `(stackName?)` | `Record<string, SyncStatus>` — one stack, or every open stack |
182
+ | `useDomain` | `(stack, domainName)` | `{ domain, loading, error }` |
183
+ | `useDomainList` | `(stack, selector)` | `{ domainList, loading, error }` |
184
+ | `useDomainRelations` | `(stack, domainName, query?)` | `{ docs, loading, error }` — live relation documents |
185
+ | `useDomainCreate` | `(stack)` | `(name, cardinality, sourceClass, targetClass, description?) => Promise<Domain>` |
139
186
 
140
- ### `useDocStack()`
187
+ The package also re-exports DocStack's document-modelling types (`Patch`, `ClassModel`, `AttributeModel`, `Document`, `SyncStatus`, `StackConfig`, …) — sourced from `@docstack/client` so a consumer using both never ends up holding two structurally identical but distinct copies of `Patch`.
141
188
 
142
- A hook that returns the initialized DocStack client instance.
189
+ ## 🧩 Patterns
143
190
 
144
- - **Returns**: The `DocStack` instance (or `null` if not yet initialized). Use this to access `getStack()`, run queries, or execute jobs.
191
+ **Gate on readiness, not on loading.** `useDocStack()` is `null` until the provider finishes opening its databases:
145
192
 
146
- ### `useQuerySQL(dbName: string, sql: string, ...params: any[])`
193
+ ```tsx
194
+ const Root = () => {
195
+ const docstack = useDocStack();
196
+ if (!docstack) return <Splash />;
197
+ return <App />;
198
+ };
199
+ ```
147
200
 
148
- A hook to execute SQL queries against the local database.
201
+ **A database per workspace.** `config` is an array, so multiple databases are the normal case, not a workaround. Give each workspace its own stack and pass the active one's name down:
202
+
203
+ ```tsx
204
+ <StackProvider config={workspaces.map(w => ({ name: w.stackName, patches: WORKSPACE_PATCHES }))}>
205
+ <Workspace stackName={active.stackName} />
206
+ </StackProvider>
207
+ ```
149
208
 
150
- - **Arguments**:
151
- - `dbName`: The name of the database stack.
152
- - `sql`: The SQL query string.
153
- - `params`: Variable arguments for query parameters.
154
- - **Returns**:
155
- - `result`: An object containing `rows` (the query results).
156
- - `loading`: Boolean indicating execution status.
209
+ Every hook takes the stack name as its first argument, so switching workspaces is a prop change — not a remount, and not a second `DocStack` instance racing the first.
157
210
 
158
- ### `useFind(dbName: string, query: { selector: object, fields?: string[] })`
211
+ **Snapshots where you mean them.** A report that should not shift under the reader wants `{ live: false }`. Say it at the call site so the next reader knows it was a decision.
159
212
 
160
- A hook to find documents using a MongoDB-style selector.
213
+ **Reach for the client when hooks aren't the right shape.** `useDocStack()` gives you the full instance — `getStack(name)` for transactions, exports, job execution or `sync()`.
161
214
 
162
- - **Arguments**:
163
- - `dbName`: The name of the database stack.
164
- - `query`: An object with a `selector` property (Mango query syntax).
165
- - **Returns**:
166
- - `docs`: An array of matching documents.
167
- - `loading`: Boolean indicating if the search is in progress.
215
+ ## 🔍 How it compares
168
216
 
169
- ### `useClassList(dbName: string, selector: object)`
217
+ If you have used `useLiveQuery` in [Dexie](https://dexie.org/) or the reactive queries in [RxDB](https://rxdb.info/), the reactivity model here will feel familiar: a query that re-runs when its data changes, backed by local storage.
170
218
 
171
- A hook to retrieve a list of available classes (schemas) in the database.
219
+ What differs is what sits underneath the hook. A `useQuerySQL` call resolves against an engine that already applies schema validation, access scopes and field-level decryption, and the SQL it runs supports joins, aggregation and subqueries rather than a selector API. And because DocStack replicates to any PouchDB-compatible remote — including a folder in the end user's own Google Drive — `useSyncStatus` can describe a sync you never had to run a server for.
172
220
 
173
- - **Arguments**:
174
- - `dbName`: The name of the database stack.
175
- - `selector`: A filter object to select specific classes.
176
- - **Returns**:
177
- - `classList`: An array of `Class` instances.
178
- - `loading`: Boolean indicating if the list is loading.
221
+ ## 📖 Documentation
179
222
 
180
- ### `useClassCreate(dbName: string)`
223
+ * [Full documentation](https://onyx-og.github.io/docstack/)
224
+ * [@docstack/client](https://github.com/onyx-og/docstack/blob/main/packages/client/README.md) — the engine these hooks wrap
225
+ * [Architecture decisions](https://github.com/onyx-og/docstack/tree/main/specs/adr) — including [ADR-0025](https://github.com/onyx-og/docstack/blob/main/specs/adr/0025-live-usequerysql.md) on live `useQuerySQL` and [ADR-0035](https://github.com/onyx-og/docstack/blob/main/specs/adr/0035-react-usefind-never-applies-an-empty-result.md) on `useFind` result ordering
226
+ * [Contributing](https://github.com/onyx-og/docstack/blob/main/CONTRIBUTING.md)
181
227
 
182
- A hook that returns a function to create new classes dynamically.
228
+ ## License
183
229
 
184
- - **Arguments**:
185
- - `dbName`: The name of the database stack.
186
- - **Returns**:
187
- - A function `(className: string, description?: string) => Promise<Class>` to create a new class.
230
+ [CC-BY-SA-4.0](https://github.com/onyx-og/docstack/blob/main/LICENSE.md) · © Onyx AC, LLC
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -137,6 +137,9 @@ export const useFind = (stack, query, sort, limit = 50) => {
137
137
  const [docs, setDocs] = useState([]);
138
138
  const [loading, setLoading] = useState(true);
139
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);
140
143
  useEffect(() => {
141
144
  var _a;
142
145
  // Check if the docStack instance is available
@@ -155,22 +158,27 @@ export const useFind = (stack, query, sort, limit = 50) => {
155
158
  }
156
159
  setLoading(true);
157
160
  const runQuery = () => __awaiter(void 0, void 0, void 0, function* () {
161
+ const id = ++runId.current;
158
162
  try {
159
163
  const stackInstance = docStack.getStack(stack);
160
164
  if (stackInstance) {
161
- // Run the initial query
162
- const initialDocs = yield stackInstance.findDocuments(query.selector, query.fields);
163
- if (initialDocs.docs.length) {
164
- let docs = initialDocs.docs; // [TODO] Check types
165
- setDocs(docs);
166
- }
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);
167
173
  }
168
174
  }
169
175
  catch (err) {
170
- setError(err);
176
+ if (id === runId.current)
177
+ setError(err);
171
178
  }
172
179
  finally {
173
- setLoading(false);
180
+ if (id === runId.current)
181
+ setLoading(false);
174
182
  }
175
183
  });
176
184
  runQuery();
File without changes
package/lib/hooks/sync.js CHANGED
File without changes
package/lib/index.d.ts CHANGED
File without changes
package/lib/index.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docstack/react",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "One does not simply stack documents.",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/index.js",
@@ -23,7 +23,8 @@
23
23
  "components",
24
24
  "database",
25
25
  "documents",
26
- "nosql", "sql",
26
+ "nosql",
27
+ "sql",
27
28
  "storage",
28
29
  "state-management"
29
30
  ],
@@ -38,7 +39,7 @@
38
39
  "react-dom": "^19.2.3"
39
40
  },
40
41
  "dependencies": {
41
- "@docstack/client": "^0.1.5",
42
+ "@docstack/client": "^0.3.0",
42
43
  "react": "^19.2.3",
43
44
  "react-dom": "^19.2.3"
44
45
  },
File without changes
File without changes
File without changes
File without changes
@@ -184,6 +184,10 @@ export const useFind = (stack: string, query: {
184
184
  const [loading, setLoading] = useState(true);
185
185
  const [error, setError] = useState(null);
186
186
 
187
+ // Guards against a slow earlier run overwriting a fast later one - the same
188
+ // discipline as useQuerySQL's runId above.
189
+ const runId = useRef(0);
190
+
187
191
  useEffect(() => {
188
192
  // Check if the docStack instance is available
189
193
  if (!docStack) {
@@ -203,21 +207,22 @@ export const useFind = (stack: string, query: {
203
207
  setLoading(true);
204
208
 
205
209
  const runQuery = async () => {
210
+ const id = ++runId.current;
206
211
  try {
207
212
  const stackInstance = docStack.getStack(stack);
208
213
  if (stackInstance) {
209
- // Run the initial query
210
- const initialDocs = await stackInstance.findDocuments(query.selector, query.fields);
211
- if (initialDocs.docs.length) {
212
- let docs = initialDocs.docs as Document[]; // [TODO] Check types
213
- setDocs(docs);
214
- }
214
+ const found = await stackInstance.findDocuments(query.selector, query.fields);
215
+ // An empty result is a result. This setter used to be guarded on
216
+ // `.docs.length`, so a live list could gain rows but never lose its
217
+ // last one - a deleted document stayed on screen until a remount.
218
+ // The hazard that guard was standing in for is *staleness*, and the
219
+ // counter above owns that. See ADR-0035.
220
+ if (id === runId.current) setDocs(found.docs as Document[]);
215
221
  }
216
-
217
222
  } catch (err: any) {
218
- setError(err);
223
+ if (id === runId.current) setError(err);
219
224
  } finally {
220
- setLoading(false);
225
+ if (id === runId.current) setLoading(false);
221
226
  }
222
227
  };
223
228
 
package/src/hooks/sync.ts CHANGED
File without changes
package/src/index.ts CHANGED
File without changes