@docstack/client 0.1.8 → 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 +350 -122
- package/lib/core/index.d.ts +3 -1
- package/lib/core/stack.d.ts +147 -0
- package/lib/core/sync/index.d.ts +17 -2
- 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 +26 -0
- package/lib/index.d.ts +12 -2
- package/lib/index.js +4083 -267
- package/lib/index.umd.js +4094 -269
- package/lib/utils/index.d.ts +4 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,214 +1,442 @@
|
|
|
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, 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
|
-
|
|
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 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
|
-
|
|
38
|
+
`pouchdb-browser` and `pouchdb-find` are **peer dependencies** — DocStack does not bundle the storage layer, so you control its version.
|
|
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 and policies are 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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, 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
|
|
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
|
-
//
|
|
254
|
+
// Run it now
|
|
128
255
|
const run = await stack.jobEngine.executeJob('Job-ArchiveOldTasks');
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
291
|
+
### 8. Field-level encryption
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
await Attribute.create(userClass, 'socialSecurityNumber', 'string', 'SSN', { encrypted: true });
|
|
150
295
|
```
|
|
151
296
|
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
182
|
-
await stack.
|
|
304
|
+
// or
|
|
305
|
+
if (stack.isLocked()) await stack.unlock(hexKey);
|
|
183
306
|
```
|
|
184
307
|
|
|
185
|
-
|
|
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
|
-
|
|
310
|
+
### 9. Domains — relationships with integrity
|
|
188
311
|
|
|
189
312
|
```typescript
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
| **Schema Engine** | Zod-
|
|
207
|
-
| **Query Engine** | SQL parser
|
|
208
|
-
| **Job Engine** |
|
|
209
|
-
| **Crypto Engine** |
|
|
210
|
-
| **Policy Engine** |
|
|
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.
|
package/lib/core/index.d.ts
CHANGED
|
@@ -304,7 +304,7 @@ export { JOB_SCHEDULE_DOC_ID } from "./job-engine/scheduler.js";
|
|
|
304
304
|
export type { SchedulerOptions, SchedulerHost, JobScheduleState, TickReport, SkipReason, } from "./job-engine/scheduler.js";
|
|
305
305
|
export { parseSchedule, nextOccurrence, MIN_PERIOD_MS } from "./job-engine/schedule.js";
|
|
306
306
|
export type { ParsedSchedule } from "./job-engine/schedule.js";
|
|
307
|
-
export { StackSyncHandle, DocStackSyncHandle, SyncSchemaMismatchError, SYNC_META_DOC_ID, readRemoteSchemaVersion, publishSchemaVersion, createReplicationFilter, isInternalDoc, resolveInternalClasses, createClassFilter, hasClassRules, DATA_MODEL_CLASSES, withFilterIdentity, describeFilter, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, INTERNAL_DOC_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, deriveTenantScope, classTenants, } from "./sync/index.js";
|
|
307
|
+
export { StackSyncHandle, DocStackSyncHandle, SyncSchemaMismatchError, SYNC_META_DOC_ID, readRemoteSchemaVersion, readRemoteConsumerSchemaVersion, publishSchemaVersion, createReplicationFilter, isInternalDoc, resolveInternalClasses, createClassFilter, hasClassRules, DATA_MODEL_CLASSES, withFilterIdentity, describeFilter, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, INTERNAL_DOC_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, deriveTenantScope, classTenants, } from "./sync/index.js";
|
|
308
308
|
export type { SyncDirection, SyncState, SyncStatus, StackSyncOptions, DocStackSyncOptions, RemoteResolver, SyncMetaDoc, InternalDocFilterOptions, ClassFilterOptions, TenantScope, } from "./sync/index.js";
|
|
309
309
|
export type { ClassBuildOptions } from "./class.js";
|
|
310
310
|
export { CONTENT_EXPORT_FORMAT, META_CLASSES, isContentClassName, isContentDocument, isContentRelation, stripTransientFields, } from "./content-transfer.js";
|
|
@@ -313,6 +313,8 @@ export { SYSTEM_SEEDED_DOC_IDS } from "./datamodel/index.js";
|
|
|
313
313
|
export { collectQueryClasses } from "./query-engine/index.js";
|
|
314
314
|
export { StackWriteGuardError } from "./guarded-db.js";
|
|
315
315
|
export { StackLockedError } from "../plugins/pouchdb.js";
|
|
316
|
+
export { TransactionEngine, TransactionHandle, TransactionDb, TransactionsDisabledError, TransactionStateError, TransactionValidationError, TransactionConflictError, TransactionUnsupportedDocError, } from "./transaction-engine/index.js";
|
|
317
|
+
export type { TransactionCommitReport, TransactionStatus } from "./transaction-engine/index.js";
|
|
316
318
|
/**
|
|
317
319
|
* Key-identity helpers, for applications that re-key a database.
|
|
318
320
|
*
|