@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/index.js CHANGED
@@ -1,43 +1,7 @@
1
1
  /**
2
- * LacertaDB V0.10.1 - Production Library with QuickStore (Optimized)
3
- * * A high-performance, browser-based document database with support for:
4
- * - IndexedDB storage with connection pooling
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
- const serializer = new TurboSerial({
66
- compression: true, // Enable built-in compression
67
- preservePropertyDescriptors: true, // Preserve property descriptors
68
- deduplication: true, // Deduplicate repeated values
69
- simdOptimization: true, // Use SIMD instructions when available
70
- detectCircular: true, // Handle circular references
71
- shareArrayBuffers: true, // Share ArrayBuffer references
72
- allowFunction: false, // Allow function storage/retrieval (security gate)
73
- serializeFunctions: false, // Capture and reconstruct function source
74
- memoryPoolSize: 65536*16 // Initial memory pool size (1MB)
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 = base64.decode(indexStr);
131
- const list = serializer.deserialize(decoded);
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
- const list = Array.from(this._indexCache);
148
- const serializedIndex = serializer.serialize(list);
149
- const encodedIndex = base64.encode(serializedIndex);
150
- localStorage.setItem(this._indexKey, encodedIndex);
151
- this._dirty = false;
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
- const list = Array.from(this._indexCache);
166
- const serializedIndex = serializer.serialize(list);
167
- const encodedIndex = base64.encode(serializedIndex);
168
- localStorage.setItem(this._indexKey, encodedIndex);
169
- this._dirty = false;
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 = serializer.serialize(data);
177
- const encodedData = base64.encode(serializedData);
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 = base64.decode(stored);
200
- return serializer.deserialize(decoded);
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 = base64.decode(existingMetadata.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 = base64.decode(existingMetadata.wrappedKey);
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 = base64.encode(wrappedKey);
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 = base64.decode(this._wrappedKeyBlob);
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 = base64.encode(wrappedKey);
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 = serializer.serialize(data);
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 = serializer.serialize(privateKey);
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 base64.encode(result);
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 = base64.decode(encryptedKeyString);
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: base64.encode(this._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
- rangeSearch(min, max, results) {
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
- while (i < this.n) {
1240
- if (!this.leaf && this.children[i]) {
1241
- this.children[i].rangeSearch(min, max, results);
1231
+ if (min !== null) {
1232
+ while (i < this.n && this.keys[i] < min) {
1233
+ i++;
1242
1234
  }
1243
- if (this.keys[i] >= min && this.keys[i] <= max) {
1244
- if (this.values[i]) {
1245
- this.values[i].forEach(v => results.push(v));
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
- i++;
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
- remove(key, value) {
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
- if (this.values[i]) {
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
- if (this.values[i].size === 0) {
1344
- for (let j = i; j < this.n - 1; j++) {
1345
- this.keys[j] = this.keys[j + 1];
1346
- this.values[j] = this.values[j + 1];
1347
- }
1348
- this.n--;
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] < key) i++;
1402
- s.children[i].insertNonFull(key, value);
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, Infinity, results);
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(-Infinity, max, results);
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
- console.warn('BTree index issues detected and fixed:', issues);
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
- repaired: issues.length
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 base64.encode(new Uint8Array(hash));
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
- if (options.$gte !== undefined && options.$lte !== undefined) {
1883
- const docs = indexData.range(options.$gte, options.$lte);
1884
- docs.forEach(doc => results.add(doc));
1885
- } else if (options.$gte !== undefined) {
1886
- const docs = indexData.rangeFrom(options.$gte);
1887
- docs.forEach(doc => results.add(doc));
1888
- } else if (options.$gt !== undefined) {
1889
- const docs = indexData.rangeFrom(options.$gt);
1890
- docs.forEach(doc => {
1891
- if (doc !== options.$gt) results.add(doc);
1892
- });
1893
- } else if (options.$lte !== undefined) {
1894
- const docs = indexData.rangeTo(options.$lte);
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 = serializer.serialize(metadata);
1975
- const encoded = base64.encode(serialized);
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 = base64.decode(stored);
1995
- const metadata = serializer.deserialize(decoded);
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
- report[name] = indexData.verify();
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 = serializer.serialize(this.data);
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 = serializer.deserialize(unpacked);
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 = serializer.serialize(this.data);
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 = serializer.deserialize(unpacked);
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 = serializer.serialize(dataToStore);
2525
- const encodedData = base64.encode(serializedData);
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
- // Ignore quota errors here to prevent crash
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
- const dataToStore = {
2588
- sizeLimitKB: this.sizeLimitKB,
2589
- bufferLimitKB: this.bufferLimitKB,
2590
- freeSpaceEvery: this.freeSpaceEvery
2591
- };
2592
- const serializedData = serializer.serialize(dataToStore);
2593
- const encodedData = base64.encode(serializedData);
2594
- localStorage.setItem(key, encodedData);
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
- const groupKey = typeof idField === 'string' ?
2737
- queryEngine.getFieldValue(doc, idField.replace('$', '')) :
2738
- JSON.stringify(idField);
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 = base64.encode(serializer.serialize({filter, options}));
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
- return Promise.all(updates.map(update =>
3377
- this.update(update.id, update.data, options)
3378
- .then(id => ({success: true, id}))
3379
- .catch(error => ({success: false, id: update.id, error: error.message}))
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
- return Promise.all(normalizedItems.map(({id, options}) =>
3394
- this.delete(id, options)
3395
- .then(() => ({success: true, id}))
3396
- .catch(error => ({success: false, id, error: error.message}))
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 = base64.decode(stored);
3560
- existingMetadata = serializer.deserialize(decoded);
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 = serializer.serialize(newMeta);
3568
- const encoded = base64.encode(serialized);
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 = serializer.serialize(newMeta);
3582
- const encoded = base64.encode(serialized);
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.9.2',
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 = serializer.serialize(data);
3721
- return base64.encode(serialized);
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 = serializer.serialize(data);
4202
+ const serializedData = this._serializer.serialize(data);
3726
4203
  const encrypted = await encryption.encrypt(serializedData, password);
3727
- return base64.encode(encrypted);
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 = base64.decode(data);
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 = serializer.deserialize(decrypted);
4216
+ parsed = this._serializer.deserialize(decrypted);
3740
4217
  } else {
3741
- parsed = serializer.deserialize(decoded);
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.9.2',
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 = base64.decode(exported);
3884
- backup.databases[dbName] = serializer.deserialize(decoded);
4373
+ const decoded = this._base64.decode(exported);
4374
+ backup.databases[dbName] = this._serializer.deserialize(decoded);
3885
4375
  }
3886
4376
 
3887
- const serializedBackup = serializer.serialize(backup);
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 base64.encode(encrypted);
4381
+ return this._base64.encode(encrypted);
3892
4382
  }
3893
- return base64.encode(serializedBackup);
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 = base64.decode(backupData);
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 = serializer.deserialize(decrypted);
4393
+ backup = this._serializer.deserialize(decrypted);
3904
4394
  } else {
3905
- backup = serializer.deserialize(decodedData);
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 = base64.encode(serializer.serialize(dbData));
4404
+ const encodedDbData = this._base64.encode(this._serializer.serialize(dbData));
3915
4405
  const importResult = await db.import(encodedDbData);
3916
4406
 
3917
4407
  results.databases++;