@docstack/client 0.1.6 → 0.2.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.
package/README.md CHANGED
@@ -1,214 +1,442 @@
1
+ [![npm](https://img.shields.io/npm/v/@docstack/client)](https://www.npmjs.com/package/@docstack/client)
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)
1
4
  [![Donate](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://www.paypal.com/donate/?hosted_button_id=4QSQ8L9AK2C74)
2
5
 
3
-
4
6
  # @docstack/client
5
7
 
6
8
  **One does not simply stack documents.**
7
9
 
8
- DocStack Client is a standalone, browser-based datastore built on PouchDB. It bridges the gap between simple client-side storage and full-fledged backend databases by bringing schemas, relationships, SQL-like querying, and background processing directly to the browser.
10
+ An **offline-first embedded database for the browser**, built on PouchDB and IndexedDB. It brings the things you would otherwise build yourself **schema validation, a SQL query engine, triggers, background jobs, role-based access policies, field-level encryption, versioned migrations and named write transactions** — into the client, where your application actually runs. When a connection exists, everything replicates to any PouchDB- or CouchDB-compatible remote, including the user's own Google Drive.
11
+
12
+ No server required. No network round trip on the read path. TypeScript throughout.
9
13
 
10
- It is designed for applications that require **offline capabilities**, **strong data consistency**, and **secure local storage**, all while sharing the same powerful data model as the DocStack server.
14
+ ---
11
15
 
12
- ## 🚀 Why DocStack?
16
+ ## 🚀 Why DocStack
13
17
 
14
- ### Business Advantages
18
+ ### What it means for the people using your app
15
19
 
16
- - **Logic as Data**: Store business rules, validation scripts, and background jobs as documents in the database. Update your application's behavior dynamically without redeploying the entire codebase.
17
- - **Zero-Latency UX**: By running the database locally, your UI updates instantly. Data syncs to the server in the background when a connection is available.
18
- - **Enterprise-Grade Security**: Built-in field-level encryption ensures that sensitive user data (PII, health records) is encrypted *before* it hits the disk, protecting it even if the device is compromised.
20
+ * **It works with no connection.** Not "degrades gracefully" — works. Reads, writes, validation, joins and business rules all resolve against local storage, so there is no spinner waiting on a server and no failure mode when the train enters a tunnel.
21
+ * **It is instant.** The database is in the same process as the UI. Writes land in single-digit milliseconds, so optimistic-update machinery becomes unnecessary: the update *is* the write.
22
+ * **Their data can stay theirs.** Sync to the user's own Google Drive folder and the application never holds their records at all while encrypted attributes stay unreadable even to the storage provider.
19
23
 
20
- ### Technical Highlights
24
+ ### What it means for you building it
21
25
 
22
- - **Structured NoSQL**: Enjoy the flexibility of JSON documents with the rigor of strict schemas (powered by Zod).
23
- - **SQL in the Browser**: Stop writing complex map/reduce functions. Query your local data using standard SQL syntax, including `JOIN`s, `UNION`s, and aggregations.
24
- - **Reactive Architecture**: Subscribe to changes on specific classes or documents to update your UI in real-time.
25
- - **Background Jobs**: Offload heavy processing (data cleanup, report generation) to background workers that run independently of the UI thread.
26
+ * **Skip the backend for a whole class of app.** Validation, access control, migrations and background work usually justify a server. Here they are engine features, so a genuinely useful application can ship with no backend to run, secure, scale or pay for.
27
+ * **Logic as data.** Triggers, jobs and policies are documents. Change a validation rule or a business process by writing a document — no redeploy, and the change replicates to every device like any other data.
28
+ * **Migrations you can trust.** Schema changes are declarative patch documents with a semver ledger: applied exactly once, all-or-nothing, and gated at sync so a device with an older model cannot pull documents its schema can't describe.
29
+ * **Encryption you don't have to hand-roll.** Mark an attribute `encrypted` and it is ciphertext on disk and on the remote, transparently decrypted on read for the session that holds the key.
30
+ * **SQL instead of map/reduce.** Joins, aggregation, subqueries and pagination against local documents, with index pushdown where the planner can prove it is safe.
26
31
 
27
32
  ## 📦 Installation
28
33
 
29
- To add the client to your project, use your preferred package manager.
30
-
31
34
  ```bash
32
- npm install @docstack/client
35
+ npm install @docstack/client pouchdb-browser pouchdb-find
33
36
  ```
34
37
 
35
- ## Quick Start
38
+ `pouchdb-browser` and `pouchdb-find` are **peer dependencies** — DocStack does not bundle the storage layer, so you control its version.
36
39
 
37
- Initialize the stack and create your first data model.
40
+ ## Quick start
38
41
 
39
42
  ```typescript
40
43
  import { ClientStack, Class, Attribute } from '@docstack/client';
41
44
 
42
- // 1. Initialize the stack (creates a local PouchDB instance)
43
- const stack = await ClientStack.create('my-app-db');
45
+ // 1. Open a local database. It is created on first use.
46
+ const stack = await ClientStack.create('my-app');
44
47
 
45
- // 2. Define a 'Task' class
46
- const taskClass = await Class.create(stack, 'Task', 'class', 'User Tasks');
48
+ // 2. Define a class — a schema, stored as a document.
49
+ const taskClass = await Class.create(stack, 'Task', 'class', 'User tasks');
47
50
 
48
- // 3. Add attributes to the schema
49
- await Attribute.create(taskClass, 'title', 'string', 'Task Title', { mandatory: true });
51
+ // 3. Give it attributes.
52
+ await Attribute.create(taskClass, 'title', 'string', 'Task title', { mandatory: true });
53
+ await Attribute.create(taskClass, 'priority', 'string', 'Priority');
50
54
  await Attribute.create(taskClass, 'isComplete', 'boolean', 'Done?', { defaultValue: false });
51
55
 
52
- // 4. Create a document
53
- const myTask = await taskClass.add({
54
- title: 'Install DocStack',
55
- isComplete: true
56
- });
56
+ // 4. Write. Validation, defaults and triggers all run here.
57
+ const task = await taskClass.add({ title: 'Install DocStack', priority: 'high' });
57
58
 
58
- console.log('Created Task:', myTask);
59
+ // 5. Read it back with SQL.
60
+ const { rows } = await stack.query('SELECT title FROM Task WHERE isComplete = false');
59
61
  ```
60
62
 
61
- ## 📚 Core Features & Usage
63
+ Building a React app? [`@docstack/react`](https://github.com/onyx-og/docstack/blob/main/packages/react/README.md) wraps all of this in a provider and live hooks.
64
+
65
+ ## 📚 Features
62
66
 
63
- ### 1. Advanced Querying (SQL-like)
67
+ ### 1. SQL against local documents
64
68
 
65
- DocStack includes a powerful query engine that translates SQL into optimized PouchDB selectors and in-memory operations.
69
+ The query engine parses SQL, plans it against the available indexes, and executes what it cannot push down in memory.
66
70
 
67
71
  ```typescript
68
- // Find all high-priority tasks, join with Assignee, and sort
72
+ // Joins, filtering and ordering
69
73
  const { rows } = await stack.query(`
70
74
  SELECT t.title, u.username AS assignee
71
75
  FROM Task AS t
72
76
  JOIN User AS u ON u._id = t.assigneeId
73
77
  WHERE t.priority = 'high' AND t.isComplete = false
74
78
  ORDER BY t.createdAt DESC
79
+ LIMIT 20
80
+ `);
81
+
82
+ // Placeholders are positional
83
+ const { rows: mine } = await stack.query(
84
+ 'SELECT * FROM Task WHERE assigneeId = ? AND priority = ?',
85
+ currentUserId, 'high'
86
+ );
87
+
88
+ // Aggregation and subqueries
89
+ const { rows: busy } = await stack.query(`
90
+ SELECT assigneeId, COUNT(*) AS open
91
+ FROM Task
92
+ WHERE isComplete = false AND assigneeId IN (SELECT _id FROM User WHERE active = true)
93
+ GROUP BY assigneeId
94
+ HAVING COUNT(*) > 5
75
95
  `);
76
96
  ```
77
97
 
78
- ### 2. Relationships & Domains
98
+ `WHERE`, `ORDER BY LIMIT` and range predicates push down into the index where the planner can prove the result is identical; encryption and policies are consulted first, because a filter applied to ciphertext would answer the wrong question.
79
99
 
80
- In addition to foreign keys, define strict relationships between data types using Domains.
100
+ For results too large to materialise, stream them the scan pages by keyset and stops early when a `LIMIT` is satisfied:
81
101
 
82
102
  ```typescript
83
- import { Domain } from '@docstack/client';
103
+ for await (const doc of stack.findDocumentsIterator({ '~class': 'Task' })) {
104
+ process(doc);
105
+ }
106
+ ```
84
107
 
85
- // Define a 1:N relationship between Projects and Tasks
86
- const domain = await Domain.create(
87
- stack,
88
- null,
89
- 'ProjectTasks',
90
- 'domain',
91
- '1:N',
92
- projectClass,
93
- taskClass
94
- );
108
+ ### 2. Schema migrations as patches
109
+
110
+ A patch is a document describing a versioned change. Hand the chain to the stack at open time and it applies whatever this device has not seen yet — in order, exactly once, recorded in a ledger.
95
111
 
96
- // Now you can traverse relationships easily or enforce referential integrity.
112
+ ```typescript
113
+ const PATCHES = [
114
+ {
115
+ '~class': 'patch',
116
+ _id: 'my-app-0.1.0',
117
+ version: '0.1.0',
118
+ target: 'my-app',
119
+ changelog: 'Add the Task class.',
120
+ active: true,
121
+ docs: [{
122
+ '~class': 'class',
123
+ _id: 'Task',
124
+ name: 'Task',
125
+ description: 'A user task',
126
+ schema: {
127
+ title: { name: 'title', type: 'string', config: { mandatory: true } },
128
+ isComplete: { name: 'isComplete', type: 'boolean', config: { defaultValue: false } },
129
+ },
130
+ }],
131
+ },
132
+ {
133
+ '~class': 'patch',
134
+ _id: 'my-app-0.2.0',
135
+ version: '0.2.0',
136
+ target: 'my-app',
137
+ changelog: 'Tasks carry a priority.',
138
+ active: true,
139
+ docs: [{
140
+ '~class': 'class',
141
+ _id: 'Task',
142
+ name: 'Task',
143
+ schema: {
144
+ priority: { name: 'priority', type: 'enum', config: { values: [{ value: 'low' }, { value: 'high' }] } },
145
+ },
146
+ }],
147
+ },
148
+ ];
149
+
150
+ const stack = await ClientStack.create('my-app', { patches: PATCHES });
97
151
  ```
98
152
 
99
- ### 3. Background Jobs
153
+ The second patch **merges** into the class rather than replacing it, so a chain composes into one schema. The whole pending chain applies through a single internal transaction: if patch N+1 is invalid, nothing from the chain persists and the error names the patch, class and attribute at fault. A patch can also carry one-shot migration jobs that massage data in the same commit as the model change.
154
+
155
+ ### 3. Sync to anything
156
+
157
+ Replication is transport-agnostic on purpose: `remote` is whatever PouchDB database you hand over, so DocStack never learns about your provider and you never pay for a transport you don't use.
158
+
159
+ ```typescript
160
+ const handle = await stack.sync({
161
+ remote: 'https://example.com/my-app', // a URL, a PouchDB instance, or a resolver function
162
+ direction: 'both', // default
163
+ live: true, // default: keep following changes
164
+ classes: { exclude: ['Draft'] }, // what travels
165
+ });
166
+
167
+ handle.addEventListener('sync-status', () => {
168
+ const status = stack.getSyncStatus();
169
+ // `lastConvergedAt` is the honest "last synced": a cycle finished with nothing
170
+ // left to send. `lastActiveAt` only says documents moved.
171
+ render(status.state, status.lastConvergedAt);
172
+ });
173
+ ```
174
+
175
+ DocStack's own internal documents stay on the device by default, and a **schema gate** compares the system and consumer patch versions on both sides before anything replicates — a device whose model is behind refuses with `SyncSchemaMismatchError` rather than pulling documents it cannot describe.
176
+
177
+ Classes can opt out of replication entirely by being declared `ephemeral` (documents describe *this run of this client*, and are emptied when the stack next opens — logs, caches, drafts).
100
178
 
101
- Define executable logic stored in the database. Jobs run in a sandboxed environment and can be triggered manually or by system events.
179
+ Running many databases? `DocStack.sync()` binds them all through one handle, resolving a remote per stack:
102
180
 
103
181
  ```typescript
104
- // Define a job that archives old tasks
105
- const archiveJob = {
182
+ await docstack.sync({
183
+ remote: (stack) => new PouchDB(`https://example.com/${stack.name}`),
184
+ });
185
+ ```
186
+
187
+ ### 4. Named write transactions
188
+
189
+ Opt in per stack. A handle stages validated writes in memory; reads through the handle see the staged state overlaid on committed state; commit flushes the journal as **one batch** through the full authoring pipeline.
190
+
191
+ ```typescript
192
+ const stack = await ClientStack.create('my-app', { transactions: true });
193
+
194
+ const t = stack.beginTransaction();
195
+ try {
196
+ const order = await t.createDoc(null, 'Order', { customerId, total: 0 });
197
+ for (const line of lines) {
198
+ await t.createDoc(null, 'OrderLine', { orderId: order._id, ...line });
199
+ }
200
+
201
+ // Reads through the handle see what you staged; nothing else does.
202
+ const { rows } = await t.query('SELECT SUM(amount) AS total FROM OrderLine WHERE orderId = ?', order._id);
203
+
204
+ const report = await stack.commit(t);
205
+ console.log(report.written.length, 'documents landed');
206
+ console.log('atomic:', report.adapter.atomicBatch);
207
+ } catch (error) {
208
+ stack.discardTransaction(t);
209
+ throw error;
210
+ }
211
+ ```
212
+
213
+ A write that fails validation, policy or the locked-stack check stages nothing, and a batch with one bad document unwinds entirely. Commit re-runs that sweep against the current world and refuses with `TransactionConflictError` if a document changed underneath — persisting nothing and leaving the transaction open to retry.
214
+
215
+ **Atomicity is reported, not assumed.** Every commit report carries the storage adapter's honest answer in `adapter.atomicBatch`: adapters that commit a batch as one storage transaction report `true`; on IndexedDB, results are per-document, and a revision pre-flight shrinks — but does not eliminate — the window. A partial commit leaves `status: "partial"` with only the failed entries retained, so a raced document conflicts on retry instead of being silently overwritten.
216
+
217
+ Uncommitted stages are memory-only: `close()`, `reset()` and a page reload discard them. Uncommitted means not real.
218
+
219
+ ### 5. Triggers
220
+
221
+ Small pieces of JavaScript attached to a class, stored as data and hydrated at runtime. They run before or after a document operation.
222
+
223
+ ```typescript
224
+ await blogPostClass.addTrigger('generate-slug', {
225
+ name: 'generate-slug',
226
+ order: 'before',
227
+ run: `document.slug = document.title.toLowerCase().replace(/\\s+/g, '-'); return document;`,
228
+ });
229
+
230
+ const post = await blogPostClass.add({ title: 'Hello World' });
231
+ console.log(post.slug); // 'hello-world'
232
+ ```
233
+
234
+ ### 6. Background jobs and the scheduler
235
+
236
+ `JobEngine` executes a job when asked. `JobScheduler` decides when to ask, under the constraints a client actually imposes — an app that is closed most of the time, timers that freeze, and several devices holding replicas of the same job.
237
+
238
+ ```typescript
239
+ await stack.db.bulkDocs([{
106
240
  _id: 'Job-ArchiveOldTasks',
107
241
  '~class': '~Job',
108
- name: 'Archive Tasks',
242
+ name: 'Archive tasks',
109
243
  type: 'user',
110
244
  workerPlatform: 'client',
111
245
  isEnabled: true,
112
246
  content: `
113
247
  async function execute(stack, params) {
114
248
  const { rows } = await stack.query("SELECT _id FROM Task WHERE isComplete = true");
115
- for (const row of rows) {
116
- // Custom logic to move task to archive...
117
- console.log('Archiving:', row._id);
118
- }
119
249
  return { metadata: { archivedCount: rows.length } };
120
250
  }
121
- `
122
- };
123
-
124
- // Save the job definition
125
- await stack.db.bulkDocs([archiveJob]);
251
+ `,
252
+ }]);
126
253
 
127
- // Execute it
254
+ // Run it now
128
255
  const run = await stack.jobEngine.executeJob('Job-ArchiveOldTasks');
129
- console.log('Job Status:', run.status);
256
+
257
+ // Or let it run unattended — the application names the jobs allowed to do so.
258
+ stack.jobScheduler.start({
259
+ jobs: ['Job-ArchiveOldTasks'],
260
+ intervalMs: 60_000,
261
+ onRun: (run) => console.log(run.status),
262
+ });
130
263
  ```
131
264
 
132
- ### 4. Triggers
265
+ There is deliberately no "run everything": job content replicates and is executable, so unattended execution is an allow-list. `pinnedHashes` lets the application pin the code it expects a job to have.
133
266
 
134
- Define custom data validations and transformations that execute automatically before or after document operations. Trigger logic is stored as data and hydrated at runtime.
267
+ ### 7. Access policies
268
+
269
+ Rule-based read and write control, evaluated per session against the document in question.
135
270
 
136
271
  ```typescript
137
- // This trigger automatically generates a URL-friendly slug from a document's title
138
- const generateSlugTrigger = {
139
- name: 'generate-slug-from-title',
140
- order: 'before',
141
- run: `document.slug = document.title.toLowerCase().replace(/\\s+/g, '-'); return document;`
142
- };
272
+ await stack.db.bulkDocs([
273
+ {
274
+ _id: 'Policy-Article-EditorsWrite',
275
+ '~class': '~Policy',
276
+ targetClass: ['Class-Article'],
277
+ groupId: 'Group-Editors',
278
+ rule: `return session && session.sessionStatus === 'active';`,
279
+ },
280
+ {
281
+ _id: 'Policy-Article-PublicRead',
282
+ '~class': '~Policy',
283
+ targetClass: ['Class-Article'],
284
+ rule: `if (document.status === 'published') return true;`,
285
+ },
286
+ ]);
287
+ ```
143
288
 
144
- // Add the trigger to a class schema
145
- await blogPostClass.addTrigger(generateSlugTrigger);
289
+ Because policies are documents scoped by group and user, one database can serve multiple tenants without per-tenant application code — and the query engine consults them before deciding whether a filter can be pushed down.
146
290
 
147
- // Now when you save a BlogPost, the slug is generated automatically
148
- const post = await blogPostClass.add({ title: 'Hello World' });
149
- console.log(post.slug); // 'hello-world'
291
+ ### 8. Field-level encryption
292
+
293
+ ```typescript
294
+ await Attribute.create(userClass, 'socialSecurityNumber', 'string', 'SSN', { encrypted: true });
150
295
  ```
151
296
 
152
- ### 5. Access Policies
297
+ The value is encrypted with a document key (PBKDF2-derived, AES-GCM) before it reaches storage. It is ciphertext on disk **and on every remote it replicates to** — decrypted only on the way out, for a session holding the key.
153
298
 
154
- Control who can read or write data with granular, rule-based policies. Great for implementing role-based access control (RBAC) and multi-tenant applications.
299
+ DocStack never invents that key: one generated per session could not outlive it, and a second device would generate a different one. Supply it at open time, or open **locked** and unlock later:
155
300
 
156
301
  ```typescript
157
- // Policy: Only users in the 'editors' group can write to Article documents
158
- const editorsOnlyWritePolicy = {
159
- _id: 'Policy-Article-EditorsWrite',
160
- '~class': '~Policy',
161
- targetClass: ['Class-Article'],
162
- groupId: 'Group-Editors',
163
- rule: `
164
- // 'session' and 'document' are injected at runtime
165
- return session && session.sessionStatus === 'active';
166
- `
167
- };
168
-
169
- // Policy: Published articles are readable by anyone
170
- const publicReadPolicy = {
171
- _id: 'Policy-Article-PublicRead',
172
- '~class': '~Policy',
173
- targetClass: ['Class-Article'],
174
- rule: `
175
- if (document.status === 'published') {
176
- return true;
177
- }
178
- `
179
- };
302
+ const stack = await ClientStack.create('my-app', { documentKey: hexKey });
180
303
 
181
- // Save policies to activate them
182
- await stack.db.bulkDocs([editorsOnlyWritePolicy, publicReadPolicy]);
304
+ // or
305
+ if (stack.isLocked()) await stack.unlock(hexKey);
183
306
  ```
184
307
 
185
- ### 6. Security & Encryption
308
+ A locked stack is readable but refuses writes to any class carrying encrypted attributes — and patches that would need to re-encrypt data defer until unlock rather than failing the open.
186
309
 
187
- Protect sensitive data transparently.
310
+ ### 9. Domains — relationships with integrity
188
311
 
189
312
  ```typescript
190
- // Define a class with an encrypted field
191
- await Attribute.create(userClass, 'socialSecurityNumber', 'string', 'SSN', {
192
- encrypted: true
193
- });
313
+ import { Domain } from '@docstack/client';
314
+
315
+ const domain = await Domain.create(
316
+ stack, null, 'ProjectTasks', 'domain', '1:N',
317
+ projectClass, taskClass, 'A project has many tasks'
318
+ );
319
+ ```
320
+
321
+ Relations are documents too, judged by their endpoints at write time — and during replication, so a relation never travels to a device that lacks the things it relates.
194
322
 
195
- // When you save a document, 'socialSecurityNumber' is encrypted using a document secure key.
196
- // It is stored as ciphertext in PouchDB and only decrypted when accessed via the API.
323
+ ### 10. Moving content between stacks
324
+
325
+ Export application content without the datamodel that describes it, and import it into a stack that already has the schema:
326
+
327
+ ```typescript
328
+ const payload = await stack.exportContent({ classes: ['Task', 'Project'] });
329
+ const report = await target.importContent(payload, { overwrite: false });
197
330
  ```
198
331
 
199
- ## 🧩 Architecture
332
+ ### 11. Classes that aren't worth a schema
333
+
334
+ Two flags change what a class costs:
335
+
336
+ * **`simple`** — documents are stored as given: no schema, no validation, no triggers, no relation checks. A bag of documents, for caches and logs.
337
+ * **`ephemeral`** — documents describe this run of this client: emptied when the stack next opens, and never replicated.
338
+
339
+ ## 📊 Performance
340
+
341
+ Measured in a real browser against IndexedDB. Both tables are reproducible with `BENCH=1 npx playwright test zz-bench` from this package.
342
+
343
+ **Transactions** — 100 documents ([ADR-0039](https://github.com/onyx-og/docstack/blob/main/specs/adr/0039-transactions-stage-above-the-plugin-and-commit-through-it.md)):
200
344
 
201
- DocStack Client is composed of several modular engines:
345
+ | Path | Cost |
346
+ |---|---|
347
+ | Stage 100 documents | 43.1 ms total · 0.43 ms/doc · **0 backend queries** |
348
+ | Commit 100 | 43.1 ms — **parity** with the non-transactional batch write (46.3 ms) |
349
+ | Overlay read, empty stage | 19.2 ms vs 17.2 ms plain — same query count (fast-path parity) |
350
+ | Overlay read, 100 staged over 100 committed | 66.2 ms (unwindowed query + in-memory union) |
351
+ | Refused commit (conflict pre-flight) | 1.3 ms, zero writes |
352
+ | Discard 100 | 0.1 ms |
353
+
354
+ Staging costs nothing at the storage layer, and committing costs what the same write would have cost anyway — so a transaction is not a tax you pay for safety.
355
+
356
+ **What the authoring pipeline costs** — 150 writes ([ADR-0028](https://github.com/onyx-og/docstack/blob/main/specs/adr/0028-ephemeral-and-simple-classes.md)):
357
+
358
+ | Path | Cost |
359
+ |---|---|
360
+ | Bare documents, no class | 860 ms |
361
+ | Through the full authoring path | 1549 ms |
362
+
363
+ **1.8×** for validation, defaults, triggers, relation checks and encryption — and the dominant cost in both rows is the IndexedDB write itself, not DocStack. Where that 1.8× still matters, `simple` classes take the fast path by design.
364
+
365
+ ## 🔍 How it compares
366
+
367
+ Against raw PouchDB — the honest baseline, since DocStack is built on it:
368
+
369
+ | | PouchDB / IndexedDB | @docstack/client |
370
+ |---|---|---|
371
+ | Local storage & replication | ✅ | ✅ (same protocol) |
372
+ | Schema & validation | write it yourself | ✅ Zod-backed, stored as documents |
373
+ | Querying | Mango selectors, hand-written map/reduce | ✅ SQL — joins, aggregation, subqueries, pushdown |
374
+ | Business logic on write | application code | ✅ triggers, stored as data |
375
+ | Background work | application code | ✅ job engine + unattended scheduler |
376
+ | Access control | none | ✅ policy engine, per class and session |
377
+ | Field-level encryption | build it | ✅ transparent, opaque to the remote |
378
+ | Schema migrations | build it | ✅ versioned patches with a ledger and a sync gate |
379
+ | Multi-document atomicity | none | ✅ staged transactions, with reported guarantees |
380
+
381
+ **Where DocStack sits in the offline-first field.** If you want a fast reactive local store and will own the rest yourself, [Dexie](https://dexie.org/) is a lighter and excellent choice. [RxDB](https://rxdb.info/) covers similar ground — schemas, reactivity, pluggable replication — and is the closest neighbour; [WatermelonDB](https://watermelondb.dev/) targets large React Native datasets on SQLite; [Firestore](https://firebase.google.com/docs/firestore) gives you a managed backend with offline caching, at the cost of running on someone else's infrastructure and terms.
382
+
383
+ What distinguishes DocStack is a narrower bet than "a better local database":
384
+
385
+ * **Logic as data.** Triggers, jobs and policies are documents that replicate and can change at runtime, rather than code compiled into a release. Behaviour ships like data.
386
+ * **Encryption the remote cannot read.** Field-level encryption is applied before storage and before replication, so the sync target is a place to keep bytes, not a party you trust.
387
+ * **Bring your own remote.** Replication targets any PouchDB-compatible database — including a folder in the end user's own Drive, which makes "we don't hold your data" an architecture rather than a promise.
388
+
389
+ Pick accordingly: these are different bets, not rankings.
390
+
391
+ ## 🧩 Architecture
202
392
 
203
393
  | Engine | Description |
204
- |--------|-------------|
205
- | **Core DB** | PouchDB for storage and sync |
206
- | **Schema Engine** | Zod-based validation and schema hydration |
207
- | **Query Engine** | SQL parser and planner for complex data retrieval |
208
- | **Job Engine** | Manages asynchronous tasks and background workers |
209
- | **Crypto Engine** | Handles key derivation (PBKDF2) and AES-GCM encryption |
210
- | **Policy Engine** | Enforces granular read/write access rules per class or user-targeted, based on user sessions and document's content |
394
+ |---|---|
395
+ | **Core DB** | PouchDB for storage and replication |
396
+ | **Schema Engine** | Zod-backed validation, class hydration, schema propagation |
397
+ | **Query Engine** | SQL parser, planner and executor |
398
+ | **Job Engine** | Background jobs, runs, and the unattended scheduler |
399
+ | **Crypto Engine** | PBKDF2 key derivation and AES-GCM field encryption |
400
+ | **Policy Engine** | Read/write rules per class, group and session |
401
+ | **Transaction Engine** | Staged writes, overlay reads, one-batch commit |
402
+ | **Sync Layer** | Lifecycle, replication filters, convergence state, schema gate |
403
+
404
+ Every one of these is pinned by the Playwright suite in [`src-test/`](https://github.com/onyx-og/docstack/tree/main/packages/client/src-test) — transactions and their overlay, crypto-aware queries, policy enforcement, subqueries, replication filters, late-joining stacks, patch chains.
405
+
406
+ ## 💾 Storage and sync transports
407
+
408
+ Storage is IndexedDB via `pouchdb-browser` by default. For replication, `remote` accepts any PouchDB-compatible database:
409
+
410
+ * **Any CouchDB-compatible endpoint** — pass the URL.
411
+ * **[@docstack/pouchdb-adapter-googledrive](https://github.com/onyx-ac/docstack-pouchdb-adapter-gdrive)** — the user's own Drive folder as a remote: append-only log, lazy loading, multi-writer safe, auto-compaction, no `googleapis` dependency.
412
+
413
+ ```typescript
414
+ import PouchDB from 'pouchdb-browser';
415
+ import GoogleDriveAdapter from '@docstack/pouchdb-adapter-googledrive';
416
+
417
+ PouchDB.plugin(GoogleDriveAdapter({ accessToken, pollingIntervalMs: 5000 }));
418
+
419
+ // One folder per database — pass `folderName` per remote, not in the plugin config,
420
+ // or every stack interleaves its change log with the others'.
421
+ await docstack.sync({
422
+ remote: (stack) => new PouchDB(stack.name, {
423
+ adapter: 'googledrive',
424
+ folderName: `my-app/${stack.name}`,
425
+ }),
426
+ });
427
+ ```
428
+
429
+ ## 📖 Documentation
430
+
431
+ * [Full documentation](https://onyx-og.github.io/docstack/) — architecture, guides, API reference
432
+ * [Architecture decisions](https://github.com/onyx-og/docstack/tree/main/specs/adr) — why the engine is shaped this way
433
+ * [Changelog](https://github.com/onyx-og/docstack/blob/main/packages/client/CHANGELOG.md)
434
+ * [Contributing](https://github.com/onyx-og/docstack/blob/main/CONTRIBUTING.md)
435
+
436
+ ## License
437
+
438
+ [CC-BY-SA-4.0](https://github.com/onyx-og/docstack/blob/main/LICENSE.md) · © Onyx AC, LLC
211
439
 
212
440
  ---
213
441
 
214
- Built with ❤️ for the modern web.
442
+ Built with ❤️ for the modern web.
@@ -49,4 +49,5 @@ export declare const createGuardedDb: <T extends {}>(db: PouchDB.Database<T>) =>
49
49
  export declare const createReplicationDb: <T extends {}>(db: PouchDB.Database<T>, pristine: {
50
50
  bulkDocs: Function;
51
51
  bulkGet: Function;
52
+ get: Function;
52
53
  }) => PouchDB.Database<T>;