@mrbelloc/encrypted-store 0.3.0 → 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.
Files changed (56) hide show
  1. package/README.md +367 -179
  2. package/dist/encryptedStore.d.ts +55 -72
  3. package/dist/encryptedStore.d.ts.map +1 -1
  4. package/dist/encryptedStore.js +299 -270
  5. package/dist/encryptedStore.js.map +1 -1
  6. package/dist/encryption.d.ts +1 -6
  7. package/dist/encryption.d.ts.map +1 -1
  8. package/dist/encryption.js +3 -5
  9. package/dist/encryption.js.map +1 -1
  10. package/dist/index.d.ts +6 -5
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +7 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/kb-dir-partykit/_test_db_1_1768430534431_data_.json +4 -0
  15. package/dist/kb-dir-partykit/_test_db_1_1768430534431_idx_data_.json +4 -0
  16. package/dist/kb-dir-partykit/_test_db_1_1768430534431_idx_meta_.json +4 -0
  17. package/dist/kb-dir-partykit/_test_db_1_1768430534431_idx_wal_.json +4 -0
  18. package/dist/kb-dir-partykit/_test_db_1_1768430534431_meta_.json +4 -0
  19. package/dist/kb-dir-partykit/_test_db_1_1768430534431_wal_.json +4 -0
  20. package/dist/kb-dir-partykit/_test_db_2_1768430536612_data_.json +4 -0
  21. package/dist/kb-dir-partykit/_test_db_2_1768430536612_idx_data_.json +4 -0
  22. package/dist/kb-dir-partykit/_test_db_2_1768430536612_idx_meta_.json +4 -0
  23. package/dist/kb-dir-partykit/_test_db_2_1768430536612_idx_wal_.json +4 -0
  24. package/dist/kb-dir-partykit/_test_db_2_1768430536612_meta_.json +4 -0
  25. package/dist/kb-dir-partykit/_test_db_2_1768430536612_wal_.json +4 -0
  26. package/dist/kb-dir-partykit/_test_sync_db_1768430808558_data_.json +4 -0
  27. package/dist/kb-dir-partykit/_test_sync_db_1768430808558_idx_data_.json +4 -0
  28. package/dist/kb-dir-partykit/_test_sync_db_1768430808558_idx_meta_.json +4 -0
  29. package/dist/kb-dir-partykit/_test_sync_db_1768430808558_idx_wal_.json +4 -0
  30. package/dist/kb-dir-partykit/_test_sync_db_1768430808558_meta_.json +4 -0
  31. package/dist/kb-dir-partykit/_test_sync_db_1768430808558_wal_.json +4 -0
  32. package/dist/kb-dir-partykit/_test_sync_db_1768430839116_data_.json +4 -0
  33. package/dist/kb-dir-partykit/_test_sync_db_1768430839116_idx_data_.json +4 -0
  34. package/dist/kb-dir-partykit/_test_sync_db_1768430839116_idx_meta_.json +4 -0
  35. package/dist/kb-dir-partykit/_test_sync_db_1768430839116_idx_wal_.json +4 -0
  36. package/dist/kb-dir-partykit/_test_sync_db_1768430839116_meta_.json +4 -0
  37. package/dist/kb-dir-partykit/_test_sync_db_1768430839116_wal_.json +4 -0
  38. package/dist/kb-dir-partykit/_test_sync_db_1768430931049_data_.json +4 -0
  39. package/dist/kb-dir-partykit/_test_sync_db_1768430931049_idx_data_.json +4 -0
  40. package/dist/kb-dir-partykit/_test_sync_db_1768430931049_idx_meta_.json +4 -0
  41. package/dist/kb-dir-partykit/_test_sync_db_1768430931049_idx_wal_.json +4 -0
  42. package/dist/kb-dir-partykit/_test_sync_db_1768430931049_meta_.json +4 -0
  43. package/dist/kb-dir-partykit/_test_sync_db_1768430931049_wal_.json +4 -0
  44. package/dist/kb-dir-partykit/_test_sync_db_1768430955075_data_.json +4 -0
  45. package/dist/kb-dir-partykit/_test_sync_db_1768430955075_idx_data_.json +4 -0
  46. package/dist/kb-dir-partykit/_test_sync_db_1768430955075_idx_meta_.json +4 -0
  47. package/dist/kb-dir-partykit/_test_sync_db_1768430955075_idx_wal_.json +4 -0
  48. package/dist/kb-dir-partykit/_test_sync_db_1768430955075_meta_.json +4 -0
  49. package/dist/kb-dir-partykit/_test_sync_db_1768430955075_wal_.json +4 -0
  50. package/dist/kb-dir-partykit/_test_sync_db_1768522643733_data_.json +4 -0
  51. package/dist/kb-dir-partykit/_test_sync_db_1768522643733_idx_data_.json +4 -0
  52. package/dist/kb-dir-partykit/_test_sync_db_1768522643733_idx_meta_.json +4 -0
  53. package/dist/kb-dir-partykit/_test_sync_db_1768522643733_idx_wal_.json +4 -0
  54. package/dist/kb-dir-partykit/_test_sync_db_1768522643733_meta_.json +4 -0
  55. package/dist/kb-dir-partykit/_test_sync_db_1768522643733_wal_.json +4 -0
  56. package/package.json +14 -9
