@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/dist/encryptedStore.js
CHANGED
|
@@ -1,338 +1,367 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Encrypted storage with change detection
|
|
3
|
-
*
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
18
|
+
/** Load all documents and set up change detection */
|
|
27
19
|
async loadAll() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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.
|
|
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(
|
|
62
|
-
// Generate ID if not provided
|
|
67
|
+
async put(table, doc) {
|
|
63
68
|
if (!doc._id) {
|
|
64
|
-
doc._id =
|
|
69
|
+
doc._id =
|
|
70
|
+
crypto.randomUUID?.() ||
|
|
71
|
+
`${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
65
72
|
}
|
|
66
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
73
|
-
// which will reload, compute diff, and fire events
|
|
74
|
-
return doc;
|
|
84
|
+
return { ...doc, _table: table };
|
|
75
85
|
}
|
|
76
|
-
/** Get a document
|
|
77
|
-
async get(
|
|
78
|
-
const fullId = `${type}_${id}`;
|
|
86
|
+
/** Get a document by table and id */
|
|
87
|
+
async get(table, id) {
|
|
79
88
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
|
84
|
-
// Document not found
|
|
103
|
+
catch {
|
|
85
104
|
return null;
|
|
86
105
|
}
|
|
87
106
|
}
|
|
88
107
|
/** Delete a document */
|
|
89
|
-
async delete(
|
|
90
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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.
|
|
115
|
-
this.connection = null;
|
|
116
|
-
throw error;
|
|
115
|
+
console.warn(`[EncryptedStore] Could not delete ${fullId}:`, error);
|
|
117
116
|
}
|
|
118
117
|
}
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
149
|
+
return docs;
|
|
126
150
|
}
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
}
|
|
146
|
-
|
|
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
|
-
/**
|
|
149
|
-
async
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
console.warn(`Failed to remove conflict ${fullId}@${rev}:`, error);
|
|
163
222
|
}
|
|
164
223
|
}
|
|
165
|
-
return { encryptedMap, fullIdMap };
|
|
166
224
|
}
|
|
167
|
-
/**
|
|
168
|
-
|
|
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
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
const dataToEncrypt = {};
|
|
349
|
+
const data = {};
|
|
260
350
|
for (const [key, value] of Object.entries(doc)) {
|
|
261
351
|
if (!key.startsWith("_")) {
|
|
262
|
-
|
|
352
|
+
data[key] = value;
|
|
263
353
|
}
|
|
264
354
|
}
|
|
265
|
-
|
|
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:
|
|
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
|
|
287
|
-
if (
|
|
288
|
-
|
|
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
|