@docstack/client 0.1.8 → 0.3.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.
Files changed (47) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +373 -132
  3. package/lib/core/attribute.d.ts +0 -0
  4. package/lib/core/class.d.ts +0 -0
  5. package/lib/core/content-transfer.d.ts +0 -0
  6. package/lib/core/crypto-engine/index.d.ts +63 -5
  7. package/lib/core/crypto-engine/utils.d.ts +10 -2
  8. package/lib/core/datamodel/index.d.ts +0 -0
  9. package/lib/core/domain.d.ts +0 -0
  10. package/lib/core/guarded-db.d.ts +0 -0
  11. package/lib/core/index.d.ts +4 -2
  12. package/lib/core/job-engine/index.d.ts +0 -0
  13. package/lib/core/job-engine/schedule.d.ts +0 -0
  14. package/lib/core/job-engine/scheduler.d.ts +0 -0
  15. package/lib/core/query-engine/accumulators.d.ts +0 -0
  16. package/lib/core/query-engine/classes.d.ts +0 -0
  17. package/lib/core/query-engine/evaluator.d.ts +0 -0
  18. package/lib/core/query-engine/executor.d.ts +0 -0
  19. package/lib/core/query-engine/index.d.ts +0 -0
  20. package/lib/core/query-engine/parser.d.ts +0 -0
  21. package/lib/core/query-engine/planner.d.ts +0 -0
  22. package/lib/core/stack.d.ts +223 -8
  23. package/lib/core/sync/class-filter.d.ts +0 -0
  24. package/lib/core/sync/filter-identity.d.ts +0 -0
  25. package/lib/core/sync/index.d.ts +17 -2
  26. package/lib/core/sync/internal-docs.d.ts +0 -0
  27. package/lib/core/sync/tenants.d.ts +0 -0
  28. package/lib/core/test-utils/docstack.d.ts +0 -0
  29. package/lib/core/transaction-engine/errors.d.ts +57 -0
  30. package/lib/core/transaction-engine/handle.d.ts +165 -0
  31. package/lib/core/transaction-engine/index.d.ts +82 -0
  32. package/lib/core/transaction-engine/overlay.d.ts +66 -0
  33. package/lib/core/transaction-engine/stage.d.ts +50 -0
  34. package/lib/core/transaction-engine/sweep.d.ts +25 -0
  35. package/lib/core/trigger/index.d.ts +0 -0
  36. package/lib/index.d.ts +12 -2
  37. package/lib/index.js +4710 -733
  38. package/lib/index.umd.js +5259 -623
  39. package/lib/index2.js +641 -0
  40. package/lib/plugins/pouchdb.d.ts +16 -1
  41. package/lib/utils/crypto/index.d.ts +0 -0
  42. package/lib/utils/index.d.ts +4 -2
  43. package/lib/utils/logger/index.d.ts +0 -0
  44. package/lib/utils/logger/transport.d.ts +0 -0
  45. package/lib/workers/dataModel.d.ts +0 -0
  46. package/package.json +3 -1
  47. package/lib/core/policy-engine/index.d.ts +0 -132
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -1,214 +1,455 @@
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, cryptographic access scopes, 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, migrations and access scopes 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. `@docstack/abe`, the CP-ABE primitive behind access scopes, is installed as a dependency and loaded lazily, only when a stack declares scopes.
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 is 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
95
109
 
96
- // Now you can traverse relationships easily or enforce referential integrity.
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.
111
+
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 });
151
+ ```
152
+
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('status', (event) => {
168
+ const status = event.detail; // also `stack.getSyncStatus()`, or `sync-status` on the stack
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
+ });
97
173
  ```
98
174
 
99
- ### 3. Background Jobs
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.
100
176
 
