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