@pixagram/lacerta-db 0.11.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * LacertaDB V0.11.1 - Production Library
2
+ * Production Library
3
3
  * @module LacertaDB
4
- * @version 0.11.1
4
+ * @version 0.12.0
5
5
  * @license MIT
6
6
  * @author Pixagram SA
7
7
  */
@@ -27,16 +27,19 @@ import TurboSerial from "@pixagram/turboserial";
27
27
  import TurboBase64 from "@pixagram/turbobase64";
28
28
 
29
29
  // Default TurboSerial configuration (overridable via LacertaDB constructor)
30
+ // Tuned for cache/entity-store workloads: plain JSON objects, no circular refs,
31
+ // no property descriptors, no internal compression (Document-level compression
32
+ // is a separate opt-in via options.compressed).
30
33
  const TURBO_SERIAL_DEFAULTS = {
31
- compression: true,
32
- preservePropertyDescriptors: true,
34
+ compression: false,
35
+ preservePropertyDescriptors: false,
33
36
  deduplication: false,
34
37
  simdOptimization: true,
35
- detectCircular: true,
36
- shareArrayBuffers: true,
38
+ detectCircular: false,
39
+ shareArrayBuffers: false,
37
40
  allowFunction: false,
38
41
  serializeFunctions: false,
39
- memoryPoolSize: 65536 * 16
42
+ memoryPoolSize: 65536
40
43
  };
41
44
 
42
45
  // ========================
