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