@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
@@ -1,338 +1,367 @@
1
1
  /**
2
- * Encrypted storage with change detection for small datasets
3
- * Wraps Fireproof with AES-256-GCM encryption + real-time event system
2
+ * Encrypted storage with change detection using PouchDB
3
+ * Simple API: put, get, delete, loadAll
4
4
  */
5
5
  import { EncryptionHelper } from "./encryption.js";
6
- /**
7
- * EncryptedStore class
8
- *
9
- * Main entry point for encrypted storage with change detection
10
- */
11
6
  export class EncryptedStore {
12
7
  db;
13
8
  encryptionHelper;
14
9
  listener;
15
- knownIds = new Set(); // Set of known document IDs (stripped, e.g., "alice")
16
- fullIdMap = new Map(); // stripped id -> full id with table
17
- isSubscribed = false;
18
- unsubscribe = null; // Callback to unsubscribe from changes
19
- connection = null;
20
- clock = null; // High water mark for changes() API
10
+ changesHandler = null;
11
+ syncHandler = null;
12
+ processingChain = Promise.resolve();
21
13
  constructor(db, password, listener) {
22
14
  this.db = db;
23
15
  this.encryptionHelper = new EncryptionHelper(password);
24
- this.listener = listener;
16
+ this.listener = listener || { onChange: () => { }, onDelete: () => { } };
25
17
  }
26
- /** Load all documents and set up change detection (call once after creating store) */
18
+ /** Load all documents and set up change detection */
27
19
  async loadAll() {
28
- // Reconnect subscription to ensure we receive all changes
29
- this.reconnect();
30
- const { encryptedMap, fullIdMap } = await this.readAllEncrypted();
31
- this.fullIdMap = fullIdMap;
32
- // Build initial set of known IDs
33
- this.knownIds = new Set(encryptedMap.keys());
34
- // Decrypt all documents for initial docsAdded event
35
- const docs = [];
36
- for (const [id, encryptedData] of encryptedMap) {
37
- try {
38
- const decrypted = await this.decryptFromEncryptedData(encryptedData, id);
39
- docs.push(decrypted);
20
+ try {
21
+ const result = await this.db.allDocs({
22
+ include_docs: true,
23
+ conflicts: true,
24
+ });
25
+ const docs = [];
26
+ const errors = [];
27
+ const conflicts = [];
28
+ for (const row of result.rows) {
29
+ if (!row.doc || row.id.startsWith("_design/"))
30
+ continue;
31
+ const encryptedDoc = row.doc;
32
+ if (encryptedDoc.d) {
33
+ try {
34
+ const doc = await this.decryptDoc(encryptedDoc);
35
+ docs.push(doc);
36
+ // Check for conflicts
37
+ if (encryptedDoc._conflicts && encryptedDoc._conflicts.length > 0) {
38
+ const conflictInfo = await this.buildConflictInfo(encryptedDoc._id, encryptedDoc._rev, encryptedDoc._conflicts, doc);
39
+ conflicts.push(conflictInfo);
40
+ }
41
+ }
42
+ catch (error) {
43
+ errors.push({
44
+ docId: encryptedDoc._id,
45
+ error: error instanceof Error ? error : new Error(String(error)),
46
+ rawDoc: encryptedDoc,
47
+ });
48
+ }
49
+ }
40
50
  }
41
- catch (error) {
42
- // Skip documents we can't decrypt
51
+ if (docs.length > 0) {
52
+ this.listener.onChange(docs);
53
+ }
54
+ if (errors.length > 0 && this.listener.onError) {
55
+ this.listener.onError(errors);
56
+ }
57
+ if (conflicts.length > 0 && this.listener.onConflict) {
58
+ this.listener.onConflict(conflicts);
43
59
  }
44
- }
45
- // Initialize clock for changes() API
46
- try {
47
- const result = await this.db.changes();
48
- this.clock = result.clock;
49
- console.log("[EncryptedStore] Initialized changes clock");
50
60
  }
51
61
  catch (error) {
52
- console.warn("[EncryptedStore] db.changes() not available, falling back to subscription only");
53
- }
54
- // Fire initial docsAdded for everything, grouped by table
55
- if (docs.length > 0) {
56
- const events = this.groupByTable(docs, fullIdMap);
57
- this.listener.docsAdded(events);
62
+ console.error("[EncryptedStore] loadAll failed:", error);
58
63
  }
64
+ this.setupSubscription();
59
65
  }
60
66
  /** Create or update a document */
61
- async put(type, doc) {
62
- // Generate ID if not provided
67
+ async put(table, doc) {
63
68
  if (!doc._id) {
64
- doc._id = this.generateId();
69
+ doc._id =
70
+ crypto.randomUUID?.() ||
71
+ `${Date.now()}-${Math.random().toString(36).slice(2)}`;
65
72
  }
66
- // Build full ID with type prefix
67
- const fullId = `${type}_${doc._id}`;
68
- // Encrypt the document
73
+ const fullId = `${table}_${doc._id}`;
69
74
  const encryptedDoc = await this.encryptDoc(doc, fullId);
70
- // Store in Fireproof
75
+ // Preserve _rev if document exists
76
+ try {
77
+ const existing = await this.db.get(fullId);
78
+ encryptedDoc._rev = existing._rev;
79
+ }
80
+ catch {
81
+ // Document doesn't exist, that's fine
82
+ }
71
83
  await this.db.put(encryptedDoc);
72
- // Fireproof's subscribe will trigger handleChange()
73
- // which will reload, compute diff, and fire events
74
- return doc;
84
+ return { ...doc, _table: table };
75
85
  }
76
- /** Get a document (returns null if not found) */
77
- async get(type, id) {
78
- const fullId = `${type}_${id}`;
86
+ /** Get a document by table and id */
87
+ async get(table, id) {
79
88
  try {
80
- const encryptedDoc = await this.db.get(fullId);
81
- return await this.decryptDoc(encryptedDoc, id);
89
+ const fullId = `${table}_${id}`;
90
+ const encryptedDoc = (await this.db.get(fullId, {
91
+ conflicts: true,
92
+ }));
93
+ const doc = await this.decryptDoc(encryptedDoc);
94
+ // Notify about conflicts if present
95
+ if (encryptedDoc._conflicts &&
96
+ encryptedDoc._conflicts.length > 0 &&
97
+ this.listener.onConflict) {
98
+ const conflictInfo = await this.buildConflictInfo(encryptedDoc._id, encryptedDoc._rev, encryptedDoc._conflicts, doc);
99
+ this.listener.onConflict([conflictInfo]);
100
+ }
101
+ return doc;
82
102
  }
83
- catch (error) {
84
- // Document not found
103
+ catch {
85
104
  return null;
86
105
  }
87
106
  }
88
107
  /** Delete a document */
89
- async delete(type, id) {
90
- // Build full ID with type prefix
91
- const fullId = `${type}_${id}`;
92
- // Delete from Fireproof
93
- await this.db.del(fullId);
94
- // Fireproof's subscribe will trigger handleChange()
95
- // which will detect the deletion and fire docsDeleted event
96
- }
97
- /** Connect to remote sync with any Fireproof connector */
98
- async connectRemote(connector, options) {
99
- this.disconnectRemote();
108
+ async delete(table, id) {
109
+ const fullId = `${table}_${id}`;
100
110
  try {
101
- console.log(`[EncryptedStore] Connecting to ${options.host} with namespace: ${options.namespace}`);
102
- // Call the connector function
103
- const connection = connector(this.db, options.namespace, options.host);
104
- // Store connection
105
- this.connection = {
106
- ready: connection.ready || Promise.resolve(),
107
- disconnect: connection.disconnect,
108
- };
109
- // Wait for connection to be ready
110
- await this.connection.ready;
111
- console.log(`[EncryptedStore] ✓ Connected to remote`);
111
+ const doc = await this.db.get(fullId);
112
+ await this.db.remove(doc);
112
113
  }
113
114
  catch (error) {
114
- console.error(`[EncryptedStore] Failed to connect:`, error);
115
- this.connection = null;
116
- throw error;
115
+ console.warn(`[EncryptedStore] Could not delete ${fullId}:`, error);
117
116
  }
118
117
  }
119
- /** Disconnect from remote sync */
120
- disconnectRemote() {
121
- if (this.connection && this.connection.disconnect) {
122
- this.connection.disconnect();
123
- console.log("[EncryptedStore] Disconnected from remote");
118
+ /** Get all documents (optionally filtered by table) */
119
+ async getAll(table) {
120
+ const result = await this.db.allDocs({
121
+ include_docs: true,
122
+ conflicts: true,
123
+ });
124
+ const docs = [];
125
+ const errors = [];
126
+ for (const row of result.rows) {
127
+ if (!row.doc || row.id.startsWith("_design/"))
128
+ continue;
129
+ const encryptedDoc = row.doc;
130
+ if (encryptedDoc.d) {
131
+ try {
132
+ const doc = await this.decryptDoc(encryptedDoc);
133
+ if (!table || doc._table === table) {
134
+ docs.push(doc);
135
+ }
136
+ }
137
+ catch (error) {
138
+ errors.push({
139
+ docId: encryptedDoc._id,
140
+ error: error instanceof Error ? error : new Error(String(error)),
141
+ rawDoc: encryptedDoc,
142
+ });
143
+ }
144
+ }
145
+ }
146
+ if (errors.length > 0 && this.listener.onError) {
147
+ this.listener.onError(errors);
124
148
  }
125
- this.connection = null;
149
+ return docs;
126
150
  }
127
- /** Reconnect subscription to ensure remote changes are received */
128
- reconnect() {
129
- // Cancel existing subscription if any
130
- if (this.unsubscribe) {
131
- console.log("[EncryptedStore] Canceling existing subscription");
132
- this.unsubscribe();
133
- this.unsubscribe = null;
134
- this.isSubscribed = false;
151
+ /** Connect to remote CouchDB for sync */
152
+ async connectRemote(options) {
153
+ this.disconnectRemote();
154
+ const syncOptions = {
155
+ live: options.live ?? true,
156
+ retry: options.retry ?? true,
157
+ };
158
+ this.syncHandler = this.db.sync(options.url, syncOptions);
159
+ // Setup sync event listeners
160
+ if (this.listener.onSync) {
161
+ this.syncHandler
162
+ .on("change", (info) => {
163
+ if (this.listener.onSync) {
164
+ this.listener.onSync({
165
+ direction: info.direction,
166
+ change: info.change,
167
+ });
168
+ }
169
+ })
170
+ .on("error", (err) => {
171
+ console.error("[EncryptedStore] sync error:", err);
172
+ });
135
173
  }
136
- // Re-subscribe to database changes
137
- // We treat subscription as a "something changed" notification
138
- // and always query db.changes() to get actual changes (external indexer pattern)
139
- console.log("[EncryptedStore] Reconnecting subscription (remote=true)");
140
- this.unsubscribe = this.db.subscribe(() => {
141
- console.log("[EncryptedStore] Subscription notified - querying db.changes()");
142
- this.handleChange().catch((err) => {
143
- console.error("EncryptedStore: Error handling change:", err);
174
+ // Wait for initial sync to start
175
+ return new Promise((resolve, reject) => {
176
+ let resolved = false;
177
+ const timeout = setTimeout(() => {
178
+ if (!resolved) {
179
+ resolved = true;
180
+ resolve();
181
+ }
182
+ }, 5000);
183
+ this.syncHandler.on("active", () => {
184
+ if (!resolved) {
185
+ clearTimeout(timeout);
186
+ resolved = true;
187
+ resolve();
188
+ }
189
+ });
190
+ this.syncHandler.on("error", (err) => {
191
+ if (!resolved) {
192
+ clearTimeout(timeout);
193
+ resolved = true;
194
+ reject(err);
195
+ }
144
196
  });
145
- }, true); // Include remote changes
146
- this.isSubscribed = true;
197
+ });
198
+ }
199
+ /** Disconnect from remote sync */
200
+ disconnectRemote() {
201
+ if (this.syncHandler) {
202
+ this.syncHandler.cancel();
203
+ this.syncHandler = null;
204
+ }
147
205
  }
148
- /** Read all encrypted documents (without decrypting) */
149
- async readAllEncrypted() {
150
- const result = await this.db.query("_id", { descending: false });
151
- const encryptedMap = new Map();
152
- const fullIdMap = new Map();
153
- // Get docs from either result.docs or result.rows
154
- const allDocs = result.docs || result.rows.map((row) => row.doc).filter(Boolean);
155
- for (const doc of allDocs) {
206
+ /** Resolve a conflict by choosing the winner */
207
+ async resolveConflict(table, id, winningDoc) {
208
+ const fullId = `${table}_${id}`;
209
+ const doc = (await this.db.get(fullId, { conflicts: true }));
210
+ if (!doc._conflicts || doc._conflicts.length === 0) {
211
+ throw new Error(`No conflicts found for ${fullId}`);
212
+ }
213
+ // Update with winning document
214
+ await this.put(table, winningDoc);
215
+ // Remove all conflicting revisions
216
+ for (const rev of doc._conflicts) {
156
217
  try {
157
- const { type, id } = this.parseFullId(doc._id);
158
- encryptedMap.set(id, doc.d); // Store encrypted data
159
- fullIdMap.set(id, doc._id); // Map "alice" -> "users_alice"
218
+ await this.db.remove(fullId, rev);
160
219
  }
161
220
  catch (error) {
162
- // Skip documents with invalid ID format
221
+ console.warn(`Failed to remove conflict ${fullId}@${rev}:`, error);
163
222
  }
164
223
  }
165
- return { encryptedMap, fullIdMap };
166
224
  }
167
- /**
168
- * Query db.changes() and process any new changes (external indexer pattern)
169
- * This is called whenever the subscription fires
170
- */
171
- async handleChange() {
225
+ /** Check if a document has conflicts without triggering the callback */
226
+ async getConflictInfo(table, id) {
172
227
  try {
173
- // Query changes since last clock
174
- const result = await this.db.changes(this.clock);
175
- console.log(`[EncryptedStore] db.changes() returned ${result.rows.length} changes`);
176
- if (result.rows.length === 0) {
177
- return;
178
- }
179
- // Update clock first
180
- this.clock = result.clock;
181
- // Transform db.changes() format to processable format
182
- // db.changes() returns { key, value: { _id, d }, clock }
183
- const changes = result.rows.map((row) => row.value);
184
- const newDocs = [];
185
- const changedDocs = [];
186
- const deletedDocs = [];
187
- for (const change of changes) {
188
- if (change.d) {
189
- // Has encrypted data - it's a create or update
190
- try {
191
- const { type, id } = this.parseFullId(change._id);
192
- // Decrypt the document
193
- const decrypted = await this.decryptFromEncryptedData(change.d, id);
194
- // Check if it's new or changed
195
- if (this.knownIds.has(id)) {
196
- changedDocs.push(decrypted);
197
- }
198
- else {
199
- newDocs.push(decrypted);
200
- this.knownIds.add(id);
201
- this.fullIdMap.set(id, change._id); // Update fullIdMap for new docs
202
- }
203
- }
204
- catch (error) {
205
- // Skip documents we can't decrypt or parse
206
- }
207
- }
208
- else {
209
- // No encrypted data - it's a deletion
210
- try {
211
- const { type, id } = this.parseFullId(change._id);
212
- if (this.knownIds.has(id)) {
213
- deletedDocs.push({ _id: id });
214
- this.knownIds.delete(id);
215
- // Keep fullIdMap entry for the deletion event, remove after
216
- }
217
- }
218
- catch (error) {
219
- // Skip invalid IDs
220
- }
221
- }
228
+ const fullId = `${table}_${id}`;
229
+ const encryptedDoc = (await this.db.get(fullId, {
230
+ conflicts: true,
231
+ }));
232
+ if (!encryptedDoc._conflicts || encryptedDoc._conflicts.length === 0) {
233
+ return null;
222
234
  }
223
- // Fire events grouped by table
224
- console.log(`[EncryptedStore] Firing events: ${newDocs.length} new, ${changedDocs.length} changed, ${deletedDocs.length} deleted`);
225
- if (newDocs.length > 0) {
226
- const events = this.groupByTable(newDocs, this.fullIdMap);
227
- this.listener.docsAdded(events);
228
- }
229
- if (changedDocs.length > 0) {
230
- const events = this.groupByTable(changedDocs, this.fullIdMap);
231
- this.listener.docsChanged(events);
235
+ const doc = await this.decryptDoc(encryptedDoc);
236
+ return await this.buildConflictInfo(encryptedDoc._id, encryptedDoc._rev, encryptedDoc._conflicts, doc);
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }
242
+ /** Re-subscribe to changes (useful after disconnect/reconnect) */
243
+ reconnect() {
244
+ if (this.changesHandler) {
245
+ this.changesHandler.cancel();
246
+ this.changesHandler = null;
247
+ }
248
+ this.setupSubscription();
249
+ }
250
+ setupSubscription() {
251
+ this.changesHandler = this.db
252
+ .changes({
253
+ since: "now",
254
+ live: true,
255
+ include_docs: true,
256
+ conflicts: true,
257
+ })
258
+ .on("change", (change) => {
259
+ this.processingChain = this.processingChain
260
+ .then(() => this.handleChange(change))
261
+ .catch((err) => console.error("[EncryptedStore] handleChange error:", err));
262
+ })
263
+ .on("error", (err) => {
264
+ console.error("[EncryptedStore] changes feed error:", err);
265
+ });
266
+ }
267
+ async handleChange(change) {
268
+ if (change.id.startsWith("_design/"))
269
+ return;
270
+ const encryptedDoc = change.doc;
271
+ // Deletion
272
+ if (change.deleted || !encryptedDoc?.d) {
273
+ const parsed = this.parseFullId(change.id);
274
+ if (parsed) {
275
+ this.listener.onDelete([{ _id: parsed.id, _table: parsed.table }]);
232
276
  }
233
- if (deletedDocs.length > 0) {
234
- const events = this.groupDeletedByTable(deletedDocs);
235
- this.listener.docsDeleted(events);
236
- // Clean up fullIdMap entries for deleted docs
237
- for (const doc of deletedDocs) {
238
- this.fullIdMap.delete(doc._id);
239
- }
277
+ return;
278
+ }
279
+ // Changed/added document
280
+ const errors = [];
281
+ const conflicts = [];
282
+ try {
283
+ const doc = await this.decryptDoc(encryptedDoc);
284
+ // Check for conflicts
285
+ if (encryptedDoc._conflicts && encryptedDoc._conflicts.length > 0) {
286
+ const conflictInfo = await this.buildConflictInfo(encryptedDoc._id, encryptedDoc._rev, encryptedDoc._conflicts, doc);
287
+ conflicts.push(conflictInfo);
240
288
  }
289
+ this.listener.onChange([doc]);
241
290
  }
242
291
  catch (error) {
243
- console.error("[EncryptedStore] db.changes() failed:", error);
292
+ errors.push({
293
+ docId: encryptedDoc._id,
294
+ error: error instanceof Error ? error : new Error(String(error)),
295
+ rawDoc: encryptedDoc,
296
+ });
297
+ }
298
+ if (errors.length > 0 && this.listener.onError) {
299
+ this.listener.onError(errors);
300
+ }
301
+ if (conflicts.length > 0 && this.listener.onConflict) {
302
+ this.listener.onConflict(conflicts);
244
303
  }
245
304
  }
246
- async decryptFromEncryptedData(encryptedData, id) {
247
- // Decrypt the data
248
- const decryptedJson = await this.encryptionHelper.decrypt(encryptedData);
249
- const decryptedData = JSON.parse(decryptedJson);
250
- // Build final document
251
- const doc = {
252
- _id: id,
253
- ...decryptedData,
305
+ async buildConflictInfo(fullId, currentRev, conflictRevs, winnerDoc) {
306
+ const parsed = this.parseFullId(fullId);
307
+ if (!parsed) {
308
+ throw new Error(`Invalid ID format: ${fullId}`);
309
+ }
310
+ const losers = [];
311
+ const errors = [];
312
+ for (const rev of conflictRevs) {
313
+ try {
314
+ const conflictDoc = (await this.db.get(fullId, {
315
+ rev,
316
+ }));
317
+ const decrypted = await this.decryptDoc(conflictDoc);
318
+ losers.push(decrypted);
319
+ }
320
+ catch (error) {
321
+ errors.push({
322
+ docId: `${fullId}@${rev}`,
323
+ error: error instanceof Error ? error : new Error(String(error)),
324
+ rawDoc: { _id: fullId, _rev: rev },
325
+ });
326
+ }
327
+ }
328
+ if (errors.length > 0 && this.listener.onError) {
329
+ this.listener.onError(errors);
330
+ }
331
+ return {
332
+ docId: fullId,
333
+ table: parsed.table,
334
+ id: parsed.id,
335
+ currentRev,
336
+ conflictRevs,
337
+ winner: winnerDoc,
338
+ losers,
254
339
  };
255
- return doc;
340
+ }
341
+ async decryptDoc(encryptedDoc) {
342
+ const parsed = this.parseFullId(encryptedDoc._id);
343
+ if (!parsed)
344
+ throw new Error(`Invalid ID format: ${encryptedDoc._id}`);
345
+ const decrypted = JSON.parse(await this.encryptionHelper.decrypt(encryptedDoc.d));
346
+ return { _id: parsed.id, _table: parsed.table, ...decrypted };
256
347
  }
257
348
  async encryptDoc(doc, fullId) {
258
- // Extract fields to encrypt (everything except _id)
259
- const dataToEncrypt = {};
349
+ const data = {};
260
350
  for (const [key, value] of Object.entries(doc)) {
261
351
  if (!key.startsWith("_")) {
262
- dataToEncrypt[key] = value;
352
+ data[key] = value;
263
353
  }
264
354
  }
265
- // Encrypt as JSON
266
- const encrypted = await this.encryptionHelper.encrypt(JSON.stringify(dataToEncrypt));
267
- // Build encrypted doc
268
- const encryptedDoc = {
355
+ return {
269
356
  _id: fullId,
270
- d: encrypted,
271
- };
272
- return encryptedDoc;
273
- }
274
- async decryptDoc(encryptedDoc, id) {
275
- // Decrypt the 'd' field
276
- const decryptedJson = await this.encryptionHelper.decrypt(encryptedDoc.d);
277
- const decryptedData = JSON.parse(decryptedJson);
278
- // Build final document
279
- const doc = {
280
- _id: id,
281
- ...decryptedData,
357
+ d: await this.encryptionHelper.encrypt(JSON.stringify(data)),
282
358
  };
283
- return doc;
284
359
  }
285
360
  parseFullId(fullId) {
286
- const firstUnderscore = fullId.indexOf("_");
287
- if (firstUnderscore === -1) {
288
- throw new Error(`Invalid document ID format: ${fullId}`);
289
- }
290
- return {
291
- type: fullId.substring(0, firstUnderscore),
292
- id: fullId.substring(firstUnderscore + 1),
293
- };
294
- }
295
- generateId() {
296
- // Use crypto.randomUUID if available, otherwise fallback
297
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
298
- return crypto.randomUUID();
299
- }
300
- // Fallback for environments without crypto.randomUUID
301
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
302
- }
303
- groupByTable(docs, fullIdMap) {
304
- const grouped = new Map();
305
- for (const doc of docs) {
306
- const fullId = fullIdMap.get(doc._id);
307
- if (fullId) {
308
- const { type } = this.parseFullId(fullId);
309
- if (!grouped.has(type)) {
310
- grouped.set(type, []);
311
- }
312
- grouped.get(type).push(doc);
313
- }
314
- }
315
- return Array.from(grouped.entries()).map(([table, docs]) => ({
316
- table,
317
- docs,
318
- }));
319
- }
320
- groupDeletedByTable(deletedDocs) {
321
- const grouped = new Map();
322
- for (const doc of deletedDocs) {
323
- const fullId = this.fullIdMap.get(doc._id);
324
- if (fullId) {
325
- const { type } = this.parseFullId(fullId);
326
- if (!grouped.has(type)) {
327
- grouped.set(type, []);
328
- }
329
- grouped.get(type).push(doc);
330
- }
331
- }
332
- return Array.from(grouped.entries()).map(([table, docs]) => ({
333
- table,
334
- docs: docs, // Cast since deletedDocs is simpler structure
335
- }));
361
+ const idx = fullId.indexOf("_");
362
+ if (idx === -1)
363
+ return null;
364
+ return { table: fullId.slice(0, idx), id: fullId.slice(idx + 1) };
336
365
  }
337
366
  }
338
367
  //# sourceMappingURL=encryptedStore.js.map