@pixagram/lacerta-db 0.10.2 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * LacertaDB V0.10.2 - Production Library
2
+ * LacertaDB V0.11.1 - Production Library
3
3
  * @module LacertaDB
4
- * @version 0.9.2
4
+ * @version 0.11.1
5
5
  * @license MIT
6
6
  * @author Pixagram SA
7
7
  */
@@ -26,19 +26,18 @@ if (typeof window !== 'undefined' && !window.requestIdleCallback) {
26
26
  import TurboSerial from "@pixagram/turboserial";
27
27
  import TurboBase64 from "@pixagram/turbobase64";
28
28
 
29
- const serializer = new TurboSerial({
30
- compression: true, // Enable built-in compression
31
- preservePropertyDescriptors: true, // Preserve property descriptors
32
- deduplication: true, // Deduplicate repeated values
33
- simdOptimization: true, // Use SIMD instructions when available
34
- detectCircular: true, // Handle circular references
35
- shareArrayBuffers: true, // Share ArrayBuffer references
36
- allowFunction: false, // Allow function storage/retrieval (security gate)
37
- serializeFunctions: false, // Capture and reconstruct function source
38
- memoryPoolSize: 65536*16 // Initial memory pool size (1MB)
39
- });
40
-
41
- const base64 = new TurboBase64();
29
+ // Default TurboSerial configuration (overridable via LacertaDB constructor)
30
+ const TURBO_SERIAL_DEFAULTS = {
31
+ compression: true,
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
+ };
42
41
 
43
42
  // ========================
44
43
  // Quick Store (Optimized)
@@ -49,15 +48,17 @@ const base64 = new TurboBase64();
49
48
  * Keeps index in memory to avoid blocking main thread with JSON parsing.
50
49
  */
51
50
  class QuickStore {
52
- constructor(dbName) {
51
+ constructor(dbName, serializer, base64) {
53
52
  this._dbName = dbName;
53
+ this._serializer = serializer;
54
+ this._base64 = base64;
54
55
  this._keyPrefix = `lacertadb_${dbName}_quickstore_`;
55
56
  this._indexKey = `${this._keyPrefix}index`;
56
-
57
+
57
58
  // Optimization: Keep index in memory using a Set for O(1) lookups
58
59
  this._indexCache = new Set();
59
60
  this._indexLoaded = false;
60
-
61
+
61
62
  // Async persistence state
62
63
  this._saveIndexTimer = null;
63
64
  this._dirty = false;
@@ -87,12 +88,12 @@ class QuickStore {
87
88
 
88
89
  _ensureIndexLoaded() {
89
90
  if (this._indexLoaded) return;
90
-
91
+
91
92
  const indexStr = localStorage.getItem(this._indexKey);
92
93
  if (indexStr) {
93
94
  try {
94
- const decoded = base64.decode(indexStr);
95
- const list = serializer.deserialize(decoded);
95
+ const decoded = this._base64.decode(indexStr);
96
+ const list = this._serializer.deserialize(decoded);
96
97
  this._indexCache = new Set(list);
97
98
  } catch (e) {
98
99
  console.warn('QuickStore index corrupted, resetting.', e);
@@ -110,8 +111,8 @@ class QuickStore {
110
111
  if (!this._dirty) return;
111
112
  try {
112
113
  const list = Array.from(this._indexCache);
113
- const serializedIndex = serializer.serialize(list);
114
- const encodedIndex = base64.encode(serializedIndex);
114
+ const serializedIndex = this._serializer.serialize(list);
115
+ const encodedIndex = this._base64.encode(serializedIndex);
115
116
  localStorage.setItem(this._indexKey, encodedIndex);
116
117
  this._dirty = false;
117
118
  } catch (e) {
@@ -128,7 +129,7 @@ class QuickStore {
128
129
  };
129
130
 
130
131
  // Debounce with idle callback to prevent UI freezing
131
- if (window.requestIdleCallback) {
132
+ if (typeof window !== 'undefined' && window.requestIdleCallback) {
132
133
  this._saveIndexTimer = window.requestIdleCallback(save);
133
134
  } else {
134
135
  this._saveIndexTimer = setTimeout(save, 200);
@@ -139,8 +140,8 @@ class QuickStore {
139
140
  if (!this._dirty) return;
140
141
  try {
141
142
  const list = Array.from(this._indexCache);
142
- const serializedIndex = serializer.serialize(list);
143
- const encodedIndex = base64.encode(serializedIndex);
143
+ const serializedIndex = this._serializer.serialize(list);
144
+ const encodedIndex = this._base64.encode(serializedIndex);
144
145
  localStorage.setItem(this._indexKey, encodedIndex);
145
146
  this._dirty = false;
146
147
  } catch (e) {
@@ -159,8 +160,8 @@ class QuickStore {
159
160
  this._ensureIndexLoaded();
160
161
  const key = `${this._keyPrefix}data_${docId}`;
161
162
  try {
162
- const serializedData = serializer.serialize(data);
163
- const encodedData = base64.encode(serializedData);
163
+ const serializedData = this._serializer.serialize(data);
164
+ const encodedData = this._base64.encode(serializedData);
164
165
  localStorage.setItem(key, encodedData);
165
166
 
166
167
  if (!this._indexCache.has(docId)) {
@@ -182,8 +183,8 @@ class QuickStore {
182
183
  const stored = localStorage.getItem(key);
183
184
  if (stored) {
184
185
  try {
185
- const decoded = base64.decode(stored);
186
- return serializer.deserialize(decoded);
186
+ const decoded = this._base64.decode(stored);
187
+ return this._serializer.deserialize(decoded);
187
188
  } catch (e) {
188
189
  console.error('Failed to parse QuickStore data:', e);
189
190
  }
@@ -567,7 +568,7 @@ class CacheStrategy {
567
568
  const type = this._config.type || 'lru';
568
569
  const max = this._config.maxSize || 100;
569
570
  const ttl = this._config.ttl;
570
-
571
+
571
572
  if (type === 'none' || this._config.enabled === false) return null;
572
573
  if (type === 'ttl') return new TTLCache(ttl);
573
574
  if (type === 'lfu') return new LFUCache(max, ttl);
@@ -614,9 +615,9 @@ class CacheStrategy {
614
615
  // ========================
615
616
 
616
617
  class BrowserCompressionUtility {
617
- // Magic header to distinguish compressed data.
618
+ // Magic header to distinguish compressed data.
618
619
  // 0x01 = Compressed (Deflate), 0x00 = Raw
619
-
620
+
620
621
  async compress(input) {
621
622
  if (!(input instanceof Uint8Array)) {
622
623
  throw new TypeError('Input must be Uint8Array');
@@ -642,7 +643,7 @@ class BrowserCompressionUtility {
642
643
  throw new TypeError('Input must be Uint8Array');
643
644
  }
644
645
  if (input.length === 0) return input;
645
-
646
+
646
647
  const marker = input[0];
647
648
  const data = input.slice(1);
648
649
 
@@ -768,13 +769,15 @@ class BrowserEncryptionUtility {
768
769
  // ========================
769
770
 
770
771
  class SecureDatabaseEncryption {
771
- constructor(config = {}) {
772
+ constructor(config = {}, serializer, base64) {
772
773
  this._iterations = config.iterations || 600000; // Increased to OWASP recommendation
773
774
  this._hashAlgorithm = config.hashAlgorithm || 'SHA-256';
774
775
  this._keyLength = config.keyLength || 256;
775
776
  this._saltLength = config.saltLength || 32;
776
777
  this._initialized = false;
777
-
778
+ this._serializer = serializer;
779
+ this._base64 = base64;
780
+
778
781
  this._masterKey = null; // The actual key used for data encryption
779
782
  this._hmacKey = null; // Key for HMAC operations
780
783
  this._salt = null; // Salt for KEK derivation
@@ -793,11 +796,11 @@ class SecureDatabaseEncryption {
793
796
 
794
797
  if (existingMetadata) {
795
798
  // Load existing
796
- this._salt = base64.decode(existingMetadata.salt);
799
+ this._salt = this._base64.decode(existingMetadata.salt);
797
800
  const kek = await this._deriveKEK(pinBytes, this._salt);
798
-
801
+
799
802
  // Unwrap Master Key
800
- const wrappedBytes = base64.decode(existingMetadata.wrappedKey);
803
+ const wrappedBytes = this._base64.decode(existingMetadata.wrappedKey);
801
804
  const iv = wrappedBytes.slice(0, 12);
802
805
  const encryptedMK = wrappedBytes.slice(12);
803
806
 
@@ -813,22 +816,22 @@ class SecureDatabaseEncryption {
813
816
  // New Database
814
817
  this._salt = crypto.getRandomValues(new Uint8Array(this._saltLength));
815
818
  const kek = await this._deriveKEK(pinBytes, this._salt);
816
-
819
+
817
820
  // Generate Master Keys (64 bytes: 32 enc + 32 hmac)
818
821
  const rawKeys = crypto.getRandomValues(new Uint8Array(64));
819
822
  await this._importMasterKeys(rawKeys.buffer);
820
-
823
+
821
824
  // Wrap Master Key
822
825
  const iv = crypto.getRandomValues(new Uint8Array(12));
823
826
  const encryptedMK = await crypto.subtle.encrypt(
824
827
  { name: 'AES-GCM', iv }, kek, rawKeys
825
828
  );
826
-
829
+
827
830
  const wrappedKey = new Uint8Array(12 + encryptedMK.byteLength);
828
831
  wrappedKey.set(iv, 0);
829
832
  wrappedKey.set(new Uint8Array(encryptedMK), 12);
830
-
831
- this._wrappedKeyBlob = base64.encode(wrappedKey);
833
+
834
+ this._wrappedKeyBlob = this._base64.encode(wrappedKey);
832
835
  }
833
836
 
834
837
  this._initialized = true;
@@ -882,7 +885,7 @@ class SecureDatabaseEncryption {
882
885
 
883
886
  // Verify old PIN by attempting to unwrap current master key
884
887
  const oldKek = await this._deriveKEK(new TextEncoder().encode(oldPin), this._salt);
885
- const currentWrappedBytes = base64.decode(this._wrappedKeyBlob);
888
+ const currentWrappedBytes = this._base64.decode(this._wrappedKeyBlob);
886
889
  const currentIv = currentWrappedBytes.slice(0, 12);
887
890
  const currentEncMK = currentWrappedBytes.slice(12);
888
891
  try {
@@ -910,8 +913,8 @@ class SecureDatabaseEncryption {
910
913
 
911
914
  // Update State
912
915
  this._salt = newSalt;
913
- this._wrappedKeyBlob = base64.encode(wrappedKey);
914
-
916
+ this._wrappedKeyBlob = this._base64.encode(wrappedKey);
917
+
915
918
  return this.exportMetadata();
916
919
  }
917
920
 
@@ -926,7 +929,7 @@ class SecureDatabaseEncryption {
926
929
  } else if (data instanceof Uint8Array) {
927
930
  dataBytes = data;
928
931
  } else {
929
- dataBytes = serializer.serialize(data);
932
+ dataBytes = this._serializer.serialize(data);
930
933
  }
931
934
 
932
935
  const iv = crypto.getRandomValues(new Uint8Array(12));
@@ -1007,7 +1010,7 @@ class SecureDatabaseEncryption {
1007
1010
  } else if (privateKey instanceof Uint8Array) {
1008
1011
  keyData = privateKey;
1009
1012
  } else {
1010
- keyData = serializer.serialize(privateKey);
1013
+ keyData = this._serializer.serialize(privateKey);
1011
1014
  }
1012
1015
 
1013
1016
  const iv = crypto.getRandomValues(new Uint8Array(12));
@@ -1033,7 +1036,7 @@ class SecureDatabaseEncryption {
1033
1036
  result.set(authData, 16);
1034
1037
  result.set(new Uint8Array(encryptedKey), 16 + authData.length);
1035
1038
 
1036
- return base64.encode(result);
1039
+ return this._base64.encode(result);
1037
1040
  }
1038
1041
 
1039
1042
  async decryptPrivateKey(encryptedKeyString, additionalAuth = '') {
@@ -1041,7 +1044,7 @@ class SecureDatabaseEncryption {
1041
1044
  throw new Error('Database encryption not initialized');
1042
1045
  }
1043
1046
 
1044
- const encryptedPackage = base64.decode(encryptedKeyString);
1047
+ const encryptedPackage = this._base64.decode(encryptedKeyString);
1045
1048
 
1046
1049
  const iv = encryptedPackage.slice(0, 12);
1047
1050
  const authLengthBytes = encryptedPackage.slice(12, 16);
@@ -1101,7 +1104,7 @@ class SecureDatabaseEncryption {
1101
1104
 
1102
1105
  exportMetadata() {
1103
1106
  return {
1104
- salt: base64.encode(this._salt),
1107
+ salt: this._base64.encode(this._salt),
1105
1108
  wrappedKey: this._wrappedKeyBlob,
1106
1109
  iterations: this._iterations,
1107
1110
  algorithm: 'AES-GCM-256',
@@ -1138,7 +1141,7 @@ class QuadTree {
1138
1141
  if (!this.divided) this._subdivide();
1139
1142
 
1140
1143
  return (this.northeast.insert(point) || this.northwest.insert(point) ||
1141
- this.southeast.insert(point) || this.southwest.insert(point));
1144
+ this.southeast.insert(point) || this.southwest.insert(point));
1142
1145
  }
1143
1146
 
1144
1147
  query(range, found = []) { // range: {x, y, w, h}
@@ -1180,12 +1183,12 @@ class QuadTree {
1180
1183
 
1181
1184
  _contains(b, p) {
1182
1185
  return (p.x >= b.x - b.w && p.x <= b.x + b.w &&
1183
- p.y >= b.y - b.h && p.y <= b.y + b.h);
1186
+ p.y >= b.y - b.h && p.y <= b.y + b.h);
1184
1187
  }
1185
1188
 
1186
1189
  _intersects(a, b) {
1187
1190
  return !(b.x - b.w > a.x + a.w || b.x + b.w < a.x - a.w ||
1188
- b.y - b.h > a.y + a.h || b.y + b.h < a.y - a.h);
1191
+ b.y - b.h > a.y + a.h || b.y + b.h < a.y - a.h);
1189
1192
  }
1190
1193
  }
1191
1194
 
@@ -1220,26 +1223,53 @@ class BTreeNode {
1220
1223
  return this.children[i] ? this.children[i].search(key) : null;
1221
1224
  }
1222
1225
 
1223
- 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
1224
1230
  let i = 0;
1225
- while (i < this.n) {
1231
+ if (min !== null) {
1232
+ while (i < this.n && this.keys[i] < min) {
1233
+ i++;
1234
+ }
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;
1253
+ }
1254
+ }
1255
+
1256
+ // Descend into left child of current key
1226
1257
  if (!this.leaf && this.children[i]) {
1227
- this.children[i].rangeSearch(min, max, results);
1258
+ this.children[i].rangeSearch(min, max, results, excludeMin, excludeMax);
1228
1259
  }
1229
1260
 
1230
- // Treat null as unbounded (wildcard) to avoid broken string vs Infinity comparisons
1231
- const meetsMin = min === null || this.keys[i] >= min;
1232
- const meetsMax = max === null || this.keys[i] <= max;
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);
1233
1264
 
1234
- if (meetsMin && meetsMax) {
1235
- if (this.values[i]) {
1236
- this.values[i].forEach(v => results.push(v));
1237
- }
1265
+ if (meetsMin && meetsMax && this.values[i]) {
1266
+ this.values[i].forEach(v => results.push(v));
1238
1267
  }
1239
- i++;
1240
1268
  }
1269
+
1270
+ // Descend into rightmost child
1241
1271
  if (!this.leaf && this.children[i]) {
1242
- this.children[i].rangeSearch(min, max, results);
1272
+ this.children[i].rangeSearch(min, max, results, excludeMin, excludeMax);
1243
1273
  }
1244
1274
  }
1245
1275
 
@@ -1279,6 +1309,18 @@ class BTreeNode {
1279
1309
  i++;
1280
1310
  if (this.children[i] && this.children[i].n === 2 * this.order - 1) {
1281
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
+
1282
1324
  if (this.keys[i] < key) {
1283
1325
  i++;
1284
1326
  }
@@ -1306,6 +1348,17 @@ class BTreeNode {
1306
1348
 
1307
1349
  y.n = this.order - 1;
1308
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
+
1309
1362
  for (let j = this.n; j >= i + 1; j--) {
1310
1363
  this.children[j + 1] = this.children[j];
1311
1364
  }
@@ -1319,31 +1372,267 @@ class BTreeNode {
1319
1372
 
1320
1373
  this.keys[i] = y.keys[this.order - 1];
1321
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;
1322
1378
  this.n++;
1323
1379
  }
1324
1380
 
1325
- 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) {
1326
1579
  let i = 0;
1327
1580
  while (i < this.n && key > this.keys[i]) {
1328
1581
  i++;
1329
1582
  }
1330
1583
 
1331
1584
  if (i < this.n && key === this.keys[i]) {
1332
- if (this.values[i]) {
1585
+ // Key found at this node
1586
+ let shouldRemoveEntry = removeEntire;
1587
+
1588
+ if (!shouldRemoveEntry && this.values[i]) {
1333
1589
  this.values[i].delete(value);
1334
- if (this.values[i].size === 0) {
1335
- for (let j = i; j < this.n - 1; j++) {
1336
- this.keys[j] = this.keys[j + 1];
1337
- this.values[j] = this.values[j + 1];
1338
- }
1339
- 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);
1340
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;
1341
1622
  }
1342
- } else if (!this.leaf && this.children[i]) {
1343
- this.children[i].remove(key, value);
1344
1623
  }
1345
1624
  }
1346
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
+
1347
1636
  verify() {
1348
1637
  const issues = [];
1349
1638
  for (let i = 1; i < this.n; i++) {
@@ -1377,6 +1666,14 @@ class BTreeIndex {
1377
1666
  this.verify();
1378
1667
  }
1379
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
+
1380
1677
  if (!this._root) {
1381
1678
  this._root = new BTreeNode(this._order, true);
1382
1679
  this._root.keys[0] = key;
@@ -1388,9 +1685,15 @@ class BTreeIndex {
1388
1685
  s.children[0] = this._root;
1389
1686
  s.splitChild(0, this._root);
1390
1687
 
1688
+ // FIX: Check if promoted median equals key
1391
1689
  let i = 0;
1392
- if (s.keys[0] < key) i++;
1393
- 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
+ }
1394
1697
 
1395
1698
  this._root = s;
1396
1699
  } else {
@@ -1411,24 +1714,24 @@ class BTreeIndex {
1411
1714
  return this._root.search(key) !== null;
1412
1715
  }
1413
1716
 
1414
- range(min, max) {
1717
+ range(min, max, excludeMin = false, excludeMax = false) {
1415
1718
  if (!this._root) return [];
1416
1719
  const results = [];
1417
- this._root.rangeSearch(min, max, results);
1720
+ this._root.rangeSearch(min, max, results, excludeMin, excludeMax);
1418
1721
  return results;
1419
1722
  }
1420
1723
 
1421
- rangeFrom(min) {
1724
+ rangeFrom(min, excludeMin = false) {
1422
1725
  if (!this._root) return [];
1423
1726
  const results = [];
1424
- this._root.rangeSearch(min, null, results);
1727
+ this._root.rangeSearch(min, null, results, excludeMin, false);
1425
1728
  return results;
1426
1729
  }
1427
1730
 
1428
- rangeTo(max) {
1731
+ rangeTo(max, excludeMax = false) {
1429
1732
  if (!this._root) return [];
1430
1733
  const results = [];
1431
- this._root.rangeSearch(null, max, results);
1734
+ this._root.rangeSearch(null, max, results, false, excludeMax);
1432
1735
  return results;
1433
1736
  }
1434
1737
 
@@ -1437,6 +1740,7 @@ class BTreeIndex {
1437
1740
  const existing = this._root.search(key);
1438
1741
  if (existing && existing.has(value)) {
1439
1742
  this._root.remove(key, value);
1743
+ // Shrink root if it became empty (all keys merged down)
1440
1744
  if (this._root.n === 0 && !this._root.leaf && this._root.children[0]) {
1441
1745
  this._root = this._root.children[0];
1442
1746
  }
@@ -1449,15 +1753,22 @@ class BTreeIndex {
1449
1753
  if (!this._root) return { healthy: true, issues: [] };
1450
1754
  const issues = this._root.verify();
1451
1755
  if (issues.length > 0) {
1452
- 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);
1453
1759
  }
1454
1760
  return {
1455
1761
  healthy: issues.length === 0,
1456
1762
  issues,
1457
- repaired: issues.length
1763
+ requiresRebuild: issues.length > 0
1458
1764
  };
1459
1765
  }
1460
1766
 
1767
+ clear() {
1768
+ this._root = null;
1769
+ this._size = 0;
1770
+ }
1771
+
1461
1772
  get size() {
1462
1773
  return this._size;
1463
1774
  }
@@ -1471,7 +1782,7 @@ class TextIndex {
1471
1782
  constructor() {
1472
1783
  this._invertedIndex = new Map();
1473
1784
  this._docTokens = new Map();
1474
- this._segmenter = typeof Intl !== 'undefined' && Intl.Segmenter ?
1785
+ this._segmenter = typeof Intl !== 'undefined' && Intl.Segmenter ?
1475
1786
  new Intl.Segmenter(undefined, { granularity: 'word' }) : null;
1476
1787
  }
1477
1788
 
@@ -1557,7 +1868,7 @@ class TextIndex {
1557
1868
 
1558
1869
  class GeoIndex {
1559
1870
  constructor() {
1560
- this._tree = new QuadTree({x: 0, y: 0, w: 180, h: 90});
1871
+ this._tree = new QuadTree({x: 0, y: 0, w: 180, h: 90});
1561
1872
  this._size = 0;
1562
1873
  }
1563
1874
 
@@ -1580,15 +1891,15 @@ class GeoIndex {
1580
1891
  }
1581
1892
 
1582
1893
  findNear(center, maxDistance) {
1583
- const rangeDeg = maxDistance / 111;
1894
+ const rangeDeg = maxDistance / 111;
1584
1895
  const range = {
1585
- x: center.lng, y: center.lat,
1896
+ x: center.lng, y: center.lat,
1586
1897
  w: rangeDeg, h: rangeDeg
1587
1898
  };
1588
-
1899
+
1589
1900
  const candidates = this._tree.query(range);
1590
1901
  const results = [];
1591
-
1902
+
1592
1903
  for (const p of candidates) {
1593
1904
  const distance = this._haversine(center, {lat: p.y, lng: p.x});
1594
1905
  if (distance <= maxDistance) {
@@ -1607,7 +1918,7 @@ class GeoIndex {
1607
1918
  const h = (bounds.maxLat - bounds.minLat) / 2;
1608
1919
  const x = bounds.minLng + w;
1609
1920
  const y = bounds.minLat + h;
1610
-
1921
+
1611
1922
  const candidates = this._tree.query({x, y, w, h});
1612
1923
  return candidates.map(p => p.data);
1613
1924
  }
@@ -1641,6 +1952,8 @@ class GeoIndex {
1641
1952
  class IndexManager {
1642
1953
  constructor(collection) {
1643
1954
  this._collection = collection;
1955
+ this._serializer = collection.database._serializer;
1956
+ this._base64 = collection.database._base64;
1644
1957
  this._indexes = new Map();
1645
1958
  this._indexData = new Map();
1646
1959
  this._indexQueue = [];
@@ -1690,30 +2003,30 @@ class IndexManager {
1690
2003
  // This prevents transaction timeouts caused by async crypto operations inside the loop
1691
2004
  let lastKey = null;
1692
2005
  const batchSize = 100; // Keep batch small for responsiveness
1693
-
2006
+
1694
2007
  while (true) {
1695
2008
  // 1. Fetch Batch (Transaction opens and closes here)
1696
2009
  const batch = await this._collection._indexedDB.getBatch(
1697
- this._collection._db,
1698
- 'documents',
1699
- lastKey,
2010
+ this._collection._db,
2011
+ 'documents',
2012
+ lastKey,
1700
2013
  batchSize
1701
2014
  );
1702
-
2015
+
1703
2016
  if (batch.length === 0) break;
1704
-
2017
+
1705
2018
  // 2. Process Batch (Async crypto operations safe here)
1706
2019
  for (const docData of batch) {
1707
2020
  lastKey = docData._id; // Update for next batch
1708
2021
  let doc = docData;
1709
-
2022
+
1710
2023
  if (docData.packedData) {
1711
2024
  const d = new Document(docData, {
1712
- compressed: docData._compressed,
2025
+ compressed: docData._compressed,
1713
2026
  encrypted: docData._encrypted
1714
- });
2027
+ }, this._serializer);
1715
2028
  // This await is what killed the transaction before
1716
- await d.unpack(this._collection.database.encryption);
2029
+ await d.unpack(this._collection.database.encryption);
1717
2030
  doc = d.objectOutput();
1718
2031
  }
1719
2032
 
@@ -1734,7 +2047,7 @@ class IndexManager {
1734
2047
 
1735
2048
  this._addToIndex(indexData, value, doc._id, index.type);
1736
2049
  }
1737
-
2050
+
1738
2051
  // Optional: Yield to main thread briefly to prevent UI freeze
1739
2052
  if (window.requestIdleCallback) await new Promise(r => window.requestIdleCallback(r));
1740
2053
  }
@@ -1774,11 +2087,11 @@ class IndexManager {
1774
2087
  break;
1775
2088
  }
1776
2089
  }
1777
-
2090
+
1778
2091
  async _hashVal(val) {
1779
2092
  const msg = new TextEncoder().encode(String(val));
1780
2093
  const hash = await crypto.subtle.digest('SHA-256', msg);
1781
- return base64.encode(new Uint8Array(hash));
2094
+ return this._base64.encode(new Uint8Array(hash));
1782
2095
  }
1783
2096
 
1784
2097
  async updateIndexForDocument(docId, oldDoc, newDoc) {
@@ -1788,7 +2101,7 @@ class IndexManager {
1788
2101
 
1789
2102
  let oldValue = oldDoc ? this._getFieldValue(oldDoc, index.fieldPath) : undefined;
1790
2103
  let newValue = newDoc ? this._getFieldValue(newDoc, index.fieldPath) : undefined;
1791
-
2104
+
1792
2105
  if (index.hashed) {
1793
2106
  if (oldValue) oldValue = await this._hashVal(oldValue);
1794
2107
  if (newValue) newValue = await this._hashVal(newValue);
@@ -1834,9 +2147,9 @@ class IndexManager {
1834
2147
  if (!index || !indexData) {
1835
2148
  throw new Error(`Index '${indexName}' not found`);
1836
2149
  }
1837
-
2150
+
1838
2151
  if (index.hashed && typeof queryOptions !== 'object') {
1839
- queryOptions = await this._hashVal(queryOptions);
2152
+ queryOptions = await this._hashVal(queryOptions);
1840
2153
  }
1841
2154
 
1842
2155
  return this._queryIndex(indexData, queryOptions, index.type);
@@ -1870,25 +2183,20 @@ class IndexManager {
1870
2183
  docs.forEach(doc => results.add(doc));
1871
2184
  }
1872
2185
 
1873
- if (options.$gte !== undefined && options.$lte !== undefined) {
1874
- const docs = indexData.range(options.$gte, options.$lte);
1875
- docs.forEach(doc => results.add(doc));
1876
- } else if (options.$gte !== undefined) {
1877
- const docs = indexData.rangeFrom(options.$gte);
1878
- docs.forEach(doc => results.add(doc));
1879
- } else if (options.$gt !== undefined) {
1880
- const docs = indexData.rangeFrom(options.$gt);
1881
- docs.forEach(doc => {
1882
- if (doc !== options.$gt) results.add(doc);
1883
- });
1884
- } else if (options.$lte !== undefined) {
1885
- 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);
1886
2199
  docs.forEach(doc => results.add(doc));
1887
- } else if (options.$lt !== undefined) {
1888
- const docs = indexData.rangeTo(options.$lt);
1889
- docs.forEach(doc => {
1890
- if (doc !== options.$lt) results.add(doc);
1891
- });
1892
2200
  }
1893
2201
 
1894
2202
  return Array.from(results);
@@ -1962,8 +2270,8 @@ class IndexManager {
1962
2270
  ...index
1963
2271
  }))
1964
2272
  };
1965
- const serialized = serializer.serialize(metadata);
1966
- const encoded = base64.encode(serialized);
2273
+ const serialized = this._serializer.serialize(metadata);
2274
+ const encoded = this._base64.encode(serialized);
1967
2275
  localStorage.setItem(key, encoded);
1968
2276
  resolve();
1969
2277
  };
@@ -1982,8 +2290,8 @@ class IndexManager {
1982
2290
  if (!stored) return;
1983
2291
 
1984
2292
  try {
1985
- const decoded = base64.decode(stored);
1986
- const metadata = serializer.deserialize(decoded);
2293
+ const decoded = this._base64.decode(stored);
2294
+ const metadata = this._serializer.deserialize(decoded);
1987
2295
 
1988
2296
  for (const indexDef of metadata.indexes) {
1989
2297
  const { name, ...index } = indexDef;
@@ -2026,7 +2334,13 @@ class IndexManager {
2026
2334
  report[name] = { status: 'missing', rebuilt: true };
2027
2335
  await this.rebuildIndex(name);
2028
2336
  } else if (indexData.verify) {
2029
- 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;
2030
2344
  } else {
2031
2345
  report[name] = { status: 'ok' };
2032
2346
  }
@@ -2310,7 +2624,7 @@ class IndexedDBUtility {
2310
2624
  // ========================
2311
2625
 
2312
2626
  class Document {
2313
- constructor(data = {}, options = {}) {
2627
+ constructor(data = {}, options = {}, serializer) {
2314
2628
  this._id = data._id || this._generateId();
2315
2629
  this._created = data._created || Date.now();
2316
2630
  this._modified = data._modified || Date.now();
@@ -2321,6 +2635,7 @@ class Document {
2321
2635
  this._data = null;
2322
2636
  this._packedData = data.packedData || null;
2323
2637
  this._compression = new BrowserCompressionUtility();
2638
+ this._serializer = serializer;
2324
2639
 
2325
2640
  if (data.data) {
2326
2641
  this.data = data.data;
@@ -2341,7 +2656,7 @@ class Document {
2341
2656
 
2342
2657
  async pack(encryptionUtil = null) {
2343
2658
  try {
2344
- let packed = serializer.serialize(this.data);
2659
+ let packed = this._serializer.serialize(this.data);
2345
2660
  if (this._compressed) {
2346
2661
  packed = await this._compression.compress(packed);
2347
2662
  }
@@ -2370,7 +2685,7 @@ class Document {
2370
2685
  throw new Error('Empty unpacked data');
2371
2686
  }
2372
2687
 
2373
- this.data = serializer.deserialize(unpacked);
2688
+ this.data = this._serializer.deserialize(unpacked);
2374
2689
 
2375
2690
  if (typeof this.data !== 'object' || this.data === null) {
2376
2691
  throw new Error('Invalid deserialized data');
@@ -2385,7 +2700,7 @@ class Document {
2385
2700
  }
2386
2701
 
2387
2702
  packSync() {
2388
- let packed = serializer.serialize(this.data);
2703
+ let packed = this._serializer.serialize(this.data);
2389
2704
  if (this._compressed) {
2390
2705
  packed = this._compression.compressSync(packed);
2391
2706
  }
@@ -2401,7 +2716,7 @@ class Document {
2401
2716
  if (this._compressed) {
2402
2717
  unpacked = this._compression.decompressSync(unpacked);
2403
2718
  }
2404
- this.data = serializer.deserialize(unpacked);
2719
+ this.data = this._serializer.deserialize(unpacked);
2405
2720
  return this.data;
2406
2721
  }
2407
2722
 
@@ -2438,73 +2753,307 @@ class Document {
2438
2753
  // ========================
2439
2754
 
2440
2755
  class CollectionMetadata {
2441
- constructor(name, data = {}) {
2756
+ constructor(name, data = {}, serializer, base64, dbName) {
2442
2757
  this.name = name;
2758
+ this._serializer = serializer;
2759
+ this._base64 = base64;
2760
+ this._dbName = dbName;
2761
+ this._storageKey = dbName ? `lacertadb_${dbName}_${name}_collmeta` : null;
2762
+
2763
+ // Aggregate stats
2443
2764
  this.sizeKB = data.sizeKB || 0;
2444
2765
  this.length = data.length || 0;
2445
2766
  this.createdAt = data.createdAt || Date.now();
2446
2767
  this.modifiedAt = data.modifiedAt || Date.now();
2447
- // Removed detailed per-doc tracking to save space
2448
- this.documentSizes = {};
2449
- this.documentModifiedAt = {};
2450
- this.documentPermanent = {};
2451
- this.documentAttachments = {};
2768
+
2769
+ // Per-document tracking (in-memory Maps for O(1) ops)
2770
+ this._docSizes = new Map(data._docSizes || []); // docId -> sizeKB
2771
+ this._docModified = new Map(data._docModified || []); // docId -> timestamp
2772
+ this._docPermanent = new Map(data._docPermanent || []); // docId -> boolean
2773
+ this._docAttachments = new Map(data._docAttachments || []); // docId -> count
2774
+
2775
+ // Debounced persistence
2776
+ this._dirty = false;
2777
+ this._saveTimer = null;
2778
+
2779
+ // Safety: flush on page unload
2780
+ this._flushHandler = () => this._flushSync();
2781
+ if (typeof window !== 'undefined') {
2782
+ window.addEventListener('beforeunload', this._flushHandler);
2783
+ }
2452
2784
  }
2453
2785
 
2454
- addDocument(docId, sizeKB, isPermanent, attachmentCount) {
2455
- // Optimization: Don't store detailed map in LS to avoid QuotaExceeded
2456
- // this.documentSizes[docId] = sizeKB;
2786
+ // ---- Mutations (in-memory only, schedule async save) ----
2787
+
2788
+ addDocument(docId, sizeKB, isPermanent = false, attachmentCount = 0) {
2789
+ this._docSizes.set(docId, sizeKB);
2790
+ this._docModified.set(docId, Date.now());
2791
+ this._docPermanent.set(docId, isPermanent);
2792
+ this._docAttachments.set(docId, attachmentCount);
2793
+
2457
2794
  this.sizeKB += sizeKB;
2458
2795
  this.length++;
2459
2796
  this.modifiedAt = Date.now();
2797
+ this._scheduleSave();
2460
2798
  }
2461
2799
 
2462
- updateDocument(docId, newSizeKB, isPermanent, attachmentCount) {
2463
- // Approximate tracking
2464
- // const oldSize = this.documentSizes[docId] || 0;
2465
- // this.sizeKB = this.sizeKB - oldSize + newSizeKB;
2800
+ updateDocument(docId, newSizeKB, isPermanent = false, attachmentCount = 0) {
2801
+ const oldSize = this._docSizes.get(docId) || 0;
2802
+ this.sizeKB = this.sizeKB - oldSize + newSizeKB;
2803
+
2804
+ this._docSizes.set(docId, newSizeKB);
2805
+ this._docModified.set(docId, Date.now());
2806
+ this._docPermanent.set(docId, isPermanent);
2807
+ this._docAttachments.set(docId, attachmentCount);
2808
+
2466
2809
  this.modifiedAt = Date.now();
2810
+ this._scheduleSave();
2467
2811
  }
2468
2812
 
2469
2813
  removeDocument(docId) {
2470
- // const sizeKB = this.documentSizes[docId] || 0;
2471
- // this.sizeKB -= sizeKB;
2472
- // this.length--;
2814
+ const sizeKB = this._docSizes.get(docId) || 0;
2815
+ this.sizeKB -= sizeKB;
2816
+ this.length--;
2817
+
2818
+ this._docSizes.delete(docId);
2819
+ this._docModified.delete(docId);
2820
+ this._docPermanent.delete(docId);
2821
+ this._docAttachments.delete(docId);
2822
+
2473
2823
  this.modifiedAt = Date.now();
2824
+ this._scheduleSave();
2474
2825
  }
2475
2826
 
2827
+ // ---- Queries (instant from memory) ----
2828
+
2476
2829
  getOldestNonPermanentDocuments(count) {
2477
- // If we removed the maps, we can't do this efficiently from metadata alone.
2478
- // Should query DB index 'modified' instead.
2479
- return [];
2830
+ const candidates = [];
2831
+ for (const [docId, modified] of this._docModified) {
2832
+ if (!this._docPermanent.get(docId)) {
2833
+ candidates.push({ id: docId, modified });
2834
+ }
2835
+ }
2836
+ candidates.sort((a, b) => a.modified - b.modified);
2837
+ return candidates.slice(0, count).map(c => c.id);
2838
+ }
2839
+
2840
+ getDocumentSize(docId) {
2841
+ return this._docSizes.get(docId) || 0;
2842
+ }
2843
+
2844
+ isDocumentPermanent(docId) {
2845
+ return this._docPermanent.get(docId) || false;
2846
+ }
2847
+
2848
+ hasDocument(docId) {
2849
+ return this._docSizes.has(docId);
2850
+ }
2851
+
2852
+ // ---- Aggregate snapshot (for DatabaseMetadata) ----
2853
+
2854
+ getAggregateSnapshot() {
2855
+ return {
2856
+ sizeKB: this.sizeKB,
2857
+ length: this.length,
2858
+ createdAt: this.createdAt,
2859
+ modifiedAt: this.modifiedAt
2860
+ };
2861
+ }
2862
+
2863
+ // ---- Persistence ----
2864
+
2865
+ _scheduleSave() {
2866
+ this._dirty = true;
2867
+ if (this._saveTimer) return;
2868
+
2869
+ const save = () => {
2870
+ this._saveTimer = null;
2871
+ if (!this._dirty) return;
2872
+ this._persistToStorage();
2873
+ };
2874
+
2875
+ if (typeof window !== 'undefined' && window.requestIdleCallback) {
2876
+ this._saveTimer = window.requestIdleCallback(save);
2877
+ } else {
2878
+ this._saveTimer = setTimeout(save, 300);
2879
+ }
2880
+ }
2881
+
2882
+ _flushSync() {
2883
+ if (!this._dirty) return;
2884
+ this._persistToStorage();
2885
+ }
2886
+
2887
+ _persistToStorage() {
2888
+ if (!this._storageKey || !this._serializer || !this._base64) return;
2889
+ try {
2890
+ const dataToStore = {
2891
+ sizeKB: this.sizeKB,
2892
+ length: this.length,
2893
+ createdAt: this.createdAt,
2894
+ modifiedAt: this.modifiedAt,
2895
+ _docSizes: Array.from(this._docSizes.entries()),
2896
+ _docModified: Array.from(this._docModified.entries()),
2897
+ _docPermanent: Array.from(this._docPermanent.entries()),
2898
+ _docAttachments: Array.from(this._docAttachments.entries())
2899
+ };
2900
+ const serialized = this._serializer.serialize(dataToStore);
2901
+ const encoded = this._base64.encode(serialized);
2902
+ localStorage.setItem(this._storageKey, encoded);
2903
+ this._dirty = false;
2904
+ } catch (e) {
2905
+ if (e.name === 'QuotaExceededError') {
2906
+ // Fallback: persist only aggregate stats (drop per-doc maps)
2907
+ console.warn('CollectionMetadata: quota exceeded, saving aggregates only');
2908
+ try {
2909
+ const fallback = {
2910
+ sizeKB: this.sizeKB,
2911
+ length: this.length,
2912
+ createdAt: this.createdAt,
2913
+ modifiedAt: this.modifiedAt
2914
+ };
2915
+ const serialized = this._serializer.serialize(fallback);
2916
+ const encoded = this._base64.encode(serialized);
2917
+ localStorage.setItem(this._storageKey, encoded);
2918
+ this._dirty = false;
2919
+ } catch (e2) {
2920
+ console.error('CollectionMetadata: fallback save also failed:', e2);
2921
+ }
2922
+ } else {
2923
+ console.error('CollectionMetadata save failed:', e);
2924
+ }
2925
+ }
2926
+ }
2927
+
2928
+ static load(dbName, collName, serializer, base64) {
2929
+ const key = `lacertadb_${dbName}_${collName}_collmeta`;
2930
+ const stored = localStorage.getItem(key);
2931
+ if (stored) {
2932
+ try {
2933
+ const decoded = base64.decode(stored);
2934
+ const data = serializer.deserialize(decoded);
2935
+ return new CollectionMetadata(collName, data, serializer, base64, dbName);
2936
+ } catch (e) {
2937
+ console.warn('CollectionMetadata corrupted, resetting:', e);
2938
+ }
2939
+ }
2940
+ return new CollectionMetadata(collName, {}, serializer, base64, dbName);
2941
+ }
2942
+
2943
+ // ---- Lifecycle ----
2944
+
2945
+ destroy() {
2946
+ this._flushSync();
2947
+ if (typeof window !== 'undefined' && this._flushHandler) {
2948
+ window.removeEventListener('beforeunload', this._flushHandler);
2949
+ this._flushHandler = null;
2950
+ }
2951
+ if (this._saveTimer) {
2952
+ if (typeof window !== 'undefined' && window.cancelIdleCallback) {
2953
+ window.cancelIdleCallback(this._saveTimer);
2954
+ } else {
2955
+ clearTimeout(this._saveTimer);
2956
+ }
2957
+ this._saveTimer = null;
2958
+ }
2959
+ }
2960
+
2961
+ clear() {
2962
+ this.sizeKB = 0;
2963
+ this.length = 0;
2964
+ this.modifiedAt = Date.now();
2965
+ this._docSizes.clear();
2966
+ this._docModified.clear();
2967
+ this._docPermanent.clear();
2968
+ this._docAttachments.clear();
2969
+ this._dirty = true;
2970
+ this._flushSync();
2480
2971
  }
2481
2972
  }
2482
2973
 
2483
2974
  class DatabaseMetadata {
2484
- constructor(name, data = {}) {
2975
+ constructor(name, data = {}, serializer, base64) {
2485
2976
  this.name = name;
2977
+ this._serializer = serializer;
2978
+ this._base64 = base64;
2486
2979
  this.collections = data.collections || {};
2487
2980
  this.totalSizeKB = data.totalSizeKB || 0;
2488
2981
  this.totalLength = data.totalLength || 0;
2489
2982
  this.modifiedAt = data.modifiedAt || Date.now();
2983
+
2984
+ // Debounced persistence
2985
+ this._dirty = false;
2986
+ this._saveTimer = null;
2987
+
2988
+ this._flushHandler = () => this._flushSync();
2989
+ if (typeof window !== 'undefined') {
2990
+ window.addEventListener('beforeunload', this._flushHandler);
2991
+ }
2490
2992
  }
2491
2993
 
2492
- static load(dbName) {
2994
+ static load(dbName, serializer, base64) {
2493
2995
  const key = `lacertadb_${dbName}_metadata`;
2494
2996
  const stored = localStorage.getItem(key);
2495
2997
  if (stored) {
2496
2998
  try {
2497
2999
  const decoded = base64.decode(stored);
2498
3000
  const data = serializer.deserialize(decoded);
2499
- return new DatabaseMetadata(dbName, data);
3001
+ return new DatabaseMetadata(dbName, data, serializer, base64);
2500
3002
  } catch (e) {
2501
3003
  console.error('Failed to load metadata:', e);
2502
3004
  }
2503
3005
  }
2504
- return new DatabaseMetadata(dbName);
3006
+ return new DatabaseMetadata(dbName, {}, serializer, base64);
2505
3007
  }
2506
3008
 
2507
- save() {
3009
+ setCollection(collectionMetadata) {
3010
+ this.collections[collectionMetadata.name] = collectionMetadata.getAggregateSnapshot();
3011
+ this._recalculate();
3012
+ this._scheduleSave();
3013
+ }
3014
+
3015
+ removeCollection(collectionName) {
3016
+ delete this.collections[collectionName];
3017
+ this._recalculate();
3018
+ this._scheduleSave();
3019
+ }
3020
+
3021
+ _recalculate() {
3022
+ this.totalSizeKB = 0;
3023
+ this.totalLength = 0;
3024
+ for (const collName in this.collections) {
3025
+ const coll = this.collections[collName];
3026
+ this.totalSizeKB += coll.sizeKB;
3027
+ this.totalLength += coll.length;
3028
+ }
3029
+ this.modifiedAt = Date.now();
3030
+ }
3031
+
3032
+ // ---- Debounced persistence ----
3033
+
3034
+ _scheduleSave() {
3035
+ this._dirty = true;
3036
+ if (this._saveTimer) return;
3037
+
3038
+ const save = () => {
3039
+ this._saveTimer = null;
3040
+ if (!this._dirty) return;
3041
+ this._persistToStorage();
3042
+ };
3043
+
3044
+ if (typeof window !== 'undefined' && window.requestIdleCallback) {
3045
+ this._saveTimer = window.requestIdleCallback(save);
3046
+ } else {
3047
+ this._saveTimer = setTimeout(save, 300);
3048
+ }
3049
+ }
3050
+
3051
+ _flushSync() {
3052
+ if (!this._dirty) return;
3053
+ this._persistToStorage();
3054
+ }
3055
+
3056
+ _persistToStorage() {
2508
3057
  const key = `lacertadb_${this.name}_metadata`;
2509
3058
  try {
2510
3059
  const dataToStore = {
@@ -2513,9 +3062,10 @@ class DatabaseMetadata {
2513
3062
  totalLength: this.totalLength,
2514
3063
  modifiedAt: this.modifiedAt
2515
3064
  };
2516
- const serializedData = serializer.serialize(dataToStore);
2517
- const encodedData = base64.encode(serializedData);
3065
+ const serializedData = this._serializer.serialize(dataToStore);
3066
+ const encodedData = this._base64.encode(serializedData);
2518
3067
  localStorage.setItem(key, encodedData);
3068
+ this._dirty = false;
2519
3069
  } catch (e) {
2520
3070
  if (e.name === 'QuotaExceededError') {
2521
3071
  console.error('CRITICAL: LocalStorage quota exceeded. Metadata not saved for db:', this.name);
@@ -2528,57 +3078,55 @@ class DatabaseMetadata {
2528
3078
  }
2529
3079
  }
2530
3080
 
2531
- setCollection(collectionMetadata) {
2532
- this.collections[collectionMetadata.name] = {
2533
- sizeKB: collectionMetadata.sizeKB,
2534
- length: collectionMetadata.length,
2535
- createdAt: collectionMetadata.createdAt,
2536
- modifiedAt: collectionMetadata.modifiedAt
2537
- };
2538
- this._recalculate();
2539
- this.save();
3081
+ // Force immediate save (for critical operations like clearAll)
3082
+ save() {
3083
+ this._dirty = true;
3084
+ this._flushSync();
2540
3085
  }
2541
3086
 
2542
- removeCollection(collectionName) {
2543
- delete this.collections[collectionName];
2544
- this._recalculate();
2545
- this.save();
2546
- }
3087
+ // ---- Lifecycle ----
2547
3088
 
2548
- _recalculate() {
2549
- this.totalSizeKB = 0;
2550
- this.totalLength = 0;
2551
- for (const collName in this.collections) {
2552
- const coll = this.collections[collName];
2553
- this.totalSizeKB += coll.sizeKB;
2554
- this.totalLength += coll.length;
3089
+ destroy() {
3090
+ this._flushSync();
3091
+ if (typeof window !== 'undefined' && this._flushHandler) {
3092
+ window.removeEventListener('beforeunload', this._flushHandler);
3093
+ this._flushHandler = null;
3094
+ }
3095
+ if (this._saveTimer) {
3096
+ if (typeof window !== 'undefined' && window.cancelIdleCallback) {
3097
+ window.cancelIdleCallback(this._saveTimer);
3098
+ } else {
3099
+ clearTimeout(this._saveTimer);
3100
+ }
3101
+ this._saveTimer = null;
2555
3102
  }
2556
- this.modifiedAt = Date.now();
2557
3103
  }
2558
3104
  }
2559
3105
 
2560
3106
  class Settings {
2561
- constructor(dbName, data = {}) {
3107
+ constructor(dbName, data = {}, serializer, base64) {
2562
3108
  this.dbName = dbName;
3109
+ this._serializer = serializer;
3110
+ this._base64 = base64;
2563
3111
  this.sizeLimitKB = data.sizeLimitKB != null ? data.sizeLimitKB : Infinity;
2564
3112
  const defaultBuffer = this.sizeLimitKB === Infinity ? Infinity : this.sizeLimitKB * 0.8;
2565
3113
  this.bufferLimitKB = data.bufferLimitKB != null ? data.bufferLimitKB : defaultBuffer;
2566
3114
  this.freeSpaceEvery = this.sizeLimitKB === Infinity ? 0 : (data.freeSpaceEvery || 10000);
2567
3115
  }
2568
3116
 
2569
- static load(dbName) {
3117
+ static load(dbName, serializer, base64) {
2570
3118
  const key = `lacertadb_${dbName}_settings`;
2571
3119
  const stored = localStorage.getItem(key);
2572
3120
  if (stored) {
2573
3121
  try {
2574
3122
  const decoded = base64.decode(stored);
2575
3123
  const data = serializer.deserialize(decoded);
2576
- return new Settings(dbName, data);
3124
+ return new Settings(dbName, data, serializer, base64);
2577
3125
  } catch (e) {
2578
3126
  console.error('Failed to load settings:', e);
2579
3127
  }
2580
3128
  }
2581
- return new Settings(dbName);
3129
+ return new Settings(dbName, {}, serializer, base64);
2582
3130
  }
2583
3131
 
2584
3132
  save() {
@@ -2589,8 +3137,8 @@ class Settings {
2589
3137
  bufferLimitKB: this.bufferLimitKB,
2590
3138
  freeSpaceEvery: this.freeSpaceEvery
2591
3139
  };
2592
- const serializedData = serializer.serialize(dataToStore);
2593
- const encodedData = base64.encode(serializedData);
3140
+ const serializedData = this._serializer.serialize(dataToStore);
3141
+ const encodedData = this._base64.encode(serializedData);
2594
3142
  localStorage.setItem(key, encodedData);
2595
3143
  } catch (e) {
2596
3144
  if (e.name === 'QuotaExceededError') {
@@ -3039,6 +3587,8 @@ class Collection {
3039
3587
  constructor(name, database) {
3040
3588
  this.name = name;
3041
3589
  this.database = database;
3590
+ this._serializer = database._serializer;
3591
+ this._base64 = database._base64;
3042
3592
  this._db = null;
3043
3593
  this._metadata = null;
3044
3594
  this._settings = database.settings;
@@ -3082,8 +3632,10 @@ class Collection {
3082
3632
  }
3083
3633
  });
3084
3634
 
3085
- const metadataData = this.database.metadata.collections[this.name];
3086
- this._metadata = new CollectionMetadata(this.name, metadataData);
3635
+ // Load per-collection metadata (with per-doc tracking) from its own localStorage key
3636
+ this._metadata = CollectionMetadata.load(
3637
+ this.database.name, this.name, this._serializer, this._base64
3638
+ );
3087
3639
 
3088
3640
  await this._indexManager.loadIndexMetadata();
3089
3641
 
@@ -3131,7 +3683,7 @@ class Collection {
3131
3683
  const doc = new Document({data: documentData, _id: options.id}, {
3132
3684
  compressed: options.compressed !== false,
3133
3685
  permanent: options.permanent || false
3134
- });
3686
+ }, this._serializer);
3135
3687
 
3136
3688
  const attachments = options.attachments;
3137
3689
  if (attachments && attachments.length > 0) {
@@ -3173,7 +3725,7 @@ class Collection {
3173
3725
  const doc = new Document(stored, {
3174
3726
  encrypted: stored._encrypted,
3175
3727
  compressed: stored._compressed
3176
- });
3728
+ }, this._serializer);
3177
3729
 
3178
3730
  if (stored.packedData) {
3179
3731
  await doc.unpack(this.database.encryption);
@@ -3196,7 +3748,7 @@ class Collection {
3196
3748
  const doc = new Document(docData, {
3197
3749
  encrypted: docData._encrypted,
3198
3750
  compressed: docData._compressed
3199
- });
3751
+ }, this._serializer);
3200
3752
  if (docData.packedData) {
3201
3753
  await doc.unpack(this.database.encryption);
3202
3754
  }
@@ -3218,7 +3770,7 @@ class Collection {
3218
3770
  throw new LacertaDBError(`Document with id '${docId}' not found for update.`, 'DOCUMENT_NOT_FOUND');
3219
3771
  }
3220
3772
 
3221
- const existingDoc = new Document(stored);
3773
+ const existingDoc = new Document(stored, {}, this._serializer);
3222
3774
  if (stored.packedData) await existingDoc.unpack(this.database.encryption);
3223
3775
 
3224
3776
  const oldDocOutput = existingDoc.objectOutput();
@@ -3231,7 +3783,7 @@ class Collection {
3231
3783
  }, {
3232
3784
  compressed: options.compressed !== undefined ? options.compressed : stored._compressed,
3233
3785
  permanent: options.permanent !== undefined ? options.permanent : stored._permanent
3234
- });
3786
+ }, this._serializer);
3235
3787
  doc._modified = Date.now();
3236
3788
 
3237
3789
  const attachments = options.attachments;
@@ -3306,7 +3858,7 @@ class Collection {
3306
3858
 
3307
3859
  const startTime = performance.now();
3308
3860
 
3309
- const cacheKey = base64.encode(serializer.serialize({filter, options}));
3861
+ const cacheKey = this._base64.encode(this._serializer.serialize({filter, options}));
3310
3862
  const cached = this._cacheStrategy.get(cacheKey);
3311
3863
 
3312
3864
  if (cached) {
@@ -3376,7 +3928,7 @@ class Collection {
3376
3928
  const doc = new Document({data: documentData}, {
3377
3929
  compressed: options.compressed !== false,
3378
3930
  permanent: options.permanent || false
3379
- });
3931
+ }, this._serializer);
3380
3932
 
3381
3933
  await doc.pack(this.database.encryption);
3382
3934
  operations.push({
@@ -3427,7 +3979,7 @@ class Collection {
3427
3979
  continue;
3428
3980
  }
3429
3981
 
3430
- const existingDoc = new Document(stored);
3982
+ const existingDoc = new Document(stored, {}, this._serializer);
3431
3983
  if (stored.packedData) await existingDoc.unpack(this.database.encryption);
3432
3984
 
3433
3985
  oldDocs.push(existingDoc.objectOutput());
@@ -3440,7 +3992,7 @@ class Collection {
3440
3992
  }, {
3441
3993
  compressed: options.compressed !== undefined ? options.compressed : stored._compressed,
3442
3994
  permanent: options.permanent !== undefined ? options.permanent : stored._permanent
3443
- });
3995
+ }, this._serializer);
3444
3996
  doc._modified = Date.now();
3445
3997
  doc._attachments = stored._attachments;
3446
3998
 
@@ -3596,7 +4148,11 @@ class Collection {
3596
4148
  await this._indexedDB.clear(this._db, 'documents');
3597
4149
 
3598
4150
  // Reset metadata
3599
- this._metadata = new CollectionMetadata(this.name);
4151
+ if (this._metadata) this._metadata.destroy();
4152
+ this._metadata = new CollectionMetadata(
4153
+ this.name, {}, this._serializer, this._base64, this.database.name
4154
+ );
4155
+ this._metadata._flushSync();
3600
4156
  this.database.metadata.setCollection(this._metadata);
3601
4157
 
3602
4158
  // Clear cache
@@ -3624,6 +4180,11 @@ class Collection {
3624
4180
  }
3625
4181
 
3626
4182
  destroy() {
4183
+ // Flush and destroy collection metadata
4184
+ if (this._metadata) {
4185
+ this._metadata.destroy();
4186
+ }
4187
+
3627
4188
  // Clear the cleanup interval
3628
4189
  if (this._cleanupInterval) {
3629
4190
  clearInterval(this._cleanupInterval);
@@ -3652,13 +4213,15 @@ class Collection {
3652
4213
  // ========================
3653
4214
 
3654
4215
  class Database {
3655
- constructor(name, performanceMonitor) {
4216
+ constructor(name, performanceMonitor, serializer, base64) {
3656
4217
  this.name = name;
3657
4218
  this._collections = new Map();
3658
4219
  this._metadata = null;
3659
4220
  this._settings = null;
3660
4221
  this._quickStore = null;
3661
4222
  this._performanceMonitor = performanceMonitor;
4223
+ this._serializer = serializer;
4224
+ this._base64 = base64;
3662
4225
 
3663
4226
  // Database-level encryption
3664
4227
  this._encryption = null;
@@ -3693,9 +4256,9 @@ class Database {
3693
4256
  }
3694
4257
 
3695
4258
  async init(options = {}) {
3696
- this._metadata = DatabaseMetadata.load(this.name);
3697
- this._settings = Settings.load(this.name);
3698
- this._quickStore = new QuickStore(this.name);
4259
+ this._metadata = DatabaseMetadata.load(this.name, this._serializer, this._base64);
4260
+ this._settings = Settings.load(this.name, this._serializer, this._base64);
4261
+ this._quickStore = new QuickStore(this.name, this._serializer, this._base64);
3699
4262
 
3700
4263
  if (options.pin) {
3701
4264
  await this._initializeEncryption(options.pin, options.salt, options.encryptionConfig);
@@ -3708,18 +4271,18 @@ class Database {
3708
4271
  const encMetaKey = `lacertadb_${this.name}_encryption`;
3709
4272
  let existingMetadata = null;
3710
4273
  const stored = localStorage.getItem(encMetaKey);
3711
-
4274
+
3712
4275
  if (stored) {
3713
- const decoded = base64.decode(stored);
3714
- existingMetadata = serializer.deserialize(decoded);
4276
+ const decoded = this._base64.decode(stored);
4277
+ existingMetadata = this._serializer.deserialize(decoded);
3715
4278
  }
3716
4279
 
3717
- this._encryption = new SecureDatabaseEncryption(config);
4280
+ this._encryption = new SecureDatabaseEncryption(config, this._serializer, this._base64);
3718
4281
  const newMeta = await this._encryption.initialize(pin, existingMetadata);
3719
4282
 
3720
4283
  if (!existingMetadata) {
3721
- const serialized = serializer.serialize(newMeta);
3722
- const encoded = base64.encode(serialized);
4284
+ const serialized = this._serializer.serialize(newMeta);
4285
+ const encoded = this._base64.encode(serialized);
3723
4286
  localStorage.setItem(encMetaKey, encoded);
3724
4287
  }
3725
4288
  }
@@ -3732,8 +4295,8 @@ class Database {
3732
4295
  const newMeta = await this._encryption.changePin(oldPin, newPin);
3733
4296
 
3734
4297
  const encMetaKey = `lacertadb_${this.name}_encryption`;
3735
- const serialized = serializer.serialize(newMeta);
3736
- const encoded = base64.encode(serialized);
4298
+ const serialized = this._serializer.serialize(newMeta);
4299
+ const encoded = this._base64.encode(serialized);
3737
4300
  localStorage.setItem(encMetaKey, encoded);
3738
4301
 
3739
4302
  return true;
@@ -3791,7 +4354,9 @@ class Database {
3791
4354
  this._collections.set(name, collection);
3792
4355
 
3793
4356
  if (!this._metadata.collections[name]) {
3794
- this._metadata.setCollection(new CollectionMetadata(name));
4357
+ this._metadata.setCollection(new CollectionMetadata(
4358
+ name, {}, this._serializer, this._base64, this.name
4359
+ ));
3795
4360
  }
3796
4361
  return collection;
3797
4362
  }
@@ -3825,6 +4390,10 @@ class Database {
3825
4390
 
3826
4391
  this._metadata.removeCollection(name);
3827
4392
 
4393
+ // Clean up collection-level metadata and index localStorage keys
4394
+ localStorage.removeItem(`lacertadb_${this.name}_${name}_collmeta`);
4395
+ localStorage.removeItem(`lacertadb_${this.name}_${name}_indexes`);
4396
+
3828
4397
  const dbName = `${this.name}_${name}`;
3829
4398
  await new Promise((resolve, reject) => {
3830
4399
  const deleteReq = indexedDB.deleteDatabase(dbName);
@@ -3859,7 +4428,7 @@ class Database {
3859
4428
 
3860
4429
  async export(format = 'json', password = null) {
3861
4430
  const data = {
3862
- version: '0.9.2',
4431
+ version: '0.10.2',
3863
4432
  database: this.name,
3864
4433
  timestamp: Date.now(),
3865
4434
  collections: {}
@@ -3871,14 +4440,14 @@ class Database {
3871
4440
  }
3872
4441
 
3873
4442
  if (format === 'json') {
3874
- const serialized = serializer.serialize(data);
3875
- return base64.encode(serialized);
4443
+ const serialized = this._serializer.serialize(data);
4444
+ return this._base64.encode(serialized);
3876
4445
  }
3877
4446
  if (format === 'encrypted' && password) {
3878
4447
  const encryption = new BrowserEncryptionUtility();
3879
- const serializedData = serializer.serialize(data);
4448
+ const serializedData = this._serializer.serialize(data);
3880
4449
  const encrypted = await encryption.encrypt(serializedData, password);
3881
- return base64.encode(encrypted);
4450
+ return this._base64.encode(encrypted);
3882
4451
  }
3883
4452
  throw new LacertaDBError(`Unsupported export format: ${format}`, 'INVALID_FORMAT');
3884
4453
  }
@@ -3886,13 +4455,13 @@ class Database {
3886
4455
  async import(data, format = 'json', password = null) {
3887
4456
  let parsed;
3888
4457
  try {
3889
- const decoded = base64.decode(data);
4458
+ const decoded = this._base64.decode(data);
3890
4459
  if (format === 'encrypted' && password) {
3891
4460
  const encryption = new BrowserEncryptionUtility();
3892
4461
  const decrypted = await encryption.decrypt(decoded, password);
3893
- parsed = serializer.deserialize(decrypted);
4462
+ parsed = this._serializer.deserialize(decrypted);
3894
4463
  } else {
3895
- parsed = serializer.deserialize(decoded);
4464
+ parsed = this._serializer.deserialize(decoded);
3896
4465
  }
3897
4466
  } catch (e) {
3898
4467
  throw new LacertaDBError('Failed to parse import data', 'IMPORT_PARSE_FAILED', e);
@@ -3923,7 +4492,8 @@ class Database {
3923
4492
  async clearAll() {
3924
4493
  await Promise.all([...this._collections.keys()].map(name => this.dropCollection(name)));
3925
4494
  this._collections.clear();
3926
- this._metadata = new DatabaseMetadata(this.name);
4495
+ if (this._metadata) this._metadata.destroy();
4496
+ this._metadata = new DatabaseMetadata(this.name, {}, this._serializer, this._base64);
3927
4497
  this._metadata.save();
3928
4498
  this._quickStore.clear();
3929
4499
  }
@@ -3943,6 +4513,11 @@ class Database {
3943
4513
  this._quickStore.destroy();
3944
4514
  }
3945
4515
 
4516
+ // Destroy database metadata
4517
+ if (this._metadata) {
4518
+ this._metadata.destroy();
4519
+ }
4520
+
3946
4521
  // Destroy encryption
3947
4522
  if (this._encryption) {
3948
4523
  this._encryption.destroy();
@@ -3961,18 +4536,31 @@ class Database {
3961
4536
  // ========================
3962
4537
 
3963
4538
  class LacertaDB {
3964
- constructor() {
4539
+ constructor(config = {}) {
3965
4540
  this._databases = new Map();
3966
4541
  this._performanceMonitor = new PerformanceMonitor();
4542
+
4543
+ // Instantiate serializer with user-overridable config merged over defaults
4544
+ const serialConfig = { ...TURBO_SERIAL_DEFAULTS, ...(config.turboSerial || {}) };
4545
+ this._serializer = new TurboSerial(serialConfig);
4546
+ this._base64 = new TurboBase64();
3967
4547
  }
3968
4548
 
3969
4549
  get performanceMonitor() {
3970
4550
  return this._performanceMonitor;
3971
4551
  }
3972
4552
 
4553
+ get serializer() {
4554
+ return this._serializer;
4555
+ }
4556
+
4557
+ get base64() {
4558
+ return this._base64;
4559
+ }
4560
+
3973
4561
  async getDatabase(name, options = {}) {
3974
4562
  if (!this._databases.has(name)) {
3975
- const db = new Database(name, this._performanceMonitor);
4563
+ const db = new Database(name, this._performanceMonitor, this._serializer, this._base64);
3976
4564
  await db.init(options);
3977
4565
  this._databases.set(name, db);
3978
4566
  }
@@ -3996,7 +4584,7 @@ class LacertaDB {
3996
4584
  });
3997
4585
 
3998
4586
  // Clean up quickstore
3999
- const quickStore = new QuickStore(name);
4587
+ const quickStore = new QuickStore(name, this._serializer, this._base64);
4000
4588
  quickStore.clear();
4001
4589
 
4002
4590
  // Clean up all collections and indexes
@@ -4026,7 +4614,7 @@ class LacertaDB {
4026
4614
 
4027
4615
  async createBackup(password = null) {
4028
4616
  const backup = {
4029
- version: '0.9.2',
4617
+ version: '0.10.2',
4030
4618
  timestamp: Date.now(),
4031
4619
  databases: {}
4032
4620
  };
@@ -4034,29 +4622,29 @@ class LacertaDB {
4034
4622
  for (const dbName of this.listDatabases()) {
4035
4623
  const db = await this.getDatabase(dbName);
4036
4624
  const exported = await db.export('json');
4037
- const decoded = base64.decode(exported);
4038
- backup.databases[dbName] = serializer.deserialize(decoded);
4625
+ const decoded = this._base64.decode(exported);
4626
+ backup.databases[dbName] = this._serializer.deserialize(decoded);
4039
4627
  }
4040
4628
 
4041
- const serializedBackup = serializer.serialize(backup);
4629
+ const serializedBackup = this._serializer.serialize(backup);
4042
4630
  if (password) {
4043
4631
  const encryption = new BrowserEncryptionUtility();
4044
4632
  const encrypted = await encryption.encrypt(serializedBackup, password);
4045
- return base64.encode(encrypted);
4633
+ return this._base64.encode(encrypted);
4046
4634
  }
4047
- return base64.encode(serializedBackup);
4635
+ return this._base64.encode(serializedBackup);
4048
4636
  }
4049
4637
 
4050
4638
  async restoreBackup(backupData, password = null) {
4051
4639
  let backup;
4052
4640
  try {
4053
- let decodedData = base64.decode(backupData);
4641
+ let decodedData = this._base64.decode(backupData);
4054
4642
  if (password) {
4055
4643
  const encryption = new BrowserEncryptionUtility();
4056
4644
  const decrypted = await encryption.decrypt(decodedData, password);
4057
- backup = serializer.deserialize(decrypted);
4645
+ backup = this._serializer.deserialize(decrypted);
4058
4646
  } else {
4059
- backup = serializer.deserialize(decodedData);
4647
+ backup = this._serializer.deserialize(decodedData);
4060
4648
  }
4061
4649
  } catch (e) {
4062
4650
  throw new LacertaDBError('Failed to parse backup data', 'BACKUP_PARSE_FAILED', e);
@@ -4065,7 +4653,7 @@ class LacertaDB {
4065
4653
  const results = { databases: 0, collections: 0, documents: 0 };
4066
4654
  for (const [dbName, dbData] of Object.entries(backup.databases)) {
4067
4655
  const db = await this.getDatabase(dbName);
4068
- const encodedDbData = base64.encode(serializer.serialize(dbData));
4656
+ const encodedDbData = this._base64.encode(this._serializer.serialize(dbData));
4069
4657
  const importResult = await db.import(encodedDbData);
4070
4658
 
4071
4659
  results.databases++;
@@ -4115,4 +4703,4 @@ export {
4115
4703
  IndexedDBConnectionPool,
4116
4704
  BrowserCompressionUtility,
4117
4705
  BrowserEncryptionUtility
4118
- };
4706
+ };