101
- Define executable logic stored in the database. Jobs run in a sandboxed environment and can be triggered manually or by system events.
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).
178
+
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 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
+ const content = `
240
+ async function execute(stack, params) {
241
+ const { rows } = await stack.query("SELECT _id FROM Task WHERE isComplete = true");
242
+ return { metadata: { archivedCount: rows.length } };
243
+ }
244
+ `;
245
+ // `hash` is mandatory: the SHA-256 of `content`, verified before every run.
246
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content));
247
+ const hash = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
248
+
249
+ await stack.db.bulkDocs([{
106
250
  _id: 'Job-ArchiveOldTasks',
107
251
  '~class': '~Job',
108
- name: 'Archive Tasks',
252
+ name: 'Archive tasks',
109
253
  type: 'user',
110
254
  workerPlatform: 'client',
111
255
  isEnabled: true,
112
- content: `
113
- async function execute(stack, params) {
114
- 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
- return { metadata: { archivedCount: rows.length } };
120
- }
121
- `
122
- };
123
-
124
- // Save the job definition
125
- await stack.db.bulkDocs([archiveJob]);
126
-
127
- // Execute it
256
+ content,
257
+ hash,
258
+ }]);
259
+
260
+ // Run it now
128
261
  const run = await stack.jobEngine.executeJob('Job-ArchiveOldTasks');
129
- console.log('Job Status:', run.status);
262
+
263
+ // Or let it run unattended — the application names the jobs allowed to do so.
264
+ stack.jobScheduler.start({
265
+ jobs: ['Job-ArchiveOldTasks'],
266
+ intervalMs: 60_000,
267
+ onRun: (run) => console.log(run.status),
268
+ });
130
269
  ```
131
270
 
132
- ### 4. Triggers
271
+ 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.
272
+
273
+ ### 7. Access scopes
133
274
 
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.
275
+ Access is a property of the ciphertext, not a rule that runs. Content belongs to a **scope** whose content key is sealed under an **attribute policy** (CP-ABE, the AC17 scheme, through [`@docstack/abe`](https://github.com/onyx-og/docstack/blob/main/packages/abe/README.md)). A device whose attribute key satisfies the policy opens the scope; one whose key does not holds the same ciphertext and reads `null`. There is no client-side check to bypass, so the guarantee holds against the device owner too.
135
276
 
136
277
  ```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
- };
278
+ // Authority side your server, an admin ceremony. Master keys never reach a device.
279
+ import { setup, keygen } from '@docstack/abe';
280
+
281
+ const { pk, msk } = await setup();
282
+ const hrScope = await ClientStack.buildAccessScope({
283
+ scopeId: 'hr',
284
+ policyString: '"role:hr" or "clearance:exec"',
285
+ pk,
286
+ });
287
+ const aliceKey = await keygen(msk, ['role:hr']);
288
+ // Ship `hrScope` in an application patch; hand `aliceKey` to Alice's devices.
143
289
 
144
- // Add the trigger to a class schema
145
- await blogPostClass.addTrigger(generateSlugTrigger);
290
+ // Device side.
291
+ const stack = await ClientStack.create('my-app', {
292
+ documentKey,
293
+ accessKeys: { attributeKey: aliceKey }, // or later: await stack.unlockScopes(aliceKey)
294
+ });
146
295
 
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'
296
+ await salaryClass.add({ who: 'alice', amount: '100000', '~scope': 'hr' }); // seals under hr's key
297
+ stack.isScopeLocked('hr'); // false for Alice; true for a device whose key does not satisfy the policy
150
298
  ```
151
299
 
152
- ### 5. Access Policies
300
+ A document joins a scope with the reserved `~scope` field, or inherits its class's `defaultScope`. The scope decides *under which key* the class's `encrypted: true` attributes seal; the schema still decides *which* attributes. Writing into a scope the session cannot open throws `StackLockedError` with `scopeId` set, and a write whose label disagrees with its payload's key is refused with `StackScopeMismatchError` rather than re-sealed. Revoking a member is publishing a new scope version with a policy the departed key no longer satisfies.
153
301
 
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.
302
+ Conditional access is write-time labeling ("published means public" is the write choosing the scope), and behavioural rules stay application code. The formula language, the guarantees and the limits, stated plainly, are in the [access control](https://onyx-og.github.io/docstack/docs/concepts/access-control/) section of the documentation.
303
+
304
+ ### 8. Field-level encryption
155
305
 
156
306
  ```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
