@pixagram/lacerta-db 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.min.js +5 -41
- package/dist/index.min.js +5 -41
- package/index.js +705 -215
- package/package.json +1 -1
- package/readme.md +1 -1
package/index.js
CHANGED
|
@@ -1,43 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LacertaDB V0.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* - Multiple caching strategies (LRU, LFU, TTL)
|
|
6
|
-
* - Full-text search and geospatial indexing
|
|
7
|
-
* - Document encryption and compression
|
|
8
|
-
* - Binary attachments via OPFS (Origin Private File System)
|
|
9
|
-
* - MongoDB-like query syntax and aggregation pipeline
|
|
10
|
-
* - Schema migrations and versioning
|
|
11
|
-
* - QuickStore for fast localStorage-based operations
|
|
12
|
-
* * Changelog V0.9.2:
|
|
13
|
-
* - CRITICAL: Fixed non-extractable CryptoKey in _importMasterKeys (changePin was broken).
|
|
14
|
-
* - CRITICAL: Fixed TransactionInactiveError in batchOperation (sync request queueing).
|
|
15
|
-
* - CRITICAL: Fixed batchAdd crash when called without options (from import()).
|
|
16
|
-
* - SECURITY: Constant-time comparison in _arrayEquals (timing attack prevention).
|
|
17
|
-
* - SECURITY: Unbiased PIN generation via rejection sampling.
|
|
18
|
-
* - SECURITY: changePin now verifies oldPin before allowing change.
|
|
19
|
-
* - SECURITY: Standardized AES-GCM IV to 12 bytes in encryptPrivateKey/decryptPrivateKey.
|
|
20
|
-
* - FIX: Environment-safe polyfills (no bare `window` at module scope).
|
|
21
|
-
* - FIX: BTreeIndex.remove only decrements _size when key/value actually existed.
|
|
22
|
-
* - FIX: LFUCache.has() now checks TTL expiration.
|
|
23
|
-
* - FIX: GeoIndex._size cannot go negative on redundant removePoint calls.
|
|
24
|
-
* - FIX: $group aggregation uses JSON.stringify for object keys (Map comparison).
|
|
25
|
-
* - FIX: TextIndex tokenizer minimum length normalized across code paths.
|
|
26
|
-
* - FIX: QuickStore properly cleans up beforeunload listener via destroy().
|
|
27
|
-
* - FIX: Consistent window.requestIdleCallback usage in _saveIndexMetadata.
|
|
28
|
-
* - FIX: Block-scoped const in $avg aggregation case.
|
|
29
|
-
* * Changelog V0.9.1:
|
|
30
|
-
* - CRITICAL: Implemented Master Key Wrapping for encryption (fixes data loss on PIN change).
|
|
31
|
-
* - CRITICAL: Fixed UI freezing in QuickStore using in-memory caching and async persistence.
|
|
32
|
-
* - CRITICAL: Replaced O(N) GeoIndex with O(log N) QuadTree.
|
|
33
|
-
* - CRITICAL: Fixed TransactionInactiveError by implementing Batch Processing for Indexes.
|
|
34
|
-
* - SECURITY: Increased PBKDF2 iterations to 600,000 (OWASP standard).
|
|
35
|
-
* - OPTIMIZATION: Removed Global Mutex for read operations (concurrency fix).
|
|
36
|
-
* - OPTIMIZATION: Implemented Cursor-based indexing (OOM fix).
|
|
37
|
-
* - FIX: Added Magic Byte check for robust compression detection.
|
|
38
|
-
* - FIX: Use Intl.Segmenter for proper CJK text tokenization.
|
|
39
|
-
* * @module LacertaDB
|
|
40
|
-
* @version 0.9.2
|
|
2
|
+
* LacertaDB V0.11.0 - Production Library
|
|
3
|
+
* @module LacertaDB
|
|
4
|
+
* @version 0.11.0
|
|
41
5
|
* @license MIT
|
|
42
6
|
* @author Pixagram SA
|
|
43
7
|
*/
|
|
@@ -62,19 +26,18 @@ if (typeof window !== 'undefined' && !window.requestIdleCallback) {
|
|
|
62
26
|
import TurboSerial from "@pixagram/turboserial";
|
|
63
27
|
import TurboBase64 from "@pixagram/turbobase64";
|
|
64
28
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const base64 = new TurboBase64();
|
|
29
|
+
// Default TurboSerial configuration (overridable via LacertaDB constructor)
|
|
30
|
+
const TURBO_SERIAL_DEFAULTS = {
|
|
31
|
+
compression: false,
|
|
32
|
+
preservePropertyDescriptors: true,
|
|
33
|
+
deduplication: false,
|
|
34
|
+
simdOptimization: true,
|
|
35
|
+
detectCircular: true,
|
|
36
|
+
shareArrayBuffers: true,
|
|
37
|
+
allowFunction: false,
|
|
38
|
+
serializeFunctions: false,
|
|
39
|
+
memoryPoolSize: 65536 * 16
|
|
40
|
+
};
|
|
78
41
|
|
|
79
42
|
// ========================
|
|
80
43
|
// Quick Store (Optimized)
|
|
@@ -85,8 +48,10 @@ const base64 = new TurboBase64();
|
|
|
85
48
|
* Keeps index in memory to avoid blocking main thread with JSON parsing.
|
|
86
49
|
*/
|
|
87
50
|
class QuickStore {
|
|
88
|
-
constructor(dbName) {
|
|
51
|
+
constructor(dbName, serializer, base64) {
|
|
89
52
|
this._dbName = dbName;
|
|
53
|
+
this._serializer = serializer;
|
|
54
|
+
this._base64 = base64;
|
|
90
55
|
this._keyPrefix = `lacertadb_${dbName}_quickstore_`;
|
|
91
56
|
this._indexKey = `${this._keyPrefix}index`;
|
|
92
57
|
|
|
@@ -127,8 +92,8 @@ class QuickStore {
|
|
|
127
92
|
const indexStr = localStorage.getItem(this._indexKey);
|
|
128
93
|
if (indexStr) {
|
|
129
94
|
try {
|
|
130
|
-
const decoded =
|
|
131
|
-
const list =
|
|
95
|
+
const decoded = this._base64.decode(indexStr);
|
|
96
|
+
const list = this._serializer.deserialize(decoded);
|
|
132
97
|
this._indexCache = new Set(list);
|
|
133
98
|
} catch (e) {
|
|
134
99
|
console.warn('QuickStore index corrupted, resetting.', e);
|
|
@@ -144,16 +109,27 @@ class QuickStore {
|
|
|
144
109
|
|
|
145
110
|
const save = () => {
|
|
146
111
|
if (!this._dirty) return;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
112
|
+
try {
|
|
113
|
+
const list = Array.from(this._indexCache);
|
|
114
|
+
const serializedIndex = this._serializer.serialize(list);
|
|
115
|
+
const encodedIndex = this._base64.encode(serializedIndex);
|
|
116
|
+
localStorage.setItem(this._indexKey, encodedIndex);
|
|
117
|
+
this._dirty = false;
|
|
118
|
+
} catch (e) {
|
|
119
|
+
if (e.name === 'QuotaExceededError') {
|
|
120
|
+
console.error('CRITICAL: QuickStore index save failed — localStorage quota exceeded');
|
|
121
|
+
if (typeof window !== 'undefined') {
|
|
122
|
+
window.dispatchEvent(new CustomEvent('lacertadb:quotaexceeded', { detail: { source: 'quickstore', db: this._dbName } }));
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
console.error('QuickStore index save failed:', e);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
152
128
|
this._saveIndexTimer = null;
|
|
153
129
|
};
|
|
154
130
|
|
|
155
131
|
// Debounce with idle callback to prevent UI freezing
|
|
156
|
-
if (window.requestIdleCallback) {
|
|
132
|
+
if (typeof window !== 'undefined' && window.requestIdleCallback) {
|
|
157
133
|
this._saveIndexTimer = window.requestIdleCallback(save);
|
|
158
134
|
} else {
|
|
159
135
|
this._saveIndexTimer = setTimeout(save, 200);
|
|
@@ -162,19 +138,30 @@ class QuickStore {
|
|
|
162
138
|
|
|
163
139
|
_flushSync() {
|
|
164
140
|
if (!this._dirty) return;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
141
|
+
try {
|
|
142
|
+
const list = Array.from(this._indexCache);
|
|
143
|
+
const serializedIndex = this._serializer.serialize(list);
|
|
144
|
+
const encodedIndex = this._base64.encode(serializedIndex);
|
|
145
|
+
localStorage.setItem(this._indexKey, encodedIndex);
|
|
146
|
+
this._dirty = false;
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (e.name === 'QuotaExceededError') {
|
|
149
|
+
console.error('CRITICAL: QuickStore flush failed — localStorage quota exceeded');
|
|
150
|
+
if (typeof window !== 'undefined') {
|
|
151
|
+
window.dispatchEvent(new CustomEvent('lacertadb:quotaexceeded', { detail: { source: 'quickstore_flush', db: this._dbName } }));
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
console.error('QuickStore flush failed:', e);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
170
157
|
}
|
|
171
158
|
|
|
172
159
|
add(docId, data) {
|
|
173
160
|
this._ensureIndexLoaded();
|
|
174
161
|
const key = `${this._keyPrefix}data_${docId}`;
|
|
175
162
|
try {
|
|
176
|
-
const serializedData =
|
|
177
|
-
const encodedData =
|
|
163
|
+
const serializedData = this._serializer.serialize(data);
|
|
164
|
+
const encodedData = this._base64.encode(serializedData);
|
|
178
165
|
localStorage.setItem(key, encodedData);
|
|
179
166
|
|
|
180
167
|
if (!this._indexCache.has(docId)) {
|
|
@@ -196,8 +183,8 @@ class QuickStore {
|
|
|
196
183
|
const stored = localStorage.getItem(key);
|
|
197
184
|
if (stored) {
|
|
198
185
|
try {
|
|
199
|
-
const decoded =
|
|
200
|
-
return
|
|
186
|
+
const decoded = this._base64.decode(stored);
|
|
187
|
+
return this._serializer.deserialize(decoded);
|
|
201
188
|
} catch (e) {
|
|
202
189
|
console.error('Failed to parse QuickStore data:', e);
|
|
203
190
|
}
|
|
@@ -782,12 +769,14 @@ class BrowserEncryptionUtility {
|
|
|
782
769
|
// ========================
|
|
783
770
|
|
|
784
771
|
class SecureDatabaseEncryption {
|
|
785
|
-
constructor(config = {}) {
|
|
772
|
+
constructor(config = {}, serializer, base64) {
|
|
786
773
|
this._iterations = config.iterations || 600000; // Increased to OWASP recommendation
|
|
787
774
|
this._hashAlgorithm = config.hashAlgorithm || 'SHA-256';
|
|
788
775
|
this._keyLength = config.keyLength || 256;
|
|
789
776
|
this._saltLength = config.saltLength || 32;
|
|
790
777
|
this._initialized = false;
|
|
778
|
+
this._serializer = serializer;
|
|
779
|
+
this._base64 = base64;
|
|
791
780
|
|
|
792
781
|
this._masterKey = null; // The actual key used for data encryption
|
|
793
782
|
this._hmacKey = null; // Key for HMAC operations
|
|
@@ -807,11 +796,11 @@ class SecureDatabaseEncryption {
|
|
|
807
796
|
|
|
808
797
|
if (existingMetadata) {
|
|
809
798
|
// Load existing
|
|
810
|
-
this._salt =
|
|
799
|
+
this._salt = this._base64.decode(existingMetadata.salt);
|
|
811
800
|
const kek = await this._deriveKEK(pinBytes, this._salt);
|
|
812
801
|
|
|
813
802
|
// Unwrap Master Key
|
|
814
|
-
const wrappedBytes =
|
|
803
|
+
const wrappedBytes = this._base64.decode(existingMetadata.wrappedKey);
|
|
815
804
|
const iv = wrappedBytes.slice(0, 12);
|
|
816
805
|
const encryptedMK = wrappedBytes.slice(12);
|
|
817
806
|
|
|
@@ -842,7 +831,7 @@ class SecureDatabaseEncryption {
|
|
|
842
831
|
wrappedKey.set(iv, 0);
|
|
843
832
|
wrappedKey.set(new Uint8Array(encryptedMK), 12);
|
|
844
833
|
|
|
845
|
-
this._wrappedKeyBlob =
|
|
834
|
+
this._wrappedKeyBlob = this._base64.encode(wrappedKey);
|
|
846
835
|
}
|
|
847
836
|
|
|
848
837
|
this._initialized = true;
|
|
@@ -896,7 +885,7 @@ class SecureDatabaseEncryption {
|
|
|
896
885
|
|
|
897
886
|
// Verify old PIN by attempting to unwrap current master key
|
|
898
887
|
const oldKek = await this._deriveKEK(new TextEncoder().encode(oldPin), this._salt);
|
|
899
|
-
const currentWrappedBytes =
|
|
888
|
+
const currentWrappedBytes = this._base64.decode(this._wrappedKeyBlob);
|
|
900
889
|
const currentIv = currentWrappedBytes.slice(0, 12);
|
|
901
890
|
const currentEncMK = currentWrappedBytes.slice(12);
|
|
902
891
|
try {
|
|
@@ -924,7 +913,7 @@ class SecureDatabaseEncryption {
|
|
|
924
913
|
|
|
925
914
|
// Update State
|
|
926
915
|
this._salt = newSalt;
|
|
927
|
-
this._wrappedKeyBlob =
|
|
916
|
+
this._wrappedKeyBlob = this._base64.encode(wrappedKey);
|
|
928
917
|
|
|
929
918
|
return this.exportMetadata();
|
|
930
919
|
}
|
|
@@ -940,7 +929,7 @@ class SecureDatabaseEncryption {
|
|
|
940
929
|
} else if (data instanceof Uint8Array) {
|
|
941
930
|
dataBytes = data;
|
|
942
931
|
} else {
|
|
943
|
-
dataBytes =
|
|
932
|
+
dataBytes = this._serializer.serialize(data);
|
|
944
933
|
}
|
|
945
934
|
|
|
946
935
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
@@ -1021,7 +1010,7 @@ class SecureDatabaseEncryption {
|
|
|
1021
1010
|
} else if (privateKey instanceof Uint8Array) {
|
|
1022
1011
|
keyData = privateKey;
|
|
1023
1012
|
} else {
|
|
1024
|
-
keyData =
|
|
1013
|
+
keyData = this._serializer.serialize(privateKey);
|
|
1025
1014
|
}
|
|
1026
1015
|
|
|
1027
1016
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
@@ -1047,7 +1036,7 @@ class SecureDatabaseEncryption {
|
|
|
1047
1036
|
result.set(authData, 16);
|
|
1048
1037
|
result.set(new Uint8Array(encryptedKey), 16 + authData.length);
|
|
1049
1038
|
|
|
1050
|
-
return
|
|
1039
|
+
return this._base64.encode(result);
|
|
1051
1040
|
}
|
|
1052
1041
|
|
|
1053
1042
|
async decryptPrivateKey(encryptedKeyString, additionalAuth = '') {
|
|
@@ -1055,7 +1044,7 @@ class SecureDatabaseEncryption {
|
|
|
1055
1044
|
throw new Error('Database encryption not initialized');
|
|
1056
1045
|
}
|
|
1057
1046
|
|
|
1058
|
-
const encryptedPackage =
|
|
1047
|
+
const encryptedPackage = this._base64.decode(encryptedKeyString);
|
|
1059
1048
|
|
|
1060
1049
|
const iv = encryptedPackage.slice(0, 12);
|
|
1061
1050
|
const authLengthBytes = encryptedPackage.slice(12, 16);
|
|
@@ -1115,7 +1104,7 @@ class SecureDatabaseEncryption {
|
|
|
1115
1104
|
|
|
1116
1105
|
exportMetadata() {
|
|
1117
1106
|
return {
|
|
1118
|
-
salt:
|
|
1107
|
+
salt: this._base64.encode(this._salt),
|
|
1119
1108
|
wrappedKey: this._wrappedKeyBlob,
|
|
1120
1109
|
iterations: this._iterations,
|
|
1121
1110
|
algorithm: 'AES-GCM-256',
|
|
@@ -1234,21 +1223,53 @@ class BTreeNode {
|
|
|
1234
1223
|
return this.children[i] ? this.children[i].search(key) : null;
|
|
1235
1224
|
}
|
|
1236
1225
|
|
|
1237
|
-
|
|
1226
|
+
// Optimized range search with subtree pruning (O(log n + k) instead of O(n))
|
|
1227
|
+
// excludeMin/excludeMax: when true, boundary values are excluded from results
|
|
1228
|
+
rangeSearch(min, max, results, excludeMin = false, excludeMax = false) {
|
|
1229
|
+
// Skip keys strictly below the min bound
|
|
1238
1230
|
let i = 0;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1231
|
+
if (min !== null) {
|
|
1232
|
+
while (i < this.n && this.keys[i] < min) {
|
|
1233
|
+
i++;
|
|
1242
1234
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1235
|
+
// If min is exclusive, also skip keys equal to min
|
|
1236
|
+
if (excludeMin) {
|
|
1237
|
+
// But first descend into the child at boundary — it may have keys > min
|
|
1238
|
+
// that are relevant. We handle this below in the loop.
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Process keys from i onward
|
|
1243
|
+
for (; i < this.n; i++) {
|
|
1244
|
+
// Early exit: if current key exceeds max (or equals max when exclusive),
|
|
1245
|
+
// descend into left child then stop — no further keys can match
|
|
1246
|
+
if (max !== null) {
|
|
1247
|
+
const pastMax = excludeMax ? this.keys[i] >= max : this.keys[i] > max;
|
|
1248
|
+
if (pastMax) {
|
|
1249
|
+
if (!this.leaf && this.children[i]) {
|
|
1250
|
+
this.children[i].rangeSearch(min, max, results, excludeMin, excludeMax);
|
|
1251
|
+
}
|
|
1252
|
+
return;
|
|
1246
1253
|
}
|
|
1247
1254
|
}
|
|
1248
|
-
|
|
1255
|
+
|
|
1256
|
+
// Descend into left child of current key
|
|
1257
|
+
if (!this.leaf && this.children[i]) {
|
|
1258
|
+
this.children[i].rangeSearch(min, max, results, excludeMin, excludeMax);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Check current key against bounds
|
|
1262
|
+
const meetsMin = min === null || (excludeMin ? this.keys[i] > min : this.keys[i] >= min);
|
|
1263
|
+
const meetsMax = max === null || (excludeMax ? this.keys[i] < max : this.keys[i] <= max);
|
|
1264
|
+
|
|
1265
|
+
if (meetsMin && meetsMax && this.values[i]) {
|
|
1266
|
+
this.values[i].forEach(v => results.push(v));
|
|
1267
|
+
}
|
|
1249
1268
|
}
|
|
1269
|
+
|
|
1270
|
+
// Descend into rightmost child
|
|
1250
1271
|
if (!this.leaf && this.children[i]) {
|
|
1251
|
-
this.children[i].rangeSearch(min, max, results);
|
|
1272
|
+
this.children[i].rangeSearch(min, max, results, excludeMin, excludeMax);
|
|
1252
1273
|
}
|
|
1253
1274
|
}
|
|
1254
1275
|
|
|
@@ -1288,6 +1309,18 @@ class BTreeNode {
|
|
|
1288
1309
|
i++;
|
|
1289
1310
|
if (this.children[i] && this.children[i].n === 2 * this.order - 1) {
|
|
1290
1311
|
this.splitChild(i, this.children[i]);
|
|
1312
|
+
|
|
1313
|
+
// FIX: After split, the promoted median may equal key.
|
|
1314
|
+
// If so, add value to it directly instead of descending
|
|
1315
|
+
// (which would create a duplicate entry in the child).
|
|
1316
|
+
if (this.keys[i] === key) {
|
|
1317
|
+
if (!this.values[i]) {
|
|
1318
|
+
this.values[i] = new Set();
|
|
1319
|
+
}
|
|
1320
|
+
this.values[i].add(value);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1291
1324
|
if (this.keys[i] < key) {
|
|
1292
1325
|
i++;
|
|
1293
1326
|
}
|
|
@@ -1315,6 +1348,17 @@ class BTreeNode {
|
|
|
1315
1348
|
|
|
1316
1349
|
y.n = this.order - 1;
|
|
1317
1350
|
|
|
1351
|
+
// Clean stale slots in y after split
|
|
1352
|
+
for (let j = this.order - 1; j < 2 * this.order - 1; j++) {
|
|
1353
|
+
y.keys[j] = undefined;
|
|
1354
|
+
y.values[j] = undefined;
|
|
1355
|
+
}
|
|
1356
|
+
if (!y.leaf) {
|
|
1357
|
+
for (let j = this.order; j < 2 * this.order; j++) {
|
|
1358
|
+
y.children[j] = undefined;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1318
1362
|
for (let j = this.n; j >= i + 1; j--) {
|
|
1319
1363
|
this.children[j + 1] = this.children[j];
|
|
1320
1364
|
}
|
|
@@ -1328,31 +1372,267 @@ class BTreeNode {
|
|
|
1328
1372
|
|
|
1329
1373
|
this.keys[i] = y.keys[this.order - 1];
|
|
1330
1374
|
this.values[i] = y.values[this.order - 1];
|
|
1375
|
+
// Clear the promoted median from y (it's now in the parent)
|
|
1376
|
+
y.keys[this.order - 1] = undefined;
|
|
1377
|
+
y.values[this.order - 1] = undefined;
|
|
1331
1378
|
this.n++;
|
|
1332
1379
|
}
|
|
1333
1380
|
|
|
1334
|
-
|
|
1381
|
+
// ---- Deletion helpers (proper B-tree delete with rebalancing) ----
|
|
1382
|
+
|
|
1383
|
+
// Get the predecessor: rightmost key in the left subtree of keys[idx]
|
|
1384
|
+
_getPredecessor(idx) {
|
|
1385
|
+
let node = this.children[idx];
|
|
1386
|
+
while (!node.leaf) {
|
|
1387
|
+
node = node.children[node.n];
|
|
1388
|
+
}
|
|
1389
|
+
return { key: node.keys[node.n - 1], value: node.values[node.n - 1] };
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Get the successor: leftmost key in the right subtree of keys[idx]
|
|
1393
|
+
_getSuccessor(idx) {
|
|
1394
|
+
let node = this.children[idx + 1];
|
|
1395
|
+
while (!node.leaf) {
|
|
1396
|
+
node = node.children[0];
|
|
1397
|
+
}
|
|
1398
|
+
return { key: node.keys[0], value: node.values[0] };
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Merge children[idx+1] into children[idx], pulling keys[idx] down as separator
|
|
1402
|
+
_merge(idx) {
|
|
1403
|
+
const child = this.children[idx];
|
|
1404
|
+
const sibling = this.children[idx + 1];
|
|
1405
|
+
const t = this.order;
|
|
1406
|
+
|
|
1407
|
+
// Pull separator key down into child
|
|
1408
|
+
child.keys[t - 1] = this.keys[idx];
|
|
1409
|
+
child.values[t - 1] = this.values[idx];
|
|
1410
|
+
|
|
1411
|
+
// Copy keys/values from sibling into child
|
|
1412
|
+
for (let j = 0; j < sibling.n; j++) {
|
|
1413
|
+
child.keys[t + j] = sibling.keys[j];
|
|
1414
|
+
child.values[t + j] = sibling.values[j];
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Copy children from sibling
|
|
1418
|
+
if (!child.leaf) {
|
|
1419
|
+
for (let j = 0; j <= sibling.n; j++) {
|
|
1420
|
+
child.children[t + j] = sibling.children[j];
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
child.n += sibling.n + 1;
|
|
1425
|
+
|
|
1426
|
+
// Shift keys/values left in this node to fill the gap
|
|
1427
|
+
for (let j = idx; j < this.n - 1; j++) {
|
|
1428
|
+
this.keys[j] = this.keys[j + 1];
|
|
1429
|
+
this.values[j] = this.values[j + 1];
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// Shift children left in this node
|
|
1433
|
+
for (let j = idx + 1; j < this.n; j++) {
|
|
1434
|
+
this.children[j] = this.children[j + 1];
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Clean stale trailing slots
|
|
1438
|
+
this.keys[this.n - 1] = undefined;
|
|
1439
|
+
this.values[this.n - 1] = undefined;
|
|
1440
|
+
this.children[this.n] = undefined;
|
|
1441
|
+
|
|
1442
|
+
this.n--;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Borrow the last key from children[idx-1] through the parent
|
|
1446
|
+
_borrowFromPrev(idx) {
|
|
1447
|
+
const child = this.children[idx];
|
|
1448
|
+
const sibling = this.children[idx - 1];
|
|
1449
|
+
|
|
1450
|
+
// Shift everything in child right by 1
|
|
1451
|
+
for (let j = child.n - 1; j >= 0; j--) {
|
|
1452
|
+
child.keys[j + 1] = child.keys[j];
|
|
1453
|
+
child.values[j + 1] = child.values[j];
|
|
1454
|
+
}
|
|
1455
|
+
if (!child.leaf) {
|
|
1456
|
+
for (let j = child.n; j >= 0; j--) {
|
|
1457
|
+
child.children[j + 1] = child.children[j];
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Move separator from parent down to child[0]
|
|
1462
|
+
child.keys[0] = this.keys[idx - 1];
|
|
1463
|
+
child.values[0] = this.values[idx - 1];
|
|
1464
|
+
|
|
1465
|
+
// Move last child of sibling to child
|
|
1466
|
+
if (!child.leaf) {
|
|
1467
|
+
child.children[0] = sibling.children[sibling.n];
|
|
1468
|
+
sibling.children[sibling.n] = undefined;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Move last key of sibling up to parent
|
|
1472
|
+
this.keys[idx - 1] = sibling.keys[sibling.n - 1];
|
|
1473
|
+
this.values[idx - 1] = sibling.values[sibling.n - 1];
|
|
1474
|
+
|
|
1475
|
+
// Clean stale slots in sibling
|
|
1476
|
+
sibling.keys[sibling.n - 1] = undefined;
|
|
1477
|
+
sibling.values[sibling.n - 1] = undefined;
|
|
1478
|
+
|
|
1479
|
+
child.n++;
|
|
1480
|
+
sibling.n--;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// Borrow the first key from children[idx+1] through the parent
|
|
1484
|
+
_borrowFromNext(idx) {
|
|
1485
|
+
const child = this.children[idx];
|
|
1486
|
+
const sibling = this.children[idx + 1];
|
|
1487
|
+
|
|
1488
|
+
// Move separator from parent down to end of child
|
|
1489
|
+
child.keys[child.n] = this.keys[idx];
|
|
1490
|
+
child.values[child.n] = this.values[idx];
|
|
1491
|
+
|
|
1492
|
+
// Move first child of sibling to child
|
|
1493
|
+
if (!child.leaf) {
|
|
1494
|
+
child.children[child.n + 1] = sibling.children[0];
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Move first key of sibling up to parent
|
|
1498
|
+
this.keys[idx] = sibling.keys[0];
|
|
1499
|
+
this.values[idx] = sibling.values[0];
|
|
1500
|
+
|
|
1501
|
+
// Shift sibling's keys/values left
|
|
1502
|
+
for (let j = 0; j < sibling.n - 1; j++) {
|
|
1503
|
+
sibling.keys[j] = sibling.keys[j + 1];
|
|
1504
|
+
sibling.values[j] = sibling.values[j + 1];
|
|
1505
|
+
}
|
|
1506
|
+
if (!sibling.leaf) {
|
|
1507
|
+
for (let j = 0; j < sibling.n; j++) {
|
|
1508
|
+
sibling.children[j] = sibling.children[j + 1];
|
|
1509
|
+
}
|
|
1510
|
+
sibling.children[sibling.n] = undefined;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Clean stale trailing slots in sibling
|
|
1514
|
+
sibling.keys[sibling.n - 1] = undefined;
|
|
1515
|
+
sibling.values[sibling.n - 1] = undefined;
|
|
1516
|
+
|
|
1517
|
+
child.n++;
|
|
1518
|
+
sibling.n--;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Ensure children[idx] has at least `order` keys (minimum degree)
|
|
1522
|
+
// so we can safely descend into it during deletion
|
|
1523
|
+
_fill(idx) {
|
|
1524
|
+
const t = this.order;
|
|
1525
|
+
if (idx > 0 && this.children[idx - 1] && this.children[idx - 1].n >= t) {
|
|
1526
|
+
this._borrowFromPrev(idx);
|
|
1527
|
+
} else if (idx < this.n && this.children[idx + 1] && this.children[idx + 1].n >= t) {
|
|
1528
|
+
this._borrowFromNext(idx);
|
|
1529
|
+
} else {
|
|
1530
|
+
// Merge with a sibling
|
|
1531
|
+
if (idx < this.n) {
|
|
1532
|
+
this._merge(idx);
|
|
1533
|
+
} else {
|
|
1534
|
+
this._merge(idx - 1);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Remove a leaf-level key entry (shift keys, values left)
|
|
1540
|
+
_removeFromLeaf(idx) {
|
|
1541
|
+
for (let j = idx; j < this.n - 1; j++) {
|
|
1542
|
+
this.keys[j] = this.keys[j + 1];
|
|
1543
|
+
this.values[j] = this.values[j + 1];
|
|
1544
|
+
}
|
|
1545
|
+
this.keys[this.n - 1] = undefined;
|
|
1546
|
+
this.values[this.n - 1] = undefined;
|
|
1547
|
+
this.n--;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Remove an internal key entry using predecessor/successor replacement
|
|
1551
|
+
_removeFromInternal(idx) {
|
|
1552
|
+
const t = this.order;
|
|
1553
|
+
const key = this.keys[idx];
|
|
1554
|
+
|
|
1555
|
+
if (this.children[idx] && this.children[idx].n >= t) {
|
|
1556
|
+
// Left child has enough keys: replace with predecessor
|
|
1557
|
+
const pred = this._getPredecessor(idx);
|
|
1558
|
+
this.keys[idx] = pred.key;
|
|
1559
|
+
this.values[idx] = pred.value;
|
|
1560
|
+
this.children[idx]._remove(pred.key, null, true);
|
|
1561
|
+
} else if (this.children[idx + 1] && this.children[idx + 1].n >= t) {
|
|
1562
|
+
// Right child has enough keys: replace with successor
|
|
1563
|
+
const succ = this._getSuccessor(idx);
|
|
1564
|
+
this.keys[idx] = succ.key;
|
|
1565
|
+
this.values[idx] = succ.value;
|
|
1566
|
+
this.children[idx + 1]._remove(succ.key, null, true);
|
|
1567
|
+
} else {
|
|
1568
|
+
// Both children at minimum: merge them, then delete from merged child
|
|
1569
|
+
this._merge(idx);
|
|
1570
|
+
this.children[idx]._remove(key, null, true);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Core removal engine.
|
|
1575
|
+
// removeEntire=false: remove one value from the Set; delete key entry if Set empties
|
|
1576
|
+
// removeEntire=true: delete the entire key entry regardless of Set contents
|
|
1577
|
+
// Returns true if a key entry was fully removed, false otherwise
|
|
1578
|
+
_remove(key, value, removeEntire) {
|
|
1335
1579
|
let i = 0;
|
|
1336
1580
|
while (i < this.n && key > this.keys[i]) {
|
|
1337
1581
|
i++;
|
|
1338
1582
|
}
|
|
1339
1583
|
|
|
1340
1584
|
if (i < this.n && key === this.keys[i]) {
|
|
1341
|
-
|
|
1585
|
+
// Key found at this node
|
|
1586
|
+
let shouldRemoveEntry = removeEntire;
|
|
1587
|
+
|
|
1588
|
+
if (!shouldRemoveEntry && this.values[i]) {
|
|
1342
1589
|
this.values[i].delete(value);
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
this.
|
|
1590
|
+
shouldRemoveEntry = this.values[i].size === 0;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (shouldRemoveEntry) {
|
|
1594
|
+
if (this.leaf) {
|
|
1595
|
+
this._removeFromLeaf(i);
|
|
1596
|
+
} else {
|
|
1597
|
+
this._removeFromInternal(i);
|
|
1349
1598
|
}
|
|
1599
|
+
return true;
|
|
1600
|
+
}
|
|
1601
|
+
return false;
|
|
1602
|
+
} else {
|
|
1603
|
+
// Key not found at this level — descend
|
|
1604
|
+
if (this.leaf) return false;
|
|
1605
|
+
|
|
1606
|
+
const isLastChild = (i === this.n);
|
|
1607
|
+
|
|
1608
|
+
// Ensure the child we descend into has enough keys for safe deletion
|
|
1609
|
+
if (this.children[i] && this.children[i].n < this.order) {
|
|
1610
|
+
this._fill(i);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// After _fill, if the last child was merged, idx shifted
|
|
1614
|
+
if (isLastChild && i > this.n) {
|
|
1615
|
+
return this.children[i - 1]
|
|
1616
|
+
? this.children[i - 1]._remove(key, value, removeEntire)
|
|
1617
|
+
: false;
|
|
1618
|
+
} else {
|
|
1619
|
+
return this.children[i]
|
|
1620
|
+
? this.children[i]._remove(key, value, removeEntire)
|
|
1621
|
+
: false;
|
|
1350
1622
|
}
|
|
1351
|
-
} else if (!this.leaf && this.children[i]) {
|
|
1352
|
-
this.children[i].remove(key, value);
|
|
1353
1623
|
}
|
|
1354
1624
|
}
|
|
1355
1625
|
|
|
1626
|
+
// Public remove: remove a single (key, value) pair
|
|
1627
|
+
remove(key, value) {
|
|
1628
|
+
return this._remove(key, value, false);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Public removeKey: remove an entire key entry (used internally for predecessor/successor cleanup)
|
|
1632
|
+
removeKey(key) {
|
|
1633
|
+
return this._remove(key, null, true);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1356
1636
|
verify() {
|
|
1357
1637
|
const issues = [];
|
|
1358
1638
|
for (let i = 1; i < this.n; i++) {
|
|
@@ -1386,6 +1666,14 @@ class BTreeIndex {
|
|
|
1386
1666
|
this.verify();
|
|
1387
1667
|
}
|
|
1388
1668
|
|
|
1669
|
+
// Check for exact duplicate (key, value) to keep _size accurate
|
|
1670
|
+
if (this._root) {
|
|
1671
|
+
const existing = this._root.search(key);
|
|
1672
|
+
if (existing && existing.has(value)) {
|
|
1673
|
+
return; // Already present, no-op
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1389
1677
|
if (!this._root) {
|
|
1390
1678
|
this._root = new BTreeNode(this._order, true);
|
|
1391
1679
|
this._root.keys[0] = key;
|
|
@@ -1397,9 +1685,15 @@ class BTreeIndex {
|
|
|
1397
1685
|
s.children[0] = this._root;
|
|
1398
1686
|
s.splitChild(0, this._root);
|
|
1399
1687
|
|
|
1688
|
+
// FIX: Check if promoted median equals key
|
|
1400
1689
|
let i = 0;
|
|
1401
|
-
if (s.keys[0]
|
|
1402
|
-
|
|
1690
|
+
if (s.keys[0] === key) {
|
|
1691
|
+
if (!s.values[0]) s.values[0] = new Set();
|
|
1692
|
+
s.values[0].add(value);
|
|
1693
|
+
} else {
|
|
1694
|
+
if (s.keys[0] < key) i++;
|
|
1695
|
+
s.children[i].insertNonFull(key, value);
|
|
1696
|
+
}
|
|
1403
1697
|
|
|
1404
1698
|
this._root = s;
|
|
1405
1699
|
} else {
|
|
@@ -1420,24 +1714,24 @@ class BTreeIndex {
|
|
|
1420
1714
|
return this._root.search(key) !== null;
|
|
1421
1715
|
}
|
|
1422
1716
|
|
|
1423
|
-
range(min, max) {
|
|
1717
|
+
range(min, max, excludeMin = false, excludeMax = false) {
|
|
1424
1718
|
if (!this._root) return [];
|
|
1425
1719
|
const results = [];
|
|
1426
|
-
this._root.rangeSearch(min, max, results);
|
|
1720
|
+
this._root.rangeSearch(min, max, results, excludeMin, excludeMax);
|
|
1427
1721
|
return results;
|
|
1428
1722
|
}
|
|
1429
1723
|
|
|
1430
|
-
rangeFrom(min) {
|
|
1724
|
+
rangeFrom(min, excludeMin = false) {
|
|
1431
1725
|
if (!this._root) return [];
|
|
1432
1726
|
const results = [];
|
|
1433
|
-
this._root.rangeSearch(min,
|
|
1727
|
+
this._root.rangeSearch(min, null, results, excludeMin, false);
|
|
1434
1728
|
return results;
|
|
1435
1729
|
}
|
|
1436
1730
|
|
|
1437
|
-
rangeTo(max) {
|
|
1731
|
+
rangeTo(max, excludeMax = false) {
|
|
1438
1732
|
if (!this._root) return [];
|
|
1439
1733
|
const results = [];
|
|
1440
|
-
this._root.rangeSearch(
|
|
1734
|
+
this._root.rangeSearch(null, max, results, false, excludeMax);
|
|
1441
1735
|
return results;
|
|
1442
1736
|
}
|
|
1443
1737
|
|
|
@@ -1446,6 +1740,7 @@ class BTreeIndex {
|
|
|
1446
1740
|
const existing = this._root.search(key);
|
|
1447
1741
|
if (existing && existing.has(value)) {
|
|
1448
1742
|
this._root.remove(key, value);
|
|
1743
|
+
// Shrink root if it became empty (all keys merged down)
|
|
1449
1744
|
if (this._root.n === 0 && !this._root.leaf && this._root.children[0]) {
|
|
1450
1745
|
this._root = this._root.children[0];
|
|
1451
1746
|
}
|
|
@@ -1458,15 +1753,22 @@ class BTreeIndex {
|
|
|
1458
1753
|
if (!this._root) return { healthy: true, issues: [] };
|
|
1459
1754
|
const issues = this._root.verify();
|
|
1460
1755
|
if (issues.length > 0) {
|
|
1461
|
-
|
|
1756
|
+
// NOTE: verify detects structural violations but cannot auto-repair.
|
|
1757
|
+
// A full rebuild is required to fix a corrupted index.
|
|
1758
|
+
console.warn('BTree index issues detected (rebuild required):', issues);
|
|
1462
1759
|
}
|
|
1463
1760
|
return {
|
|
1464
1761
|
healthy: issues.length === 0,
|
|
1465
1762
|
issues,
|
|
1466
|
-
|
|
1763
|
+
requiresRebuild: issues.length > 0
|
|
1467
1764
|
};
|
|
1468
1765
|
}
|
|
1469
1766
|
|
|
1767
|
+
clear() {
|
|
1768
|
+
this._root = null;
|
|
1769
|
+
this._size = 0;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1470
1772
|
get size() {
|
|
1471
1773
|
return this._size;
|
|
1472
1774
|
}
|
|
@@ -1650,6 +1952,8 @@ class GeoIndex {
|
|
|
1650
1952
|
class IndexManager {
|
|
1651
1953
|
constructor(collection) {
|
|
1652
1954
|
this._collection = collection;
|
|
1955
|
+
this._serializer = collection.database._serializer;
|
|
1956
|
+
this._base64 = collection.database._base64;
|
|
1653
1957
|
this._indexes = new Map();
|
|
1654
1958
|
this._indexData = new Map();
|
|
1655
1959
|
this._indexQueue = [];
|
|
@@ -1720,7 +2024,7 @@ class IndexManager {
|
|
|
1720
2024
|
const d = new Document(docData, {
|
|
1721
2025
|
compressed: docData._compressed,
|
|
1722
2026
|
encrypted: docData._encrypted
|
|
1723
|
-
});
|
|
2027
|
+
}, this._serializer);
|
|
1724
2028
|
// This await is what killed the transaction before
|
|
1725
2029
|
await d.unpack(this._collection.database.encryption);
|
|
1726
2030
|
doc = d.objectOutput();
|
|
@@ -1787,7 +2091,7 @@ class IndexManager {
|
|
|
1787
2091
|
async _hashVal(val) {
|
|
1788
2092
|
const msg = new TextEncoder().encode(String(val));
|
|
1789
2093
|
const hash = await crypto.subtle.digest('SHA-256', msg);
|
|
1790
|
-
return
|
|
2094
|
+
return this._base64.encode(new Uint8Array(hash));
|
|
1791
2095
|
}
|
|
1792
2096
|
|
|
1793
2097
|
async updateIndexForDocument(docId, oldDoc, newDoc) {
|
|
@@ -1879,25 +2183,20 @@ class IndexManager {
|
|
|
1879
2183
|
docs.forEach(doc => results.add(doc));
|
|
1880
2184
|
}
|
|
1881
2185
|
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
const
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
const docs = indexData.
|
|
2186
|
+
// Combined range queries: pick the tightest bounds and correct exclusivity
|
|
2187
|
+
const hasGte = options.$gte !== undefined;
|
|
2188
|
+
const hasGt = options.$gt !== undefined;
|
|
2189
|
+
const hasLte = options.$lte !== undefined;
|
|
2190
|
+
const hasLt = options.$lt !== undefined;
|
|
2191
|
+
|
|
2192
|
+
if (hasGte || hasGt || hasLte || hasLt) {
|
|
2193
|
+
const min = hasGte ? options.$gte : (hasGt ? options.$gt : null);
|
|
2194
|
+
const max = hasLte ? options.$lte : (hasLt ? options.$lt : null);
|
|
2195
|
+
const excludeMin = !hasGte && hasGt;
|
|
2196
|
+
const excludeMax = !hasLte && hasLt;
|
|
2197
|
+
|
|
2198
|
+
const docs = indexData.range(min, max, excludeMin, excludeMax);
|
|
1895
2199
|
docs.forEach(doc => results.add(doc));
|
|
1896
|
-
} else if (options.$lt !== undefined) {
|
|
1897
|
-
const docs = indexData.rangeTo(options.$lt);
|
|
1898
|
-
docs.forEach(doc => {
|
|
1899
|
-
if (doc !== options.$lt) results.add(doc);
|
|
1900
|
-
});
|
|
1901
2200
|
}
|
|
1902
2201
|
|
|
1903
2202
|
return Array.from(results);
|
|
@@ -1971,8 +2270,8 @@ class IndexManager {
|
|
|
1971
2270
|
...index
|
|
1972
2271
|
}))
|
|
1973
2272
|
};
|
|
1974
|
-
const serialized =
|
|
1975
|
-
const encoded =
|
|
2273
|
+
const serialized = this._serializer.serialize(metadata);
|
|
2274
|
+
const encoded = this._base64.encode(serialized);
|
|
1976
2275
|
localStorage.setItem(key, encoded);
|
|
1977
2276
|
resolve();
|
|
1978
2277
|
};
|
|
@@ -1991,8 +2290,8 @@ class IndexManager {
|
|
|
1991
2290
|
if (!stored) return;
|
|
1992
2291
|
|
|
1993
2292
|
try {
|
|
1994
|
-
const decoded =
|
|
1995
|
-
const metadata =
|
|
2293
|
+
const decoded = this._base64.decode(stored);
|
|
2294
|
+
const metadata = this._serializer.deserialize(decoded);
|
|
1996
2295
|
|
|
1997
2296
|
for (const indexDef of metadata.indexes) {
|
|
1998
2297
|
const { name, ...index } = indexDef;
|
|
@@ -2035,7 +2334,13 @@ class IndexManager {
|
|
|
2035
2334
|
report[name] = { status: 'missing', rebuilt: true };
|
|
2036
2335
|
await this.rebuildIndex(name);
|
|
2037
2336
|
} else if (indexData.verify) {
|
|
2038
|
-
|
|
2337
|
+
const result = indexData.verify();
|
|
2338
|
+
if (result.requiresRebuild) {
|
|
2339
|
+
// BTree detected structural issues — rebuild the index from source data
|
|
2340
|
+
await this.rebuildIndex(name);
|
|
2341
|
+
result.rebuilt = true;
|
|
2342
|
+
}
|
|
2343
|
+
report[name] = result;
|
|
2039
2344
|
} else {
|
|
2040
2345
|
report[name] = { status: 'ok' };
|
|
2041
2346
|
}
|
|
@@ -2222,6 +2527,7 @@ class IndexedDBUtility {
|
|
|
2222
2527
|
const request = requestFactory();
|
|
2223
2528
|
request.onsuccess = () => resolve(request.result);
|
|
2224
2529
|
request.onerror = () => reject(request.error);
|
|
2530
|
+
request.onabort = () => reject(new DOMException('Request aborted', 'AbortError'));
|
|
2225
2531
|
});
|
|
2226
2532
|
}
|
|
2227
2533
|
|
|
@@ -2318,7 +2624,7 @@ class IndexedDBUtility {
|
|
|
2318
2624
|
// ========================
|
|
2319
2625
|
|
|
2320
2626
|
class Document {
|
|
2321
|
-
constructor(data = {}, options = {}) {
|
|
2627
|
+
constructor(data = {}, options = {}, serializer) {
|
|
2322
2628
|
this._id = data._id || this._generateId();
|
|
2323
2629
|
this._created = data._created || Date.now();
|
|
2324
2630
|
this._modified = data._modified || Date.now();
|
|
@@ -2329,6 +2635,7 @@ class Document {
|
|
|
2329
2635
|
this._data = null;
|
|
2330
2636
|
this._packedData = data.packedData || null;
|
|
2331
2637
|
this._compression = new BrowserCompressionUtility();
|
|
2638
|
+
this._serializer = serializer;
|
|
2332
2639
|
|
|
2333
2640
|
if (data.data) {
|
|
2334
2641
|
this.data = data.data;
|
|
@@ -2349,7 +2656,7 @@ class Document {
|
|
|
2349
2656
|
|
|
2350
2657
|
async pack(encryptionUtil = null) {
|
|
2351
2658
|
try {
|
|
2352
|
-
let packed =
|
|
2659
|
+
let packed = this._serializer.serialize(this.data);
|
|
2353
2660
|
if (this._compressed) {
|
|
2354
2661
|
packed = await this._compression.compress(packed);
|
|
2355
2662
|
}
|
|
@@ -2378,7 +2685,7 @@ class Document {
|
|
|
2378
2685
|
throw new Error('Empty unpacked data');
|
|
2379
2686
|
}
|
|
2380
2687
|
|
|
2381
|
-
this.data =
|
|
2688
|
+
this.data = this._serializer.deserialize(unpacked);
|
|
2382
2689
|
|
|
2383
2690
|
if (typeof this.data !== 'object' || this.data === null) {
|
|
2384
2691
|
throw new Error('Invalid deserialized data');
|
|
@@ -2393,7 +2700,7 @@ class Document {
|
|
|
2393
2700
|
}
|
|
2394
2701
|
|
|
2395
2702
|
packSync() {
|
|
2396
|
-
let packed =
|
|
2703
|
+
let packed = this._serializer.serialize(this.data);
|
|
2397
2704
|
if (this._compressed) {
|
|
2398
2705
|
packed = this._compression.compressSync(packed);
|
|
2399
2706
|
}
|
|
@@ -2409,7 +2716,7 @@ class Document {
|
|
|
2409
2716
|
if (this._compressed) {
|
|
2410
2717
|
unpacked = this._compression.decompressSync(unpacked);
|
|
2411
2718
|
}
|
|
2412
|
-
this.data =
|
|
2719
|
+
this.data = this._serializer.deserialize(unpacked);
|
|
2413
2720
|
return this.data;
|
|
2414
2721
|
}
|
|
2415
2722
|
|
|
@@ -2489,27 +2796,29 @@ class CollectionMetadata {
|
|
|
2489
2796
|
}
|
|
2490
2797
|
|
|
2491
2798
|
class DatabaseMetadata {
|
|
2492
|
-
constructor(name, data = {}) {
|
|
2799
|
+
constructor(name, data = {}, serializer, base64) {
|
|
2493
2800
|
this.name = name;
|
|
2801
|
+
this._serializer = serializer;
|
|
2802
|
+
this._base64 = base64;
|
|
2494
2803
|
this.collections = data.collections || {};
|
|
2495
2804
|
this.totalSizeKB = data.totalSizeKB || 0;
|
|
2496
2805
|
this.totalLength = data.totalLength || 0;
|
|
2497
2806
|
this.modifiedAt = data.modifiedAt || Date.now();
|
|
2498
2807
|
}
|
|
2499
2808
|
|
|
2500
|
-
static load(dbName) {
|
|
2809
|
+
static load(dbName, serializer, base64) {
|
|
2501
2810
|
const key = `lacertadb_${dbName}_metadata`;
|
|
2502
2811
|
const stored = localStorage.getItem(key);
|
|
2503
2812
|
if (stored) {
|
|
2504
2813
|
try {
|
|
2505
2814
|
const decoded = base64.decode(stored);
|
|
2506
2815
|
const data = serializer.deserialize(decoded);
|
|
2507
|
-
return new DatabaseMetadata(dbName, data);
|
|
2816
|
+
return new DatabaseMetadata(dbName, data, serializer, base64);
|
|
2508
2817
|
} catch (e) {
|
|
2509
2818
|
console.error('Failed to load metadata:', e);
|
|
2510
2819
|
}
|
|
2511
2820
|
}
|
|
2512
|
-
return new DatabaseMetadata(dbName);
|
|
2821
|
+
return new DatabaseMetadata(dbName, {}, serializer, base64);
|
|
2513
2822
|
}
|
|
2514
2823
|
|
|
2515
2824
|
save() {
|
|
@@ -2521,11 +2830,18 @@ class DatabaseMetadata {
|
|
|
2521
2830
|
totalLength: this.totalLength,
|
|
2522
2831
|
modifiedAt: this.modifiedAt
|
|
2523
2832
|
};
|
|
2524
|
-
const serializedData =
|
|
2525
|
-
const encodedData =
|
|
2833
|
+
const serializedData = this._serializer.serialize(dataToStore);
|
|
2834
|
+
const encodedData = this._base64.encode(serializedData);
|
|
2526
2835
|
localStorage.setItem(key, encodedData);
|
|
2527
2836
|
} catch (e) {
|
|
2528
|
-
|
|
2837
|
+
if (e.name === 'QuotaExceededError') {
|
|
2838
|
+
console.error('CRITICAL: LocalStorage quota exceeded. Metadata not saved for db:', this.name);
|
|
2839
|
+
if (typeof window !== 'undefined') {
|
|
2840
|
+
window.dispatchEvent(new CustomEvent('lacertadb:quotaexceeded', { detail: { db: this.name } }));
|
|
2841
|
+
}
|
|
2842
|
+
} else {
|
|
2843
|
+
console.error('Failed to save metadata:', e);
|
|
2844
|
+
}
|
|
2529
2845
|
}
|
|
2530
2846
|
}
|
|
2531
2847
|
|
|
@@ -2559,39 +2875,52 @@ class DatabaseMetadata {
|
|
|
2559
2875
|
}
|
|
2560
2876
|
|
|
2561
2877
|
class Settings {
|
|
2562
|
-
constructor(dbName, data = {}) {
|
|
2878
|
+
constructor(dbName, data = {}, serializer, base64) {
|
|
2563
2879
|
this.dbName = dbName;
|
|
2880
|
+
this._serializer = serializer;
|
|
2881
|
+
this._base64 = base64;
|
|
2564
2882
|
this.sizeLimitKB = data.sizeLimitKB != null ? data.sizeLimitKB : Infinity;
|
|
2565
2883
|
const defaultBuffer = this.sizeLimitKB === Infinity ? Infinity : this.sizeLimitKB * 0.8;
|
|
2566
2884
|
this.bufferLimitKB = data.bufferLimitKB != null ? data.bufferLimitKB : defaultBuffer;
|
|
2567
2885
|
this.freeSpaceEvery = this.sizeLimitKB === Infinity ? 0 : (data.freeSpaceEvery || 10000);
|
|
2568
2886
|
}
|
|
2569
2887
|
|
|
2570
|
-
static load(dbName) {
|
|
2888
|
+
static load(dbName, serializer, base64) {
|
|
2571
2889
|
const key = `lacertadb_${dbName}_settings`;
|
|
2572
2890
|
const stored = localStorage.getItem(key);
|
|
2573
2891
|
if (stored) {
|
|
2574
2892
|
try {
|
|
2575
2893
|
const decoded = base64.decode(stored);
|
|
2576
2894
|
const data = serializer.deserialize(decoded);
|
|
2577
|
-
return new Settings(dbName, data);
|
|
2895
|
+
return new Settings(dbName, data, serializer, base64);
|
|
2578
2896
|
} catch (e) {
|
|
2579
2897
|
console.error('Failed to load settings:', e);
|
|
2580
2898
|
}
|
|
2581
2899
|
}
|
|
2582
|
-
return new Settings(dbName);
|
|
2900
|
+
return new Settings(dbName, {}, serializer, base64);
|
|
2583
2901
|
}
|
|
2584
2902
|
|
|
2585
2903
|
save() {
|
|
2586
2904
|
const key = `lacertadb_${this.dbName}_settings`;
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2905
|
+
try {
|
|
2906
|
+
const dataToStore = {
|
|
2907
|
+
sizeLimitKB: this.sizeLimitKB,
|
|
2908
|
+
bufferLimitKB: this.bufferLimitKB,
|
|
2909
|
+
freeSpaceEvery: this.freeSpaceEvery
|
|
2910
|
+
};
|
|
2911
|
+
const serializedData = this._serializer.serialize(dataToStore);
|
|
2912
|
+
const encodedData = this._base64.encode(serializedData);
|
|
2913
|
+
localStorage.setItem(key, encodedData);
|
|
2914
|
+
} catch (e) {
|
|
2915
|
+
if (e.name === 'QuotaExceededError') {
|
|
2916
|
+
console.error('CRITICAL: Settings save failed — localStorage quota exceeded');
|
|
2917
|
+
if (typeof window !== 'undefined') {
|
|
2918
|
+
window.dispatchEvent(new CustomEvent('lacertadb:quotaexceeded', { detail: { source: 'settings', db: this.dbName } }));
|
|
2919
|
+
}
|
|
2920
|
+
} else {
|
|
2921
|
+
console.error('Settings save failed:', e);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2595
2924
|
}
|
|
2596
2925
|
|
|
2597
2926
|
updateSettings(newSettings) {
|
|
@@ -2732,10 +3061,40 @@ class AggregationPipeline {
|
|
|
2732
3061
|
const groups = new Map();
|
|
2733
3062
|
const idField = groupSpec._id;
|
|
2734
3063
|
|
|
3064
|
+
// Helper: recursively sort object keys for stable JSON serialization
|
|
3065
|
+
const stableStringify = (obj) => {
|
|
3066
|
+
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
3067
|
+
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
|
3068
|
+
const sorted = Object.keys(obj).sort();
|
|
3069
|
+
return '{' + sorted.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
|
|
3070
|
+
};
|
|
3071
|
+
|
|
3072
|
+
// Helper: resolve $field references in an _id expression object
|
|
3073
|
+
const resolveIdValue = (doc, idExpr) => {
|
|
3074
|
+
if (typeof idExpr === 'string' && idExpr.startsWith('$')) {
|
|
3075
|
+
return queryEngine.getFieldValue(doc, idExpr.substring(1));
|
|
3076
|
+
}
|
|
3077
|
+
if (idExpr !== null && typeof idExpr === 'object' && !Array.isArray(idExpr)) {
|
|
3078
|
+
const resolved = {};
|
|
3079
|
+
for (const key in idExpr) {
|
|
3080
|
+
resolved[key] = resolveIdValue(doc, idExpr[key]);
|
|
3081
|
+
}
|
|
3082
|
+
return resolved;
|
|
3083
|
+
}
|
|
3084
|
+
return idExpr;
|
|
3085
|
+
};
|
|
3086
|
+
|
|
2735
3087
|
for (const doc of docs) {
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
3088
|
+
let groupKey;
|
|
3089
|
+
if (typeof idField === 'string') {
|
|
3090
|
+
groupKey = idField.startsWith('$')
|
|
3091
|
+
? queryEngine.getFieldValue(doc, idField.substring(1))
|
|
3092
|
+
: idField;
|
|
3093
|
+
} else if (idField !== null && typeof idField === 'object') {
|
|
3094
|
+
groupKey = stableStringify(resolveIdValue(doc, idField));
|
|
3095
|
+
} else {
|
|
3096
|
+
groupKey = idField; // null or literal
|
|
3097
|
+
}
|
|
2739
3098
|
|
|
2740
3099
|
if (!groups.has(groupKey)) {
|
|
2741
3100
|
groups.set(groupKey, { _id: groupKey, docs: [] });
|
|
@@ -2999,6 +3358,8 @@ class Collection {
|
|
|
2999
3358
|
constructor(name, database) {
|
|
3000
3359
|
this.name = name;
|
|
3001
3360
|
this.database = database;
|
|
3361
|
+
this._serializer = database._serializer;
|
|
3362
|
+
this._base64 = database._base64;
|
|
3002
3363
|
this._db = null;
|
|
3003
3364
|
this._metadata = null;
|
|
3004
3365
|
this._settings = database.settings;
|
|
@@ -3091,7 +3452,7 @@ class Collection {
|
|
|
3091
3452
|
const doc = new Document({data: documentData, _id: options.id}, {
|
|
3092
3453
|
compressed: options.compressed !== false,
|
|
3093
3454
|
permanent: options.permanent || false
|
|
3094
|
-
});
|
|
3455
|
+
}, this._serializer);
|
|
3095
3456
|
|
|
3096
3457
|
const attachments = options.attachments;
|
|
3097
3458
|
if (attachments && attachments.length > 0) {
|
|
@@ -3133,7 +3494,7 @@ class Collection {
|
|
|
3133
3494
|
const doc = new Document(stored, {
|
|
3134
3495
|
encrypted: stored._encrypted,
|
|
3135
3496
|
compressed: stored._compressed
|
|
3136
|
-
});
|
|
3497
|
+
}, this._serializer);
|
|
3137
3498
|
|
|
3138
3499
|
if (stored.packedData) {
|
|
3139
3500
|
await doc.unpack(this.database.encryption);
|
|
@@ -3156,7 +3517,7 @@ class Collection {
|
|
|
3156
3517
|
const doc = new Document(docData, {
|
|
3157
3518
|
encrypted: docData._encrypted,
|
|
3158
3519
|
compressed: docData._compressed
|
|
3159
|
-
});
|
|
3520
|
+
}, this._serializer);
|
|
3160
3521
|
if (docData.packedData) {
|
|
3161
3522
|
await doc.unpack(this.database.encryption);
|
|
3162
3523
|
}
|
|
@@ -3178,7 +3539,7 @@ class Collection {
|
|
|
3178
3539
|
throw new LacertaDBError(`Document with id '${docId}' not found for update.`, 'DOCUMENT_NOT_FOUND');
|
|
3179
3540
|
}
|
|
3180
3541
|
|
|
3181
|
-
const existingDoc = new Document(stored);
|
|
3542
|
+
const existingDoc = new Document(stored, {}, this._serializer);
|
|
3182
3543
|
if (stored.packedData) await existingDoc.unpack(this.database.encryption);
|
|
3183
3544
|
|
|
3184
3545
|
const oldDocOutput = existingDoc.objectOutput();
|
|
@@ -3191,7 +3552,7 @@ class Collection {
|
|
|
3191
3552
|
}, {
|
|
3192
3553
|
compressed: options.compressed !== undefined ? options.compressed : stored._compressed,
|
|
3193
3554
|
permanent: options.permanent !== undefined ? options.permanent : stored._permanent
|
|
3194
|
-
});
|
|
3555
|
+
}, this._serializer);
|
|
3195
3556
|
doc._modified = Date.now();
|
|
3196
3557
|
|
|
3197
3558
|
const attachments = options.attachments;
|
|
@@ -3266,7 +3627,7 @@ class Collection {
|
|
|
3266
3627
|
|
|
3267
3628
|
const startTime = performance.now();
|
|
3268
3629
|
|
|
3269
|
-
const cacheKey =
|
|
3630
|
+
const cacheKey = this._base64.encode(this._serializer.serialize({filter, options}));
|
|
3270
3631
|
const cached = this._cacheStrategy.get(cacheKey);
|
|
3271
3632
|
|
|
3272
3633
|
if (cached) {
|
|
@@ -3336,7 +3697,7 @@ class Collection {
|
|
|
3336
3697
|
const doc = new Document({data: documentData}, {
|
|
3337
3698
|
compressed: options.compressed !== false,
|
|
3338
3699
|
permanent: options.permanent || false
|
|
3339
|
-
});
|
|
3700
|
+
}, this._serializer);
|
|
3340
3701
|
|
|
3341
3702
|
await doc.pack(this.database.encryption);
|
|
3342
3703
|
operations.push({
|
|
@@ -3370,31 +3731,145 @@ class Collection {
|
|
|
3370
3731
|
}));
|
|
3371
3732
|
}
|
|
3372
3733
|
|
|
3373
|
-
async batchUpdate(updates, options) {
|
|
3734
|
+
async batchUpdate(updates, options = {}) {
|
|
3374
3735
|
if (!this._initialized) await this.init();
|
|
3375
3736
|
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3737
|
+
const startTime = performance.now();
|
|
3738
|
+
const operations = [];
|
|
3739
|
+
const oldDocs = [];
|
|
3740
|
+
const newDocs = [];
|
|
3741
|
+
const skipped = [];
|
|
3742
|
+
|
|
3743
|
+
// Phase 1: Read all existing docs and prepare put operations
|
|
3744
|
+
for (const update of updates) {
|
|
3745
|
+
const stored = await this._indexedDB.get(this._db, 'documents', update.id);
|
|
3746
|
+
if (!stored) {
|
|
3747
|
+
skipped.push({ success: false, id: update.id, error: 'Document not found' });
|
|
3748
|
+
continue;
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
const existingDoc = new Document(stored, {}, this._serializer);
|
|
3752
|
+
if (stored.packedData) await existingDoc.unpack(this.database.encryption);
|
|
3753
|
+
|
|
3754
|
+
oldDocs.push(existingDoc.objectOutput());
|
|
3755
|
+
|
|
3756
|
+
const updatedData = { ...existingDoc.data, ...update.data };
|
|
3757
|
+
const doc = new Document({
|
|
3758
|
+
_id: update.id,
|
|
3759
|
+
_created: stored._created,
|
|
3760
|
+
data: updatedData
|
|
3761
|
+
}, {
|
|
3762
|
+
compressed: options.compressed !== undefined ? options.compressed : stored._compressed,
|
|
3763
|
+
permanent: options.permanent !== undefined ? options.permanent : stored._permanent
|
|
3764
|
+
}, this._serializer);
|
|
3765
|
+
doc._modified = Date.now();
|
|
3766
|
+
doc._attachments = stored._attachments;
|
|
3767
|
+
|
|
3768
|
+
await doc.pack(this.database.encryption);
|
|
3769
|
+
newDocs.push(doc);
|
|
3770
|
+
|
|
3771
|
+
operations.push({
|
|
3772
|
+
type: 'put',
|
|
3773
|
+
data: doc.databaseOutput()
|
|
3774
|
+
});
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
if (operations.length === 0) return skipped;
|
|
3778
|
+
|
|
3779
|
+
// Phase 2: Single-transaction write
|
|
3780
|
+
const dbResults = await this._indexedDB.batchOperation(this._db, operations);
|
|
3781
|
+
|
|
3782
|
+
// Phase 3: Update indexes and metadata post-transaction
|
|
3783
|
+
for (let i = 0; i < newDocs.length; i++) {
|
|
3784
|
+
if (dbResults[i].success) {
|
|
3785
|
+
const doc = newDocs[i];
|
|
3786
|
+
await this._indexManager.updateIndexForDocument(doc._id, oldDocs[i], doc.objectOutput());
|
|
3787
|
+
|
|
3788
|
+
const sizeKB = doc._packedData.byteLength / 1024;
|
|
3789
|
+
this._metadata.updateDocument(doc._id, sizeKB, doc._permanent, doc._attachments.length);
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
this.database.metadata.setCollection(this._metadata);
|
|
3794
|
+
this._cacheStrategy.clear();
|
|
3795
|
+
|
|
3796
|
+
if (this._performanceMonitor) {
|
|
3797
|
+
this._performanceMonitor.recordOperation('batchUpdate', performance.now() - startTime);
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
return [
|
|
3801
|
+
...dbResults.map((r, i) => ({ ...r, id: newDocs[i]._id })),
|
|
3802
|
+
...skipped
|
|
3803
|
+
];
|
|
3381
3804
|
}
|
|
3382
3805
|
|
|
3383
3806
|
async batchDelete(items) {
|
|
3384
3807
|
if (!this._initialized) await this.init();
|
|
3385
3808
|
|
|
3809
|
+
const startTime = performance.now();
|
|
3386
3810
|
const normalizedItems = items.map(item => {
|
|
3387
3811
|
if (typeof item === 'string') {
|
|
3388
|
-
return {id: item, options: {}};
|
|
3812
|
+
return { id: item, options: {} };
|
|
3389
3813
|
}
|
|
3390
|
-
return {id: item.id, options: item.options || {}};
|
|
3814
|
+
return { id: item.id, options: item.options || {} };
|
|
3391
3815
|
});
|
|
3392
3816
|
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3817
|
+
const operations = [];
|
|
3818
|
+
const docsToRemove = [];
|
|
3819
|
+
const skipped = [];
|
|
3820
|
+
|
|
3821
|
+
// Phase 1: Validate all documents and prepare delete operations
|
|
3822
|
+
for (const { id, options } of normalizedItems) {
|
|
3823
|
+
const doc = await this._indexedDB.get(this._db, 'documents', id);
|
|
3824
|
+
if (!doc) {
|
|
3825
|
+
skipped.push({ success: false, id, error: 'Document not found' });
|
|
3826
|
+
continue;
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
if (doc._permanent && !options.force) {
|
|
3830
|
+
skipped.push({ success: false, id, error: 'Cannot delete permanent document without force flag' });
|
|
3831
|
+
continue;
|
|
3832
|
+
}
|
|
3833
|
+
|
|
3834
|
+
const fullDoc = await this.get(id);
|
|
3835
|
+
docsToRemove.push({ id, fullDoc, stored: doc });
|
|
3836
|
+
|
|
3837
|
+
operations.push({
|
|
3838
|
+
type: 'delete',
|
|
3839
|
+
key: id
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
if (operations.length === 0) return skipped;
|
|
3844
|
+
|
|
3845
|
+
// Phase 2: Single-transaction delete
|
|
3846
|
+
const dbResults = await this._indexedDB.batchOperation(this._db, operations);
|
|
3847
|
+
|
|
3848
|
+
// Phase 3: Update indexes, OPFS cleanup, and metadata post-transaction
|
|
3849
|
+
for (let i = 0; i < docsToRemove.length; i++) {
|
|
3850
|
+
if (dbResults[i].success) {
|
|
3851
|
+
const { id, fullDoc, stored } = docsToRemove[i];
|
|
3852
|
+
await this._indexManager.updateIndexForDocument(id, fullDoc, null);
|
|
3853
|
+
|
|
3854
|
+
if (stored._attachments && stored._attachments.length > 0) {
|
|
3855
|
+
await this._opfs.deleteAttachments(this.database.name, this.name, id);
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
this._metadata.removeDocument(id);
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
this.database.metadata.setCollection(this._metadata);
|
|
3863
|
+
this._cacheStrategy.clear();
|
|
3864
|
+
|
|
3865
|
+
if (this._performanceMonitor) {
|
|
3866
|
+
this._performanceMonitor.recordOperation('batchDelete', performance.now() - startTime);
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
return [
|
|
3870
|
+
...dbResults.map((r, i) => ({ ...r, id: docsToRemove[i].id })),
|
|
3871
|
+
...skipped
|
|
3872
|
+
];
|
|
3398
3873
|
}
|
|
3399
3874
|
|
|
3400
3875
|
async _checkSpaceLimit() {
|
|
@@ -3498,13 +3973,15 @@ class Collection {
|
|
|
3498
3973
|
// ========================
|
|
3499
3974
|
|
|
3500
3975
|
class Database {
|
|
3501
|
-
constructor(name, performanceMonitor) {
|
|
3976
|
+
constructor(name, performanceMonitor, serializer, base64) {
|
|
3502
3977
|
this.name = name;
|
|
3503
3978
|
this._collections = new Map();
|
|
3504
3979
|
this._metadata = null;
|
|
3505
3980
|
this._settings = null;
|
|
3506
3981
|
this._quickStore = null;
|
|
3507
3982
|
this._performanceMonitor = performanceMonitor;
|
|
3983
|
+
this._serializer = serializer;
|
|
3984
|
+
this._base64 = base64;
|
|
3508
3985
|
|
|
3509
3986
|
// Database-level encryption
|
|
3510
3987
|
this._encryption = null;
|
|
@@ -3539,9 +4016,9 @@ class Database {
|
|
|
3539
4016
|
}
|
|
3540
4017
|
|
|
3541
4018
|
async init(options = {}) {
|
|
3542
|
-
this._metadata = DatabaseMetadata.load(this.name);
|
|
3543
|
-
this._settings = Settings.load(this.name);
|
|
3544
|
-
this._quickStore = new QuickStore(this.name);
|
|
4019
|
+
this._metadata = DatabaseMetadata.load(this.name, this._serializer, this._base64);
|
|
4020
|
+
this._settings = Settings.load(this.name, this._serializer, this._base64);
|
|
4021
|
+
this._quickStore = new QuickStore(this.name, this._serializer, this._base64);
|
|
3545
4022
|
|
|
3546
4023
|
if (options.pin) {
|
|
3547
4024
|
await this._initializeEncryption(options.pin, options.salt, options.encryptionConfig);
|
|
@@ -3556,16 +4033,16 @@ class Database {
|
|
|
3556
4033
|
const stored = localStorage.getItem(encMetaKey);
|
|
3557
4034
|
|
|
3558
4035
|
if (stored) {
|
|
3559
|
-
const decoded =
|
|
3560
|
-
existingMetadata =
|
|
4036
|
+
const decoded = this._base64.decode(stored);
|
|
4037
|
+
existingMetadata = this._serializer.deserialize(decoded);
|
|
3561
4038
|
}
|
|
3562
4039
|
|
|
3563
|
-
this._encryption = new SecureDatabaseEncryption(config);
|
|
4040
|
+
this._encryption = new SecureDatabaseEncryption(config, this._serializer, this._base64);
|
|
3564
4041
|
const newMeta = await this._encryption.initialize(pin, existingMetadata);
|
|
3565
4042
|
|
|
3566
4043
|
if (!existingMetadata) {
|
|
3567
|
-
const serialized =
|
|
3568
|
-
const encoded =
|
|
4044
|
+
const serialized = this._serializer.serialize(newMeta);
|
|
4045
|
+
const encoded = this._base64.encode(serialized);
|
|
3569
4046
|
localStorage.setItem(encMetaKey, encoded);
|
|
3570
4047
|
}
|
|
3571
4048
|
}
|
|
@@ -3578,8 +4055,8 @@ class Database {
|
|
|
3578
4055
|
const newMeta = await this._encryption.changePin(oldPin, newPin);
|
|
3579
4056
|
|
|
3580
4057
|
const encMetaKey = `lacertadb_${this.name}_encryption`;
|
|
3581
|
-
const serialized =
|
|
3582
|
-
const encoded =
|
|
4058
|
+
const serialized = this._serializer.serialize(newMeta);
|
|
4059
|
+
const encoded = this._base64.encode(serialized);
|
|
3583
4060
|
localStorage.setItem(encMetaKey, encoded);
|
|
3584
4061
|
|
|
3585
4062
|
return true;
|
|
@@ -3705,7 +4182,7 @@ class Database {
|
|
|
3705
4182
|
|
|
3706
4183
|
async export(format = 'json', password = null) {
|
|
3707
4184
|
const data = {
|
|
3708
|
-
version: '0.
|
|
4185
|
+
version: '0.10.2',
|
|
3709
4186
|
database: this.name,
|
|
3710
4187
|
timestamp: Date.now(),
|
|
3711
4188
|
collections: {}
|
|
@@ -3717,14 +4194,14 @@ class Database {
|
|
|
3717
4194
|
}
|
|
3718
4195
|
|
|
3719
4196
|
if (format === 'json') {
|
|
3720
|
-
const serialized =
|
|
3721
|
-
return
|
|
4197
|
+
const serialized = this._serializer.serialize(data);
|
|
4198
|
+
return this._base64.encode(serialized);
|
|
3722
4199
|
}
|
|
3723
4200
|
if (format === 'encrypted' && password) {
|
|
3724
4201
|
const encryption = new BrowserEncryptionUtility();
|
|
3725
|
-
const serializedData =
|
|
4202
|
+
const serializedData = this._serializer.serialize(data);
|
|
3726
4203
|
const encrypted = await encryption.encrypt(serializedData, password);
|
|
3727
|
-
return
|
|
4204
|
+
return this._base64.encode(encrypted);
|
|
3728
4205
|
}
|
|
3729
4206
|
throw new LacertaDBError(`Unsupported export format: ${format}`, 'INVALID_FORMAT');
|
|
3730
4207
|
}
|
|
@@ -3732,13 +4209,13 @@ class Database {
|
|
|
3732
4209
|
async import(data, format = 'json', password = null) {
|
|
3733
4210
|
let parsed;
|
|
3734
4211
|
try {
|
|
3735
|
-
const decoded =
|
|
4212
|
+
const decoded = this._base64.decode(data);
|
|
3736
4213
|
if (format === 'encrypted' && password) {
|
|
3737
4214
|
const encryption = new BrowserEncryptionUtility();
|
|
3738
4215
|
const decrypted = await encryption.decrypt(decoded, password);
|
|
3739
|
-
parsed =
|
|
4216
|
+
parsed = this._serializer.deserialize(decrypted);
|
|
3740
4217
|
} else {
|
|
3741
|
-
parsed =
|
|
4218
|
+
parsed = this._serializer.deserialize(decoded);
|
|
3742
4219
|
}
|
|
3743
4220
|
} catch (e) {
|
|
3744
4221
|
throw new LacertaDBError('Failed to parse import data', 'IMPORT_PARSE_FAILED', e);
|
|
@@ -3769,7 +4246,7 @@ class Database {
|
|
|
3769
4246
|
async clearAll() {
|
|
3770
4247
|
await Promise.all([...this._collections.keys()].map(name => this.dropCollection(name)));
|
|
3771
4248
|
this._collections.clear();
|
|
3772
|
-
this._metadata = new DatabaseMetadata(this.name);
|
|
4249
|
+
this._metadata = new DatabaseMetadata(this.name, {}, this._serializer, this._base64);
|
|
3773
4250
|
this._metadata.save();
|
|
3774
4251
|
this._quickStore.clear();
|
|
3775
4252
|
}
|
|
@@ -3807,18 +4284,31 @@ class Database {
|
|
|
3807
4284
|
// ========================
|
|
3808
4285
|
|
|
3809
4286
|
class LacertaDB {
|
|
3810
|
-
constructor() {
|
|
4287
|
+
constructor(config = {}) {
|
|
3811
4288
|
this._databases = new Map();
|
|
3812
4289
|
this._performanceMonitor = new PerformanceMonitor();
|
|
4290
|
+
|
|
4291
|
+
// Instantiate serializer with user-overridable config merged over defaults
|
|
4292
|
+
const serialConfig = { ...TURBO_SERIAL_DEFAULTS, ...(config.turboSerial || {}) };
|
|
4293
|
+
this._serializer = new TurboSerial(serialConfig);
|
|
4294
|
+
this._base64 = new TurboBase64();
|
|
3813
4295
|
}
|
|
3814
4296
|
|
|
3815
4297
|
get performanceMonitor() {
|
|
3816
4298
|
return this._performanceMonitor;
|
|
3817
4299
|
}
|
|
3818
4300
|
|
|
4301
|
+
get serializer() {
|
|
4302
|
+
return this._serializer;
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
get base64() {
|
|
4306
|
+
return this._base64;
|
|
4307
|
+
}
|
|
4308
|
+
|
|
3819
4309
|
async getDatabase(name, options = {}) {
|
|
3820
4310
|
if (!this._databases.has(name)) {
|
|
3821
|
-
const db = new Database(name, this._performanceMonitor);
|
|
4311
|
+
const db = new Database(name, this._performanceMonitor, this._serializer, this._base64);
|
|
3822
4312
|
await db.init(options);
|
|
3823
4313
|
this._databases.set(name, db);
|
|
3824
4314
|
}
|
|
@@ -3842,7 +4332,7 @@ class LacertaDB {
|
|
|
3842
4332
|
});
|
|
3843
4333
|
|
|
3844
4334
|
// Clean up quickstore
|
|
3845
|
-
const quickStore = new QuickStore(name);
|
|
4335
|
+
const quickStore = new QuickStore(name, this._serializer, this._base64);
|
|
3846
4336
|
quickStore.clear();
|
|
3847
4337
|
|
|
3848
4338
|
// Clean up all collections and indexes
|
|
@@ -3872,7 +4362,7 @@ class LacertaDB {
|
|
|
3872
4362
|
|
|
3873
4363
|
async createBackup(password = null) {
|
|
3874
4364
|
const backup = {
|
|
3875
|
-
version: '0.
|
|
4365
|
+
version: '0.10.2',
|
|
3876
4366
|
timestamp: Date.now(),
|
|
3877
4367
|
databases: {}
|
|
3878
4368
|
};
|
|
@@ -3880,29 +4370,29 @@ class LacertaDB {
|
|
|
3880
4370
|
for (const dbName of this.listDatabases()) {
|
|
3881
4371
|
const db = await this.getDatabase(dbName);
|
|
3882
4372
|
const exported = await db.export('json');
|
|
3883
|
-
const decoded =
|
|
3884
|
-
backup.databases[dbName] =
|
|
4373
|
+
const decoded = this._base64.decode(exported);
|
|
4374
|
+
backup.databases[dbName] = this._serializer.deserialize(decoded);
|
|
3885
4375
|
}
|
|
3886
4376
|
|
|
3887
|
-
const serializedBackup =
|
|
4377
|
+
const serializedBackup = this._serializer.serialize(backup);
|
|
3888
4378
|
if (password) {
|
|
3889
4379
|
const encryption = new BrowserEncryptionUtility();
|
|
3890
4380
|
const encrypted = await encryption.encrypt(serializedBackup, password);
|
|
3891
|
-
return
|
|
4381
|
+
return this._base64.encode(encrypted);
|
|
3892
4382
|
}
|
|
3893
|
-
return
|
|
4383
|
+
return this._base64.encode(serializedBackup);
|
|
3894
4384
|
}
|
|
3895
4385
|
|
|
3896
4386
|
async restoreBackup(backupData, password = null) {
|
|
3897
4387
|
let backup;
|
|
3898
4388
|
try {
|
|
3899
|
-
let decodedData =
|
|
4389
|
+
let decodedData = this._base64.decode(backupData);
|
|
3900
4390
|
if (password) {
|
|
3901
4391
|
const encryption = new BrowserEncryptionUtility();
|
|
3902
4392
|
const decrypted = await encryption.decrypt(decodedData, password);
|
|
3903
|
-
backup =
|
|
4393
|
+
backup = this._serializer.deserialize(decrypted);
|
|
3904
4394
|
} else {
|
|
3905
|
-
backup =
|
|
4395
|
+
backup = this._serializer.deserialize(decodedData);
|
|
3906
4396
|
}
|
|
3907
4397
|
} catch (e) {
|
|
3908
4398
|
throw new LacertaDBError('Failed to parse backup data', 'BACKUP_PARSE_FAILED', e);
|
|
@@ -3911,7 +4401,7 @@ class LacertaDB {
|
|
|
3911
4401
|
const results = { databases: 0, collections: 0, documents: 0 };
|
|
3912
4402
|
for (const [dbName, dbData] of Object.entries(backup.databases)) {
|
|
3913
4403
|
const db = await this.getDatabase(dbName);
|
|
3914
|
-
const encodedDbData =
|
|
4404
|
+
const encodedDbData = this._base64.encode(this._serializer.serialize(dbData));
|
|
3915
4405
|
const importResult = await db.import(encodedDbData);
|
|
3916
4406
|
|
|
3917
4407
|
results.databases++;
|