package/README.md CHANGED
@@ -1,292 +1,480 @@
1
1
  # Encrypted Store
2
2
 
3
- Client-side encrypted storage with change detection for PWAs. Built on [Fireproof](https://use-fireproof.com).
3
+ Client-side encrypted document storage with change detection using PouchDB and AES-256-GCM encryption.
4
4
 
5
- **For small data that can live in memory** - Designed for PWAs that manage datasets that fit comfortably in browser memory.
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 before storage
10
- - 🔄 Real-time change detection (added/changed/deleted events)
11
- - 📱 PWA-ready with offline-first support
12
- - 🌐 Optional remote sync (PartyKit, Netlify)
13
- - 📦 TypeScript with full type safety
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 { EncryptedStore, fireproof } from "@mrbelloc/encrypted-store";
25
-
26
- // Create database and encrypted store
27
- const db = fireproof("myapp");
28
- const store = new EncryptedStore(db, "my-password", {
29
- docsAdded: (events) => {
30
- events.forEach(({ table, docs }) => {
31
- console.log(`New ${table}:`, docs);
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
- docsChanged: (events) => {
35
- events.forEach(({ table, docs }) => {
36
- console.log(`Updated ${table}:`, docs);
37
- });
53
+ onDelete: (docs) => {
54
+ console.log('Documents deleted:', docs);
38
55
  },
39
- docsDeleted: (events) => {
40
- events.forEach(({ table, docs }) => {
41
- console.log(`Deleted ${table}:`, docs);
42
- });
56
+ onConflict: (conflicts) => {
57
+ console.log('Conflicts detected:', conflicts);
43
58
  },
44
- decryptionError: (events) => {
45
- events.forEach(({ docId, error, doc }) => {
46
- console.error(`Failed to decrypt ${docId}:`, error.message);
47
- console.log("Raw encrypted document:", doc);
48
- });
59
+ onSync: (info) => {
60
+ console.log('Sync progress:', info);
49
61
  },
62
+ onError: (errors) => {
63
+ console.error('Decryption errors:', errors);
64
+ }
50
65
  });
51
66
 
52
- // Load existing data
67
+ // Load existing data and start change detection
53
68
  await store.loadAll();
54
69
 
55
70
  // Create/update documents
56
- await store.put("users", { _id: "alice", name: "Alice", age: 30 });
71
+ await store.put('expenses', {
72
+ _id: 'lunch',
73
+ amount: 15.50,
74
+ date: '2024-01-15'
75
+ });
76
+
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' }
80
+
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
57
97
 
58
- // Get documents
59
- const user = await store.get("users", "alice");
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
+ });
60
108
 
61
- // Delete documents
62
- await store.delete("users", "alice");
109
+ await store.loadAll();
63
110
  ```
64
111
 
65
112
  ## API Reference
66
113
 
67
- ### `new EncryptedStore(db, password, listener)`
114
+ ### `new EncryptedStore(db, password, listener?)`
68
115
 
69
116
  Creates an encrypted store.
70
117
 
71
- - `db`: Fireproof database instance
118
+ - `db`: PouchDB database instance
72
119
  - `password`: Encryption password (string)
73
- - `listener`: Object with callbacks:
74
- - `docsAdded(events: TableEvent[])`: Fired when new documents are added
75
- - `docsChanged(events: TableEvent[])`: Fired when documents are updated
76
- - `docsDeleted(events: TableEvent[])`: Fired when documents are deleted
77
- - `decryptionError?(events: DecryptionErrorEvent[])`: Optional. Fired when documents fail to decrypt
78
-
79
- Each `TableEvent` has:
120
+ - `listener`: Optional object with callbacks
80
121
 
81
- - `table`: Document type (e.g., "users", "transactions")
82
- - `docs`: Array of documents with that type
122
+ ### Listener Callbacks
83
123
 
84
- Each `DecryptionErrorEvent` has:
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
+ ```
85
133
 
86
- - `docId`: Full document ID that failed to decrypt (e.g., "users_alice")
87
- - `error`: Error object with details about why decryption failed
88
- - `doc`: The raw encrypted document from Fireproof (includes `_id` and `d` fields)
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
89
139
 
90
140
  ### `await store.loadAll()`
91
141
 
92
- Loads all existing documents and sets up change detection. Call this once after creating the store.
142
+ Loads all existing documents and starts change detection. Call this once after creating the store.
93
143
 
94
- ### `await store.put(type, doc)`
144
+ ### `await store.put(table, doc)`
95
145
 
96
146
  Creates or updates a document.
97
147
 
98
- - `type`: Document type / table name (string)
99
- - `doc`: Document object with `_id` field (will be generated if missing)
148
+ - `table`: Document type (e.g., "expenses", "tasks")
149
+ - `doc`: Document object with optional `_id` field (generated if missing)
100
150
 
101
- Returns the document.
151
+ Returns the document with `_table` field added.
102
152
 
103
- ### `await store.get(type, id)`
153
+ ### `await store.get(table, id)`
104
154
 
105
- Retrieves a document by type and ID. Returns `null` if not found.
155
+ Gets a document by table and ID. Returns `null` if not found.
106
156
 
107
- ### `await store.delete(type, id)`
157
+ ### `await store.delete(table, id)`
108
158
 
109
- Deletes a document by type and ID.
159
+ Deletes a document by table and ID.
110
160
 
111
- ## Remote Sync
161
+ ### `await store.getAll(table?)`
112
162
 
113
- Sync encrypted data across devices with any Fireproof connector:
163
+ Gets all documents, optionally filtered by table.
114
164
 
115
165
  ```typescript
116
- // Install the connector you want
117
- // npm install @fireproof/partykit
118
- // or
119
- // npm install @fireproof/netlify
120
-
121
- import { connect } from "@fireproof/partykit";
122
- // or
123
- // import { connect } from "@fireproof/netlify";
124
-
125
- // Connect using the connector function
126
- await store.connectRemote(connect, {
127
- namespace: "my-app",
128
- host: "http://localhost:1999", // or your server URL
129
- });
166
+ const allExpenses = await store.getAll('expenses');
167
+ const allDocs = await store.getAll();
168
+ ```
130
169
 
131
- // Disconnect
132
- store.disconnectRemote();
170
+ ### `await store.connectRemote(options)`
133
171
 
134
- // Works with any connector that follows the Fireproof connector interface
172
+ Connects to a remote CouchDB server for sync.
173
+
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
+ }
135
180
  ```
136
181
 
137
- **Note:** Remote servers only see encrypted blobs - they cannot read your data.
182
+ ### `store.disconnectRemote()`
138
183
 
139
- ### Testing Remote Sync
184
+ Disconnects from remote sync.
140
185
 
141
- To test that remote sync is working:
186
+ ### `await store.getConflictInfo(table, id)`
142
187
 
143
- 1. **Connect to a remote server** using one of the Fireproof connectors (PartyKit, Netlify, etc.)
144
- 2. **Monitor decryption errors** - If data arrives from another client encrypted with a different password or corrupted, you'll receive `decryptionError` events
145
- 3. **Use the decryptionError callback** to verify connectivity and detect synchronization issues
188
+ Check if a document has conflicts without triggering the callback. Returns `ConflictInfo` if conflicts exist, or `null` if none.
146
189
 
147
190
  ```typescript
148
- const store = new EncryptedStore(db, "my-password", {
149
- docsAdded: (events) => {
150
- console.log("✓ Received new documents from remote");
151
- },
152
- docsChanged: (events) => {
153
- console.log("✓ Received document updates from remote");
154
- },
155
- docsDeleted: (events) => {
156
- console.log("✓ Received document deletions from remote");
157
- },
158
- decryptionError: (events) => {
159
- // This fires when remote data can't be decrypted
160
- // Useful for detecting sync issues or password mismatches
161
- events.forEach(({ docId, error, doc }) => {
162
- console.warn(`⚠ Failed to decrypt ${docId}:`, error.message);
163
- console.log("Raw encrypted document available for debugging:", doc);
164
- });
165
- },
166
- });
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
167
224
 
168
- await store.connectRemote(connect, {
169
- namespace: "my-app",
170
- host: "http://localhost:1999",
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
+ }
171
274
  });
172
275
  ```
173
276
 
174
- **Important:** Unlike traditional databases like CouchDB/PouchDB, Fireproof connectors don't provide connection status events. The subscription mechanism handles both local and remote changes automatically. Monitor the event callbacks to verify sync is working.
277
+ ## Deployment Options
175
278
 
176
- ## Decryption Error Handling
279
+ ### Free Tier Options
177
280
 
178
- The store automatically handles documents that fail to decrypt:
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
+ ```
179
287
 
180
- - **Wrong password**: Documents encrypted with a different password will trigger `decryptionError` events
181
- - **Corrupted data**: Malformed or corrupted encrypted data is caught and reported
182
- - **Graceful degradation**: Successfully decrypted documents are still processed; failures don't stop the store
183
- - **Remote sync issues**: Detect when remote clients are using different passwords or sending corrupted data
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
+ ```
184
293
 
185
- This is especially useful when:
186
- - Testing remote sync connectivity
187
- - Debugging encryption/decryption issues
188
- - Detecting data corruption
189
- - Monitoring multi-client scenarios with different encryption keys
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
+ ```
190
300
 
191
- ## How It Works
301
+ ### Backup Strategy
192
302
 
193
- 1. **Encryption**: Documents are encrypted with AES-256-GCM before storage
194
- 2. **Storage**: Encrypted blobs stored in Fireproof (local-first IndexedDB)
195
- 3. **Change Detection**: Fireproof's subscribe notifies us of changes
196
- 4. **Diff Computation**: We track IDs and decrypt only changed documents
197
- 5. **Events**: Your app gets organized events by table (added/changed/deleted)
198
- 6. **Error Handling**: Failed decryptions are reported via optional callback
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
+ ```
199
312
 
200
313
  ## Example: React Integration
201
314
 
202
315
  ```typescript
203
- import { useState, useEffect } from "react";
204
- import { EncryptedStore, fireproof } from "@mrbelloc/encrypted-store";
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';
205
320
 
206
321
  function useEncryptedStore(dbName: string, password: string) {
207
- const [users, setUsers] = useState<Map<string, any>>(new Map());
322
+ const [expenses, setExpenses] = useState<Map<string, any>>(new Map());
208
323
  const [store, setStore] = useState<EncryptedStore | null>(null);
209
324
 
210
325
  useEffect(() => {
211
- const db = fireproof(dbName);
326
+ const db = new PouchDB(dbName);
212
327
  const encryptedStore = new EncryptedStore(db, password, {
213
- docsAdded: (events) => {
214
- events.forEach(({ table, docs }) => {
215
- if (table === "users") {
216
- setUsers((prev) => {
217
- const next = new Map(prev);
218
- docs.forEach((doc) => next.set(doc._id, doc));
219
- return next;
220
- });
221
- }
222
- });
223
- },
224
- docsChanged: (events) => {
225
- events.forEach(({ table, docs }) => {
226
- if (table === "users") {
227
- setUsers((prev) => {
228
- const next = new Map(prev);
229
- docs.forEach((doc) => next.set(doc._id, doc));
230
- return next;
231
- });
232
- }
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;
233
337
  });
234
338
  },
235
- docsDeleted: (events) => {
236
- events.forEach(({ table, docs }) => {
237
- if (table === "users") {
238
- setUsers((prev) => {
239
- const next = new Map(prev);
240
- docs.forEach((doc) => next.delete(doc._id));
241
- return next;
242
- });
243
- }
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;
244
348
  });
245
349
  },
246
- decryptionError: (events) => {
247
- // Handle decryption errors (optional)
248
- events.forEach(({ docId, error, doc }) => {
249
- console.error(`Failed to decrypt ${docId}:`, error.message);
250
- // Raw encrypted document is available in doc.d if needed
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);
251
356
  });
252
- },
357
+ }
253
358
  });
254
359
 
255
360
  encryptedStore.loadAll();
256
361
  setStore(encryptedStore);
362
+
363
+ return () => {
364
+ encryptedStore.disconnectRemote();
365
+ };
257
366
  }, [dbName, password]);
258
367
 
259
- return { users: Array.from(users.values()), store };
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
+ );
260
393
  }
