@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.
- package/LICENSE +0 -0
- package/README.md +373 -132
- package/lib/core/attribute.d.ts +0 -0
- package/lib/core/class.d.ts +0 -0
- package/lib/core/content-transfer.d.ts +0 -0
- package/lib/core/crypto-engine/index.d.ts +63 -5
- package/lib/core/crypto-engine/utils.d.ts +10 -2
- package/lib/core/datamodel/index.d.ts +0 -0
- package/lib/core/domain.d.ts +0 -0
- package/lib/core/guarded-db.d.ts +0 -0
- package/lib/core/index.d.ts +4 -2
- package/lib/core/job-engine/index.d.ts +0 -0
- package/lib/core/job-engine/schedule.d.ts +0 -0
- package/lib/core/job-engine/scheduler.d.ts +0 -0
- package/lib/core/query-engine/accumulators.d.ts +0 -0
- package/lib/core/query-engine/classes.d.ts +0 -0
- package/lib/core/query-engine/evaluator.d.ts +0 -0
- package/lib/core/query-engine/executor.d.ts +0 -0
- package/lib/core/query-engine/index.d.ts +0 -0
- package/lib/core/query-engine/parser.d.ts +0 -0
- package/lib/core/query-engine/planner.d.ts +0 -0
- package/lib/core/stack.d.ts +223 -8
- package/lib/core/sync/class-filter.d.ts +0 -0
- package/lib/core/sync/filter-identity.d.ts +0 -0
- package/lib/core/sync/index.d.ts +17 -2
- package/lib/core/sync/internal-docs.d.ts +0 -0
- package/lib/core/sync/tenants.d.ts +0 -0
- package/lib/core/test-utils/docstack.d.ts +0 -0
- package/lib/core/transaction-engine/errors.d.ts +57 -0
- package/lib/core/transaction-engine/handle.d.ts +165 -0
- package/lib/core/transaction-engine/index.d.ts +82 -0
- package/lib/core/transaction-engine/overlay.d.ts +66 -0
- package/lib/core/transaction-engine/stage.d.ts +50 -0
- package/lib/core/transaction-engine/sweep.d.ts +25 -0
- package/lib/core/trigger/index.d.ts +0 -0
- package/lib/index.d.ts +12 -2
- package/lib/index.js +4710 -733
- package/lib/index.umd.js +5259 -623
- package/lib/index2.js +641 -0
- package/lib/plugins/pouchdb.d.ts +16 -1
- package/lib/utils/crypto/index.d.ts +0 -0
- package/lib/utils/index.d.ts +4 -2
- package/lib/utils/logger/index.d.ts +0 -0
- package/lib/utils/logger/transport.d.ts +0 -0
- package/lib/workers/dataModel.d.ts +0 -0
- package/package.json +3 -1
- 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
|
+
[](https://www.npmjs.com/package/@docstack/client)
|
|
2
|
+
[](https://onyx-og.github.io/docstack/)
|
|
3
|
+
[](https://github.com/onyx-og/docstack/blob/main/LICENSE.md)
|
|
1
4
|
[](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
|
-
|
|
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
|
-
|
|
14
|
+
---
|
|
11
15
|
|
|
12
|
-
## 🚀 Why DocStack
|
|
16
|
+
## 🚀 Why DocStack
|
|
13
17
|
|
|
14
|
-
###
|
|
18
|
+
### What it means for the people using your app
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
###
|
|
24
|
+
### What it means for you building it
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
## ⚡ Quick start
|
|
38
41
|
|
|
39
42
|
```typescript
|
|
40
43
|
import { ClientStack, Class, Attribute } from '@docstack/client';
|
|
41
44
|
|
|
42
|
-
// 1.
|
|
43
|
-
const stack = await ClientStack.create('my-app
|
|
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
|
|
46
|
-
const taskClass = await Class.create(stack, 'Task', 'class', 'User
|
|
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.
|
|
49
|
-
await Attribute.create(taskClass, 'title', 'string', 'Task
|
|
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.
|
|
53
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
67
|
+
### 1. SQL against local documents
|
|
64
68
|
|
|
65
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
for await (const doc of stack.findDocumentsIterator({ '~class': 'Task' })) {
|
|
104
|
+
process(doc);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
84
107
|
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
252
|
+
name: 'Archive tasks',
|
|
109
253
|
type: 'user',
|
|
110
254
|
workerPlatform: 'client',
|
|
111
255
|
isEnabled: true,
|
|
112
|
-
content
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
//
|
|
145
|
-
await
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
196
|
-
|
|
317
|
+
// or
|
|
318
|
+
if (stack.isLocked()) await stack.unlock(hexKey);
|
|
197
319
|
```
|
|
198
320
|
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
| **Schema Engine** | Zod-
|
|
207
|
-
| **Query Engine** | SQL parser
|
|
208
|
-
| **Job Engine** |
|
|
209
|
-
| **Crypto Engine** |
|
|
210
|
-
| **
|
|
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.
|
package/lib/core/attribute.d.ts
CHANGED
|
File without changes
|
package/lib/core/class.d.ts
CHANGED
|
File without changes
|
|
File without changes
|