@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/dist/browser.min.js +4 -4
- package/dist/index.min.js +4 -4
- package/index.js +747 -179
- package/package.json +2 -2
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:
|
|
44
|
+
detectCircular: false,
|
|
36
45
|
shareArrayBuffers: true,
|
|
37
46
|
allowFunction: false,
|
|
38
47
|
serializeFunctions: false,
|
|
39
|
-
memoryPoolSize: 65536 *
|
|
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
|
-
*
|
|
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
|
-
//
|
|
59
|
-
this.
|
|
60
|
-
this.
|
|
71
|
+
// In-memory cache: docId → deserialized data
|
|
72
|
+
this._docs = new Map();
|
|
73
|
+
this._hydrated = false;
|
|
61
74
|
|
|
62
|
-
//
|
|
63
|
-
this.
|
|
64
|
-
this.
|
|
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:
|
|
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.
|
|
93
|
+
if (this._saveTimer) {
|
|
80
94
|
if (typeof window !== 'undefined' && window.cancelIdleCallback) {
|
|
81
|
-
window.cancelIdleCallback(this.
|
|
95
|
+
window.cancelIdleCallback(this._saveTimer);
|
|
82
96
|
} else {
|
|
83
|
-
clearTimeout(this.
|
|
97
|
+
clearTimeout(this._saveTimer);
|
|
84
98
|
}
|
|
85
|
-
this.
|
|
99
|
+
this._saveTimer = null;
|
|
86
100
|
}
|
|
87
101
|
}
|
|
88
102
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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.
|
|
128
|
+
this._hydrated = true;
|
|
104
129
|
}
|
|
105
130
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (this.
|
|
131
|
+
/** Schedule debounced persistence of dirty entries */
|
|
132
|
+
_scheduleSave() {
|
|
133
|
+
if (this._saveTimer) return;
|
|
109
134
|
|
|
110
135
|
const save = () => {
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
141
|
+
this._saveTimer = window.requestIdleCallback(save);
|
|
134
142
|
} else {
|
|
135
|
-
this.
|
|
143
|
+
this._saveTimer = setTimeout(save, 200);
|
|
136
144
|
}
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
/** Persist only dirty documents and the index if changed */
|
|
148
|
+
_persistDirty() {
|
|
141
149
|
try {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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: '
|
|
174
|
+
window.dispatchEvent(new CustomEvent('lacertadb:quotaexceeded', { detail: { source: 'quickstore', db: this._dbName } }));
|
|
152
175
|
}
|
|
153
176
|
} else {
|
|
154
|
-
console.error('QuickStore
|
|
177
|
+
console.error('QuickStore save failed:', e);
|
|
155
178
|
}
|
|
156
179
|
}
|
|
157
180
|
}
|
|
158
181
|
|
|
159
|
-
|
|
160
|
-
this.
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
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.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
this.
|
|
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.
|
|
218
|
+
this._ensureHydrated();
|
|
212
219
|
const results = [];
|
|
213
|
-
for (const docId of this.
|
|
214
|
-
|
|
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.
|
|
228
|
-
for (const docId of this.
|
|
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.
|
|
233
|
-
this.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
243
|
-
return this.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2071
|
-
|
|
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
|
-
|
|
2321
|
-
|
|
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, [
|
|
2612
|
-
const store = tx.objectStore(
|
|
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
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
3789
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
3958
|
-
|
|
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
|
|
4216
|
+
compressed: options.compressed || false,
|
|
3973
4217
|
permanent: options.permanent || false
|
|
3974
4218
|
}, this._serializer);
|
|
3975
4219
|
|
|
3976
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
4177
|
-
|
|
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,
|
|
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
|
-
//
|
|
4243
|
-
if (this.
|
|
4244
|
-
|
|
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
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|