@mrbelloc/encrypted-store 0.2.3 → 1.3.1
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 +385 -117
- package/dist/encryptedStore.d.ts +55 -72
- package/dist/encryptedStore.d.ts.map +1 -1
- package/dist/encryptedStore.js +299 -270
- package/dist/encryptedStore.js.map +1 -1
- package/dist/encryption.d.ts +1 -6
- package/dist/encryption.d.ts.map +1 -1
- package/dist/encryption.js +3 -5
- package/dist/encryption.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/kb-dir-partykit/_test_db_1_1768430534431_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_1_1768430534431_idx_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_1_1768430534431_idx_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_1_1768430534431_idx_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_1_1768430534431_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_1_1768430534431_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_2_1768430536612_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_2_1768430536612_idx_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_2_1768430536612_idx_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_2_1768430536612_idx_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_2_1768430536612_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_db_2_1768430536612_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430808558_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430808558_idx_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430808558_idx_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430808558_idx_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430808558_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430808558_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430839116_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430839116_idx_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430839116_idx_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430839116_idx_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430839116_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430839116_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430931049_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430931049_idx_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430931049_idx_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430931049_idx_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430931049_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430931049_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430955075_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430955075_idx_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430955075_idx_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430955075_idx_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430955075_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768430955075_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768522643733_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768522643733_idx_data_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768522643733_idx_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768522643733_idx_wal_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768522643733_meta_.json +4 -0
- package/dist/kb-dir-partykit/_test_sync_db_1768522643733_wal_.json +4 -0
- package/package.json +14 -9
package/README.md
CHANGED
|
@@ -1,212 +1,480 @@
|
|
|
1
1
|
# Encrypted Store
|
|
2
2
|
|
|
3
|
-
Client-side encrypted storage with change detection
|
|
3
|
+
Client-side encrypted document storage with change detection using PouchDB and AES-256-GCM encryption.
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Simple API for offline-first apps** - PUT, GET, DELETE documents with automatic sync to CouchDB.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- 🔐 AES-256-GCM encryption
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
9
|
+
- 🔐 AES-256-GCM encryption with WebCrypto API
|
|
10
|
+
- 📦 Simple document API: `put`, `get`, `delete`, `getAll`
|
|
11
|
+
- 🔄 Real-time change detection (`onChange`, `onDelete`)
|
|
12
|
+
- ⚠️ Conflict detection and resolution
|
|
13
|
+
- 🌐 Sync to CouchDB (or any PouchDB-compatible server)
|
|
14
|
+
- 📊 Sync progress events
|
|
15
|
+
- 🔌 Offline-first with automatic retry
|
|
16
|
+
- 📱 Works in browser and Node.js
|
|
17
|
+
- 🎯 TypeScript with full type safety
|
|
14
18
|
|
|
15
19
|
## Installation
|
|
16
20
|
|
|
21
|
+
### For Browser (Vite/Webpack)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @mrbelloc/encrypted-store pouchdb-browser@^8.0.1 events
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Note:** Currently requires PouchDB v8. PouchDB v9 has compatibility issues with TypeScript types and some bundlers.
|
|
28
|
+
|
|
29
|
+
**Required for Vite:** Install the `events` package to fix "Class extends value [object Object]" errors.
|
|
30
|
+
|
|
31
|
+
### For Node.js
|
|
32
|
+
|
|
17
33
|
```bash
|
|
18
|
-
npm install @mrbelloc/encrypted-store
|
|
34
|
+
npm install @mrbelloc/encrypted-store pouchdb
|
|
19
35
|
```
|
|
20
36
|
|
|
21
37
|
## Quick Start
|
|
22
38
|
|
|
39
|
+
### Browser (Vite/React/Vue/Svelte)
|
|
40
|
+
|
|
23
41
|
```typescript
|
|
24
|
-
import
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
import PouchDBModule from 'pouchdb-browser';
|
|
43
|
+
// Workaround for ESM/CommonJS compatibility in some bundlers
|
|
44
|
+
const PouchDB = PouchDBModule.default || PouchDBModule;
|
|
45
|
+
import { EncryptedStore } from '@mrbelloc/encrypted-store';
|
|
46
|
+
|
|
47
|
+
// Create database and encrypted store (uses IndexedDB in browser)
|
|
48
|
+
const db = new PouchDB('myapp');
|
|
49
|
+
const store = new EncryptedStore(db, 'my-password', {
|
|
50
|
+
onChange: (docs) => {
|
|
51
|
+
console.log('Documents changed:', docs);
|
|
33
52
|
},
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
console.log(`Updated ${table}:`, docs);
|
|
37
|
-
});
|
|
53
|
+
onDelete: (docs) => {
|
|
54
|
+
console.log('Documents deleted:', docs);
|
|
38
55
|
},
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
onConflict: (conflicts) => {
|
|
57
|
+
console.log('Conflicts detected:', conflicts);
|
|
58
|
+
},
|
|
59
|
+
onSync: (info) => {
|
|
60
|
+
console.log('Sync progress:', info);
|
|
43
61
|
},
|
|
62
|
+
onError: (errors) => {
|
|
63
|
+
console.error('Decryption errors:', errors);
|
|
64
|
+
}
|
|
44
65
|
});
|
|
45
66
|
|
|
46
|
-
// Load existing data
|
|
67
|
+
// Load existing data and start change detection
|
|
47
68
|
await store.loadAll();
|
|
48
69
|
|
|
49
70
|
// Create/update documents
|
|
50
|
-
await store.put(
|
|
71
|
+
await store.put('expenses', {
|
|
72
|
+
_id: 'lunch',
|
|
73
|
+
amount: 15.50,
|
|
74
|
+
date: '2024-01-15'
|
|
75
|
+
});
|
|
51
76
|
|
|
52
|
-
// Get
|
|
53
|
-
const
|
|
77
|
+
// Get a document
|
|
78
|
+
const expense = await store.get('expenses', 'lunch');
|
|
79
|
+
console.log(expense); // { _id: 'lunch', _table: 'expenses', amount: 15.50, date: '2024-01-15' }
|
|
54
80
|
|
|
55
|
-
//
|
|
56
|
-
await store.
|
|
81
|
+
// Get all documents (optionally filtered by table)
|
|
82
|
+
const allExpenses = await store.getAll('expenses');
|
|
83
|
+
const allDocs = await store.getAll();
|
|
84
|
+
|
|
85
|
+
// Delete a document
|
|
86
|
+
await store.delete('expenses', 'lunch');
|
|
87
|
+
|
|
88
|
+
// Sync to CouchDB
|
|
89
|
+
await store.connectRemote({
|
|
90
|
+
url: 'http://localhost:5984/myapp',
|
|
91
|
+
live: true,
|
|
92
|
+
retry: true
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Node.js
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import PouchDB from 'pouchdb';
|
|
100
|
+
import { EncryptedStore } from '@mrbelloc/encrypted-store';
|
|
101
|
+
|
|
102
|
+
// Create database and encrypted store (uses LevelDB in Node)
|
|
103
|
+
const db = new PouchDB('myapp');
|
|
104
|
+
const store = new EncryptedStore(db, 'my-password', {
|
|
105
|
+
onChange: (docs) => console.log('Changed:', docs),
|
|
106
|
+
onDelete: (docs) => console.log('Deleted:', docs),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await store.loadAll();
|
|
57
110
|
```
|
|
58
111
|
|
|
59
112
|
## API Reference
|
|
60
113
|
|
|
61
|
-
### `new EncryptedStore(db, password, listener)`
|
|
114
|
+
### `new EncryptedStore(db, password, listener?)`
|
|
62
115
|
|
|
63
116
|
Creates an encrypted store.
|
|
64
117
|
|
|
65
|
-
- `db`:
|
|
118
|
+
- `db`: PouchDB database instance
|
|
66
119
|
- `password`: Encryption password (string)
|
|
67
|
-
- `listener`:
|
|
68
|
-
- `docsAdded(events: TableEvent[])`: Fired when new documents are added
|
|
69
|
-
- `docsChanged(events: TableEvent[])`: Fired when documents are updated
|
|
70
|
-
- `docsDeleted(events: TableEvent[])`: Fired when documents are deleted
|
|
120
|
+
- `listener`: Optional object with callbacks
|
|
71
121
|
|
|
72
|
-
|
|
122
|
+
### Listener Callbacks
|
|
73
123
|
|
|
74
|
-
|
|
75
|
-
|
|
124
|
+
```typescript
|
|
125
|
+
interface StoreListener {
|
|
126
|
+
onChange: (docs: Doc[]) => void;
|
|
127
|
+
onDelete: (docs: Doc[]) => void;
|
|
128
|
+
onConflict?: (conflicts: ConflictInfo[]) => void;
|
|
129
|
+
onSync?: (info: SyncInfo) => void;
|
|
130
|
+
onError?: (errors: DecryptionErrorEvent[]) => void;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- **`onChange(docs)`**: Called when documents are added or updated
|
|
135
|
+
- **`onDelete(docs)`**: Called when documents are deleted
|
|
136
|
+
- **`onConflict(conflicts)`**: Called when conflicts are detected
|
|
137
|
+
- **`onSync(info)`**: Called during sync operations
|
|
138
|
+
- **`onError(errors)`**: Called when documents fail to decrypt
|
|
76
139
|
|
|
77
140
|
### `await store.loadAll()`
|
|
78
141
|
|
|
79
|
-
Loads all existing documents and
|
|
142
|
+
Loads all existing documents and starts change detection. Call this once after creating the store.
|
|
80
143
|
|
|
81
|
-
### `await store.put(
|
|
144
|
+
### `await store.put(table, doc)`
|
|
82
145
|
|
|
83
146
|
Creates or updates a document.
|
|
84
147
|
|
|
85
|
-
- `
|
|
86
|
-
- `doc`: Document object with `_id` field (
|
|
148
|
+
- `table`: Document type (e.g., "expenses", "tasks")
|
|
149
|
+
- `doc`: Document object with optional `_id` field (generated if missing)
|
|
87
150
|
|
|
88
|
-
Returns the document.
|
|
151
|
+
Returns the document with `_table` field added.
|
|
89
152
|
|
|
90
|
-
### `await store.get(
|
|
153
|
+
### `await store.get(table, id)`
|
|
91
154
|
|
|
92
|
-
|
|
155
|
+
Gets a document by table and ID. Returns `null` if not found.
|
|
93
156
|
|
|
94
|
-
### `await store.delete(
|
|
157
|
+
### `await store.delete(table, id)`
|
|
95
158
|
|
|
96
|
-
Deletes a document by
|
|
159
|
+
Deletes a document by table and ID.
|
|
97
160
|
|
|
98
|
-
|
|
161
|
+
### `await store.getAll(table?)`
|
|
99
162
|
|
|
100
|
-
|
|
163
|
+
Gets all documents, optionally filtered by table.
|
|
101
164
|
|
|
102
165
|
```typescript
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
import { connect } from "@fireproof/partykit";
|
|
109
|
-
// or
|
|
110
|
-
// import { connect } from "@fireproof/netlify";
|
|
111
|
-
|
|
112
|
-
// Connect using the connector function
|
|
113
|
-
await store.connectRemote(connect, {
|
|
114
|
-
namespace: "my-app",
|
|
115
|
-
host: "http://localhost:1999", // or your server URL
|
|
116
|
-
});
|
|
166
|
+
const allExpenses = await store.getAll('expenses');
|
|
167
|
+
const allDocs = await store.getAll();
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### `await store.connectRemote(options)`
|
|
117
171
|
|
|
118
|
-
|
|
119
|
-
store.disconnectRemote();
|
|
172
|
+
Connects to a remote CouchDB server for sync.
|
|
120
173
|
|
|
121
|
-
|
|
174
|
+
```typescript
|
|
175
|
+
interface RemoteOptions {
|
|
176
|
+
url: string; // CouchDB URL
|
|
177
|
+
live?: boolean; // Continuous sync (default: true)
|
|
178
|
+
retry?: boolean; // Auto-retry on failure (default: true)
|
|
179
|
+
}
|
|
122
180
|
```
|
|
123
181
|
|
|
124
|
-
|
|
182
|
+
### `store.disconnectRemote()`
|
|
125
183
|
|
|
126
|
-
|
|
184
|
+
Disconnects from remote sync.
|
|
127
185
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
186
|
+
### `await store.getConflictInfo(table, id)`
|
|
187
|
+
|
|
188
|
+
Check if a document has conflicts without triggering the callback. Returns `ConflictInfo` if conflicts exist, or `null` if none.
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
const conflictInfo = await store.getConflictInfo('expenses', 'lunch');
|
|
192
|
+
if (conflictInfo) {
|
|
193
|
+
console.log('Conflict detected!');
|
|
194
|
+
console.log('Winner:', conflictInfo.winner);
|
|
195
|
+
console.log('Losers:', conflictInfo.losers);
|
|
196
|
+
// Handle the conflict
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### `await store.resolveConflict(table, id, winningDoc)`
|
|
201
|
+
|
|
202
|
+
Manually resolve a conflict by choosing the winning document.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// Option 1: Use in onConflict callback
|
|
206
|
+
store.listener.onConflict = async (conflicts) => {
|
|
207
|
+
for (const conflict of conflicts) {
|
|
208
|
+
// Pick the document with the latest timestamp
|
|
209
|
+
const latest = [conflict.winner, ...conflict.losers]
|
|
210
|
+
.sort((a, b) => b.timestamp - a.timestamp)[0];
|
|
211
|
+
|
|
212
|
+
await store.resolveConflict(conflict.table, conflict.id, latest);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Option 2: Check manually and resolve
|
|
217
|
+
const conflict = await store.getConflictInfo('expenses', 'lunch');
|
|
218
|
+
if (conflict) {
|
|
219
|
+
await store.resolveConflict('expenses', 'lunch', conflict.winner);
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Conflict Detection
|
|
224
|
+
|
|
225
|
+
When the same document is edited offline on multiple devices, PouchDB detects conflicts automatically:
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
interface ConflictInfo {
|
|
229
|
+
docId: string; // Full document ID (e.g., "expenses_lunch")
|
|
230
|
+
table: string; // Document table (e.g., "expenses")
|
|
231
|
+
id: string; // Document ID (e.g., "lunch")
|
|
232
|
+
currentRev: string; // Current revision ID
|
|
233
|
+
conflictRevs: string[];// Conflicting revision IDs
|
|
234
|
+
winner: Doc; // The winning document (current version)
|
|
235
|
+
losers: Doc[]; // Conflicting versions
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The `onConflict` callback gives you both the winner and all conflicting versions, so you can:
|
|
240
|
+
- Show a UI for manual resolution
|
|
241
|
+
- Auto-resolve based on timestamps
|
|
242
|
+
- Merge changes programmatically
|
|
243
|
+
- Log conflicts for review
|
|
244
|
+
|
|
245
|
+
## Sync Events
|
|
246
|
+
|
|
247
|
+
Monitor sync progress with the `onSync` callback:
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
interface SyncInfo {
|
|
251
|
+
direction: 'push' | 'pull' | 'both';
|
|
252
|
+
change: {
|
|
253
|
+
docs_read?: number;
|
|
254
|
+
docs_written?: number;
|
|
255
|
+
doc_write_failures?: number;
|
|
256
|
+
errors?: any[];
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Example:
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
const store = new EncryptedStore(db, password, {
|
|
265
|
+
onChange: (docs) => console.log('Changed:', docs.length),
|
|
266
|
+
onDelete: (docs) => console.log('Deleted:', docs.length),
|
|
267
|
+
onSync: (info) => {
|
|
268
|
+
if (info.direction === 'push') {
|
|
269
|
+
console.log(`Pushed ${info.change.docs_written} docs to server`);
|
|
270
|
+
} else {
|
|
271
|
+
console.log(`Pulled ${info.change.docs_read} docs from server`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Deployment Options
|
|
278
|
+
|
|
279
|
+
### Free Tier Options
|
|
280
|
+
|
|
281
|
+
1. **IBM Cloudant** - Free tier: 1GB storage, 20 req/sec
|
|
282
|
+
```typescript
|
|
283
|
+
await store.connectRemote({
|
|
284
|
+
url: 'https://username:password@username.cloudant.com/mydb'
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
2. **Oracle Cloud Free Tier** - Run your own CouchDB
|
|
289
|
+
```bash
|
|
290
|
+
# On Oracle VM
|
|
291
|
+
docker run -d -p 5984:5984 -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password couchdb
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
3. **Self-hosted** - CouchDB on any VPS ($5/month)
|
|
295
|
+
```typescript
|
|
296
|
+
await store.connectRemote({
|
|
297
|
+
url: 'http://admin:password@your-server.com:5984/mydb'
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Backup Strategy
|
|
302
|
+
|
|
303
|
+
Example using Oracle Free Tier + S3:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
# Daily backup script
|
|
307
|
+
#!/bin/bash
|
|
308
|
+
TODAY=$(date +%Y-%m-%d)
|
|
309
|
+
curl -X GET http://admin:password@localhost:5984/mydb/_all_docs?include_docs=true > backup-$TODAY.json
|
|
310
|
+
aws s3 cp backup-$TODAY.json s3://my-backups/couchdb/
|
|
311
|
+
```
|
|
133
312
|
|
|
134
313
|
## Example: React Integration
|
|
135
314
|
|
|
136
315
|
```typescript
|
|
137
|
-
import { useState, useEffect } from
|
|
138
|
-
import
|
|
316
|
+
import { useState, useEffect } from 'react';
|
|
317
|
+
import PouchDBModule from 'pouchdb-browser';
|
|
318
|
+
const PouchDB = PouchDBModule.default || PouchDBModule;
|
|
319
|
+
import { EncryptedStore } from '@mrbelloc/encrypted-store';
|
|
139
320
|
|
|
140
321
|
function useEncryptedStore(dbName: string, password: string) {
|
|
141
|
-
const [
|
|
322
|
+
const [expenses, setExpenses] = useState<Map<string, any>>(new Map());
|
|
142
323
|
const [store, setStore] = useState<EncryptedStore | null>(null);
|
|
143
324
|
|
|
144
325
|
useEffect(() => {
|
|
145
|
-
const db =
|
|
326
|
+
const db = new PouchDB(dbName);
|
|
146
327
|
const encryptedStore = new EncryptedStore(db, password, {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
328
|
+
onChange: (docs) => {
|
|
329
|
+
setExpenses((prev) => {
|
|
330
|
+
const next = new Map(prev);
|
|
331
|
+
docs.forEach((doc) => {
|
|
332
|
+
if (doc._table === 'expenses') {
|
|
333
|
+
next.set(doc._id, doc);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
return next;
|
|
156
337
|
});
|
|
157
338
|
},
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
339
|
+
onDelete: (docs) => {
|
|
340
|
+
setExpenses((prev) => {
|
|
341
|
+
const next = new Map(prev);
|
|
342
|
+
docs.forEach((doc) => {
|
|
343
|
+
if (doc._table === 'expenses') {
|
|
344
|
+
next.delete(doc._id);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
return next;
|
|
167
348
|
});
|
|
168
349
|
},
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return next;
|
|
176
|
-
});
|
|
177
|
-
}
|
|
350
|
+
onConflict: (conflicts) => {
|
|
351
|
+
// Auto-resolve: pick latest by timestamp
|
|
352
|
+
conflicts.forEach(async (conflict) => {
|
|
353
|
+
const latest = [conflict.winner, ...conflict.losers]
|
|
354
|
+
.sort((a, b) => b.timestamp - a.timestamp)[0];
|
|
355
|
+
await encryptedStore.resolveConflict(conflict.table, conflict.id, latest);
|
|
178
356
|
});
|
|
179
|
-
}
|
|
357
|
+
}
|
|
180
358
|
});
|
|
181
359
|
|
|
182
360
|
encryptedStore.loadAll();
|
|
183
361
|
setStore(encryptedStore);
|
|
362
|
+
|
|
363
|
+
return () => {
|
|
364
|
+
encryptedStore.disconnectRemote();
|
|
365
|
+
};
|
|
184
366
|
}, [dbName, password]);
|
|
185
367
|
|
|
186
|
-
return {
|
|
368
|
+
return { expenses: Array.from(expenses.values()), store };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function App() {
|
|
372
|
+
const { expenses, store } = useEncryptedStore('myapp', 'my-password');
|
|
373
|
+
|
|
374
|
+
const addExpense = async () => {
|
|
375
|
+
await store?.put('expenses', {
|
|
376
|
+
_id: crypto.randomUUID(),
|
|
377
|
+
amount: 25,
|
|
378
|
+
description: 'Coffee',
|
|
379
|
+
timestamp: Date.now()
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<div>
|
|
385
|
+
<button onClick={addExpense}>Add Expense</button>
|
|
386
|
+
<ul>
|
|
387
|
+
{expenses.map((exp) => (
|
|
388
|
+
<li key={exp._id}>{exp.description}: ${exp.amount}</li>
|
|
389
|
+
))}
|
|
390
|
+
</ul>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
187
393
|
}
|
|
188
394
|
```
|
|
189
395
|
|
|
190
396
|
## TypeScript Types
|
|
191
397
|
|
|
192
398
|
```typescript
|
|
193
|
-
interface
|
|
399
|
+
interface Doc {
|
|
400
|
+
_id: string;
|
|
401
|
+
_table: string;
|
|
402
|
+
[key: string]: any;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
interface ConflictInfo {
|
|
406
|
+
docId: string;
|
|
194
407
|
table: string;
|
|
195
|
-
|
|
408
|
+
id: string;
|
|
409
|
+
currentRev: string;
|
|
410
|
+
conflictRevs: string[];
|
|
411
|
+
winner: Doc;
|
|
412
|
+
losers: Doc[];
|
|
196
413
|
}
|
|
197
414
|
|
|
198
|
-
interface
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
415
|
+
interface SyncInfo {
|
|
416
|
+
direction: 'push' | 'pull' | 'both';
|
|
417
|
+
change: {
|
|
418
|
+
docs_read?: number;
|
|
419
|
+
docs_written?: number;
|
|
420
|
+
doc_write_failures?: number;
|
|
421
|
+
errors?: any[];
|
|
422
|
+
};
|
|
202
423
|
}
|
|
203
424
|
|
|
204
|
-
interface
|
|
205
|
-
|
|
206
|
-
|
|
425
|
+
interface DecryptionErrorEvent {
|
|
426
|
+
docId: string;
|
|
427
|
+
error: Error;
|
|
428
|
+
rawDoc: any;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
interface RemoteOptions {
|
|
432
|
+
url: string;
|
|
433
|
+
live?: boolean;
|
|
434
|
+
retry?: boolean;
|
|
207
435
|
}
|
|
208
436
|
```
|
|
209
437
|
|
|
438
|
+
## How It Works
|
|
439
|
+
|
|
440
|
+
1. **Encryption**: Documents are encrypted with AES-256-GCM before storage
|
|
441
|
+
2. **Storage**: Encrypted data stored in PouchDB (IndexedDB in browser, LevelDB in Node)
|
|
442
|
+
3. **Change Detection**: PouchDB's changes feed notifies of all changes
|
|
443
|
+
4. **Conflict Detection**: PouchDB's MVCC detects conflicts automatically
|
|
444
|
+
5. **Sync**: Bi-directional sync with CouchDB using PouchDB replication
|
|
445
|
+
6. **Events**: Callbacks notify your app of changes, conflicts, and sync progress
|
|
446
|
+
|
|
447
|
+
## Browser vs Node.js
|
|
448
|
+
|
|
449
|
+
### Browser (Vite/Webpack)
|
|
450
|
+
- Use `pouchdb-browser@^8.0.1` - includes IndexedDB adapter
|
|
451
|
+
- Smaller bundle size
|
|
452
|
+
- Works with Vite, Webpack, etc.
|
|
453
|
+
- **Vite users**:
|
|
454
|
+
- Add `define: { global: 'globalThis' }` to vite.config.ts (PouchDB v8 requirement)
|
|
455
|
+
- Install `events` package: `npm install events` (fixes "Class extends" errors)
|
|
456
|
+
- **Import**: Use `const PouchDB = PouchDBModule.default || PouchDBModule` for better compatibility
|
|
457
|
+
|
|
458
|
+
### Node.js
|
|
459
|
+
- Use `pouchdb` - includes LevelDB adapter
|
|
460
|
+
- For CLI tools, servers, etc.
|
|
461
|
+
|
|
462
|
+
## Security Notes
|
|
463
|
+
|
|
464
|
+
- Encryption happens client-side before any data leaves the device
|
|
465
|
+
- Remote servers only see encrypted blobs
|
|
466
|
+
- Password is never transmitted or stored
|
|
467
|
+
- Use a strong password (consider using a key derivation function like PBKDF2)
|
|
468
|
+
|
|
210
469
|
## License
|
|
211
470
|
|
|
212
471
|
MIT
|
|
472
|
+
|
|
473
|
+
## Why PouchDB?
|
|
474
|
+
|
|
475
|
+
- **Mature**: 10+ years of production use
|
|
476
|
+
- **Reliable**: Battle-tested conflict resolution
|
|
477
|
+
- **Compatible**: Works with any CouchDB server
|
|
478
|
+
- **Offline-first**: Built for unreliable networks
|
|
479
|
+
- **Simple**: Easy to understand replication model
|
|
480
|
+
- **Free**: No vendor lock-in, self-hostable
|