261
394
  ```
262
395
 
263
396
  ## TypeScript Types
264
397
 
265
398
  ```typescript
266
- interface TableEvent {
399
+ interface Doc {
400
+ _id: string;
401
+ _table: string;
402
+ [key: string]: any;
403
+ }
404
+
405
+ interface ConflictInfo {
406
+ docId: string;
267
407
  table: string;
268
- docs: Doc[];
408
+ id: string;
409
+ currentRev: string;
410
+ conflictRevs: string[];
411
+ winner: Doc;
412
+ losers: Doc[];
413
+ }
414
+
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
+ };
269
423
  }
270
424
 
271
425
  interface DecryptionErrorEvent {
272
426
  docId: string;
273
427
  error: Error;
274
- doc: any; // The raw encrypted document from Fireproof
428
+ rawDoc: any;
275
429
  }
276
430
 
277
- interface StoreListener {
278
- docsAdded: (events: TableEvent[]) => void;
279
- docsChanged: (events: TableEvent[]) => void;
280
- docsDeleted: (events: TableEvent[]) => void;
281
- decryptionError?: (events: DecryptionErrorEvent[]) => void;
282
- }
283
-
284
- interface Doc {
285
- _id: string;
286
- [key: string]: any;
431
+ interface RemoteOptions {
432
+ url: string;
433
+ live?: boolean;
434
+ retry?: boolean;
287
435
  }
288
436
  ```
289
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
+
290
469
  ## License
291
470
 
292
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