@@ -45,7 +48,11 @@ const TURBO_SERIAL_DEFAULTS = {
45
48
 
46
49
  /**
47
50
  * Optimized QuickStore.
48
- * Keeps index in memory to avoid blocking main thread with JSON parsing.
51
+ * All documents live in an in-memory Map for O(1) reads (no serialization overhead).
52
+ * localStorage is only touched on:
53
+ * - Lazy hydration (first access loads all docs from localStorage into memory)
54
+ * - Debounced writes (add/update/delete schedule an async persist)
55
+ * - beforeunload flush (synchronous save of dirty entries)
49
56
  */
50
57
  class QuickStore {
51
58
  constructor(dbName, serializer, base64) {
@@ -55,15 +62,16 @@ class QuickStore {
55
62
  this._keyPrefix = `lacertadb_${dbName}_quickstore_`;
56
63
  this._indexKey = `${this._keyPrefix}index`;
57
64
 
58
- // Optimization: Keep index in memory using a Set for O(1) lookups
59
- this._indexCache = new Set();
60
- this._indexLoaded = false;
65
+ // In-memory cache: docId deserialized data
66
+ this._docs = new Map();
67
+ this._hydrated = false;
61
68
 
62
- // Async persistence state
63
- this._saveIndexTimer = null;
64
- this._dirty = false;
69
+ // Dirty tracking: set of docIds that need localStorage persistence
70
+ this._dirtyDocs = new Set();
71
+ this._dirtyIndex = false;
72
+ this._saveTimer = null;
65
73
 
66
- // Safety: Flush on unload to prevent data loss
74
+ // Safety: flush on unload
67
75
  this._flushHandler = () => this._flushSync();
68
76
  if (typeof window !== 'undefined') {
69
77
  window.addEventListener('beforeunload', this._flushHandler);
@@ -76,120 +84,114 @@ class QuickStore {
76
84
  window.removeEventListener('beforeunload', this._flushHandler);
77
85
  this._flushHandler = null;
78
86
  }
79
- if (this._saveIndexTimer) {
87
+ if (this._saveTimer) {
80
88
  if (typeof window !== 'undefined' && window.cancelIdleCallback) {
81
- window.cancelIdleCallback(this._saveIndexTimer);
89
+ window.cancelIdleCallback(this._saveTimer);
82
90
  } else {
83
- clearTimeout(this._saveIndexTimer);
91
+ clearTimeout(this._saveTimer);
84
92
  }
85
- this._saveIndexTimer = null;
93
+ this._saveTimer = null;
86
94
  }
87
95
  }
88
96
 
89
- _ensureIndexLoaded() {
90
- if (this._indexLoaded) return;
97
+ /** Lazy hydration: load all docs from localStorage into memory on first access */
98
+ _ensureHydrated() {
99
+ if (this._hydrated) return;
91
100
 
92
101
  const indexStr = localStorage.getItem(this._indexKey);
93
102
  if (indexStr) {
94
103
  try {
95
104
  const decoded = this._base64.decode(indexStr);
96
105
  const list = this._serializer.deserialize(decoded);
97
- this._indexCache = new Set(list);
106
+ for (const docId of list) {
107
+ const key = `${this._keyPrefix}data_${docId}`;
108
+ const stored = localStorage.getItem(key);
109
+ if (stored) {
110
+ try {
111
+ const decodedDoc = this._base64.decode(stored);
112
+ this._docs.set(docId, this._serializer.deserialize(decodedDoc));
113
+ } catch (e) {
114
+ // Corrupted entry — skip it
115
+ }
116
+ }
117
+ }
98
118
  } catch (e) {
99
119
  console.warn('QuickStore index corrupted, resetting.', e);
100
- this._indexCache = new Set();
101
120
  }
102
121
  }
103
- this._indexLoaded = true;
122
+ this._hydrated = true;
104
123
  }
105
124
 
106
- _scheduleIndexSave() {
107
- this._dirty = true;
108
- if (this._saveIndexTimer) return;
125
+ /** Schedule debounced persistence of dirty entries */
126
+ _scheduleSave() {
127
+ if (this._saveTimer) return;
109
128
 
110
129
  const save = () => {
111
- if (!this._dirty) return;
112
- try {
113
- const list = Array.from(this._indexCache);
114
- const serializedIndex = this._serializer.serialize(list);
115
- const encodedIndex = this._base64.encode(serializedIndex);
116
- localStorage.setItem(this._indexKey, encodedIndex);
117
- this._dirty = false;
118
- } catch (e) {
119
- if (e.name === 'QuotaExceededError') {
120
- console.error('CRITICAL: QuickStore index save failed — localStorage quota exceeded');
121
- if (typeof window !== 'undefined') {
122
- window.dispatchEvent(new CustomEvent('lacertadb:quotaexceeded', { detail: { source: 'quickstore', db: this._dbName } }));
123
- }
124
- } else {
125
- console.error('QuickStore index save failed:', e);
126
- }
127
- }
128
- this._saveIndexTimer = null;
130
+ this._saveTimer = null;
131
+ this._persistDirty();
129
132
  };
130
133
 
131
- // Debounce with idle callback to prevent UI freezing
132
134
  if (typeof window !== 'undefined' && window.requestIdleCallback) {
133
- this._saveIndexTimer = window.requestIdleCallback(save);
135
+ this._saveTimer = window.requestIdleCallback(save);
134
136
  } else {
135
- this._saveIndexTimer = setTimeout(save, 200);
137
+ this._saveTimer = setTimeout(save, 200);
136
138
  }
137
139
  }
138
140
 
139
- _flushSync() {
140
- if (!this._dirty) return;
141
+ /** Persist only dirty documents and the index if changed */
142
+ _persistDirty() {
141
143
  try {
142
- const list = Array.from(this._indexCache);
143
- const serializedIndex = this._serializer.serialize(list);
144
- const encodedIndex = this._base64.encode(serializedIndex);
145
- localStorage.setItem(this._indexKey, encodedIndex);
146
- this._dirty = false;
144
+ for (const docId of this._dirtyDocs) {
145
+ const key = `${this._keyPrefix}data_${docId}`;
146
+ const data = this._docs.get(docId);
147
+ if (data !== undefined) {
148
+ const serialized = this._serializer.serialize(data);
149
+ const encoded = this._base64.encode(serialized);
150
+ localStorage.setItem(key, encoded);
151
+ } else {
152
+ localStorage.removeItem(key);
153
+ }
154
+ }
155
+ this._dirtyDocs.clear();
156
+
157
+ if (this._dirtyIndex) {
158
+ const list = Array.from(this._docs.keys());
159
+ const serialized = this._serializer.serialize(list);
160
+ const encoded = this._base64.encode(serialized);
161
+ localStorage.setItem(this._indexKey, encoded);
162
+ this._dirtyIndex = false;
163
+ }
147
164
  } catch (e) {
148
165
  if (e.name === 'QuotaExceededError') {
149
- console.error('CRITICAL: QuickStore flush failed — localStorage quota exceeded');
166
+ console.error('CRITICAL: QuickStore save failed — localStorage quota exceeded');
150
167
  if (typeof window !== 'undefined') {
151
- window.dispatchEvent(new CustomEvent('lacertadb:quotaexceeded', { detail: { source: 'quickstore_flush', db: this._dbName } }));
168
+ window.dispatchEvent(new CustomEvent('lacertadb:quotaexceeded', { detail: { source: 'quickstore', db: this._dbName } }));
152
169
  }
153
170
  } else {
154
- console.error('QuickStore flush failed:', e);
171
+ console.error('QuickStore save failed:', e);
155
172
  }
156
173
  }
157
174
  }
158
175
 
159
- add(docId, data) {
160
- this._ensureIndexLoaded();
161
- const key = `${this._keyPrefix}data_${docId}`;
162
- try {
163
- const serializedData = this._serializer.serialize(data);
164
- const encodedData = this._base64.encode(serializedData);
165
- localStorage.setItem(key, encodedData);
176
+ _flushSync() {
177
+ if (this._dirtyDocs.size === 0 && !this._dirtyIndex) return;
178
+ this._persistDirty();
179
+ }
166
180
 
167
- if (!this._indexCache.has(docId)) {
168
- this._indexCache.add(docId);
169
- this._scheduleIndexSave();
170
- }
171
- return true;
172
- } catch (e) {
173
- if (e.name === 'QuotaExceededError') {
174
- throw new LacertaDBError('QuickStore quota exceeded', 'QUOTA_EXCEEDED', e);
175
- }
176
- return false;
177
- }
181
+ add(docId, data) {
182
+ this._ensureHydrated();
183
+ const isNew = !this._docs.has(docId);
184
+ this._docs.set(docId, data);
185
+ this._dirtyDocs.add(docId);
186
+ if (isNew) this._dirtyIndex = true;
187
+ this._scheduleSave();
188
+ return true;
178
189
  }
179
190
 
180
191
  get(docId) {
181
- // Direct O(1) access
182
- const key = `${this._keyPrefix}data_${docId}`;
183
- const stored = localStorage.getItem(key);
184
- if (stored) {
185
- try {
186
- const decoded = this._base64.decode(stored);
187
- return this._serializer.deserialize(decoded);
188
- } catch (e) {
189
- console.error('Failed to parse QuickStore data:', e);
190
- }
191
- }
192
- return null;
192
+ this._ensureHydrated();
193
+ const data = this._docs.get(docId);
194
+ return data !== undefined ? data : null;
193
195
  }
194
196
 
195
197
  update(docId, data) {
@@ -197,22 +199,20 @@ class QuickStore {
197
199
  }
198
200
 
199
201
  delete(docId) {
200
- this._ensureIndexLoaded();
201
- const key = `${this._keyPrefix}data_${docId}`;
202
- localStorage.removeItem(key);
203
-
204
- if (this._indexCache.has(docId)) {
205
- this._indexCache.delete(docId);
206
- this._scheduleIndexSave();
202
+ this._ensureHydrated();
203
+ if (this._docs.has(docId)) {
204
+ this._docs.delete(docId);
205
+ this._dirtyDocs.add(docId); // marks for localStorage removal
206
+ this._dirtyIndex = true;
207
+ this._scheduleSave();
207
208
  }
208
209
  }
209
210
 
210
211
  getAll() {
211
- this._ensureIndexLoaded();
212
+ this._ensureHydrated();
212
213
  const results = [];
213
- for (const docId of this._indexCache) {
214
- const doc = this.get(docId);
215
- if (doc) results.push({ _id: docId, ...doc });
214
+ for (const [docId, data] of this._docs) {
215
+ results.push({ _id: docId, ...data });
216
216
  }
217
217
  return results;
218
218
  }
@@ -224,23 +224,27 @@ class QuickStore {
224
224
  }
225
225
 
226
226
  clear() {
227
- this._ensureIndexLoaded();
228
- for (const docId of this._indexCache) {
227
+ this._ensureHydrated();
228
+ for (const docId of this._docs.keys()) {
229
229
  localStorage.removeItem(`${this._keyPrefix}data_${docId}`);
230
230
  }
231
231
  localStorage.removeItem(this._indexKey);
232
- this._indexCache.clear();
233
- this._dirty = false;
234
- if (this._saveIndexTimer) {
235
- if (window.cancelIdleCallback) window.cancelIdleCallback(this._saveIndexTimer);
236
- else clearTimeout(this._saveIndexTimer);
237
- this._saveIndexTimer = null;
232
+ this._docs.clear();
233
+ this._dirtyDocs.clear();
234
+ this._dirtyIndex = false;
235
+ if (this._saveTimer) {
236
+ if (typeof window !== 'undefined' && window.cancelIdleCallback) {
237
+ window.cancelIdleCallback(this._saveTimer);
238
+ } else {
239
+ clearTimeout(this._saveTimer);
240
+ }
241
+ this._saveTimer = null;
238
242
  }
239
243
  }
240
244
 
241
245
  get size() {
242
- this._ensureIndexLoaded();
243
- return this._indexCache.size;
246
+ this._ensureHydrated();
247
+ return this._docs.size;
244
248
  }
245
249
  }
246
250
 
@@ -364,8 +368,9 @@ class LacertaDBError extends Error {
364
368
  this.name = 'LacertaDBError';
365
369
  this.code = code;
366
370
  this.originalError = originalError || null;
367
- this.timestamp = new Date().toISOString();
371
+ this._ts = Date.now();
368
372
  }
373
+ get timestamp() { return new Date(this._ts).toISOString(); }
369
374
  }
370
375
 
371
376
  // ========================
@@ -1791,6 +1796,52 @@ class BTreeIndex {
1791
1796
  get size() {
1792
1797
  return this._size;
1793
1798
  }
1799
+
1800
+ /**
1801
+ * Export all entries as a flat sorted array for persistence.
1802
+ * Format: [[key, [docId1, docId2, ...]], ...]
1803
+ * @returns {Array}
1804
+ */
1805
+ toSortedEntries() {
1806
+ if (!this._root) return [];
1807
+ const entries = [];
1808
+ this._collectInOrder(this._root, entries);
1809
+ return entries;
1810
+ }
1811
+
1812
+ /** @private In-order traversal to collect all key-value pairs */
1813
+ _collectInOrder(node, entries) {
1814
+ for (let i = 0; i < node.n; i++) {
1815
+ if (!node.leaf && node.children[i]) {
1816
+ this._collectInOrder(node.children[i], entries);
1817
+ }
1818
+ if (node.values[i] && node.values[i].size > 0) {
1819
+ entries.push([node.keys[i], Array.from(node.values[i])]);
1820
+ }
1821
+ }
1822
+ if (!node.leaf && node.children[node.n]) {
1823
+ this._collectInOrder(node.children[node.n], entries);
1824
+ }
1825
+ }
1826
+
1827
+ /**
1828
+ * Restore a BTreeIndex from persisted sorted entries.
1829
+ * Much faster than full document scan + unpack.
1830
+ * @param {Array} entries - [[key, [docId1, ...]], ...]
1831
+ * @param {number} [order=4]
1832
+ * @returns {BTreeIndex}
1833
+ */
1834
+ static fromSortedEntries(entries, order = 4) {
1835
+ const tree = new BTreeIndex(order);
1836
+ for (let i = 0; i < entries.length; i++) {
1837
+ const [key, values] = entries[i];
1838
+ if (key === undefined || key === null) continue;
1839
+ for (let j = 0; j < values.length; j++) {
1840
+ tree.insert(key, values[j]);
1841
+ }
1842
+ }
1843
+ return tree;
1844
+ }
1794
1845
  }
1795
1846
 
1796
1847
  // ========================
@@ -1977,12 +2028,20 @@ class IndexManager {
1977
2028
  this._indexData = new Map();
1978
2029
  this._indexQueue = [];
1979
2030
  this._processing = false;
2031
+
2032
+ // Debounced persistence — coalesce many writes into one IDB save
2033
+ this._dirtyIndexes = new Set();
2034
+ this._persistTimer = null;
2035
+ this._persistDelay = 2000; // ms — save at most every 2s
1980
2036
  }
1981
2037
 
1982
2038
  get indexes() {
1983
2039
  return this._indexes;
1984
2040
  }
1985
2041
 
2042
+ /** Reserved _id prefix for persisted index entries in the documents store */
2043
+ static get IDX_PREFIX() { return '__lacerta_idx_'; }
2044
+
1986
2045
  async createIndex(fieldPath, options = {}) {
1987
2046
  const indexName = options.name || fieldPath;
1988
2047
 
@@ -2009,6 +2068,11 @@ class IndexManager {
2009
2068
  return indexName;
2010
2069
  }
2011
2070
 
2071
+ /**
2072
+ * Full rebuild: scan all documents from IDB, extract field values, build index.
2073
+ * This is the SLOW path — only used on first-ever index creation or when
2074
+ * persisted index data is missing/corrupt.
2075
+ */
2012
2076
  async rebuildIndex(indexName) {
2013
2077
  const index = this._indexes.get(indexName);
2014
2078
  if (!index) {
@@ -2018,25 +2082,26 @@ class IndexManager {
2018
2082
  const indexData = this._createIndexStructure(index.type);
2019
2083
  this._indexData.set(indexName, indexData);
2020
2084
 
2021
- // Optimization: Use Batched Processing instead of single Cursor
2022
- // This prevents transaction timeouts caused by async crypto operations inside the loop
2023
2085
  let lastKey = null;
2024
- const batchSize = 100; // Keep batch small for responsiveness
2086
+ const batchSize = 200;
2025
2087
 
2026
2088
  while (true) {
2027
- // 1. Fetch Batch (Transaction opens and closes here)
2028
2089
  const batch = await this._collection._indexedDB.getBatch(
2029
2090
  this._collection._db,
2030
- 'documents',
2091
+ this._collection._storeName,
2031
2092
  lastKey,
2032
2093
  batchSize
2033
2094
  );
2034
2095
 
2035
2096
  if (batch.length === 0) break;
2036
2097
 
2037
- // 2. Process Batch (Async crypto operations safe here)
2038
2098
  for (const docData of batch) {
2039
- lastKey = docData._id; // Update for next batch
2099
+ // Skip persisted index entries
2100
+ if (typeof docData._id === 'string' && docData._id.startsWith(IndexManager.IDX_PREFIX)) {
2101
+ lastKey = docData._id;
2102
+ continue;
2103
+ }
2104
+ lastKey = docData._id;
2040
2105
  let doc = docData;
2041
2106
 
2042
2107
  if (docData.packedData) {
@@ -2044,7 +2109,6 @@ class IndexManager {
2044
2109
  compressed: docData._compressed,
2045
2110
  encrypted: docData._encrypted
2046
2111
  }, this._serializer);
2047
- // This await is what killed the transaction before
2048
2112
  await d.unpack(this._collection.database.encryption);
2049
2113
  doc = d.objectOutput();
2050
2114
  }
@@ -2056,7 +2120,6 @@ class IndexManager {
2056
2120
  }
2057
2121
 
2058
2122
  if (index.unique && indexData.has && indexData.has(value)) {
2059
- console.error(`Unique constraint violation on index '${indexName}'`);
2060
2123
  continue;
2061
2124
  }
2062
2125
 
@@ -2066,9 +2129,106 @@ class IndexManager {
2066
2129
 
2067
2130
  this._addToIndex(indexData, value, doc._id, index.type);
2068
2131
  }
2132
+ }
2133
+
2134
+ // Persist immediately after a full rebuild so next load is fast
2135
+ await this._persistIndex(indexName);
2136
+ }
2137
+
2138
+ /**
2139
+ * FAST PATH: Restore a BTree index from persisted entries stored in IDB.
2140
+ * Returns true if successful, false if persisted data is missing/corrupt.
2141
+ * @param {string} indexName
2142
+ * @returns {Promise<boolean>}
2143
+ */
2144
+ async _restoreIndex(indexName) {
2145
+ const index = this._indexes.get(indexName);
2146
+ if (!index || index.type !== 'btree') return false;
2147
+
2148
+ try {
2149
+ const docId = `${IndexManager.IDX_PREFIX}${indexName}`;
2150
+ const stored = await this._collection._indexedDB.get(
2151
+ this._collection._db, this._collection._storeName, docId
2152
+ );
2153
+
2154
+ if (!stored || !stored._entries || !Array.isArray(stored._entries)) {
2155
+ return false;
2156
+ }
2157
+
2158
+ // Restore B-Tree from sorted entries — no document scanning needed
2159
+ const btree = BTreeIndex.fromSortedEntries(stored._entries, 4);
2160
+
2161
+ // Quick sanity check
2162
+ const v = btree.verify();
2163
+ if (!v.healthy) {
2164
+ console.warn(`[IndexManager] Persisted index '${indexName}' is corrupt, will rebuild`);
2165
+ return false;
2166
+ }
2167
+
2168
+ this._indexData.set(indexName, btree);
2169
+ return true;
2170
+ } catch (e) {
2171
+ return false;
2172
+ }
2173
+ }
2174
+
2175
+ /**
2176
+ * Persist a single BTree index's entries to IDB.
2177
+ * Stored as a document with reserved _id in the existing 'documents' store.
2178
+ * @param {string} indexName
2179
+ */
2180
+ async _persistIndex(indexName) {
2181
+ const indexData = this._indexData.get(indexName);
2182
+ if (!indexData || !(indexData instanceof BTreeIndex)) return;
2183
+
2184
+ try {
2185
+ const docId = `${IndexManager.IDX_PREFIX}${indexName}`;
2186
+ const payload = {
2187
+ _id: docId,
2188
+ _entries: indexData.toSortedEntries(),
2189
+ _persisted_at: Date.now(),
2190
+ _size: indexData.size
2191
+ };
2192
+
2193
+ await this._collection._indexedDB.put(
2194
+ this._collection._db, this._collection._storeName, payload
2195
+ );
2196
+ } catch (e) {
2197
+ console.warn(`[IndexManager] Failed to persist index '${indexName}':`, e.message);
2198
+ }
2199
+ }
2200
+
2201
+ /**
2202
+ * Schedule a debounced persist for modified indexes.
2203
+ * Coalesces rapid writes into a single IDB save.
2204
+ * @param {string} indexName
2205
+ */
2206
+ _schedulePersist(indexName) {
2207
+ this._dirtyIndexes.add(indexName);
2208
+
2209
+ if (this._persistTimer) return;
2210
+
2211
+ this._persistTimer = setTimeout(async () => {
2212
+ this._persistTimer = null;
2213
+ const dirty = Array.from(this._dirtyIndexes);
2214
+ this._dirtyIndexes.clear();
2215
+
2216
+ for (const name of dirty) {
2217
+ await this._persistIndex(name);
2218
+ }
2219
+ }, this._persistDelay);
2220
+ }
2069
2221
 
2070
- // Optional: Yield to main thread briefly to prevent UI freeze
2071
- if (window.requestIdleCallback) await new Promise(r => window.requestIdleCallback(r));
2222
+ /** Flush any pending index persistence immediately (e.g., before page unload) */
2223
+ async flushPersistence() {
2224
+ if (this._persistTimer) {
2225
+ clearTimeout(this._persistTimer);
2226
+ this._persistTimer = null;
2227
+ }
2228
+ const dirty = Array.from(this._dirtyIndexes);
2229
+ this._dirtyIndexes.clear();
2230
+ for (const name of dirty) {
2231
+ await this._persistIndex(name);
2072
2232
  }
2073
2233
  }
2074
2234
 
@@ -2156,6 +2316,11 @@ class IndexManager {
2156
2316
  if (newValue) indexData.addPoint(newValue, docId);
2157
2317
  break;
2158
2318
  }
2319
+
2320
+ // Schedule async persistence for modified btree indexes
2321
+ if (index.type === 'btree') {
2322
+ this._schedulePersist(indexName);
2323
+ }
2159
2324
  }
2160
2325
  }
2161
2326
 
@@ -2190,7 +2355,6 @@ class IndexManager {
2190
2355
  }
2191
2356
 
2192
2357
  _queryBTree(indexData, options) {
2193
- // Handle simple value query
2194
2358
  if (typeof options !== 'object' || options === null) {
2195
2359
  return indexData.find(options);
2196
2360
  }
@@ -2202,7 +2366,6 @@ class IndexManager {
2202
2366
  docs.forEach(doc => results.add(doc));
2203
2367
  }
2204
2368
 
2205
- // Combined range queries: pick the tightest bounds and correct exclusivity
2206
2369
  const hasGte = options.$gte !== undefined;
2207
2370
  const hasGt = options.$gt !== undefined;
2208
2371
  const hasLte = options.$lte !== undefined;
@@ -2264,7 +2427,12 @@ class IndexManager {
2264
2427
  dropIndex(indexName) {
2265
2428
  this._indexes.delete(indexName);
2266
2429
  this._indexData.delete(indexName);
2430
+ this._dirtyIndexes.delete(indexName);
2267
2431
  this._saveIndexMetadata();
2432
+
2433
+ // Remove persisted index from IDB (fire-and-forget)
2434
+ const docId = `${IndexManager.IDX_PREFIX}${indexName}`;
2435
+ this._collection._indexedDB.delete(this._collection._db, this._collection._storeName, docId).catch(() => {});
2268
2436
  }
2269
2437
 
2270
2438
  _getFieldValue(doc, path) {
@@ -2302,6 +2470,11 @@ class IndexManager {
2302
2470
  });
2303
2471
  }
2304
2472
 
2473
+ /**
2474
+ * Load index definitions and restore persisted index data.
2475
+ * FAST PATH: Restore BTree from persisted entries (no document scanning).
2476
+ * SLOW PATH: Full rebuild only if persisted data is missing/corrupt.
2477
+ */
2305
2478
  async loadIndexMetadata() {
2306
2479
  const key = `lacertadb_${this._collection.database.name}_${this._collection.name}_indexes`;
2307
2480
  const stored = localStorage.getItem(key);
@@ -2317,8 +2490,27 @@ class IndexManager {
2317
2490
  this._indexes.set(name, index);
2318
2491
  }
2319
2492
 
2320
- for (const indexName of this._indexes.keys()) {
2321
- await this.rebuildIndex(indexName);
2493
+ // Try to restore each index from persisted IDB data (fast path).
2494
+ // Only fall back to full rebuild for indexes that can't be restored.
2495
+ const needsRebuild = [];
2496
+
2497
+ for (const [indexName, index] of this._indexes) {
2498
+ if (index.type === 'btree') {
2499
+ const restored = await this._restoreIndex(indexName);
2500
+ if (!restored) {
2501
+ needsRebuild.push(indexName);
2502
+ }
2503
+ } else {
2504
+ // Non-btree indexes (text, geo, hash) always need rebuild
2505
+ needsRebuild.push(indexName);
2506
+ }
2507
+ }
2508
+
2509
+ if (needsRebuild.length > 0) {
2510
+ // Rebuild only the indexes that couldn't be restored
2511
+ for (const indexName of needsRebuild) {
2512
+ await this.rebuildIndex(indexName);
2513
+ }
2322
2514
  }
2323
2515
  } catch (error) {
2324
2516
  console.error('Failed to load index metadata:', error);
@@ -2355,7 +2547,6 @@ class IndexManager {
2355
2547
  } else if (indexData.verify) {
2356
2548
  const result = indexData.verify();
2357
2549
  if (result.requiresRebuild) {
2358
- // BTree detected structural issues — rebuild the index from source data
2359
2550
  await this.rebuildIndex(name);
2360
2551
  result.rebuilt = true;
2361
2552
  }
@@ -2368,6 +2559,10 @@ class IndexManager {
2368
2559
  }
2369
2560
 
2370
2561
  destroy() {
2562
+ if (this._persistTimer) {
2563
+ clearTimeout(this._persistTimer);
2564
+ this._persistTimer = null;
2565
+ }
2371
2566
  for (const [name, indexData] of this._indexData) {
2372
2567
  if (indexData && indexData.clear) {
2373
2568
  indexData.clear();
@@ -2375,6 +2570,7 @@ class IndexManager {
2375
2570
  }
2376
2571
  this._indexData.clear();
2377
2572
  this._indexes.clear();
2573
+ this._dirtyIndexes.clear();
2378
2574
  this._indexQueue = [];
2379
2575
  this._processing = false;
2380
2576
  }
@@ -2607,9 +2803,9 @@ class IndexedDBUtility {
2607
2803
  });
2608
2804
  }
2609
2805
 
2610
- async batchOperation(db, operations) {
2611
- return this.performTransaction(db, ['documents'], 'readwrite', tx => {
2612
- const store = tx.objectStore('documents');
2806
+ async batchOperation(db, operations, storeName = 'documents') {
2807
+ return this.performTransaction(db, [storeName], 'readwrite', tx => {
2808
+ const store = tx.objectStore(storeName);
2613
2809
 
2614
2810
  // CRITICAL: Queue ALL IDB requests synchronously to prevent
2615
2811
  // TransactionInactiveError. Do NOT use await between requests.
@@ -3632,7 +3828,8 @@ class Collection {
3632
3828
  this.database = database;
3633
3829
  this._serializer = database._serializer;
3634
3830
  this._base64 = database._base64;
3635
- this._db = null;
3831
+ this._db = null; // Reference to parent's consolidated IDB connection
3832
+ this._storeName = name; // Object store name within the consolidated database
3636
3833
  this._metadata = null;
3637
3834
  this._settings = database.settings;
3638
3835
  this._indexedDB = new IndexedDBUtility();
@@ -3648,6 +3845,12 @@ class Collection {
3648
3845
  enabled: true
3649
3846
  });
3650
3847
 
3848
+ // Document-level cache: avoids IDB reads + deserialization for repeated get() calls
3849
+ this._docCache = new LRUCache(200);
3850
+
3851
+ // Pending indexes: definitions registered before init() — applied during init
3852
+ this._pendingIndexes = [];
3853
+
3651
3854
  this._performanceMonitor = database.performanceMonitor;
3652
3855
  this._initialized = false;
3653
3856
  }
@@ -3667,13 +3870,10 @@ class Collection {
3667
3870
  async init() {
3668
3871
  if (this._initialized) return this;
3669
3872
 
3670
- const dbName = `${this.database.name}_${this.name}`;
3671
- this._db = await connectionPool.getConnection(dbName, 1, (db, oldVersion) => {
3672
- if (oldVersion < 1 && !db.objectStoreNames.contains('documents')) {
3673
- const store = db.createObjectStore('documents', {keyPath: '_id'});
3674
- store.createIndex('modified', '_modified', {unique: false});
3675
- }
3676
- });
3873
+ // Use the parent Database's consolidated IDB connection
3874
+ // (ensure store exists in case ensureCollection was used without createCollection)
3875
+ await this.database._ensureStore(this._storeName);
3876
+ this._db = this.database._db;
3677
3877
 
3678
3878
  // Load per-collection metadata (with per-doc tracking) from its own localStorage key
3679
3879
  this._metadata = CollectionMetadata.load(
@@ -3682,6 +3882,16 @@ class Collection {
3682
3882
 
3683
3883
  await this._indexManager.loadIndexMetadata();
3684
3884
 
3885
+ // Apply any indexes that were registered before init()
3886
+ if (this._pendingIndexes.length > 0) {
3887
+ for (const { fieldPath, options } of this._pendingIndexes) {
3888
+ if (!this._indexManager.indexes.has(options.name || fieldPath)) {
3889
+ await this._indexManager.createIndex(fieldPath, options).catch(() => {});
3890
+ }
3891
+ }
3892
+ this._pendingIndexes = [];
3893
+ }
3894
+
3685
3895
  if (this._settings.freeSpaceEvery > 0 && this._settings.sizeLimitKB !== Infinity) {
3686
3896
  this._cleanupInterval = setInterval(() => this._freeSpace(), this._settings.freeSpaceEvery);
3687
3897
  }
@@ -3692,6 +3902,11 @@ class Collection {
3692
3902
 
3693
3903
  // Index methods
3694
3904
  async createIndex(fieldPath, options = {}) {
3905
+ // If not yet initialized, queue the definition — will be applied during init()
3906
+ if (!this._initialized) {
3907
+ this._pendingIndexes.push({ fieldPath, options });
3908
+ return options.name || fieldPath;
3909
+ }
3695
3910
  return await this._indexManager.createIndex(fieldPath, options);
3696
3911
  }
3697
3912
 
@@ -3724,7 +3939,7 @@ class Collection {
3724
3939
  await this._trigger('beforeAdd', documentData);
3725
3940
 
3726
3941
  const doc = new Document({data: documentData, _id: options.id}, {
3727
- compressed: options.compressed !== false,
3942
+ compressed: options.compressed || false,
3728
3943
  permanent: options.permanent || false
3729
3944
  }, this._serializer);
3730
3945
 
@@ -3740,7 +3955,7 @@ class Collection {
3740
3955
 
3741
3956
  await doc.pack(this.database.encryption);
3742
3957
  const dbOutput = doc.databaseOutput();
3743
- await this._indexedDB.add(this._db, 'documents', dbOutput);
3958
+ await this._indexedDB.add(this._db, this._storeName, dbOutput);
3744
3959
 
3745
3960
  const fullDoc = doc.objectOutput();
3746
3961
  await this._indexManager.updateIndexForDocument(doc._id, null, fullDoc);
@@ -3752,15 +3967,20 @@ class Collection {
3752
3967
  await this._checkSpaceLimit();
3753
3968
  await this._trigger('afterAdd', doc);
3754
3969
  this._cacheStrategy.clear();
3970
+ this._docCache.set(doc._id, fullDoc);
3755
3971
  return doc._id;
3756
3972
  }
3757
3973
 
3758
3974
  async get(docId, options = {}) {
3759
3975
  if (!this._initialized) await this.init();
3760
3976
 
3761
- await this._trigger('beforeGet', docId);
3977
+ // Document-level cache: return immediately if cached (skips IDB + deserialize)
3978
+ if (!options.includeAttachments) {
3979
+ const cached = this._docCache.get(docId);
3980
+ if (cached) return cached;
3981
+ }
3762
3982
 
3763
- const stored = await this._indexedDB.get(this._db, 'documents', docId);
3983
+ const stored = await this._indexedDB.get(this._db, this._storeName, docId);
3764
3984
  if (!stored) {
3765
3985
  throw new LacertaDBError(`Document with id '${docId}' not found.`, 'DOCUMENT_NOT_FOUND');
3766
3986
  }
@@ -3779,14 +3999,21 @@ class Collection {
3779
3999
  }
3780
4000
 
3781
4001
  await this._trigger('afterGet', doc);
3782
- return doc.objectOutput(options.includeAttachments);
4002
+ const output = doc.objectOutput(options.includeAttachments);
4003
+ // Populate document cache (skip if attachments were included — those are transient)
4004
+ if (!options.includeAttachments) {
4005
+ this._docCache.set(docId, output);
4006
+ }
4007
+ return output;
3783
4008
  }
3784
4009
 
3785
4010
  async getAll(options = {}) {
3786
4011
  if (!this._initialized) await this.init();
3787
4012
 
3788
- const stored = await this._indexedDB.getAll(this._db, 'documents', undefined, options.limit);
3789
- return Promise.all(stored.map(async docData => {
4013
+ const stored = await this._indexedDB.getAll(this._db, this._storeName, undefined, options.limit);
4014
+ // Filter out persisted index entries (reserved _id prefix)
4015
+ const userDocs = stored.filter(d => !(typeof d._id === 'string' && d._id.startsWith(IndexManager.IDX_PREFIX)));
4016
+ return Promise.all(userDocs.map(async docData => {
3790
4017
  try {
3791
4018
  const doc = new Document(docData, {
3792
4019
  encrypted: docData._encrypted,
@@ -3808,7 +4035,7 @@ class Collection {
3808
4035
 
3809
4036
  await this._trigger('beforeUpdate', {docId, updates});
3810
4037
 
3811
- const stored = await this._indexedDB.get(this._db, 'documents', docId);
4038
+ const stored = await this._indexedDB.get(this._db, this._storeName, docId);
3812
4039
  if (!stored) {
3813
4040
  throw new LacertaDBError(`Document with id '${docId}' not found for update.`, 'DOCUMENT_NOT_FOUND');
3814
4041
  }
@@ -3844,7 +4071,7 @@ class Collection {
3844
4071
 
3845
4072
  await doc.pack(this.database.encryption);
3846
4073
  const dbOutput = doc.databaseOutput();
3847
- await this._indexedDB.put(this._db, 'documents', dbOutput);
4074
+ await this._indexedDB.put(this._db, this._storeName, dbOutput);
3848
4075
 
3849
4076
  const newDocOutput = doc.objectOutput();
3850
4077
  await this._indexManager.updateIndexForDocument(doc._id, oldDocOutput, newDocOutput);
@@ -3855,6 +4082,7 @@ class Collection {
3855
4082
 
3856
4083
  await this._trigger('afterUpdate', doc);
3857
4084
  this._cacheStrategy.clear();
4085
+ this._docCache.set(doc._id, newDocOutput);
3858
4086
  return doc._id;
3859
4087
  }
3860
4088
 
@@ -3863,7 +4091,7 @@ class Collection {
3863
4091
 
3864
4092
  await this._trigger('beforeDelete', docId);
3865
4093
 
3866
- const doc = await this._indexedDB.get(this._db, 'documents', docId);
4094
+ const doc = await this._indexedDB.get(this._db, this._storeName, docId);
3867
4095
  if (!doc) {
3868
4096
  throw new LacertaDBError('Document not found for deletion', 'DOCUMENT_NOT_FOUND');
3869
4097
  }
@@ -3883,7 +4111,7 @@ class Collection {
3883
4111
 
3884
4112
  await this._indexManager.updateIndexForDocument(docId, fullDoc, null);
3885
4113
 
3886
- await this._indexedDB.delete(this._db, 'documents', docId);
4114
+ await this._indexedDB.delete(this._db, this._storeName, docId);
3887
4115
  const attachments = doc._attachments;
3888
4116
  if (attachments && attachments.length > 0) {
3889
4117
  await this._opfs.deleteAttachments(this.database.name, this.name, docId);
@@ -3894,9 +4122,8 @@ class Collection {
3894
4122
 
3895
4123
  await this._trigger('afterDelete', docId);
3896
4124
  this._cacheStrategy.clear();
3897
- }
3898
-
3899
- async query(filter = {}, options = {}) {
4125
+ this._docCache.delete(docId);
4126
+ } async query(filter = {}, options = {}) {
3900
4127
  if (!this._initialized) await this.init();
3901
4128
 
3902
4129
  const startTime = performance.now();
@@ -3954,8 +4181,18 @@ class Collection {
3954
4181
  if (!this._initialized) await this.init();
3955
4182
 
3956
4183
  const startTime = performance.now();
3957
- const docs = await this.getAll();
3958
- const result = await aggregationPipeline.execute(docs, pipeline, this.database);
4184
+
4185
+ // Optimization: push leading $match down to query() which can use indexes
4186
+ let docs;
4187
+ let remainingPipeline = pipeline;
4188
+ if (pipeline.length > 0 && pipeline[0].$match) {
4189
+ docs = await this.query(pipeline[0].$match);
4190
+ remainingPipeline = pipeline.slice(1);
4191
+ } else {
4192
+ docs = await this.getAll();
4193
+ }
4194
+
4195
+ const result = await aggregationPipeline.execute(docs, remainingPipeline, this.database);
3959
4196
  if (this._performanceMonitor) this._performanceMonitor.recordOperation('aggregate', performance.now() - startTime);
3960
4197
  return result;
3961
4198
  }
@@ -3966,14 +4203,19 @@ class Collection {
3966
4203
  const startTime = performance.now();
3967
4204
  const operations = [];
3968
4205
  const results = [];
4206
+ const useSync = !this.database.encryption && !(options.compressed);
3969
4207
 
3970
4208
  for (const documentData of documents) {
3971
4209
  const doc = new Document({data: documentData}, {
3972
- compressed: options.compressed !== false,
4210
+ compressed: options.compressed || false,
3973
4211
  permanent: options.permanent || false
3974
4212
  }, this._serializer);
3975
4213
 
3976
- await doc.pack(this.database.encryption);
4214
+ if (useSync) {
4215
+ doc.packSync();
4216
+ } else {
4217
+ await doc.pack(this.database.encryption);
4218
+ }
3977
4219
  operations.push({
3978
4220
  type: 'add',
3979
4221
  data: doc.databaseOutput()
@@ -3981,7 +4223,7 @@ class Collection {
3981
4223
  results.push(doc);
3982
4224
  }
3983
4225
 
3984
- const dbResults = await this._indexedDB.batchOperation(this._db, operations);
4226
+ const dbResults = await this._indexedDB.batchOperation(this._db, operations, this._storeName);
3985
4227
 
3986
4228
  for (let i = 0; i < results.length; i++) {
3987
4229
  if (dbResults[i].success) {
@@ -3991,6 +4233,7 @@ class Collection {
3991
4233
 
3992
4234
  const sizeKB = doc._packedData.byteLength / 1024;
3993
4235
  this._metadata.addDocument(doc._id, sizeKB, doc._permanent, 0);
4236
+ this._docCache.set(doc._id, fullDoc);
3994
4237
  }
3995
4238
  }
3996
4239
 
@@ -4013,10 +4256,22 @@ class Collection {
4013
4256
  const oldDocs = [];
4014
4257
  const newDocs = [];
4015
4258
  const skipped = [];
4259
+ const useSync = !this.database.encryption && !(options.compressed);
4260
+
4261
+ // Phase 1: Bulk-fetch all existing docs in a single IDB read transaction
4262
+ const updateIds = updates.map(u => u.id);
4263
+ const storedMap = new Map();
4264
+
4265
+ // Fetch all at once via getAll, then build a Map for O(1) lookup
4266
+ const allStored = await this._indexedDB.getAll(this._db, this._storeName);
4267
+ for (const doc of allStored) {
4268
+ if (doc._id && updateIds.includes(doc._id)) {
4269
+ storedMap.set(doc._id, doc);
4270
+ }
4271
+ }
4016
4272
 
4017
- // Phase 1: Read all existing docs and prepare put operations
4018
4273
  for (const update of updates) {
4019
- const stored = await this._indexedDB.get(this._db, 'documents', update.id);
4274
+ const stored = storedMap.get(update.id);
4020
4275
  if (!stored) {
4021
4276
  skipped.push({ success: false, id: update.id, error: 'Document not found' });
4022
4277
  continue;
@@ -4039,7 +4294,11 @@ class Collection {
4039
4294
  doc._modified = Date.now();
4040
4295
  doc._attachments = stored._attachments;
4041
4296
 
4042
- await doc.pack(this.database.encryption);
4297
+ if (useSync) {
4298
+ doc.packSync();
4299
+ } else {
4300
+ await doc.pack(this.database.encryption);
4301
+ }
4043
4302
  newDocs.push(doc);
4044
4303
 
4045
4304
  operations.push({
@@ -4051,16 +4310,18 @@ class Collection {
4051
4310
  if (operations.length === 0) return skipped;
4052
4311
 
4053
4312
  // Phase 2: Single-transaction write
4054
- const dbResults = await this._indexedDB.batchOperation(this._db, operations);
4313
+ const dbResults = await this._indexedDB.batchOperation(this._db, operations, this._storeName);
4055
4314
 
4056
- // Phase 3: Update indexes and metadata post-transaction
4315
+ // Phase 3: Update indexes, metadata, and doc cache post-transaction
4057
4316
  for (let i = 0; i < newDocs.length; i++) {
4058
4317
  if (dbResults[i].success) {
4059
4318
  const doc = newDocs[i];
4060
- await this._indexManager.updateIndexForDocument(doc._id, oldDocs[i], doc.objectOutput());
4319
+ const newOutput = doc.objectOutput();
4320
+ await this._indexManager.updateIndexForDocument(doc._id, oldDocs[i], newOutput);
4061
4321
 
4062
4322
  const sizeKB = doc._packedData.byteLength / 1024;
4063
4323
  this._metadata.updateDocument(doc._id, sizeKB, doc._permanent, doc._attachments.length);
4324
+ this._docCache.set(doc._id, newOutput);
4064
4325
  }
4065
4326
  }
4066
4327
 
@@ -4094,7 +4355,7 @@ class Collection {
4094
4355
 
4095
4356
  // Phase 1: Validate all documents and prepare delete operations
4096
4357
  for (const { id, options } of normalizedItems) {
4097
- const doc = await this._indexedDB.get(this._db, 'documents', id);
4358
+ const doc = await this._indexedDB.get(this._db, this._storeName, id);
4098
4359
  if (!doc) {
4099
4360
  skipped.push({ success: false, id, error: 'Document not found' });
4100
4361
  continue;
@@ -4117,7 +4378,7 @@ class Collection {
4117
4378
  if (operations.length === 0) return skipped;
4118
4379
 
4119
4380
  // Phase 2: Single-transaction delete
4120
- const dbResults = await this._indexedDB.batchOperation(this._db, operations);
4381
+ const dbResults = await this._indexedDB.batchOperation(this._db, operations, this._storeName);
4121
4382
 
4122
4383
  // Phase 3: Update indexes, OPFS cleanup, and metadata post-transaction
4123
4384
  for (let i = 0; i < docsToRemove.length; i++) {
@@ -4130,6 +4391,7 @@ class Collection {
4130
4391
  }
4131
4392
 
4132
4393
  this._metadata.removeDocument(id);
4394
+ this._docCache.delete(id);
4133
4395
  }
4134
4396
  }
4135
4397
 
@@ -4173,14 +4435,16 @@ class Collection {
4173
4435
  }
4174
4436
 
4175
4437
  async _trigger(event, data) {
4176
- if (!this._events.has(event)) return;
4177
- for (const callback of this._events.get(event)) {
4438
+ const listeners = this._events.get(event);
4439
+ if (!listeners || listeners.length === 0) return;
4440
+ for (const callback of listeners) {
4178
4441
  await callback(data);
4179
4442
  }
4180
4443
  }
4181
4444
 
4182
4445
  clearCache() {
4183
4446
  this._cacheStrategy.clear();
4447
+ this._docCache.clear();
4184
4448
  }
4185
4449
 
4186
4450
  async clear(options = {}) {
@@ -4188,7 +4452,7 @@ class Collection {
4188
4452
 
4189
4453
  if (options.force) {
4190
4454
  // Clear documents first
4191
- await this._indexedDB.clear(this._db, 'documents');
4455
+ await this._indexedDB.clear(this._db, this._storeName);
4192
4456
 
4193
4457
  // Reset metadata
4194
4458
  if (this._metadata) this._metadata.destroy();
@@ -4200,6 +4464,7 @@ class Collection {
4200
4464
 
4201
4465
  // Clear cache
4202
4466
  this._cacheStrategy.clear();
4467
+ this._docCache.clear();
4203
4468
 
4204
4469
  // Rebuild indexes after clearing
4205
4470
  for (const indexName of this._indexManager.indexes.keys()) {
@@ -4228,6 +4493,12 @@ class Collection {
4228
4493
  this._metadata.destroy();
4229
4494
  }
4230
4495
 
4496
+ // Flush dirty index data to IDB before teardown
4497
+ if (this._indexManager) {
4498
+ this._indexManager.flushPersistence().catch(() => {});
4499
+ this._indexManager.destroy();
4500
+ }
4501
+
4231
4502
  // Clear the cleanup interval
4232
4503
  if (this._cleanupInterval) {
4233
4504
  clearInterval(this._cleanupInterval);
@@ -4239,13 +4510,14 @@ class Collection {
4239
4510
  this._cacheStrategy.destroy();
4240
4511
  }
4241
4512
 
4242
- // Release the connection
4243
- if (this._db) {
4244
- const dbName = `${this.database.name}_${this.name}`;
4245
- connectionPool.releaseConnection(dbName);
4246
- this._db = null;
4513
+ // Clear document cache
4514
+ if (this._docCache) {
4515
+ this._docCache.clear();
4247
4516
  }
4248
4517
 
4518
+ // Release the connection reference (owned by parent Database)
4519
+ this._db = null;
4520
+
4249
4521
  // Clear event listeners
4250
4522
  this._events.clear();
4251
4523
  }
@@ -4266,6 +4538,14 @@ class Database {
4266
4538
  this._serializer = serializer;
4267
4539
  this._base64 = base64;
4268
4540
 
4541
+ // Consolidated IDB connection (one per Database, not per Collection)
4542
+ this._db = null;
4543
+ this._idbVersion = 0;
4544
+ this._knownStores = new Set();
4545
+ this._ensureStorePromise = null;
4546
+ this._idbVersionKey = `lacertadb_${name}_idb_version`;
4547
+ this._idbStoresKey = `lacertadb_${name}_idb_stores`;
4548
+
4269
4549
  // Database-level encryption
4270
4550
  this._encryption = null;
4271
4551
  }
@@ -4298,11 +4578,127 @@ class Database {
4298
4578
  return !!this._encryption;
4299
4579
  }
4300
4580
 
4581
+ /**
4582
+ * Open or reuse the consolidated IDB connection.
4583
+ * All collections share this single connection.
4584
+ * @returns {Promise<IDBDatabase>}
4585
+ */
4586
+ async _getConnection() {
4587
+ if (this._db) return this._db;
4588
+
4589
+ // Load known version and stores from localStorage
4590
+ try {
4591
+ this._idbVersion = parseInt(localStorage.getItem(this._idbVersionKey), 10) || 1;
4592
+ const storedStores = localStorage.getItem(this._idbStoresKey);
4593
+ if (storedStores) {
4594
+ const decoded = this._base64.decode(storedStores);
4595
+ const list = this._serializer.deserialize(decoded);
4596
+ this._knownStores = new Set(list);
4597
+ }
4598
+ } catch (_) {
4599
+ this._idbVersion = 1;
4600
+ }
4601
+
4602
+ this._db = await this._openIDB(this._idbVersion);
4603
+ return this._db;
4604
+ }
4605
+
4606
+ /** @private Open IDB at a specific version */
4607
+ async _openIDB(version) {
4608
+ const knownStores = this._knownStores;
4609
+ return new Promise((resolve, reject) => {
4610
+ const request = indexedDB.open(`lacertadb_${this.name}`, version);
4611
+ request.onerror = () => reject(new LacertaDBError(
4612
+ 'Failed to open database', 'DATABASE_OPEN_FAILED', request.error
4613
+ ));
4614
+ request.onsuccess = () => resolve(request.result);
4615
+ request.onupgradeneeded = event => {
4616
+ const db = event.target.result;
4617
+ for (const storeName of knownStores) {
4618
+ if (!db.objectStoreNames.contains(storeName)) {
4619
+ const store = db.createObjectStore(storeName, { keyPath: '_id' });
4620
+ store.createIndex('modified', '_modified', { unique: false });
4621
+ }
4622
+ }
4623
+ };
4624
+ });
4625
+ }
4626
+
4627
+ /**
4628
+ * Ensure an object store exists for a collection.
4629
+ * If the store doesn't exist, bumps the IDB version to create it.
4630
+ * @param {string} storeName
4631
+ * @returns {Promise<void>}
4632
+ */
4633
+ /**
4634
+ * Ensure an object store exists for a collection.
4635
+ * Batches multiple new stores into a single IDB version bump.
4636
+ * Dedup-guarded so concurrent init() calls don't race.
4637
+ * @param {string} storeName
4638
+ * @returns {Promise<void>}
4639
+ */
4640
+ async _ensureStore(storeName) {
4641
+ // Already exists in current IDB — nothing to do
4642
+ if (this._db && this._db.objectStoreNames.contains(storeName)) {
4643
+ this._knownStores.add(storeName);
4644
+ return;
4645
+ }
4646
+
4647
+ this._knownStores.add(storeName);
4648
+
4649
+ // Dedup: if a version bump is already in flight, piggyback on it
4650
+ if (this._ensureStorePromise) {
4651
+ await this._ensureStorePromise;
4652
+ // After the in-flight bump, our store should now exist
4653
+ if (this._db && this._db.objectStoreNames.contains(storeName)) return;
4654
+ }
4655
+
4656
+ // Collect ALL known stores that are missing from current IDB
4657
+ const missingStores = [];
4658
+ for (const name of this._knownStores) {
4659
+ if (!this._db || !this._db.objectStoreNames.contains(name)) {
4660
+ missingStores.push(name);
4661
+ }
4662
+ }
4663
+
4664
+ if (missingStores.length === 0) return;
4665
+
4666
+ this._ensureStorePromise = (async () => {
4667
+ this._idbVersion++;
4668
+
4669
+ // Persist the new version and store list
4670
+ localStorage.setItem(this._idbVersionKey, String(this._idbVersion));
4671
+ const serialized = this._serializer.serialize(Array.from(this._knownStores));
4672
+ const encoded = this._base64.encode(serialized);
4673
+ localStorage.setItem(this._idbStoresKey, encoded);
4674
+
4675
+ // Close current connection and reopen with new version (creates all missing stores)
4676
+ if (this._db) {
4677
+ this._db.close();
4678
+ this._db = null;
4679
+ }
4680
+
4681
+ this._db = await this._openIDB(this._idbVersion);
4682
+ })();
4683
+
4684
+ try {
4685
+ await this._ensureStorePromise;
4686
+ } finally {
4687
+ this._ensureStorePromise = null;
4688
+ }
4689
+ }
4690
+
4301
4691
  async init(options = {}) {
4302
4692
  this._metadata = DatabaseMetadata.load(this.name, this._serializer, this._base64);
4303
4693
  this._settings = Settings.load(this.name, this._serializer, this._base64);
4304
4694
  this._quickStore = new QuickStore(this.name, this._serializer, this._base64);
4305
4695
 
4696
+ // Open the consolidated IDB connection
4697
+ await this._getConnection();
4698
+
4699
+ // Migrate old per-collection databases if they exist
4700
+ await this._migrateOldDatabases();
4701
+
4306
4702
  if (options.pin) {
4307
4703
  await this._initializeEncryption(options.pin, options.salt, options.encryptionConfig);
4308
4704
  }
@@ -4392,8 +4788,10 @@ class Database {
4392
4788
  throw new LacertaDBError(`Collection '${name}' already exists.`, 'COLLECTION_EXISTS');
4393
4789
  }
4394
4790
 
4791
+ // Ensure the object store exists in the consolidated IDB
4792
+ await this._ensureStore(name);
4793
+
4395
4794
  const collection = new Collection(name, this);
4396
- // Lazy initialization - don't init here
4397
4795
  this._collections.set(name, collection);
4398
4796
 
4399
4797
  if (!this._metadata.collections[name]) {
@@ -4413,6 +4811,8 @@ class Database {
4413
4811
  return collection;
4414
4812
  }
4415
4813
  if (this._metadata.collections[name]) {
4814
+ // Ensure store exists before initializing
4815
+ await this._ensureStore(name);
4416
4816
  const collection = new Collection(name, this);
4417
4817
  this._collections.set(name, collection);
4418
4818
  await collection.init();
@@ -4421,6 +4821,37 @@ class Database {
4421
4821
  throw new LacertaDBError(`Collection '${name}' not found.`, 'COLLECTION_NOT_FOUND');
4422
4822
  }
4423
4823
 
4824
+ /**
4825
+ * Ensure a collection handle exists in memory without triggering init().
4826
+ * Creates the IDB object store if needed.
4827
+ * The collection will lazy-init on first actual operation.
4828
+ * @param {string} name
4829
+ * @returns {Collection}
4830
+ */
4831
+ ensureCollection(name) {
4832
+ if (this._collections.has(name)) {
4833
+ return this._collections.get(name);
4834
+ }
4835
+ // Mark store as known — will be created on next _ensureStore or IDB open
4836
+ if (!this._knownStores.has(name)) {
4837
+ this._knownStores.add(name);
4838
+ // Persist so warm start creates all stores in one shot
4839
+ try {
4840
+ const serialized = this._serializer.serialize(Array.from(this._knownStores));
4841
+ const encoded = this._base64.encode(serialized);
4842
+ localStorage.setItem(this._idbStoresKey, encoded);
4843
+ } catch (_) {}
4844
+ }
4845
+ const collection = new Collection(name, this);
4846
+ this._collections.set(name, collection);
4847
+ if (!this._metadata.collections[name]) {
4848
+ this._metadata.setCollection(new CollectionMetadata(
4849
+ name, {}, this._serializer, this._base64, this.name
4850
+ ));
4851
+ }
4852
+ return collection;
4853
+ }
4854
+
4424
4855
  async dropCollection(name) {
4425
4856
  if (this._collections.has(name)) {
4426
4857
  const collection = this._collections.get(name);
@@ -4437,13 +4868,126 @@ class Database {
4437
4868
  localStorage.removeItem(`lacertadb_${this.name}_${name}_collmeta`);
4438
4869
  localStorage.removeItem(`lacertadb_${this.name}_${name}_indexes`);
4439
4870
 
4440
- const dbName = `${this.name}_${name}`;
4441
- await new Promise((resolve, reject) => {
4442
- const deleteReq = indexedDB.deleteDatabase(dbName);
4443
- deleteReq.onsuccess = resolve;
4444
- deleteReq.onerror = reject;
4445
- deleteReq.onblocked = () => console.warn(`Deletion of '${dbName}' is blocked.`);
4446
- });
4871
+ // Clear the store contents (can't delete an object store without version bump,
4872
+ // but clearing it is equivalent for our purposes — the empty store costs nothing)
4873
+ if (this._db && this._knownStores.has(name)) {
4874
+ try {
4875
+ const idbUtil = new IndexedDBUtility();
4876
+ await idbUtil.clear(this._db, name);
4877
+ } catch (e) {
4878
+ // Store may not exist yet if collection was never initialized
4879
+ }
4880
+ }
4881
+
4882
+ // Also clean up old per-collection database if it exists (migration residue)
4883
+ const legacyDbName = `${this.name}_${name}`;
4884
+ try {
4885
+ await new Promise((resolve, reject) => {
4886
+ const deleteReq = indexedDB.deleteDatabase(legacyDbName);
4887
+ deleteReq.onsuccess = resolve;
4888
+ deleteReq.onerror = resolve; // don't fail if it doesn't exist
4889
+ deleteReq.onblocked = resolve;
4890
+ });
4891
+ } catch (e) {}
4892
+ }
4893
+
4894
+ /**
4895
+ * Migrate data from old per-collection databases to the consolidated database.
4896
+ * Runs once on first load with the new schema. Safe to call multiple times.
4897
+ * @private
4898
+ */
4899
+ async _migrateOldDatabases() {
4900
+ const migrationKey = `lacertadb_${this.name}_consolidated`;
4901
+ if (localStorage.getItem(migrationKey)) return; // already migrated
4902
+
4903
+ const collectionNames = Object.keys(this._metadata.collections || {});
4904
+ if (collectionNames.length === 0) {
4905
+ localStorage.setItem(migrationKey, '1');
4906
+ return;
4907
+ }
4908
+
4909
+ let migrated = 0;
4910
+ for (const collName of collectionNames) {
4911
+ const legacyDbName = `${this.name}_${collName}`;
4912
+
4913
+ try {
4914
+ // Try to open the old per-collection database
4915
+ const oldDb = await new Promise((resolve, reject) => {
4916
+ const request = indexedDB.open(legacyDbName, 1);
4917
+ request.onerror = () => resolve(null);
4918
+ request.onsuccess = () => resolve(request.result);
4919
+ request.onupgradeneeded = (event) => {
4920
+ // If version was 0, it's a brand new DB — nothing to migrate
4921
+ if (event.oldVersion === 0) {
4922
+ event.target.transaction.abort();
4923
+ resolve(null);
4924
+ }
4925
+ };
4926
+ });
4927
+
4928
+ if (!oldDb) {
4929
+ // Clean up ghost database created by the probe
4930
+ indexedDB.deleteDatabase(legacyDbName);
4931
+ continue;
4932
+ }
4933
+
4934
+ // Check if the old DB has a 'documents' store
4935
+ if (!oldDb.objectStoreNames.contains('documents')) {
4936
+ oldDb.close();
4937
+ continue;
4938
+ }
4939
+
4940
+ // Read all documents from the old database
4941
+ const oldDocs = await new Promise((resolve, reject) => {
4942
+ const tx = oldDb.transaction('documents', 'readonly');
4943
+ const store = tx.objectStore('documents');
4944
+ const request = store.getAll();
4945
+ request.onsuccess = () => resolve(request.result || []);
4946
+ request.onerror = () => resolve([]);
4947
+ });
4948
+
4949
+ oldDb.close();
4950
+
4951
+ if (oldDocs.length === 0) continue;
4952
+
4953
+ // Ensure the new consolidated store exists
4954
+ await this._ensureStore(collName);
4955
+
4956
+ // Write all documents to the new consolidated store
4957
+ const idbUtil = new IndexedDBUtility();
4958
+ const ops = oldDocs.map(doc => ({ type: 'put', data: doc }));
4959
+ // Use performTransaction directly since batchOperation hardcodes 'documents'
4960
+ await idbUtil.performTransaction(this._db, [collName], 'readwrite', tx => {
4961
+ const store = tx.objectStore(collName);
4962
+ const promises = ops.map(op => {
4963
+ return new Promise((resolve, reject) => {
4964
+ const req = store.put(op.data);
4965
+ req.onsuccess = () => resolve();
4966
+ req.onerror = () => resolve(); // skip individual failures
4967
+ });
4968
+ });
4969
+ return Promise.all(promises);
4970
+ });
4971
+
4972
+ // Delete the old database
4973
+ await new Promise((resolve) => {
4974
+ const deleteReq = indexedDB.deleteDatabase(legacyDbName);
4975
+ deleteReq.onsuccess = resolve;
4976
+ deleteReq.onerror = resolve;
4977
+ deleteReq.onblocked = resolve;
4978
+ });
4979
+
4980
+ migrated++;
4981
+ } catch (e) {
4982
+ console.warn(`[LacertaDB] Migration of '${collName}' failed:`, e.message);
4983
+ }
4984
+ }
4985
+
4986
+ if (migrated > 0) {
4987
+ console.log(`[LacertaDB] Migrated ${migrated} collections to consolidated database`);
4988
+ }
4989
+
4990
+ localStorage.setItem(migrationKey, '1');
4447
4991
  }
4448
4992
 
4449
4993
  listCollections() {
@@ -4551,6 +5095,12 @@ class Database {
4551
5095
  }
4552
5096
  this._collections.clear();
4553
5097
 
5098
+ // Close consolidated IDB connection
5099
+ if (this._db) {
5100
+ this._db.close();
5101
+ this._db = null;
5102
+ }
5103
+
4554
5104
  // Clear quickstore
4555
5105
  if (this._quickStore) {
4556
5106
  this._quickStore.destroy();
@@ -4622,7 +5172,7 @@ class LacertaDB {
4622
5172
  this._databases.delete(name);
4623
5173
  }
4624
5174
 
4625
- ['metadata', 'settings', 'version', 'encryption'].forEach(suffix => {
5175
+ ['metadata', 'settings', 'version', 'encryption', 'idb_version', 'idb_stores', 'consolidated'].forEach(suffix => {
4626
5176
  localStorage.removeItem(`lacertadb_${name}_${suffix}`);
4627
5177
  });
4628
5178
 
@@ -4630,7 +5180,7 @@ class LacertaDB {
4630
5180
  const quickStore = new QuickStore(name, this._serializer, this._base64);
4631
5181
  quickStore.clear();
4632
5182
 
4633
- // Clean up all collections and indexes
5183
+ // Clean up all collection-level localStorage keys
4634
5184
  const keysToRemove = [];
4635
5185
  for (let i = 0; i < localStorage.length; i++) {
4636
5186
  const key = localStorage.key(i);
@@ -4639,6 +5189,14 @@ class LacertaDB {
4639
5189
  }
4640
5190
  }
4641
5191
  keysToRemove.forEach(key => localStorage.removeItem(key));
5192
+
5193
+ // Delete the consolidated IDB database
5194
+ await new Promise((resolve) => {
5195
+ const deleteReq = indexedDB.deleteDatabase(`lacertadb_${name}`);
5196
+ deleteReq.onsuccess = resolve;
5197
+ deleteReq.onerror = resolve;
5198
+ deleteReq.onblocked = resolve;
5199
+ });
4642
5200
  }
4643
5201
 
4644
5202
  listDatabases() {
@@ -4707,7 +5265,12 @@ class LacertaDB {
4707
5265
  }
4708
5266
 
4709
5267
  close() {
4710
- connectionPool.closeAll();
5268
+ for (const db of this._databases.values()) {
5269
+ if (db._db) {
5270
+ db._db.close();
5271
+ db._db = null;
5272
+ }
5273
+ }
4711
5274
  }
4712
5275
 
4713
5276
  destroy() {
@@ -4715,7 +5278,6 @@ class LacertaDB {
4715
5278
  db.destroy();
4716
5279
  }
4717
5280
  this._databases.clear();
4718
- connectionPool.closeAll();
4719
5281
  }
4720
5282
  }
4721
5283