@pixagram/lacerta-db 0.11.0 → 0.11.2

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.11.0 - Production Library
2
+ * LacertaDB V0.11.2 - Production Library
3
3
  * @module LacertaDB
4
- * @version 0.11.0
4
+ * @version 0.11.2
5
5
  * @license MIT
6
6
  * @author Pixagram SA
7
7
  */
@@ -29,14 +29,14 @@ import TurboBase64 from "@pixagram/turbobase64";
29
29
  // Default TurboSerial configuration (overridable via LacertaDB constructor)
30
30
  const TURBO_SERIAL_DEFAULTS = {
31
31
  compression: false,
32
- preservePropertyDescriptors: true,
32
+ preservePropertyDescriptors: false,
33
33
  deduplication: false,
34
34
  simdOptimization: true,
35
- detectCircular: true,
36
- shareArrayBuffers: true,
35
+ detectCircular: false,
36
+ shareArrayBuffers: false,
37
37
  allowFunction: false,
38
38
  serializeFunctions: false,
39
- memoryPoolSize: 65536 * 16
39
+ memoryPoolSize: 65536
40
40
  };
41
41
 
42
42
  // ========================
@@ -54,11 +54,11 @@ class QuickStore {
54
54
  this._base64 = base64;
55
55
  this._keyPrefix = `lacertadb_${dbName}_quickstore_`;
56
56
  this._indexKey = `${this._keyPrefix}index`;
57
-
57
+
58
58
  // Optimization: Keep index in memory using a Set for O(1) lookups
59
59
  this._indexCache = new Set();
60
60
  this._indexLoaded = false;
61
-
61
+
62
62
  // Async persistence state
63
63
  this._saveIndexTimer = null;
64
64
  this._dirty = false;
@@ -88,7 +88,7 @@ class QuickStore {
88
88
 
89
89
  _ensureIndexLoaded() {
90
90
  if (this._indexLoaded) return;
91
-
91
+
92
92
  const indexStr = localStorage.getItem(this._indexKey);
93
93
  if (indexStr) {
94
94
  try {
@@ -568,7 +568,7 @@ class CacheStrategy {
568
568
  const type = this._config.type || 'lru';
569
569
  const max = this._config.maxSize || 100;
570
570
  const ttl = this._config.ttl;
571
-
571
+
572
572
  if (type === 'none' || this._config.enabled === false) return null;
573
573
  if (type === 'ttl') return new TTLCache(ttl);
574
574
  if (type === 'lfu') return new LFUCache(max, ttl);
@@ -615,9 +615,9 @@ class CacheStrategy {
615
615
  // ========================
616
616
 
617
617
  class BrowserCompressionUtility {
618
- // Magic header to distinguish compressed data.
618
+ // Magic header to distinguish compressed data.
619
619
  // 0x01 = Compressed (Deflate), 0x00 = Raw
620
-
620
+
621
621
  async compress(input) {
622
622
  if (!(input instanceof Uint8Array)) {
623
623
  throw new TypeError('Input must be Uint8Array');
@@ -643,7 +643,7 @@ class BrowserCompressionUtility {
643
643
  throw new TypeError('Input must be Uint8Array');
644
644
  }
645
645
  if (input.length === 0) return input;
646
-
646
+
647
647
  const marker = input[0];
648
648
  const data = input.slice(1);
649
649
 
@@ -777,7 +777,7 @@ class SecureDatabaseEncryption {
777
777
  this._initialized = false;
778
778
  this._serializer = serializer;
779
779
  this._base64 = base64;
780
-
780
+
781
781
  this._masterKey = null; // The actual key used for data encryption
782
782
  this._hmacKey = null; // Key for HMAC operations
783
783
  this._salt = null; // Salt for KEK derivation
@@ -798,7 +798,7 @@ class SecureDatabaseEncryption {
798
798
  // Load existing
799
799
  this._salt = this._base64.decode(existingMetadata.salt);
800
800
  const kek = await this._deriveKEK(pinBytes, this._salt);
801
-
801
+
802
802
  // Unwrap Master Key
803
803
  const wrappedBytes = this._base64.decode(existingMetadata.wrappedKey);
804
804
  const iv = wrappedBytes.slice(0, 12);
@@ -816,21 +816,21 @@ class SecureDatabaseEncryption {
816
816
  // New Database
817
817
  this._salt = crypto.getRandomValues(new Uint8Array(this._saltLength));
818
818
  const kek = await this._deriveKEK(pinBytes, this._salt);
819
-
819
+
820
820
  // Generate Master Keys (64 bytes: 32 enc + 32 hmac)
821
821
  const rawKeys = crypto.getRandomValues(new Uint8Array(64));
822
822
  await this._importMasterKeys(rawKeys.buffer);
823
-
823
+
824
824
  // Wrap Master Key
825
825
  const iv = crypto.getRandomValues(new Uint8Array(12));
826
826
  const encryptedMK = await crypto.subtle.encrypt(
827
827
  { name: 'AES-GCM', iv }, kek, rawKeys
828
828
  );
829
-
829
+
830
830
  const wrappedKey = new Uint8Array(12 + encryptedMK.byteLength);
831
831
  wrappedKey.set(iv, 0);
832
832
  wrappedKey.set(new Uint8Array(encryptedMK), 12);
833
-
833
+
834
834
  this._wrappedKeyBlob = this._base64.encode(wrappedKey);
835
835
  }
836
836
 
@@ -914,7 +914,7 @@ class SecureDatabaseEncryption {
914
914
  // Update State
915
915
  this._salt = newSalt;
916
916
  this._wrappedKeyBlob = this._base64.encode(wrappedKey);
917
-
917
+
918
918
  return this.exportMetadata();
919
919
  }
920
920
 
@@ -1141,7 +1141,7 @@ class QuadTree {
1141
1141
  if (!this.divided) this._subdivide();
1142
1142
 
1143
1143
  return (this.northeast.insert(point) || this.northwest.insert(point) ||
1144
- this.southeast.insert(point) || this.southwest.insert(point));
1144
+ this.southeast.insert(point) || this.southwest.insert(point));
1145
1145
  }
1146
1146
 
1147
1147
  query(range, found = []) { // range: {x, y, w, h}
@@ -1183,12 +1183,12 @@ class QuadTree {
1183
1183
 
1184
1184
  _contains(b, p) {
1185
1185
  return (p.x >= b.x - b.w && p.x <= b.x + b.w &&
1186
- 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);
1187
1187
  }
1188
1188
 
1189
1189
  _intersects(a, b) {
1190
1190
  return !(b.x - b.w > a.x + a.w || b.x + b.w < a.x - a.w ||
1191
- 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);
1192
1192
  }
1193
1193
  }
1194
1194
 
@@ -1782,7 +1782,7 @@ class TextIndex {
1782
1782
  constructor() {
1783
1783
  this._invertedIndex = new Map();
1784
1784
  this._docTokens = new Map();
1785
- this._segmenter = typeof Intl !== 'undefined' && Intl.Segmenter ?
1785
+ this._segmenter = typeof Intl !== 'undefined' && Intl.Segmenter ?
1786
1786
  new Intl.Segmenter(undefined, { granularity: 'word' }) : null;
1787
1787
  }
1788
1788
 
@@ -1868,7 +1868,7 @@ class TextIndex {
1868
1868
 
1869
1869
  class GeoIndex {
1870
1870
  constructor() {
1871
- 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});
1872
1872
  this._size = 0;
1873
1873
  }
1874
1874
 
@@ -1891,15 +1891,15 @@ class GeoIndex {
1891
1891
  }
1892
1892
 
1893
1893
  findNear(center, maxDistance) {
1894
- const rangeDeg = maxDistance / 111;
1894
+ const rangeDeg = maxDistance / 111;
1895
1895
  const range = {
1896
- x: center.lng, y: center.lat,
1896
+ x: center.lng, y: center.lat,
1897
1897
  w: rangeDeg, h: rangeDeg
1898
1898
  };
1899
-
1899
+
1900
1900
  const candidates = this._tree.query(range);
1901
1901
  const results = [];
1902
-
1902
+
1903
1903
  for (const p of candidates) {
1904
1904
  const distance = this._haversine(center, {lat: p.y, lng: p.x});
1905
1905
  if (distance <= maxDistance) {
@@ -1918,7 +1918,7 @@ class GeoIndex {
1918
1918
  const h = (bounds.maxLat - bounds.minLat) / 2;
1919
1919
  const x = bounds.minLng + w;
1920
1920
  const y = bounds.minLat + h;
1921
-
1921
+
1922
1922
  const candidates = this._tree.query({x, y, w, h});
1923
1923
  return candidates.map(p => p.data);
1924
1924
  }
@@ -2003,30 +2003,30 @@ class IndexManager {
2003
2003
  // This prevents transaction timeouts caused by async crypto operations inside the loop
2004
2004
  let lastKey = null;
2005
2005
  const batchSize = 100; // Keep batch small for responsiveness
2006
-
2006
+
2007
2007
  while (true) {
2008
2008
  // 1. Fetch Batch (Transaction opens and closes here)
2009
2009
  const batch = await this._collection._indexedDB.getBatch(
2010
- this._collection._db,
2011
- 'documents',
2012
- lastKey,
2010
+ this._collection._db,
2011
+ 'documents',
2012
+ lastKey,
2013
2013
  batchSize
2014
2014
  );
2015
-
2015
+
2016
2016
  if (batch.length === 0) break;
2017
-
2017
+
2018
2018
  // 2. Process Batch (Async crypto operations safe here)
2019
2019
  for (const docData of batch) {
2020
2020
  lastKey = docData._id; // Update for next batch
2021
2021
  let doc = docData;
2022
-
2022
+
2023
2023
  if (docData.packedData) {
2024
2024
  const d = new Document(docData, {
2025
- compressed: docData._compressed,
2025
+ compressed: docData._compressed,
2026
2026
  encrypted: docData._encrypted
2027
2027
  }, this._serializer);
2028
2028
  // This await is what killed the transaction before
2029
- await d.unpack(this._collection.database.encryption);
2029
+ await d.unpack(this._collection.database.encryption);
2030
2030
  doc = d.objectOutput();
2031
2031
  }
2032
2032
 
@@ -2047,7 +2047,7 @@ class IndexManager {
2047
2047
 
2048
2048
  this._addToIndex(indexData, value, doc._id, index.type);
2049
2049
  }
2050
-
2050
+
2051
2051
  // Optional: Yield to main thread briefly to prevent UI freeze
2052
2052
  if (window.requestIdleCallback) await new Promise(r => window.requestIdleCallback(r));
2053
2053
  }
@@ -2087,7 +2087,7 @@ class IndexManager {
2087
2087
  break;
2088
2088
  }
2089
2089
  }
2090
-
2090
+
2091
2091
  async _hashVal(val) {
2092
2092
  const msg = new TextEncoder().encode(String(val));
2093
2093
  const hash = await crypto.subtle.digest('SHA-256', msg);
@@ -2101,7 +2101,7 @@ class IndexManager {
2101
2101
 
2102
2102
  let oldValue = oldDoc ? this._getFieldValue(oldDoc, index.fieldPath) : undefined;
2103
2103
  let newValue = newDoc ? this._getFieldValue(newDoc, index.fieldPath) : undefined;
2104
-
2104
+
2105
2105
  if (index.hashed) {
2106
2106
  if (oldValue) oldValue = await this._hashVal(oldValue);
2107
2107
  if (newValue) newValue = await this._hashVal(newValue);
@@ -2147,9 +2147,9 @@ class IndexManager {
2147
2147
  if (!index || !indexData) {
2148
2148
  throw new Error(`Index '${indexName}' not found`);
2149
2149
  }
2150
-
2150
+
2151
2151
  if (index.hashed && typeof queryOptions !== 'object') {
2152
- queryOptions = await this._hashVal(queryOptions);
2152
+ queryOptions = await this._hashVal(queryOptions);
2153
2153
  }
2154
2154
 
2155
2155
  return this._queryIndex(indexData, queryOptions, index.type);
@@ -2753,45 +2753,221 @@ class Document {
2753
2753
  // ========================
2754
2754
 
2755
2755
  class CollectionMetadata {
2756
- constructor(name, data = {}) {
2756
+ constructor(name, data = {}, serializer, base64, dbName) {
2757
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
2758
2764
  this.sizeKB = data.sizeKB || 0;
2759
2765
  this.length = data.length || 0;
2760
2766
  this.createdAt = data.createdAt || Date.now();
2761
2767
  this.modifiedAt = data.modifiedAt || Date.now();
2762
- // Removed detailed per-doc tracking to save space
2763
- this.documentSizes = {};
2764
- this.documentModifiedAt = {};
2765
- this.documentPermanent = {};
2766
- 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
+ }
2767
2784
  }
2768
2785
 
2769
- addDocument(docId, sizeKB, isPermanent, attachmentCount) {
2770
- // Optimization: Don't store detailed map in LS to avoid QuotaExceeded
2771
- // 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
+
2772
2794
  this.sizeKB += sizeKB;
2773
2795
  this.length++;
2774
2796
  this.modifiedAt = Date.now();
2797
+ this._scheduleSave();
2775
2798
  }
2776
2799
 
2777
- updateDocument(docId, newSizeKB, isPermanent, attachmentCount) {
2778
- // Approximate tracking
2779
- // const oldSize = this.documentSizes[docId] || 0;
2780
- // 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
+
2781
2809
  this.modifiedAt = Date.now();
2810
+ this._scheduleSave();
2782
2811
  }
2783
2812
 
2784
2813
  removeDocument(docId) {
2785
- // const sizeKB = this.documentSizes[docId] || 0;
2786
- // this.sizeKB -= sizeKB;
2787
- // 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
+
2788
2823
  this.modifiedAt = Date.now();
2824
+ this._scheduleSave();
2789
2825
  }
2790
2826
 
2827
+ // ---- Queries (instant from memory) ----
2828
+
2791
2829
  getOldestNonPermanentDocuments(count) {
2792
- // If we removed the maps, we can't do this efficiently from metadata alone.
2793
- // Should query DB index 'modified' instead.
2794
- 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();
2795
2971
  }
2796
2972
  }
2797
2973
 
@@ -2804,6 +2980,15 @@ class DatabaseMetadata {
2804
2980
  this.totalSizeKB = data.totalSizeKB || 0;
2805
2981
  this.totalLength = data.totalLength || 0;
2806
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
+ }
2807
2992
  }
2808
2993
 
2809
2994
  static load(dbName, serializer, base64) {
@@ -2821,7 +3006,54 @@ class DatabaseMetadata {
2821
3006
  return new DatabaseMetadata(dbName, {}, serializer, base64);
2822
3007
  }
2823
3008
 
2824
- 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() {
2825
3057
  const key = `lacertadb_${this.name}_metadata`;
2826
3058
  try {
2827
3059
  const dataToStore = {
@@ -2833,6 +3065,7 @@ class DatabaseMetadata {
2833
3065
  const serializedData = this._serializer.serialize(dataToStore);
2834
3066
  const encodedData = this._base64.encode(serializedData);
2835
3067
  localStorage.setItem(key, encodedData);
3068
+ this._dirty = false;
2836
3069
  } catch (e) {
2837
3070
  if (e.name === 'QuotaExceededError') {
2838
3071
  console.error('CRITICAL: LocalStorage quota exceeded. Metadata not saved for db:', this.name);
@@ -2845,32 +3078,28 @@ class DatabaseMetadata {
2845
3078
  }
2846
3079
  }
2847
3080
 
2848
- setCollection(collectionMetadata) {
2849
- this.collections[collectionMetadata.name] = {
2850
- sizeKB: collectionMetadata.sizeKB,
2851
- length: collectionMetadata.length,
2852
- createdAt: collectionMetadata.createdAt,
2853
- modifiedAt: collectionMetadata.modifiedAt
2854
- };
2855
- this._recalculate();
2856
- this.save();
3081
+ // Force immediate save (for critical operations like clearAll)
3082
+ save() {
3083
+ this._dirty = true;
3084
+ this._flushSync();
2857
3085
  }
2858
3086
 
2859
- removeCollection(collectionName) {
2860
- delete this.collections[collectionName];
2861
- this._recalculate();
2862
- this.save();
2863
- }
3087
+ // ---- Lifecycle ----
2864
3088
 
2865
- _recalculate() {
2866
- this.totalSizeKB = 0;
2867
- this.totalLength = 0;
2868
- for (const collName in this.collections) {
2869
- const coll = this.collections[collName];
2870
- this.totalSizeKB += coll.sizeKB;
2871
- 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;
2872
3102
  }
2873
- this.modifiedAt = Date.now();
2874
3103
  }
2875
3104
  }
2876
3105
 
@@ -3403,8 +3632,10 @@ class Collection {
3403
3632
  }
3404
3633
  });
3405
3634
 
3406
- const metadataData = this.database.metadata.collections[this.name];
3407
- 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
+ );
3408
3639
 
3409
3640
  await this._indexManager.loadIndexMetadata();
3410
3641
 
@@ -3917,7 +4148,11 @@ class Collection {
3917
4148
  await this._indexedDB.clear(this._db, 'documents');
3918
4149
 
3919
4150
  // Reset metadata
3920
- 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();
3921
4156
  this.database.metadata.setCollection(this._metadata);
3922
4157
 
3923
4158
  // Clear cache
@@ -3945,6 +4180,11 @@ class Collection {
3945
4180
  }
3946
4181
 
3947
4182
  destroy() {
4183
+ // Flush and destroy collection metadata
4184
+ if (this._metadata) {
4185
+ this._metadata.destroy();
4186
+ }
4187
+
3948
4188
  // Clear the cleanup interval
3949
4189
  if (this._cleanupInterval) {
3950
4190
  clearInterval(this._cleanupInterval);
@@ -4031,7 +4271,7 @@ class Database {
4031
4271
  const encMetaKey = `lacertadb_${this.name}_encryption`;
4032
4272
  let existingMetadata = null;
4033
4273
  const stored = localStorage.getItem(encMetaKey);
4034
-
4274
+
4035
4275
  if (stored) {
4036
4276
  const decoded = this._base64.decode(stored);
4037
4277
  existingMetadata = this._serializer.deserialize(decoded);
@@ -4114,7 +4354,9 @@ class Database {
4114
4354
  this._collections.set(name, collection);
4115
4355
 
4116
4356
  if (!this._metadata.collections[name]) {
4117
- this._metadata.setCollection(new CollectionMetadata(name));
4357
+ this._metadata.setCollection(new CollectionMetadata(
4358
+ name, {}, this._serializer, this._base64, this.name
4359
+ ));
4118
4360
  }
4119
4361
  return collection;
4120
4362
  }
@@ -4148,6 +4390,10 @@ class Database {
4148
4390
 
4149
4391
  this._metadata.removeCollection(name);
4150
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
+
4151
4397
  const dbName = `${this.name}_${name}`;
4152
4398
  await new Promise((resolve, reject) => {
4153
4399
  const deleteReq = indexedDB.deleteDatabase(dbName);
@@ -4246,6 +4492,7 @@ class Database {
4246
4492
  async clearAll() {
4247
4493
  await Promise.all([...this._collections.keys()].map(name => this.dropCollection(name)));
4248
4494
  this._collections.clear();
4495
+ if (this._metadata) this._metadata.destroy();
4249
4496
  this._metadata = new DatabaseMetadata(this.name, {}, this._serializer, this._base64);
4250
4497
  this._metadata.save();
4251
4498
  this._quickStore.clear();
@@ -4266,6 +4513,11 @@ class Database {
4266
4513
  this._quickStore.destroy();
4267
4514
  }
4268
4515
 
4516
+ // Destroy database metadata
4517
+ if (this._metadata) {
4518
+ this._metadata.destroy();
4519
+ }
4520
+
4269
4521
  // Destroy encryption
4270
4522
  if (this._encryption) {
4271
4523
  this._encryption.destroy();