- };
180
-
181
- // Save policies to activate them
182
- await stack.db.bulkDocs([editorsOnlyWritePolicy, publicReadPolicy]);
183
- ```
184
-
185
- ### 6. Security & Encryption
186
-
187
- Protect sensitive data transparently.
307
+ await Attribute.create(userClass, 'socialSecurityNumber', 'string', 'SSN', { encrypted: true });
308
+ ```
309
+
310
+ The value is AES-GCM ciphertext before it reaches storage, under the stack's document key or, for a document labeled with a scope, that scope's key. It is ciphertext on disk **and on every remote it replicates to** — decrypted only on the way out, for a session holding the key.
311
+
312
+ 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:
188
313
 
189
314
  ```typescript
190
- // Define a class with an encrypted field
191
- await Attribute.create(userClass, 'socialSecurityNumber', 'string', 'SSN', {
192
- encrypted: true
193
- });
315
+ const stack = await ClientStack.create('my-app', { documentKey: hexKey });
194
316
 
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.
317
+ // or
318
+ if (stack.isLocked()) await stack.unlock(hexKey);
197
319
  ```
198
320
 
199
- ## 🧩 Architecture
321
+ 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.
322
+
323
+ ### 9. Domains — relationships with integrity
324
+
325
+ ```typescript
326
+ import { Domain } from '@docstack/client';
327
+
328
+ const domain = await Domain.create(
329
+ stack, null, 'ProjectTasks', 'domain', '1:N',
330
+ projectClass, taskClass, 'A project has many tasks'
331
+ );
332
+ ```
333
+
334
+ 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.
335
+
336
+ ### 10. Moving content between stacks
337
+
338
+ Export application content without the datamodel that describes it, and import it into a stack that already has the schema:
339
+
340
+ ```typescript
341
+ const payload = await stack.exportContent({ classes: ['Task', 'Project'] });
342
+ const report = await target.importContent(payload, { overwrite: false });
343
+ ```
344
+
345
+ ### 11. Classes that aren't worth a schema
346
+
347
+ Two flags change what a class costs:
348
+
349
+ * **`simple`** — documents are stored as given: no schema, no validation, no triggers, no relation checks. A bag of documents, for caches and logs.
350
+ * **`ephemeral`** — documents describe this run of this client: emptied when the stack next opens, and never replicated.
351
+
352
+ ## 📊 Performance
200
353
 
201
- DocStack Client is composed of several modular engines:
354
+ Measured in a real browser against IndexedDB. Both tables are reproducible with `BENCH=1 npx playwright test zz-bench` from this package.
355
+
356
+ **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)):
357
+
358
+ | Path | Cost |
359
+ |---|---|
360
+ | Stage 100 documents | 43.1 ms total · 0.43 ms/doc · **0 backend queries** |
361
+ | Commit 100 | 43.1 ms — **parity** with the non-transactional batch write (46.3 ms) |
362
+ | Overlay read, empty stage | 19.2 ms vs 17.2 ms plain — same query count (fast-path parity) |
363
+ | Overlay read, 100 staged over 100 committed | 66.2 ms (unwindowed query + in-memory union) |
364
+ | Refused commit (conflict pre-flight) | 1.3 ms, zero writes |
365
+ | Discard 100 | 0.1 ms |
366
+
367
+ 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.
368
+
369
+ **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)):
370
+
371
+ | Path | Cost |
372
+ |---|---|
373
+ | Bare documents, no class | 860 ms |
374
+ | Through the full authoring path | 1549 ms |
375
+
376
+ **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.
377
+
378
+ ## 🔍 How it compares
379
+
380
+ Against raw PouchDB — the honest baseline, since DocStack is built on it:
381
+
382
+ | | PouchDB / IndexedDB | @docstack/client |
383
+ |---|---|---|
384
+ | Local storage & replication | ✅ | ✅ (same protocol) |
385
+ | Schema & validation | write it yourself | ✅ Zod-backed, stored as documents |
386
+ | Querying | Mango selectors, hand-written map/reduce | ✅ SQL — joins, aggregation, subqueries, pushdown |
387
+ | Business logic on write | application code | ✅ triggers, stored as data |
388
+ | Background work | application code | ✅ job engine + unattended scheduler |
389
+ | Access control | none | ✅ cryptographic scopes: attribute policies enforced by decryption |
390
+ | Field-level encryption | build it | ✅ transparent, opaque to the remote |
391
+ | Schema migrations | build it | ✅ versioned patches with a ledger and a sync gate |
392
+ | Multi-document atomicity | none | ✅ staged transactions, with reported guarantees |
393
+
394
+ **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.
395
+
396
+ What distinguishes DocStack is a narrower bet than "a better local database":
397
+
398
+ * **Logic as data.** Triggers, jobs, migrations and scopes are documents that replicate and can change at runtime, rather than code compiled into a release. Behaviour ships like data.
399
+ * **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.
400
+ * **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.
401
+
402
+ Pick accordingly: these are different bets, not rankings.
403
+
404
+ ## 🧩 Architecture
202
405
 
203
406
  | 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 |
407
+ |---|---|
408
+ | **Core DB** | PouchDB for storage and replication |
409
+ | **Schema Engine** | Zod-backed validation, class hydration, schema propagation |
410
+ | **Query Engine** | SQL parser, planner and executor |
411
+ | **Job Engine** | Background jobs, runs, and the unattended scheduler |
412
+ | **Crypto Engine** | AES-GCM field encryption under a keyring: the document key, retired keys and admitted scope keys |
413
+ | **Access scopes** | CP-ABE-sealed content keys (`@docstack/abe`), attribute-key admission, per-scope locks |
414
+ | **Transaction Engine** | Staged writes, overlay reads, one-batch commit |
415
+ | **Sync Layer** | Lifecycle, replication filters, convergence state, schema gate |
416
+
417
+ 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, access scopes, subqueries, replication filters, late-joining stacks, patch chains.
418
+
419
+ ## 💾 Storage and sync transports
420
+
421
+ Storage is IndexedDB via `pouchdb-browser` by default. For replication, `remote` accepts any PouchDB-compatible database:
422
+
423
+ * **Any CouchDB-compatible endpoint** — pass the URL.
424
+ * **[@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.
425
+
426
+ ```typescript
427
+ import PouchDB from 'pouchdb-browser';
428
+ import GoogleDriveAdapter from '@docstack/pouchdb-adapter-googledrive';
429
+
430
+ PouchDB.plugin(GoogleDriveAdapter({ accessToken, pollingIntervalMs: 5000 }));
431
+
432
+ // One folder per database — pass `folderName` per remote, not in the plugin config,
433
+ // or every stack interleaves its change log with the others'.
434
+ await docstack.sync({
435
+ remote: (stack) => new PouchDB(stack.name, {
436
+ adapter: 'googledrive',
437
+ folderName: `my-app/${stack.name}`,
438
+ }),
439
+ });
440
+ ```
441
+
442
+ ## 📖 Documentation
443
+
444
+ * [Full documentation](https://onyx-og.github.io/docstack/) — architecture, guides, API reference
445
+ * [Architecture decisions](https://github.com/onyx-og/docstack/tree/main/specs/adr) — why the engine is shaped this way
446
+ * [Changelog](https://github.com/onyx-og/docstack/blob/main/packages/client/CHANGELOG.md)
447
+ * [Contributing](https://github.com/onyx-og/docstack/blob/main/CONTRIBUTING.md)
448
+
449
+ ## License
450
+
451
+ [CC-BY-SA-4.0](https://github.com/onyx-og/docstack/blob/main/LICENSE.md) · © Onyx AC, LLC
211
452
 
212
453
  ---
213
454
 
214
- Built with ❤️ for the modern web.
455
+ Built with ❤️ for the modern web.
File without changes
File without changes
File without changes