@pixagram/lacerta-db 0.1.0 → 0.3.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/browser.js +4 -7
- package/dist/browser.min.js +8 -1
- package/dist/index.min.js +8 -1
- package/dist/lacertadb-interface.html +1641 -0
- package/index.js +1464 -1702
- package/package.json +3 -2
- package/readme.md +132 -923
package/index.js
CHANGED
|
@@ -1,2091 +1,1853 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* LacertaDB V4 - Complete Core Library (Repaired and Enhanced)
|
|
3
|
+
* A powerful browser-based document database with encryption, compression, and OPFS support
|
|
4
|
+
* @version 4.0.3 (Max Compatibility)
|
|
5
|
+
* @license MIT
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
// Note: These imports are for browser environments using a bundler (e.g., Webpack, Vite).
|
|
10
|
+
// For direct browser usage, you would use an ES module import from a URL or local path.
|
|
6
11
|
import TurboSerial from "@pixagram/turboserial";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
12
|
+
import TurboBase64 from "@pixagram/turbobase64";
|
|
13
|
+
|
|
14
|
+
const serializer = new TurboSerial({
|
|
15
|
+
compression: true,
|
|
16
|
+
deduplication: true,
|
|
17
|
+
shareArrayBuffers: true,
|
|
18
|
+
simdOptimization: true,
|
|
19
|
+
detectCircular: true
|
|
20
|
+
});
|
|
21
|
+
const base64 = new TurboBase64();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Async Mutex for managing concurrent operations
|
|
25
|
+
*/
|
|
26
|
+
class AsyncMutex {
|
|
27
|
+
constructor() {
|
|
28
|
+
this._queue = [];
|
|
29
|
+
this._locked = false;
|
|
30
|
+
}
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
acquire() {
|
|
33
|
+
return new Promise(resolve => {
|
|
34
|
+
this._queue.push(resolve);
|
|
35
|
+
this._dispatch();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
await writable.write(attachments[i].data);
|
|
37
|
-
await writable.close();
|
|
39
|
+
release() {
|
|
40
|
+
this._locked = false;
|
|
41
|
+
this._dispatch();
|
|
42
|
+
}
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
async runExclusive(callback) {
|
|
45
|
+
const release = await this.acquire();
|
|
46
|
+
try {
|
|
47
|
+
return await callback();
|
|
48
|
+
} finally {
|
|
49
|
+
release();
|
|
41
50
|
}
|
|
42
|
-
|
|
43
|
-
return attachmentPaths;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
for (const path of attachmentPaths) {
|
|
51
|
-
try {
|
|
52
|
-
const pathParts = path.split('/');
|
|
53
|
-
let dirHandle = rootHandle;
|
|
54
|
-
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
55
|
-
dirHandle = await dirHandle.getDirectoryHandle(pathParts[i]);
|
|
56
|
-
}
|
|
57
|
-
const fileHandle = await dirHandle.getFileHandle(pathParts[pathParts.length - 1]);
|
|
58
|
-
const file = await fileHandle.getFile();
|
|
59
|
-
|
|
60
|
-
attachments.push({
|
|
61
|
-
path: path,
|
|
62
|
-
data: file,
|
|
63
|
-
});
|
|
64
|
-
} catch (error) {
|
|
65
|
-
console.error(`Error retrieving attachment at "${path}": ${error.message}`);
|
|
66
|
-
}
|
|
53
|
+
_dispatch() {
|
|
54
|
+
if (this._locked || this._queue.length === 0) {
|
|
55
|
+
return;
|
|
67
56
|
}
|
|
57
|
+
this._locked = true;
|
|
58
|
+
const resolve = this._queue.shift();
|
|
59
|
+
resolve(() => this.release());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
68
62
|
|
|
69
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Custom error class for LacertaDB
|
|
65
|
+
*/
|
|
66
|
+
class LacertaDBError extends Error {
|
|
67
|
+
constructor(message, code, originalError) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = 'LacertaDBError';
|
|
70
|
+
this.code = code;
|
|
71
|
+
this.originalError = originalError || null;
|
|
72
|
+
this.timestamp = new Date().toISOString();
|
|
70
73
|
}
|
|
74
|
+
}
|
|
71
75
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
// ========================
|
|
77
|
+
// Compression Utility
|
|
78
|
+
// ========================
|
|
75
79
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
class BrowserCompressionUtility {
|
|
81
|
+
async compress(input) {
|
|
82
|
+
if (!(input instanceof Uint8Array)) {
|
|
83
|
+
throw new TypeError('Input must be Uint8Array');
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const stream = new Response(input).body
|
|
87
|
+
.pipeThrough(new CompressionStream('deflate'));
|
|
88
|
+
const compressed = await new Response(stream).arrayBuffer();
|
|
89
|
+
return new Uint8Array(compressed);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new LacertaDBError('Compression failed', 'COMPRESSION_FAILED', error);
|
|
79
92
|
}
|
|
93
|
+
}
|
|
80
94
|
|
|
95
|
+
async decompress(compressedData) {
|
|
96
|
+
if (!(compressedData instanceof Uint8Array)) {
|
|
97
|
+
throw new TypeError('Input must be Uint8Array');
|
|
98
|
+
}
|
|
81
99
|
try {
|
|
82
|
-
|
|
100
|
+
const stream = new Response(compressedData).body
|
|
101
|
+
.pipeThrough(new DecompressionStream('deflate'));
|
|
102
|
+
const decompressed = await new Response(stream).arrayBuffer();
|
|
103
|
+
return new Uint8Array(decompressed);
|
|
83
104
|
} catch (error) {
|
|
84
|
-
|
|
105
|
+
throw new LacertaDBError('Decompression failed', 'DECOMPRESSION_FAILED', error);
|
|
85
106
|
}
|
|
86
107
|
}
|
|
87
108
|
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
// Fallback sync methods are simple pass-throughs
|
|
110
|
+
compressSync(input) {
|
|
111
|
+
if (!(input instanceof Uint8Array)) {
|
|
112
|
+
throw new TypeError('Input must be Uint8Array');
|
|
113
|
+
}
|
|
114
|
+
return input;
|
|
90
115
|
}
|
|
91
|
-
}
|
|
92
116
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
117
|
+
decompressSync(compressedData) {
|
|
118
|
+
if (!(compressedData instanceof Uint8Array)) {
|
|
119
|
+
throw new TypeError('Input must be Uint8Array');
|
|
120
|
+
}
|
|
121
|
+
return compressedData;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
98
124
|
|
|
99
|
-
|
|
100
|
-
|
|
125
|
+
// ========================
|
|
126
|
+
// Encryption Utility (FIXED & IMPROVED)
|
|
127
|
+
// ========================
|
|
101
128
|
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
class BrowserEncryptionUtility {
|
|
130
|
+
async encrypt(data, password) {
|
|
131
|
+
if (!(data instanceof Uint8Array)) {
|
|
132
|
+
throw new TypeError('Data must be Uint8Array');
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
136
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
104
137
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
138
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
139
|
+
'raw',
|
|
140
|
+
new TextEncoder().encode(password),
|
|
141
|
+
'PBKDF2',
|
|
142
|
+
false,
|
|
143
|
+
['deriveKey']
|
|
144
|
+
);
|
|
108
145
|
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
const key = await crypto.subtle.deriveKey({
|
|
147
|
+
name: 'PBKDF2',
|
|
148
|
+
salt,
|
|
149
|
+
iterations: 600000,
|
|
150
|
+
hash: 'SHA-512'
|
|
151
|
+
},
|
|
152
|
+
keyMaterial, {
|
|
153
|
+
name: 'AES-GCM',
|
|
154
|
+
length: 256
|
|
155
|
+
},
|
|
156
|
+
false,
|
|
157
|
+
['encrypt']
|
|
158
|
+
);
|
|
111
159
|
|
|
112
|
-
|
|
113
|
-
|
|
160
|
+
const encrypted = await crypto.subtle.encrypt({
|
|
161
|
+
name: 'AES-GCM',
|
|
162
|
+
iv
|
|
163
|
+
},
|
|
164
|
+
key,
|
|
165
|
+
data
|
|
166
|
+
);
|
|
114
167
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
168
|
+
// The checksum was removed as AES-GCM provides this via an authentication tag.
|
|
169
|
+
const result = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
|
|
170
|
+
result.set(salt, 0);
|
|
171
|
+
result.set(iv, salt.length);
|
|
172
|
+
result.set(new Uint8Array(encrypted), salt.length + iv.length);
|
|
119
173
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
chunks.push(value);
|
|
124
|
-
totalLength += value.length;
|
|
174
|
+
return result;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
throw new LacertaDBError('Encryption failed', 'ENCRYPTION_FAILED', error);
|
|
125
177
|
}
|
|
178
|
+
}
|
|
126
179
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
result.set(chunk, offset);
|
|
131
|
-
offset += chunk.length;
|
|
180
|
+
async decrypt(wrappedData, password) {
|
|
181
|
+
if (!(wrappedData instanceof Uint8Array)) {
|
|
182
|
+
throw new TypeError('Data must be Uint8Array');
|
|
132
183
|
}
|
|
184
|
+
try {
|
|
185
|
+
const salt = wrappedData.slice(0, 16);
|
|
186
|
+
const iv = wrappedData.slice(16, 28);
|
|
187
|
+
const encryptedData = wrappedData.slice(28);
|
|
188
|
+
|
|
189
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
190
|
+
'raw',
|
|
191
|
+
new TextEncoder().encode(password),
|
|
192
|
+
'PBKDF2',
|
|
193
|
+
false,
|
|
194
|
+
['deriveKey']
|
|
195
|
+
);
|
|
133
196
|
|
|
134
|
-
|
|
135
|
-
|
|
197
|
+
const key = await crypto.subtle.deriveKey({
|
|
198
|
+
name: 'PBKDF2',
|
|
199
|
+
salt,
|
|
200
|
+
iterations: 600000,
|
|
201
|
+
hash: 'SHA-512'
|
|
202
|
+
},
|
|
203
|
+
keyMaterial, {
|
|
204
|
+
name: 'AES-GCM',
|
|
205
|
+
length: 256
|
|
206
|
+
},
|
|
207
|
+
false,
|
|
208
|
+
['decrypt']
|
|
209
|
+
);
|
|
136
210
|
|
|
137
|
-
|
|
138
|
-
|
|
211
|
+
const decrypted = await crypto.subtle.decrypt({
|
|
212
|
+
name: 'AES-GCM',
|
|
213
|
+
iv
|
|
214
|
+
},
|
|
215
|
+
key,
|
|
216
|
+
encryptedData
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Checksum verification removed. crypto.subtle.decrypt will throw on failure.
|
|
220
|
+
return new Uint8Array(decrypted);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
// Provide a more specific error for failed decryption, which often indicates a wrong password.
|
|
223
|
+
throw new LacertaDBError('Decryption failed. This may be due to an incorrect password or corrupted data.', 'DECRYPTION_FAILED', error);
|
|
224
|
+
}
|
|
139
225
|
}
|
|
140
226
|
}
|
|
141
227
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const key = await this._deriveKey(password, salt);
|
|
146
|
-
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
147
|
-
const checksum = await this._generateChecksum(data);
|
|
148
|
-
const combinedData = this._combineDataAndChecksum(data, checksum);
|
|
149
|
-
|
|
150
|
-
const encryptedData = await crypto.subtle.encrypt(
|
|
151
|
-
{ name: "AES-GCM", iv: iv },
|
|
152
|
-
key,
|
|
153
|
-
combinedData
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
return this._wrapIntoUint8Array(salt, iv, new Uint8Array(encryptedData));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
static async decrypt(wrappedData, password) {
|
|
160
|
-
const { salt, iv, encryptedData } = this._unwrapUint8Array(wrappedData);
|
|
161
|
-
const key = await this._deriveKey(password, salt);
|
|
162
|
-
|
|
163
|
-
const decryptedData = await crypto.subtle.decrypt(
|
|
164
|
-
{ name: "AES-GCM", iv: iv },
|
|
165
|
-
key,
|
|
166
|
-
encryptedData
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
const decryptedUint8Array = new Uint8Array(decryptedData);
|
|
170
|
-
const { data, checksum } = this._separateDataAndChecksum(decryptedUint8Array);
|
|
171
|
-
|
|
172
|
-
const validChecksum = await this._generateChecksum(data);
|
|
173
|
-
if (!this._verifyChecksum(validChecksum, checksum)) {
|
|
174
|
-
throw new Error("Data integrity check failed. The data has been tampered with.");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return data;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
static async _deriveKey(password, salt) {
|
|
181
|
-
const encoder = new TextEncoder();
|
|
182
|
-
const keyMaterial = await crypto.subtle.importKey(
|
|
183
|
-
"raw",
|
|
184
|
-
encoder.encode(password),
|
|
185
|
-
{ name: "PBKDF2" },
|
|
186
|
-
false,
|
|
187
|
-
["deriveKey"]
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
return await crypto.subtle.deriveKey(
|
|
191
|
-
{
|
|
192
|
-
name: "PBKDF2",
|
|
193
|
-
salt: salt,
|
|
194
|
-
iterations: 600000,
|
|
195
|
-
hash: "SHA-512"
|
|
196
|
-
},
|
|
197
|
-
keyMaterial,
|
|
198
|
-
{
|
|
199
|
-
name: "AES-GCM",
|
|
200
|
-
length: 256
|
|
201
|
-
},
|
|
202
|
-
false,
|
|
203
|
-
["encrypt", "decrypt"]
|
|
204
|
-
);
|
|
205
|
-
}
|
|
228
|
+
// ========================
|
|
229
|
+
// OPFS (Origin Private File System) Utility
|
|
230
|
+
// ========================
|
|
206
231
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
232
|
+
class OPFSUtility {
|
|
233
|
+
async saveAttachments(dbName, collectionName, documentId, attachments) {
|
|
234
|
+
try {
|
|
235
|
+
const attachmentPaths = [];
|
|
236
|
+
const root = await navigator.storage.getDirectory();
|
|
237
|
+
const dbDir = await root.getDirectoryHandle(dbName, { create: true });
|
|
238
|
+
const collDir = await dbDir.getDirectoryHandle(collectionName, { create: true });
|
|
239
|
+
const docDir = await collDir.getDirectoryHandle(documentId, { create: true });
|
|
240
|
+
|
|
241
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
242
|
+
const filename = `${index}_${attachment.name || 'file'}`;
|
|
243
|
+
const fileHandle = await docDir.getFileHandle(filename, { create: true });
|
|
244
|
+
const writable = await fileHandle.createWritable();
|
|
245
|
+
|
|
246
|
+
let dataToWrite;
|
|
247
|
+
if (attachment.data instanceof Uint8Array) {
|
|
248
|
+
dataToWrite = attachment.data;
|
|
249
|
+
} else if (attachment.data instanceof ArrayBuffer) {
|
|
250
|
+
dataToWrite = new Uint8Array(attachment.data);
|
|
251
|
+
} else if (attachment.data instanceof Blob) {
|
|
252
|
+
dataToWrite = new Uint8Array(await attachment.data.arrayBuffer());
|
|
253
|
+
} else {
|
|
254
|
+
throw new TypeError('Unsupported attachment data type');
|
|
255
|
+
}
|
|
212
256
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
257
|
+
const blob = new Blob([dataToWrite], { type: attachment.type || 'application/octet-stream' });
|
|
258
|
+
await writable.write(blob);
|
|
259
|
+
await writable.close();
|
|
260
|
+
|
|
261
|
+
const path = `/${dbName}/${collectionName}/${documentId}/${filename}`;
|
|
262
|
+
attachmentPaths.push({
|
|
263
|
+
path,
|
|
264
|
+
name: attachment.name,
|
|
265
|
+
type: attachment.type,
|
|
266
|
+
size: dataToWrite.byteLength,
|
|
267
|
+
originalName: attachment.originalName || attachment.name
|
|
268
|
+
});
|
|
218
269
|
}
|
|
270
|
+
return attachmentPaths;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
throw new LacertaDBError('Failed to save attachments', 'ATTACHMENT_SAVE_FAILED', error);
|
|
219
273
|
}
|
|
220
|
-
return true;
|
|
221
274
|
}
|
|
222
275
|
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
combined.set(checksum, data.length);
|
|
227
|
-
return combined;
|
|
228
|
-
}
|
|
276
|
+
async getAttachments(attachmentPaths) {
|
|
277
|
+
const attachments = [];
|
|
278
|
+
const root = await navigator.storage.getDirectory();
|
|
229
279
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return { data, checksum };
|
|
235
|
-
}
|
|
280
|
+
for (const attachmentInfo of attachmentPaths) {
|
|
281
|
+
try {
|
|
282
|
+
const pathParts = attachmentInfo.path.split('/').filter(p => p);
|
|
283
|
+
let currentDir = root;
|
|
236
284
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
285
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
286
|
+
currentDir = await currentDir.getDirectoryHandle(pathParts[i]);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const fileHandle = await currentDir.getFileHandle(pathParts[pathParts.length - 1]);
|
|
290
|
+
const file = await fileHandle.getFile();
|
|
291
|
+
const data = await file.arrayBuffer();
|
|
292
|
+
|
|
293
|
+
attachments.push({
|
|
294
|
+
name: attachmentInfo.originalName || attachmentInfo.name,
|
|
295
|
+
type: attachmentInfo.type,
|
|
296
|
+
data: new Uint8Array(data),
|
|
297
|
+
size: attachmentInfo.size
|
|
298
|
+
});
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error(`Failed to get attachment: ${attachmentInfo.path}`, error);
|
|
301
|
+
// Optionally, collect errors and return them
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return attachments;
|
|
243
305
|
}
|
|
244
306
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
307
|
+
async deleteAttachments(dbName, collectionName, documentId) {
|
|
308
|
+
try {
|
|
309
|
+
const root = await navigator.storage.getDirectory();
|
|
310
|
+
const dbDir = await root.getDirectoryHandle(dbName);
|
|
311
|
+
const collDir = await dbDir.getDirectoryHandle(collectionName);
|
|
312
|
+
await collDir.removeEntry(documentId, { recursive: true });
|
|
313
|
+
} catch (error) {
|
|
314
|
+
// Ignore "NotFoundError" as the directory might already be gone
|
|
315
|
+
if (error.name !== 'NotFoundError') {
|
|
316
|
+
console.error(`Failed to delete attachments for ${documentId}:`, error);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
250
319
|
}
|
|
251
320
|
|
|
252
|
-
|
|
253
|
-
|
|
321
|
+
static async prepareAttachment(file, name) {
|
|
322
|
+
let data;
|
|
323
|
+
if (file instanceof File || file instanceof Blob) {
|
|
324
|
+
const buffer = await file.arrayBuffer();
|
|
325
|
+
data = new Uint8Array(buffer);
|
|
326
|
+
} else if (file instanceof ArrayBuffer) {
|
|
327
|
+
data = new Uint8Array(file);
|
|
328
|
+
} else if (file instanceof Uint8Array) {
|
|
329
|
+
data = file;
|
|
330
|
+
} else {
|
|
331
|
+
throw new TypeError('Unsupported file type for attachment');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
name: name || file.name || 'unnamed',
|
|
336
|
+
type: file.type || 'application/octet-stream',
|
|
337
|
+
data,
|
|
338
|
+
originalName: file.name || name
|
|
339
|
+
};
|
|
254
340
|
}
|
|
255
341
|
}
|
|
256
342
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
let request;
|
|
343
|
+
// ========================
|
|
344
|
+
// IndexedDB Utility
|
|
345
|
+
// ========================
|
|
261
346
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
347
|
+
class IndexedDBUtility {
|
|
348
|
+
constructor() {
|
|
349
|
+
this.mutex = new AsyncMutex();
|
|
350
|
+
}
|
|
267
351
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const newVersion = event.newVersion;
|
|
272
|
-
console.log(`Upgrading database "${dbName}" from version ${oldVersion} to ${newVersion}`);
|
|
352
|
+
openDatabase(dbName, version = 1, upgradeCallback) {
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
const request = indexedDB.open(dbName, version);
|
|
273
355
|
|
|
356
|
+
request.onerror = () => reject(new LacertaDBError(
|
|
357
|
+
'Failed to open database', 'DATABASE_OPEN_FAILED', request.error
|
|
358
|
+
));
|
|
359
|
+
request.onsuccess = () => resolve(request.result);
|
|
360
|
+
request.onupgradeneeded = event => {
|
|
274
361
|
if (upgradeCallback) {
|
|
275
|
-
upgradeCallback(
|
|
362
|
+
upgradeCallback(event.target.result, event.oldVersion, event.newVersion);
|
|
276
363
|
}
|
|
277
364
|
};
|
|
278
|
-
|
|
279
|
-
request.onsuccess = (event) => {
|
|
280
|
-
const db = event.target.result;
|
|
281
|
-
|
|
282
|
-
db.onclose = () => {
|
|
283
|
-
console.log(`Database "${dbName}" connection is closing.`);
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
resolve(db);
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
request.onerror = (event) => {
|
|
290
|
-
reject(new Error(`Failed to open database "${dbName}": ${event.target.error.message}`));
|
|
291
|
-
};
|
|
292
365
|
});
|
|
293
366
|
}
|
|
294
367
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const tx = db.transaction(Array.isArray(storeNames) ? storeNames : [storeNames], mode);
|
|
306
|
-
const stores = Array.isArray(storeNames)
|
|
307
|
-
? storeNames.map(name => tx.objectStore(name))
|
|
308
|
-
: [tx.objectStore(storeNames)];
|
|
368
|
+
async performTransaction(db, storeNames, mode, callback, retries = 3) {
|
|
369
|
+
return this.mutex.runExclusive(async () => {
|
|
370
|
+
let lastError;
|
|
371
|
+
for (let i = 0; i < retries; i++) {
|
|
372
|
+
try {
|
|
373
|
+
return await new Promise((resolve, reject) => {
|
|
374
|
+
const transaction = db.transaction(storeNames, mode);
|
|
375
|
+
let result;
|
|
309
376
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}, 30000); // 30 second timeout
|
|
377
|
+
transaction.oncomplete = () => resolve(result);
|
|
378
|
+
transaction.onerror = () => reject(transaction.error);
|
|
379
|
+
transaction.onabort = () => reject(new Error('Transaction aborted'));
|
|
314
380
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
};
|
|
332
|
-
});
|
|
333
|
-
} catch (error) {
|
|
334
|
-
lastError = error;
|
|
335
|
-
if (attempt < retries) {
|
|
336
|
-
console.warn(`Transaction failed, retrying... (${retries - attempt} attempts left): ${error.message}`);
|
|
337
|
-
// Exponential backoff with jitter
|
|
338
|
-
const delay = Math.pow(2, attempt) * 100 + Math.random() * 100;
|
|
339
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
381
|
+
try {
|
|
382
|
+
const cbResult = callback(transaction);
|
|
383
|
+
if (cbResult instanceof Promise) {
|
|
384
|
+
cbResult.then(res => { result = res; }).catch(reject);
|
|
385
|
+
} else {
|
|
386
|
+
result = cbResult;
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
reject(error);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
} catch (error) {
|
|
393
|
+
lastError = error;
|
|
394
|
+
if (i < retries - 1) {
|
|
395
|
+
await new Promise(resolve => setTimeout(resolve, (2 ** i) * 100));
|
|
396
|
+
}
|
|
340
397
|
}
|
|
341
398
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
throw new Error(`Transaction ultimately failed after ${retries + 1} attempts: ${lastError.message}`);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
static add(store, record) {
|
|
348
|
-
return new Promise((resolve, reject) => {
|
|
349
|
-
const request = store.add(record);
|
|
350
|
-
request.onsuccess = () => resolve();
|
|
351
|
-
request.onerror = event => reject(`Failed to insert record: ${event.target.error.message}`);
|
|
399
|
+
throw new LacertaDBError('Transaction failed after retries', 'TRANSACTION_FAILED', lastError);
|
|
352
400
|
});
|
|
353
401
|
}
|
|
354
402
|
|
|
355
|
-
|
|
403
|
+
_promisifyRequest(requestFactory) {
|
|
356
404
|
return new Promise((resolve, reject) => {
|
|
357
|
-
const request =
|
|
358
|
-
request.onsuccess = () => resolve();
|
|
359
|
-
request.onerror =
|
|
405
|
+
const request = requestFactory();
|
|
406
|
+
request.onsuccess = () => resolve(request.result);
|
|
407
|
+
request.onerror = () => reject(request.error);
|
|
360
408
|
});
|
|
361
409
|
}
|
|
362
410
|
|
|
363
|
-
|
|
364
|
-
return
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
request.onerror = event => reject(`Failed to delete record: ${event.target.error.message}`);
|
|
411
|
+
add(db, storeName, value, key) {
|
|
412
|
+
return this.performTransaction(db, [storeName], 'readwrite', tx => {
|
|
413
|
+
const store = tx.objectStore(storeName);
|
|
414
|
+
return this._promisifyRequest(() => key !== undefined ? store.add(value, key) : store.add(value));
|
|
368
415
|
});
|
|
369
416
|
}
|
|
370
417
|
|
|
371
|
-
|
|
372
|
-
return
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
request.onerror = event => reject(`Failed to retrieve record with key ${key}: ${event.target.error.message}`);
|
|
418
|
+
put(db, storeName, value, key) {
|
|
419
|
+
return this.performTransaction(db, [storeName], 'readwrite', tx => {
|
|
420
|
+
const store = tx.objectStore(storeName);
|
|
421
|
+
return this._promisifyRequest(() => key !== undefined ? store.put(value, key) : store.put(value));
|
|
376
422
|
});
|
|
377
423
|
}
|
|
378
424
|
|
|
379
|
-
|
|
380
|
-
return
|
|
381
|
-
|
|
382
|
-
request.onsuccess = event => resolve(event.target.result);
|
|
383
|
-
request.onerror = event => reject(`Failed to retrieve records: ${event.target.error.message}`);
|
|
425
|
+
get(db, storeName, key) {
|
|
426
|
+
return this.performTransaction(db, [storeName], 'readonly', tx => {
|
|
427
|
+
return this._promisifyRequest(() => tx.objectStore(storeName).get(key));
|
|
384
428
|
});
|
|
385
429
|
}
|
|
386
430
|
|
|
387
|
-
|
|
388
|
-
return
|
|
389
|
-
|
|
390
|
-
request.onsuccess = event => resolve(event.target.result);
|
|
391
|
-
request.onerror = event => reject(`Failed to count records: ${event.target.error.message}`);
|
|
431
|
+
getAll(db, storeName, query, count) {
|
|
432
|
+
return this.performTransaction(db, [storeName], 'readonly', tx => {
|
|
433
|
+
return this._promisifyRequest(() => tx.objectStore(storeName).getAll(query, count));
|
|
392
434
|
});
|
|
393
435
|
}
|
|
394
436
|
|
|
395
|
-
|
|
396
|
-
return
|
|
397
|
-
|
|
398
|
-
request.onsuccess = () => resolve();
|
|
399
|
-
request.onerror = event => reject(`Failed to clear store: ${event.target.error.message}`);
|
|
437
|
+
delete(db, storeName, key) {
|
|
438
|
+
return this.performTransaction(db, [storeName], 'readwrite', tx => {
|
|
439
|
+
return this._promisifyRequest(() => tx.objectStore(storeName).delete(key));
|
|
400
440
|
});
|
|
401
441
|
}
|
|
402
442
|
|
|
403
|
-
|
|
404
|
-
return
|
|
405
|
-
|
|
406
|
-
request.onsuccess = () => resolve();
|
|
407
|
-
request.onerror = event => reject(`Failed to delete database: ${event.target.error.message}`);
|
|
443
|
+
clear(db, storeName) {
|
|
444
|
+
return this.performTransaction(db, [storeName], 'readwrite', tx => {
|
|
445
|
+
return this._promisifyRequest(() => tx.objectStore(storeName).clear());
|
|
408
446
|
});
|
|
409
447
|
}
|
|
410
448
|
|
|
411
|
-
|
|
412
|
-
return
|
|
413
|
-
|
|
414
|
-
request.onsuccess = event => {
|
|
415
|
-
const cursor = event.target.result;
|
|
416
|
-
if (cursor) {
|
|
417
|
-
processCallback(cursor.value, cursor.key);
|
|
418
|
-
cursor.continue();
|
|
419
|
-
} else {
|
|
420
|
-
resolve();
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
request.onerror = event => reject(`Cursor iteration failed: ${event.target.error.message}`);
|
|
449
|
+
count(db, storeName, query) {
|
|
450
|
+
return this.performTransaction(db, [storeName], 'readonly', tx => {
|
|
451
|
+
return this._promisifyRequest(() => tx.objectStore(storeName).count(query));
|
|
424
452
|
});
|
|
425
453
|
}
|
|
426
|
-
|
|
427
|
-
static iterateCursorSafe(store, processCallback, options = {}) {
|
|
428
|
-
return new Promise((resolve, reject) => {
|
|
429
|
-
const {
|
|
430
|
-
index = null,
|
|
431
|
-
direction = 'next',
|
|
432
|
-
range = null
|
|
433
|
-
} = options;
|
|
434
|
-
|
|
435
|
-
let source = store;
|
|
436
|
-
if (index && store.indexNames.contains(index)) {
|
|
437
|
-
source = store.index(index);
|
|
438
|
-
}
|
|
439
454
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
455
|
+
iterateCursorSafe(db, storeName, callback, direction = 'next', query) {
|
|
456
|
+
return this.performTransaction(db, [storeName], 'readonly', tx => {
|
|
457
|
+
return new Promise((resolve, reject) => {
|
|
458
|
+
const results = [];
|
|
459
|
+
const request = tx.objectStore(storeName).openCursor(query, direction);
|
|
460
|
+
|
|
461
|
+
request.onsuccess = event => {
|
|
462
|
+
const cursor = event.target.result;
|
|
463
|
+
if (cursor) {
|
|
464
|
+
try {
|
|
465
|
+
const result = callback(cursor.value, cursor.key);
|
|
466
|
+
if (result !== false) {
|
|
467
|
+
results.push(result);
|
|
468
|
+
cursor.continue();
|
|
469
|
+
} else {
|
|
470
|
+
resolve(results);
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
reject(error);
|
|
474
|
+
}
|
|
452
475
|
} else {
|
|
453
476
|
resolve(results);
|
|
454
477
|
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
};
|
|
459
|
-
|
|
460
|
-
request.onerror = () => reject(request.error);
|
|
478
|
+
};
|
|
479
|
+
request.onerror = () => reject(request.error);
|
|
480
|
+
});
|
|
461
481
|
});
|
|
462
482
|
}
|
|
463
|
-
|
|
464
|
-
toString() {
|
|
465
|
-
return '[IndexedDBUtility]';
|
|
466
|
-
}
|
|
467
483
|
}
|
|
468
484
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
return value ? TURBO.serialize(value) : null;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
static setItem(key, value) {
|
|
476
|
-
localStorage.setItem(key, TURBO.serialize(value));
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
static removeItem(key) {
|
|
480
|
-
localStorage.removeItem(key);
|
|
481
|
-
}
|
|
485
|
+
// ========================
|
|
486
|
+
// Document Class
|
|
487
|
+
// ========================
|
|
482
488
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
489
|
+
class Document {
|
|
490
|
+
constructor(data = {}, options = {}) {
|
|
491
|
+
this._id = data._id || this.generateId();
|
|
492
|
+
this._created = data._created || Date.now();
|
|
493
|
+
this._modified = data._modified || Date.now();
|
|
494
|
+
this._permanent = data._permanent || options.permanent || false;
|
|
495
|
+
this._encrypted = data._encrypted || options.encrypted || false;
|
|
496
|
+
this._compressed = data._compressed || options.compressed || false;
|
|
497
|
+
this._attachments = data._attachments || [];
|
|
498
|
+
this.data = data.data || {};
|
|
499
|
+
this.packedData = data.packedData || null;
|
|
486
500
|
|
|
487
|
-
|
|
488
|
-
|
|
501
|
+
// Utilities can be passed in or instantiated. For simplicity, we keep instantiation here.
|
|
502
|
+
this.compression = new BrowserCompressionUtility();
|
|
503
|
+
this.encryption = new BrowserEncryptionUtility();
|
|
504
|
+
this.password = options.password || null;
|
|
489
505
|
}
|
|
490
|
-
}
|
|
491
506
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
this.queue = [];
|
|
495
|
-
this.locked = false;
|
|
507
|
+
generateId() {
|
|
508
|
+
return `doc_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
496
509
|
}
|
|
497
510
|
|
|
498
|
-
async
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
} else {
|
|
504
|
-
this.queue.push(resolve);
|
|
511
|
+
async pack() {
|
|
512
|
+
try {
|
|
513
|
+
let packed = serializer.serialize(this.data);
|
|
514
|
+
if (this._compressed) {
|
|
515
|
+
packed = await this.compression.compress(packed);
|
|
505
516
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
} else {
|
|
514
|
-
this.locked = false;
|
|
517
|
+
if (this._encrypted && this.password) {
|
|
518
|
+
packed = await this.encryption.encrypt(packed, this.password);
|
|
519
|
+
}
|
|
520
|
+
this.packedData = packed;
|
|
521
|
+
return packed;
|
|
522
|
+
} catch (error) {
|
|
523
|
+
throw new LacertaDBError('Failed to pack document', 'PACK_FAILED', error);
|
|
515
524
|
}
|
|
516
525
|
}
|
|
517
526
|
|
|
518
|
-
async
|
|
519
|
-
await this.acquire();
|
|
527
|
+
async unpack() {
|
|
520
528
|
try {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
toString() {
|
|
528
|
-
return `[AsyncMutex locked:${this.locked} queue:${this.queue.length}]`;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
class DatabaseMetadata {
|
|
533
|
-
constructor(dbName) {
|
|
534
|
-
this._dbName = dbName;
|
|
535
|
-
this._metadataKey = `lacertadb_${this._dbName}_metadata`;
|
|
536
|
-
this._collections = new Map();
|
|
537
|
-
this._metadata = this._loadMetadata();
|
|
538
|
-
this._mutex = new AsyncMutex();
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
async saveMetadata() {
|
|
542
|
-
return await this._mutex.runExclusive(() => {
|
|
543
|
-
LocalStorageUtility.setItem(this.metadataKey, this.getRawMetadata());
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
async adjustTotals(sizeKBChange, lengthChange) {
|
|
548
|
-
return await this._mutex.runExclusive(() => {
|
|
549
|
-
this.data.totalSizeKB += sizeKBChange;
|
|
550
|
-
this.data.totalLength += lengthChange;
|
|
551
|
-
this.data.modifiedAt = Date.now();
|
|
552
|
-
});
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
_loadMetadata() {
|
|
556
|
-
const metadata = LocalStorageUtility.getItem(this.metadataKey);
|
|
557
|
-
if (metadata) {
|
|
558
|
-
for (const collectionName in metadata.collections) {
|
|
559
|
-
const collectionData = metadata.collections[collectionName];
|
|
560
|
-
const collectionMetadata = new CollectionMetadata(collectionName, this, collectionData);
|
|
561
|
-
this.collections.set(collectionName, collectionMetadata);
|
|
529
|
+
let unpacked = this.packedData;
|
|
530
|
+
if (this._encrypted && this.password) {
|
|
531
|
+
unpacked = await this.encryption.decrypt(unpacked, this.password);
|
|
562
532
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
modifiedAt: Date.now(),
|
|
571
|
-
};
|
|
533
|
+
if (this._compressed) {
|
|
534
|
+
unpacked = await this.compression.decompress(unpacked);
|
|
535
|
+
}
|
|
536
|
+
this.data = serializer.deserialize(unpacked);
|
|
537
|
+
return this.data;
|
|
538
|
+
} catch (error) {
|
|
539
|
+
throw new LacertaDBError('Failed to unpack document', 'UNPACK_FAILED', error);
|
|
572
540
|
}
|
|
573
541
|
}
|
|
574
542
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
set data(d) {
|
|
580
|
-
this._metadata = d;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
get name() {
|
|
584
|
-
return this.data.name;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
get metadataKey() {
|
|
588
|
-
return this._metadataKey;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
get collections() {
|
|
592
|
-
return this._collections;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
set collections(c) {
|
|
596
|
-
this._collections = c;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
get totalSizeKB() {
|
|
600
|
-
return this.data.totalSizeKB;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
get totalLength() {
|
|
604
|
-
return this.data.totalLength;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
get modifiedAt() {
|
|
608
|
-
return this.data.modifiedAt;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
getCollectionMetadata(collectionName) {
|
|
612
|
-
if (!this.collections.has(collectionName)) {
|
|
613
|
-
const collectionMetadata = new CollectionMetadata(collectionName, this);
|
|
614
|
-
this.collections.set(collectionName, collectionMetadata);
|
|
615
|
-
this.data.collections[collectionName] = collectionMetadata.getRawMetadata();
|
|
616
|
-
this.data.modifiedAt = Date.now();
|
|
543
|
+
packSync() {
|
|
544
|
+
let packed = serializer.serialize(this.data);
|
|
545
|
+
if (this._compressed) {
|
|
546
|
+
packed = this.compression.compressSync(packed);
|
|
617
547
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
getCollectionMetadataData(collectionName) {
|
|
622
|
-
const metadata = this.getCollectionMetadata(collectionName);
|
|
623
|
-
return metadata ? metadata.getRawMetadata(): {}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
removeCollectionMetadata(collectionName) {
|
|
627
|
-
const collectionMetadata = this.collections.get(collectionName);
|
|
628
|
-
if (collectionMetadata) {
|
|
629
|
-
this.data.totalSizeKB -= collectionMetadata.sizeKB;
|
|
630
|
-
this.data.totalLength -= collectionMetadata.length;
|
|
631
|
-
this.collections.delete(collectionName);
|
|
632
|
-
delete this.data.collections[collectionName];
|
|
633
|
-
this.data.modifiedAt = Date.now();
|
|
548
|
+
if (this._encrypted) {
|
|
549
|
+
throw new LacertaDBError('Synchronous encryption not supported', 'SYNC_ENCRYPT_NOT_SUPPORTED');
|
|
634
550
|
}
|
|
551
|
+
this.packedData = packed;
|
|
552
|
+
return packed;
|
|
635
553
|
}
|
|
636
554
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
555
|
+
unpackSync() {
|
|
556
|
+
let unpacked = this.packedData;
|
|
557
|
+
if (this._encrypted) {
|
|
558
|
+
throw new LacertaDBError('Synchronous decryption not supported', 'SYNC_DECRYPT_NOT_SUPPORTED');
|
|
559
|
+
}
|
|
560
|
+
if (this._compressed) {
|
|
561
|
+
unpacked = this.compression.decompressSync(unpacked);
|
|
644
562
|
}
|
|
563
|
+
this.data = serializer.deserialize(unpacked);
|
|
645
564
|
return this.data;
|
|
646
565
|
}
|
|
647
566
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
this.
|
|
567
|
+
objectOutput(includeAttachments = false) {
|
|
568
|
+
const output = {
|
|
569
|
+
_id: this._id,
|
|
570
|
+
_created: this._created,
|
|
571
|
+
_modified: this._modified,
|
|
572
|
+
_permanent: this._permanent,
|
|
573
|
+
...this.data
|
|
574
|
+
};
|
|
575
|
+
if (includeAttachments && this._attachments.length > 0) {
|
|
576
|
+
output._attachments = this._attachments;
|
|
655
577
|
}
|
|
578
|
+
return output;
|
|
656
579
|
}
|
|
657
580
|
|
|
658
|
-
|
|
659
|
-
return
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
class LacertaDBError extends Error {
|
|
672
|
-
constructor(message, code, originalError = null) {
|
|
673
|
-
super(message);
|
|
674
|
-
this.name = 'LacertaDBError';
|
|
675
|
-
this.code = code;
|
|
676
|
-
this.originalError = originalError;
|
|
677
|
-
this.timestamp = Date.now();
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
toString() {
|
|
681
|
-
return `[LacertaDBError ${this.code}: ${this.message} at ${new Date(this.timestamp).toISOString()}]`;
|
|
581
|
+
databaseOutput() {
|
|
582
|
+
return {
|
|
583
|
+
_id: this._id,
|
|
584
|
+
_created: this._created,
|
|
585
|
+
_modified: this._modified,
|
|
586
|
+
_permanent: this._permanent,
|
|
587
|
+
_encrypted: this._encrypted,
|
|
588
|
+
_compressed: this._compressed,
|
|
589
|
+
_attachments: this._attachments,
|
|
590
|
+
packedData: this.packedData
|
|
591
|
+
};
|
|
682
592
|
}
|
|
683
593
|
}
|
|
684
594
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
TRANSACTION_FAILED: 'TRANSACTION_FAILED',
|
|
689
|
-
ENCRYPTION_FAILED: 'ENCRYPTION_FAILED',
|
|
690
|
-
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
|
|
691
|
-
INVALID_OPERATION: 'INVALID_OPERATION',
|
|
692
|
-
DOCUMENT_NOT_FOUND: 'DOCUMENT_NOT_FOUND',
|
|
693
|
-
COLLECTION_NOT_FOUND: 'COLLECTION_NOT_FOUND',
|
|
694
|
-
COLLECTION_EXISTS: 'COLLECTION_EXISTS'
|
|
695
|
-
};
|
|
595
|
+
// ========================
|
|
596
|
+
// Metadata Classes
|
|
597
|
+
// ========================
|
|
696
598
|
|
|
697
599
|
class CollectionMetadata {
|
|
698
|
-
constructor(
|
|
699
|
-
this.
|
|
700
|
-
this.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
};
|
|
716
|
-
this._databaseMetadata.data.collections[collectionName] = this._metadata;
|
|
717
|
-
this._databaseMetadata.data.modifiedAt = Date.now();
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
get name() {
|
|
722
|
-
return this._collectionName;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
get keys() {
|
|
726
|
-
return Object.keys(this.documentSizes);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
get collectionName() {
|
|
730
|
-
return this.name;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
get sizeKB() {
|
|
734
|
-
return this._metadata.sizeKB;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
get length() {
|
|
738
|
-
return this._metadata.length;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
get modifiedAt() {
|
|
742
|
-
return this._metadata.modifiedAt;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
get metadata() {
|
|
746
|
-
return this._metadata;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
set metadata(m) {
|
|
750
|
-
return this._metadata = m;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
get data() {
|
|
754
|
-
return this.metadata;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
set data(m) {
|
|
758
|
-
this.metadata = m;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
get databaseMetadata() {
|
|
762
|
-
return this._databaseMetadata;
|
|
763
|
-
}
|
|
600
|
+
constructor(name, data = {}) {
|
|
601
|
+
this.name = name;
|
|
602
|
+
this.sizeKB = data.sizeKB || 0;
|
|
603
|
+
this.length = data.length || 0;
|
|
604
|
+
this.createdAt = data.createdAt || Date.now();
|
|
605
|
+
this.modifiedAt = data.modifiedAt || Date.now();
|
|
606
|
+
this.documentSizes = data.documentSizes || {};
|
|
607
|
+
this.documentModifiedAt = data.documentModifiedAt || {};
|
|
608
|
+
this.documentPermanent = data.documentPermanent || {};
|
|
609
|
+
this.documentAttachments = data.documentAttachments || {};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
addDocument(docId, sizeKB, isPermanent, attachmentCount) {
|
|
613
|
+
this.documentSizes[docId] = sizeKB;
|
|
614
|
+
this.documentModifiedAt[docId] = Date.now();
|
|
615
|
+
if (isPermanent) this.documentPermanent[docId] = true;
|
|
616
|
+
if (attachmentCount > 0) this.documentAttachments[docId] = attachmentCount;
|
|
764
617
|
|
|
765
|
-
|
|
766
|
-
this.
|
|
618
|
+
this.sizeKB += sizeKB;
|
|
619
|
+
this.length++;
|
|
620
|
+
this.modifiedAt = Date.now();
|
|
767
621
|
}
|
|
768
622
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
set documentSizes(v){ this.metadata.documentSizes = v;}
|
|
775
|
-
set documentModifiedAt(v){ this.metadata.documentModifiedAt = v;}
|
|
776
|
-
set documentPermanent(v){ this.metadata.documentPermanent = v;}
|
|
777
|
-
set documentAttachments(v){ this.metadata.documentAttachments = v;}
|
|
778
|
-
|
|
779
|
-
updateDocument(docId, docSizeKB, isPermanent = false, attachmentCount = 0) {
|
|
780
|
-
const isNewDocument = !this.keys.includes(docId);
|
|
781
|
-
const previousDocSizeKB = this.documentSizes[docId] || 0;
|
|
782
|
-
const sizeKBChange = docSizeKB - previousDocSizeKB;
|
|
783
|
-
const lengthChange = isNewDocument ? 1 : 0;
|
|
784
|
-
|
|
785
|
-
this.documentSizes[docId] = docSizeKB;
|
|
623
|
+
updateDocument(docId, newSizeKB, isPermanent, attachmentCount) {
|
|
624
|
+
const oldSize = this.documentSizes[docId] || 0;
|
|
625
|
+
this.sizeKB = this.sizeKB - oldSize + newSizeKB;
|
|
626
|
+
this.documentSizes[docId] = newSizeKB;
|
|
786
627
|
this.documentModifiedAt[docId] = Date.now();
|
|
787
|
-
this.documentPermanent[docId] = isPermanent ? 1 : 0;
|
|
788
|
-
this.documentAttachments[docId] = attachmentCount;
|
|
789
628
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
}
|
|
629
|
+
if (isPermanent) {
|
|
630
|
+
this.documentPermanent[docId] = true;
|
|
631
|
+
} else {
|
|
632
|
+
delete this.documentPermanent[docId];
|
|
633
|
+
}
|
|
796
634
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
635
|
+
if (attachmentCount > 0) {
|
|
636
|
+
this.documentAttachments[docId] = attachmentCount;
|
|
637
|
+
} else {
|
|
638
|
+
delete this.documentAttachments[docId];
|
|
801
639
|
}
|
|
802
640
|
|
|
803
|
-
|
|
641
|
+
this.modifiedAt = Date.now();
|
|
642
|
+
}
|
|
804
643
|
|
|
644
|
+
removeDocument(docId) {
|
|
645
|
+
const sizeKB = this.documentSizes[docId] || 0;
|
|
646
|
+
if (this.documentSizes[docId]) {
|
|
647
|
+
this.sizeKB -= sizeKB;
|
|
648
|
+
this.length--;
|
|
649
|
+
}
|
|
805
650
|
delete this.documentSizes[docId];
|
|
806
651
|
delete this.documentModifiedAt[docId];
|
|
807
652
|
delete this.documentPermanent[docId];
|
|
808
653
|
delete this.documentAttachments[docId];
|
|
809
|
-
|
|
810
|
-
this.metadata.sizeKB -= docSizeKB;
|
|
811
|
-
this.metadata.length -= 1;
|
|
812
|
-
this.metadata.modifiedAt = Date.now();
|
|
813
|
-
|
|
814
|
-
this.databaseMetadata.adjustTotals(-docSizeKB, -1);
|
|
815
|
-
|
|
816
|
-
return true;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
updateDocuments(updates) {
|
|
820
|
-
for (const { docId, docSizeKB, isPermanent, attachmentCount } of updates) {
|
|
821
|
-
this.updateDocument(docId, docSizeKB, isPermanent, attachmentCount || 0);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
getRawMetadata() {
|
|
826
|
-
return this.metadata;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
setRawMetadata(metadata) {
|
|
830
|
-
this.metadata = metadata;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
toString() {
|
|
834
|
-
return `[CollectionMetadata: ${this.name} | Size: ${this.sizeKB.toFixed(2)}KB | Documents: ${this.length}]`;
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
class LacertaDBResult {
|
|
839
|
-
constructor(success, data = null, error = null) {
|
|
840
|
-
this.success = success;
|
|
841
|
-
this.data = data;
|
|
842
|
-
this.error = error;
|
|
654
|
+
this.modifiedAt = Date.now();
|
|
843
655
|
}
|
|
844
656
|
|
|
845
|
-
|
|
846
|
-
return
|
|
657
|
+
getOldestNonPermanentDocuments(count) {
|
|
658
|
+
return Object.entries(this.documentModifiedAt)
|
|
659
|
+
.filter(([docId]) => !this.documentPermanent[docId])
|
|
660
|
+
.sort(([, timeA], [, timeB]) => timeA - timeB)
|
|
661
|
+
.slice(0, count)
|
|
662
|
+
.map(([docId]) => docId);
|
|
847
663
|
}
|
|
848
664
|
}
|
|
849
665
|
|
|
850
|
-
class
|
|
851
|
-
constructor(
|
|
852
|
-
this.
|
|
853
|
-
this.
|
|
854
|
-
this.
|
|
855
|
-
this.
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
}
|
|
666
|
+
class DatabaseMetadata {
|
|
667
|
+
constructor(name, data = {}) {
|
|
668
|
+
this.name = name;
|
|
669
|
+
this.collections = data.collections || {};
|
|
670
|
+
this.totalSizeKB = data.totalSizeKB || 0;
|
|
671
|
+
this.totalLength = data.totalLength || 0;
|
|
672
|
+
this.modifiedAt = data.modifiedAt || Date.now();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
static load(dbName) {
|
|
676
|
+
const key = `lacertadb_${dbName}_metadata`;
|
|
677
|
+
const stored = localStorage.getItem(key);
|
|
678
|
+
if (stored) {
|
|
679
|
+
try {
|
|
680
|
+
const decoded = base64.decode(stored);
|
|
681
|
+
const data = serializer.deserialize(decoded);
|
|
682
|
+
return new DatabaseMetadata(dbName, data);
|
|
683
|
+
} catch (e) {
|
|
684
|
+
console.error('Failed to load metadata:', e);
|
|
685
|
+
}
|
|
870
686
|
}
|
|
687
|
+
return new DatabaseMetadata(dbName);
|
|
871
688
|
}
|
|
872
689
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
packedData: packedData,
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
const key = this._documentKeyPrefix + docId;
|
|
894
|
-
localStorage.setItem(key, dataToStore);
|
|
895
|
-
|
|
896
|
-
const dataSizeKB = dataToStore.length / 1024;
|
|
897
|
-
|
|
898
|
-
const isNewDocument = !(docId in this._metadata.documentSizesKB);
|
|
899
|
-
|
|
900
|
-
if (isNewDocument) {
|
|
901
|
-
this._metadata.totalLength += 1;
|
|
902
|
-
} else {
|
|
903
|
-
this._metadata.totalSizeKB -= this._metadata.documentSizesKB[docId];
|
|
690
|
+
save() {
|
|
691
|
+
const key = `lacertadb_${this.name}_metadata`;
|
|
692
|
+
try {
|
|
693
|
+
const dataToStore = {
|
|
694
|
+
collections: this.collections,
|
|
695
|
+
totalSizeKB: this.totalSizeKB,
|
|
696
|
+
totalLength: this.totalLength,
|
|
697
|
+
modifiedAt: this.modifiedAt
|
|
698
|
+
};
|
|
699
|
+
const serializedData = serializer.serialize(dataToStore);
|
|
700
|
+
const encodedData = base64.encode(serializedData);
|
|
701
|
+
localStorage.setItem(key, encodedData);
|
|
702
|
+
} catch (e) {
|
|
703
|
+
if (e.name === 'QuotaExceededError') {
|
|
704
|
+
throw new LacertaDBError('Storage quota exceeded for metadata', 'QUOTA_EXCEEDED', e);
|
|
705
|
+
}
|
|
706
|
+
throw new LacertaDBError('Failed to save metadata', 'METADATA_SAVE_FAILED', e);
|
|
904
707
|
}
|
|
905
|
-
|
|
906
|
-
this._metadata.documentSizesKB[docId] = dataSizeKB;
|
|
907
|
-
this._metadata.documentModificationTime[docId] = document._modified;
|
|
908
|
-
this._metadata.documentPermanent[docId] = isPermanent;
|
|
909
|
-
|
|
910
|
-
this._metadata.totalSizeKB += dataSizeKB;
|
|
911
|
-
|
|
912
|
-
this._saveMetadata();
|
|
913
|
-
|
|
914
|
-
return isNewDocument;
|
|
915
708
|
}
|
|
916
709
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
delete this._metadata.documentModificationTime[docId];
|
|
931
|
-
delete this._metadata.documentPermanent[docId];
|
|
932
|
-
|
|
933
|
-
this._metadata.totalSizeKB -= docSizeKB;
|
|
934
|
-
this._metadata.totalLength -= 1;
|
|
935
|
-
|
|
936
|
-
this._saveMetadata();
|
|
937
|
-
|
|
938
|
-
return true;
|
|
939
|
-
} else {
|
|
940
|
-
return false;
|
|
941
|
-
}
|
|
710
|
+
setCollection(collectionMetadata) {
|
|
711
|
+
this.collections[collectionMetadata.name] = {
|
|
712
|
+
sizeKB: collectionMetadata.sizeKB,
|
|
713
|
+
length: collectionMetadata.length,
|
|
714
|
+
createdAt: collectionMetadata.createdAt,
|
|
715
|
+
modifiedAt: collectionMetadata.modifiedAt,
|
|
716
|
+
documentSizes: collectionMetadata.documentSizes,
|
|
717
|
+
documentModifiedAt: collectionMetadata.documentModifiedAt,
|
|
718
|
+
documentPermanent: collectionMetadata.documentPermanent,
|
|
719
|
+
documentAttachments: collectionMetadata.documentAttachments
|
|
720
|
+
};
|
|
721
|
+
this.recalculate();
|
|
722
|
+
this.save();
|
|
942
723
|
}
|
|
943
724
|
|
|
944
|
-
|
|
945
|
-
|
|
725
|
+
removeCollection(collectionName) {
|
|
726
|
+
delete this.collections[collectionName];
|
|
727
|
+
this.recalculate();
|
|
728
|
+
this.save();
|
|
946
729
|
}
|
|
947
730
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
return document.unpackSync();
|
|
956
|
-
} else {
|
|
957
|
-
return null;
|
|
731
|
+
recalculate() {
|
|
732
|
+
this.totalSizeKB = 0;
|
|
733
|
+
this.totalLength = 0;
|
|
734
|
+
for (const collName in this.collections) {
|
|
735
|
+
const coll = this.collections[collName];
|
|
736
|
+
this.totalSizeKB += coll.sizeKB;
|
|
737
|
+
this.totalLength += coll.length;
|
|
958
738
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
toString() {
|
|
962
|
-
return `[QuickStore: ${this._dbName} | Size: ${this._metadata.totalSizeKB.toFixed(2)}KB | Documents: ${this._metadata.totalLength}]`;
|
|
739
|
+
this.modifiedAt = Date.now();
|
|
963
740
|
}
|
|
964
741
|
}
|
|
965
742
|
|
|
966
|
-
class
|
|
967
|
-
constructor() {
|
|
968
|
-
this.
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
if (this.processing || this.queue.length === 0) return;
|
|
981
|
-
|
|
982
|
-
this.processing = true;
|
|
983
|
-
|
|
984
|
-
while (this.queue.length > 0) {
|
|
985
|
-
const { operation, resolve, reject } = this.queue.shift();
|
|
986
|
-
|
|
743
|
+
class Settings {
|
|
744
|
+
constructor(dbName, data = {}) {
|
|
745
|
+
this.dbName = dbName;
|
|
746
|
+
// Replaced `??` with ternary operator for compatibility
|
|
747
|
+
this.sizeLimitKB = data.sizeLimitKB != null ? data.sizeLimitKB : Infinity;
|
|
748
|
+
const defaultBuffer = this.sizeLimitKB === Infinity ? 0 : this.sizeLimitKB * 0.8;
|
|
749
|
+
this.bufferLimitKB = data.bufferLimitKB != null ? data.bufferLimitKB : defaultBuffer;
|
|
750
|
+
this.freeSpaceEvery = data.freeSpaceEvery || 10000;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
static load(dbName) {
|
|
754
|
+
const key = `lacertadb_${dbName}_settings`;
|
|
755
|
+
const stored = localStorage.getItem(key);
|
|
756
|
+
if (stored) {
|
|
987
757
|
try {
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
758
|
+
const decoded = base64.decode(stored);
|
|
759
|
+
const data = serializer.deserialize(decoded);
|
|
760
|
+
return new Settings(dbName, data);
|
|
761
|
+
} catch (e) {
|
|
762
|
+
console.error('Failed to load settings:', e);
|
|
992
763
|
}
|
|
993
764
|
}
|
|
765
|
+
return new Settings(dbName);
|
|
766
|
+
}
|
|
994
767
|
|
|
995
|
-
|
|
768
|
+
save() {
|
|
769
|
+
const key = `lacertadb_${this.dbName}_settings`;
|
|
770
|
+
const dataToStore = {
|
|
771
|
+
sizeLimitKB: this.sizeLimitKB,
|
|
772
|
+
bufferLimitKB: this.bufferLimitKB,
|
|
773
|
+
freeSpaceEvery: this.freeSpaceEvery
|
|
774
|
+
};
|
|
775
|
+
const serializedData = serializer.serialize(dataToStore);
|
|
776
|
+
const encodedData = base64.encode(serializedData);
|
|
777
|
+
localStorage.setItem(key, encodedData);
|
|
996
778
|
}
|
|
997
779
|
|
|
998
|
-
|
|
999
|
-
|
|
780
|
+
updateSettings(newSettings) {
|
|
781
|
+
Object.assign(this, newSettings);
|
|
782
|
+
this.save();
|
|
1000
783
|
}
|
|
1001
784
|
}
|
|
1002
785
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
};
|
|
786
|
+
// ========================
|
|
787
|
+
// Quick Store (localStorage based)
|
|
788
|
+
// ========================
|
|
789
|
+
|
|
790
|
+
class QuickStore {
|
|
791
|
+
constructor(dbName) {
|
|
792
|
+
this.dbName = dbName;
|
|
793
|
+
this.keyPrefix = `lacertadb_${dbName}_quickstore_`;
|
|
794
|
+
this.indexKey = `${this.keyPrefix}index`;
|
|
1013
795
|
}
|
|
1014
796
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
797
|
+
_readIndex() {
|
|
798
|
+
const indexStr = localStorage.getItem(this.indexKey);
|
|
799
|
+
if (!indexStr) return [];
|
|
800
|
+
try {
|
|
801
|
+
const decoded = base64.decode(indexStr);
|
|
802
|
+
return serializer.deserialize(decoded);
|
|
803
|
+
} catch {
|
|
804
|
+
return [];
|
|
1020
805
|
}
|
|
1021
806
|
}
|
|
1022
807
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
this._listeners[event].splice(index, 1);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
808
|
+
_writeIndex(index) {
|
|
809
|
+
const serializedIndex = serializer.serialize(index);
|
|
810
|
+
const encodedIndex = base64.encode(serializedIndex);
|
|
811
|
+
localStorage.setItem(this.indexKey, encodedIndex);
|
|
1030
812
|
}
|
|
1031
813
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
814
|
+
add(docId, data) {
|
|
815
|
+
const key = `${this.keyPrefix}data_${docId}`;
|
|
816
|
+
try {
|
|
817
|
+
const serializedData = serializer.serialize(data);
|
|
818
|
+
const encodedData = base64.encode(serializedData);
|
|
819
|
+
localStorage.setItem(key, encodedData);
|
|
820
|
+
|
|
821
|
+
const index = this._readIndex();
|
|
822
|
+
if (!index.includes(docId)) {
|
|
823
|
+
index.push(docId);
|
|
824
|
+
this._writeIndex(index);
|
|
1036
825
|
}
|
|
826
|
+
return true;
|
|
827
|
+
} catch (e) {
|
|
828
|
+
if (e.name === 'QuotaExceededError') {
|
|
829
|
+
throw new LacertaDBError('QuickStore quota exceeded', 'QUOTA_EXCEEDED', e);
|
|
830
|
+
}
|
|
831
|
+
return false;
|
|
1037
832
|
}
|
|
1038
833
|
}
|
|
1039
834
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
835
|
+
get(docId) {
|
|
836
|
+
const key = `${this.keyPrefix}data_${docId}`;
|
|
837
|
+
const stored = localStorage.getItem(key);
|
|
838
|
+
if (stored) {
|
|
839
|
+
try {
|
|
840
|
+
const decoded = base64.decode(stored);
|
|
841
|
+
return serializer.deserialize(decoded);
|
|
842
|
+
} catch (e) {
|
|
843
|
+
console.error('Failed to parse QuickStore data:', e);
|
|
844
|
+
}
|
|
1044
845
|
}
|
|
846
|
+
return null;
|
|
1045
847
|
}
|
|
1046
848
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
.map(([event, listeners]) => `${event}:${listeners.length}`)
|
|
1050
|
-
.join(' ');
|
|
1051
|
-
return `[Observer listeners:{${counts}}]`;
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
export class Database {
|
|
1056
|
-
constructor(dbName, settings = {}) {
|
|
1057
|
-
this._dbName = dbName;
|
|
1058
|
-
this._db = null;
|
|
1059
|
-
this._collections = new Map();
|
|
1060
|
-
this._metadata = new DatabaseMetadata(dbName);
|
|
1061
|
-
this._settings = new Settings(dbName, settings);
|
|
1062
|
-
this._quickStore = new QuickStore(this._dbName);
|
|
1063
|
-
this._settings.init();
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
get quickStore() {
|
|
1067
|
-
return this._quickStore;
|
|
849
|
+
update(docId, data) {
|
|
850
|
+
return this.add(docId, data);
|
|
1068
851
|
}
|
|
1069
852
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
});
|
|
1074
|
-
|
|
1075
|
-
const collectionNames = this.data.getCollectionNames();
|
|
1076
|
-
for (const collectionName of collectionNames) {
|
|
1077
|
-
const collection = new Collection(this, collectionName, this.settings);
|
|
1078
|
-
await collection.init();
|
|
1079
|
-
this.collections.set(collectionName, collection);
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
853
|
+
delete(docId) {
|
|
854
|
+
const key = `${this.keyPrefix}data_${docId}`;
|
|
855
|
+
localStorage.removeItem(key);
|
|
1082
856
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
857
|
+
let index = this._readIndex();
|
|
858
|
+
const initialLength = index.length;
|
|
859
|
+
index = index.filter(id => id !== docId);
|
|
860
|
+
if (index.length < initialLength) {
|
|
861
|
+
this._writeIndex(index);
|
|
1086
862
|
}
|
|
1087
863
|
}
|
|
1088
864
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
865
|
+
getAll() {
|
|
866
|
+
const index = this._readIndex();
|
|
867
|
+
return index.map(docId => {
|
|
868
|
+
const doc = this.get(docId);
|
|
869
|
+
return doc ? { _id: docId, ...doc } : null;
|
|
870
|
+
}).filter(Boolean);
|
|
1093
871
|
}
|
|
1094
872
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
873
|
+
clear() {
|
|
874
|
+
const index = this._readIndex();
|
|
875
|
+
for (const docId of index) {
|
|
876
|
+
localStorage.removeItem(`${this.keyPrefix}data_${docId}`);
|
|
1099
877
|
}
|
|
878
|
+
localStorage.removeItem(this.indexKey);
|
|
1100
879
|
}
|
|
880
|
+
}
|
|
1101
881
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
return new LacertaDBResult(
|
|
1106
|
-
false,
|
|
1107
|
-
this.collections.get(collectionName),
|
|
1108
|
-
new LacertaDBError(
|
|
1109
|
-
`Collection "${collectionName}" already exists`,
|
|
1110
|
-
ErrorCodes.COLLECTION_EXISTS
|
|
1111
|
-
)
|
|
1112
|
-
);
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
if (!this.db.objectStoreNames.contains(collectionName)) {
|
|
1116
|
-
const newVersion = this.db.version + 1;
|
|
1117
|
-
this.db.close();
|
|
1118
|
-
this.db = await IndexedDBUtility.openDatabase(this.name, newVersion, (db, oldVersion, newVersion) => {
|
|
1119
|
-
this._createDataStore(db, collectionName);
|
|
1120
|
-
});
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
const collection = new Collection(this, collectionName, this.settings);
|
|
1124
|
-
await collection.init();
|
|
1125
|
-
this.collections.set(collectionName, collection);
|
|
1126
|
-
|
|
1127
|
-
this.data.getCollectionMetadata(collectionName);
|
|
1128
|
-
await this.data.saveMetadata();
|
|
1129
|
-
|
|
1130
|
-
return new LacertaDBResult(true, collection);
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
// FIXED: Add proper cleanup for observers
|
|
1134
|
-
async deleteCollection(collectionName) {
|
|
1135
|
-
if (!this.collections.has(collectionName)) {
|
|
1136
|
-
return new LacertaDBResult(
|
|
1137
|
-
false,
|
|
1138
|
-
null,
|
|
1139
|
-
new LacertaDBError(
|
|
1140
|
-
`Collection "${collectionName}" does not exist`,
|
|
1141
|
-
ErrorCodes.COLLECTION_NOT_FOUND
|
|
1142
|
-
)
|
|
1143
|
-
);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
const collection = this.collections.get(collectionName);
|
|
1147
|
-
|
|
1148
|
-
// Clean up collection resources
|
|
1149
|
-
await collection.close();
|
|
1150
|
-
collection.observer._cleanup();
|
|
1151
|
-
|
|
1152
|
-
await IndexedDBUtility.performTransaction(this.db, collectionName, 'readwrite', (store) => {
|
|
1153
|
-
return IndexedDBUtility.clear(store);
|
|
1154
|
-
});
|
|
1155
|
-
|
|
1156
|
-
this.collections.delete(collectionName);
|
|
1157
|
-
this.data.removeCollectionMetadata(collectionName);
|
|
1158
|
-
await this.data.saveMetadata();
|
|
882
|
+
// ========================
|
|
883
|
+
// Query Engine
|
|
884
|
+
// ========================
|
|
1159
885
|
|
|
1160
|
-
|
|
886
|
+
class QueryEngine {
|
|
887
|
+
constructor() {
|
|
888
|
+
this.operators = {
|
|
889
|
+
// Comparison
|
|
890
|
+
'$eq': (a, b) => a === b,
|
|
891
|
+
'$ne': (a, b) => a !== b,
|
|
892
|
+
'$gt': (a, b) => a > b,
|
|
893
|
+
'$gte': (a, b) => a >= b,
|
|
894
|
+
'$lt': (a, b) => a < b,
|
|
895
|
+
'$lte': (a, b) => a <= b,
|
|
896
|
+
'$in': (a, b) => Array.isArray(b) && b.includes(a),
|
|
897
|
+
'$nin': (a, b) => Array.isArray(b) && !b.includes(a),
|
|
898
|
+
|
|
899
|
+
// Logical
|
|
900
|
+
'$and': (doc, conditions) => conditions.every(cond => this.evaluate(doc, cond)),
|
|
901
|
+
'$or': (doc, conditions) => conditions.some(cond => this.evaluate(doc, cond)),
|
|
902
|
+
'$not': (doc, condition) => !this.evaluate(doc, condition),
|
|
903
|
+
'$nor': (doc, conditions) => !conditions.some(cond => this.evaluate(doc, cond)),
|
|
904
|
+
|
|
905
|
+
// Element
|
|
906
|
+
'$exists': (value, exists) => (value !== undefined) === exists,
|
|
907
|
+
'$type': (value, type) => typeof value === type,
|
|
908
|
+
|
|
909
|
+
// Array
|
|
910
|
+
'$all': (arr, values) => Array.isArray(arr) && values.every(v => arr.includes(v)),
|
|
911
|
+
'$elemMatch': (arr, condition) => Array.isArray(arr) && arr.some(elem => this.evaluate({ value: elem }, { value: condition })),
|
|
912
|
+
'$size': (arr, size) => Array.isArray(arr) && arr.length === size,
|
|
913
|
+
|
|
914
|
+
// String
|
|
915
|
+
'$regex': (str, pattern) => {
|
|
916
|
+
if (typeof str !== 'string') return false;
|
|
917
|
+
try {
|
|
918
|
+
const regex = new RegExp(pattern);
|
|
919
|
+
return regex.test(str);
|
|
920
|
+
} catch {
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
'$text': (str, search) => typeof str === 'string' && str.toLowerCase().includes(search.toLowerCase())
|
|
925
|
+
};
|
|
1161
926
|
}
|
|
1162
927
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
this.collections.set(collectionName, collection);
|
|
1171
|
-
return new LacertaDBResult(true, collection);
|
|
928
|
+
evaluate(doc, query) {
|
|
929
|
+
for (const key in query) {
|
|
930
|
+
const value = query[key];
|
|
931
|
+
if (key.startsWith('$')) {
|
|
932
|
+
// Logical operator at root level
|
|
933
|
+
const operator = this.operators[key];
|
|
934
|
+
if (!operator || !operator(doc, value)) return false;
|
|
1172
935
|
} else {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
936
|
+
// Field-level query
|
|
937
|
+
const fieldValue = this.getFieldValue(doc, key);
|
|
938
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
939
|
+
// Operator-based comparison
|
|
940
|
+
for (const op in value) {
|
|
941
|
+
if (op.startsWith('$')) {
|
|
942
|
+
const operatorFn = this.operators[op];
|
|
943
|
+
if (!operatorFn || !operatorFn(fieldValue, value[op])) {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
// Direct equality comparison
|
|
950
|
+
if (fieldValue !== value) return false;
|
|
951
|
+
}
|
|
1181
952
|
}
|
|
1182
953
|
}
|
|
954
|
+
return true;
|
|
1183
955
|
}
|
|
1184
956
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
for (const
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
this.db.close();
|
|
1194
|
-
this.db = null;
|
|
957
|
+
getFieldValue(doc, path) {
|
|
958
|
+
// Replaced optional chaining with a loop for compatibility
|
|
959
|
+
let current = doc;
|
|
960
|
+
for (const part of path.split('.')) {
|
|
961
|
+
if (current === null || current === undefined) {
|
|
962
|
+
return undefined;
|
|
963
|
+
}
|
|
964
|
+
current = current[part];
|
|
1195
965
|
}
|
|
966
|
+
return current;
|
|
1196
967
|
}
|
|
968
|
+
}
|
|
969
|
+
const queryEngine = new QueryEngine();
|
|
1197
970
|
|
|
1198
|
-
async deleteDatabase() {
|
|
1199
|
-
await this.close();
|
|
1200
|
-
await IndexedDBUtility.deleteDatabase(this.name);
|
|
1201
|
-
LocalStorageUtility.removeItem(this.data.metadataKey);
|
|
1202
|
-
this.settings.clear();
|
|
1203
|
-
this.data = null;
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
get name() {
|
|
1207
|
-
return this._dbName;
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
get db() {
|
|
1211
|
-
return this._db;
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
set db(db) {
|
|
1215
|
-
this._db = db;
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
get data() {
|
|
1219
|
-
return this.metadata;
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
set data(d) {
|
|
1223
|
-
this.metadata = d;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
get collectionsArray() {
|
|
1227
|
-
return Array.from(this.collections.values());
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
get collections() {
|
|
1231
|
-
return this._collections;
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
get metadata() {
|
|
1235
|
-
return this._metadata;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
set metadata(d) {
|
|
1239
|
-
this._metadata = d;
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
get totalSizeKB() {
|
|
1243
|
-
return this.data.totalSizeKB;
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
get totalLength() {
|
|
1247
|
-
return this.data.totalLength;
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
get modifiedAt() {
|
|
1251
|
-
return this.data.modifiedAt;
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
get settings() {
|
|
1255
|
-
return this._settings;
|
|
1256
|
-
}
|
|
1257
971
|
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
972
|
+
// ========================
|
|
973
|
+
// Aggregation Pipeline
|
|
974
|
+
// ========================
|
|
1261
975
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
976
|
+
class AggregationPipeline {
|
|
977
|
+
constructor() {
|
|
978
|
+
this.stages = {
|
|
979
|
+
'$match': (docs, condition) => docs.filter(doc => queryEngine.evaluate(doc, condition)),
|
|
980
|
+
|
|
981
|
+
'$project': (docs, projection) => docs.map(doc => {
|
|
982
|
+
const projected = {};
|
|
983
|
+
for (const key in projection) {
|
|
984
|
+
const value = projection[key];
|
|
985
|
+
if (value === 1 || value === true) {
|
|
986
|
+
projected[key] = queryEngine.getFieldValue(doc, key);
|
|
987
|
+
} else if (typeof value === 'object') {
|
|
988
|
+
// Handle computed fields if necessary
|
|
989
|
+
} else if (typeof value === 'string' && value.startsWith('$')) {
|
|
990
|
+
projected[key] = queryEngine.getFieldValue(doc, value.substring(1));
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
// Handle exclusion projection
|
|
994
|
+
if (Object.values(projection).some(v => v === 0 || v === false)) {
|
|
995
|
+
const exclusions = Object.keys(projection).filter(k => projection[k] === 0 || projection[k] === false);
|
|
996
|
+
const included = { ...doc };
|
|
997
|
+
exclusions.forEach(key => delete included[key]);
|
|
998
|
+
return included;
|
|
999
|
+
}
|
|
1000
|
+
return projected;
|
|
1001
|
+
}),
|
|
1002
|
+
|
|
1003
|
+
'$sort': (docs, sortSpec) => [...docs].sort((a, b) => {
|
|
1004
|
+
for (const key in sortSpec) {
|
|
1005
|
+
const order = sortSpec[key]; // 1 for asc, -1 for desc
|
|
1006
|
+
const aVal = queryEngine.getFieldValue(a, key);
|
|
1007
|
+
const bVal = queryEngine.getFieldValue(b, key);
|
|
1008
|
+
if (aVal < bVal) return -order;
|
|
1009
|
+
if (aVal > bVal) return order;
|
|
1010
|
+
}
|
|
1011
|
+
return 0;
|
|
1012
|
+
}),
|
|
1266
1013
|
|
|
1267
|
-
|
|
1268
|
-
constructor(data, encryptionKey = null) {
|
|
1269
|
-
this._id = data._id || this._generateId();
|
|
1270
|
-
this._created = data._created || Date.now();
|
|
1271
|
-
this._permanent = data._permanent ? true : false;
|
|
1272
|
-
this._encrypted = data._encrypted || (encryptionKey ? true : false);
|
|
1273
|
-
this._compressed = data._compressed || false;
|
|
1014
|
+
'$limit': (docs, limit) => docs.slice(0, limit),
|
|
1274
1015
|
|
|
1275
|
-
|
|
1016
|
+
'$skip': (docs, skip) => docs.slice(skip),
|
|
1276
1017
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
this._data = null;
|
|
1281
|
-
} else {
|
|
1282
|
-
this._data = data.data || {};
|
|
1283
|
-
this._modified = Date.now();
|
|
1284
|
-
this._packedData = new Uint8Array(0);
|
|
1285
|
-
}
|
|
1018
|
+
'$group': (docs, groupSpec) => {
|
|
1019
|
+
const groups = new Map();
|
|
1020
|
+
const idField = groupSpec._id;
|
|
1286
1021
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1022
|
+
for (const doc of docs) {
|
|
1023
|
+
const groupKey = typeof idField === 'string' ?
|
|
1024
|
+
queryEngine.getFieldValue(doc, idField.replace('$', '')) :
|
|
1025
|
+
JSON.stringify(idField); // Fallback for complex IDs
|
|
1289
1026
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1027
|
+
if (!groups.has(groupKey)) {
|
|
1028
|
+
groups.set(groupKey, { _id: groupKey, docs: [] });
|
|
1029
|
+
}
|
|
1030
|
+
groups.get(groupKey).docs.push(doc);
|
|
1031
|
+
}
|
|
1293
1032
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1033
|
+
const results = [];
|
|
1034
|
+
for (const group of groups.values()) {
|
|
1035
|
+
const result = { _id: group._id };
|
|
1036
|
+
for (const fieldKey in groupSpec) {
|
|
1037
|
+
if (fieldKey === '_id') continue;
|
|
1038
|
+
const accumulator = groupSpec[fieldKey];
|
|
1039
|
+
const op = Object.keys(accumulator)[0];
|
|
1040
|
+
const field = accumulator[op].toString().replace('$', '');
|
|
1041
|
+
|
|
1042
|
+
switch(op) {
|
|
1043
|
+
case '$sum':
|
|
1044
|
+
result[fieldKey] = group.docs.reduce((sum, d) => sum + (queryEngine.getFieldValue(d, field) || 0), 0);
|
|
1045
|
+
break;
|
|
1046
|
+
case '$avg':
|
|
1047
|
+
const sum = group.docs.reduce((s, d) => s + (queryEngine.getFieldValue(d, field) || 0), 0);
|
|
1048
|
+
result[fieldKey] = sum / group.docs.length;
|
|
1049
|
+
break;
|
|
1050
|
+
case '$count':
|
|
1051
|
+
result[fieldKey] = group.docs.length;
|
|
1052
|
+
break;
|
|
1053
|
+
case '$max':
|
|
1054
|
+
result[fieldKey] = Math.max(...group.docs.map(d => queryEngine.getFieldValue(d, field)));
|
|
1055
|
+
break;
|
|
1056
|
+
case '$min':
|
|
1057
|
+
result[fieldKey] = Math.min(...group.docs.map(d => queryEngine.getFieldValue(d, field)));
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
results.push(result);
|
|
1062
|
+
}
|
|
1063
|
+
return results;
|
|
1064
|
+
},
|
|
1297
1065
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1066
|
+
'$lookup': async (docs, lookupSpec, db) => {
|
|
1067
|
+
const foreignCollection = await db.getCollection(lookupSpec.from);
|
|
1068
|
+
const foreignDocs = await foreignCollection.getAll();
|
|
1069
|
+
const foreignMap = new Map();
|
|
1070
|
+
foreignDocs.forEach(doc => {
|
|
1071
|
+
const key = queryEngine.getFieldValue(doc, lookupSpec.foreignField);
|
|
1072
|
+
if (!foreignMap.has(key)) foreignMap.set(key, []);
|
|
1073
|
+
foreignMap.get(key).push(doc);
|
|
1074
|
+
});
|
|
1301
1075
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1076
|
+
return docs.map(doc => {
|
|
1077
|
+
const localValue = queryEngine.getFieldValue(doc, lookupSpec.localField);
|
|
1078
|
+
return {
|
|
1079
|
+
...doc,
|
|
1080
|
+
[lookupSpec.as]: foreignMap.get(localValue) || []
|
|
1081
|
+
};
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1304
1085
|
}
|
|
1305
1086
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1087
|
+
async execute(docs, pipeline, db) {
|
|
1088
|
+
let result = docs;
|
|
1089
|
+
for (const stage of pipeline) {
|
|
1090
|
+
const stageName = Object.keys(stage)[0];
|
|
1091
|
+
const stageSpec = stage[stageName];
|
|
1092
|
+
const stageFunction = this.stages[stageName];
|
|
1309
1093
|
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1094
|
+
if (!stageFunction) {
|
|
1095
|
+
throw new Error(`Unknown aggregation stage: ${stageName}`);
|
|
1096
|
+
}
|
|
1313
1097
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1098
|
+
if (stageName === '$lookup') {
|
|
1099
|
+
result = await stageFunction(result, stageSpec, db);
|
|
1100
|
+
} else {
|
|
1101
|
+
result = stageFunction(result, stageSpec);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return result;
|
|
1316
1105
|
}
|
|
1106
|
+
}
|
|
1107
|
+
const aggregationPipeline = new AggregationPipeline();
|
|
1317
1108
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1109
|
+
// ========================
|
|
1110
|
+
// Migration Manager
|
|
1111
|
+
// ========================
|
|
1321
1112
|
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1113
|
+
class MigrationManager {
|
|
1114
|
+
constructor(database) {
|
|
1115
|
+
this.database = database;
|
|
1116
|
+
this.migrations = [];
|
|
1117
|
+
this.currentVersion = this.loadVersion();
|
|
1325
1118
|
}
|
|
1326
1119
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
return [];
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
const attachmentPaths = documentData._attachments || documentData.attachments;
|
|
1333
|
-
documentData.attachments = await OPFSUtility.getAttachments(attachmentPaths);
|
|
1334
|
-
return Promise.resolve(documentData);
|
|
1120
|
+
loadVersion() {
|
|
1121
|
+
return localStorage.getItem(`lacertadb_${this.database.name}_version`) || '1.0.0';
|
|
1335
1122
|
}
|
|
1336
1123
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1124
|
+
saveVersion(version) {
|
|
1125
|
+
localStorage.setItem(`lacertadb_${this.database.name}_version`, version);
|
|
1126
|
+
this.currentVersion = version;
|
|
1339
1127
|
}
|
|
1340
1128
|
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
throw new Error('Document is not encrypted.');
|
|
1344
|
-
}
|
|
1345
|
-
const decryptedData = await BrowserEncryptionUtility.decrypt(documentData.packedData, encryptionKey);
|
|
1346
|
-
const unpackedData = TURBO.deserialize(decryptedData);
|
|
1347
|
-
|
|
1348
|
-
return {
|
|
1349
|
-
_id: documentData._id,
|
|
1350
|
-
_created: documentData._created,
|
|
1351
|
-
_modified: documentData._modified,
|
|
1352
|
-
_encrypted: true,
|
|
1353
|
-
_compressed: documentData._compressed,
|
|
1354
|
-
_permanent: documentData._permanent ? true: false,
|
|
1355
|
-
attachments: documentData.attachments,
|
|
1356
|
-
data: unpackedData
|
|
1357
|
-
};
|
|
1129
|
+
addMigration(migration) {
|
|
1130
|
+
this.migrations.push(migration);
|
|
1358
1131
|
}
|
|
1359
1132
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1133
|
+
compareVersions(a, b) {
|
|
1134
|
+
const partsA = a.split('.').map(Number);
|
|
1135
|
+
const partsB = b.split('.').map(Number);
|
|
1136
|
+
const len = Math.max(partsA.length, partsB.length);
|
|
1364
1137
|
|
|
1365
|
-
let
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
packedData = await this._encryptData(packedData);
|
|
1138
|
+
for (let i = 0; i < len; i++) {
|
|
1139
|
+
const partA = partsA[i] || 0;
|
|
1140
|
+
const partB = partsB[i] || 0;
|
|
1141
|
+
if (partA > partB) return 1;
|
|
1142
|
+
if (partA < partB) return -1;
|
|
1371
1143
|
}
|
|
1372
|
-
|
|
1373
|
-
return packedData;
|
|
1144
|
+
return 0;
|
|
1374
1145
|
}
|
|
1375
1146
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1147
|
+
async runMigrations(targetVersion) {
|
|
1148
|
+
const applicableMigrations = this.migrations
|
|
1149
|
+
.filter(m => this.compareVersions(m.version, this.currentVersion) > 0 &&
|
|
1150
|
+
this.compareVersions(m.version, targetVersion) <= 0)
|
|
1151
|
+
.sort((a, b) => this.compareVersions(a.version, b.version));
|
|
1380
1152
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
if (this._compressed) {
|
|
1385
|
-
throw new Error("Packing synchronously a document being compressed is impossible.")
|
|
1153
|
+
for (const migration of applicableMigrations) {
|
|
1154
|
+
await this._applyMigration(migration, 'up');
|
|
1155
|
+
this.saveVersion(migration.version);
|
|
1386
1156
|
}
|
|
1387
|
-
|
|
1388
|
-
let packedData = TURBO.serialize(this.data);
|
|
1389
|
-
this.packedData = packedData;
|
|
1390
|
-
return packedData;
|
|
1391
1157
|
}
|
|
1392
1158
|
|
|
1393
|
-
async
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
this.data = TURBO.deserialize(unpackedData);
|
|
1159
|
+
async rollback(targetVersion) {
|
|
1160
|
+
const applicableMigrations = this.migrations
|
|
1161
|
+
.filter(m => m.down &&
|
|
1162
|
+
this.compareVersions(m.version, targetVersion) > 0 &&
|
|
1163
|
+
this.compareVersions(m.version, this.currentVersion) <= 0)
|
|
1164
|
+
.sort((a, b) => this.compareVersions(b.version, a.version));
|
|
1165
|
+
|
|
1166
|
+
for (const migration of applicableMigrations) {
|
|
1167
|
+
await this._applyMigration(migration, 'down');
|
|
1403
1168
|
}
|
|
1404
|
-
|
|
1169
|
+
this.saveVersion(targetVersion);
|
|
1405
1170
|
}
|
|
1406
1171
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1172
|
+
async _applyMigration(migration, direction) {
|
|
1173
|
+
console.log(`${direction === 'up' ? 'Running' : 'Rolling back'} migration: ${migration.name} (v${migration.version})`);
|
|
1174
|
+
const collections = await this.database.listCollections();
|
|
1175
|
+
for (const collectionName of collections) {
|
|
1176
|
+
const coll = await this.database.getCollection(collectionName);
|
|
1177
|
+
const docs = await coll.getAll();
|
|
1178
|
+
for (const doc of docs) {
|
|
1179
|
+
const updated = await migration[direction](doc);
|
|
1180
|
+
if (updated) {
|
|
1181
|
+
await coll.update(doc._id, updated);
|
|
1182
|
+
}
|
|
1414
1183
|
}
|
|
1415
|
-
this.data = TURBO.deserialize(this.packedData);
|
|
1416
1184
|
}
|
|
1417
|
-
return this.data;
|
|
1418
1185
|
}
|
|
1186
|
+
}
|
|
1419
1187
|
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
}
|
|
1188
|
+
// ========================
|
|
1189
|
+
// Performance Monitor
|
|
1190
|
+
// ========================
|
|
1424
1191
|
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1192
|
+
class PerformanceMonitor {
|
|
1193
|
+
constructor() {
|
|
1194
|
+
this.metrics = {
|
|
1195
|
+
operations: [],
|
|
1196
|
+
latencies: [],
|
|
1197
|
+
cacheHits: 0,
|
|
1198
|
+
cacheMisses: 0,
|
|
1199
|
+
memoryUsage: []
|
|
1200
|
+
};
|
|
1201
|
+
this.monitoring = false;
|
|
1202
|
+
this.monitoringInterval = null;
|
|
1428
1203
|
}
|
|
1429
1204
|
|
|
1430
|
-
|
|
1431
|
-
|
|
1205
|
+
startMonitoring() {
|
|
1206
|
+
if (this.monitoring) return;
|
|
1207
|
+
this.monitoring = true;
|
|
1208
|
+
this.monitoringInterval = setInterval(() => this.collectMetrics(), 1000);
|
|
1432
1209
|
}
|
|
1433
1210
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1211
|
+
stopMonitoring() {
|
|
1212
|
+
if (!this.monitoring) return;
|
|
1213
|
+
this.monitoring = false;
|
|
1214
|
+
clearInterval(this.monitoringInterval);
|
|
1215
|
+
this.monitoringInterval = null;
|
|
1436
1216
|
}
|
|
1437
1217
|
|
|
1438
|
-
|
|
1439
|
-
|
|
1218
|
+
recordOperation(type, duration) {
|
|
1219
|
+
if (!this.monitoring) return;
|
|
1220
|
+
this.metrics.operations.push({ type, duration, timestamp: Date.now() });
|
|
1221
|
+
this.metrics.latencies.push(duration);
|
|
1222
|
+
if (this.metrics.operations.length > 100) this.metrics.operations.shift();
|
|
1223
|
+
if (this.metrics.latencies.length > 100) this.metrics.latencies.shift();
|
|
1440
1224
|
}
|
|
1441
1225
|
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
await this.unpack();
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
const output = {
|
|
1448
|
-
_id: this._id,
|
|
1449
|
-
_created: this._created,
|
|
1450
|
-
_modified: this._modified,
|
|
1451
|
-
_permanent: this._permanent,
|
|
1452
|
-
_encrypted: this._encrypted,
|
|
1453
|
-
_compressed: this._compressed,
|
|
1454
|
-
attachments: this.attachments,
|
|
1455
|
-
data: this.data,
|
|
1456
|
-
};
|
|
1226
|
+
recordCacheHit() { this.metrics.cacheHits++; }
|
|
1227
|
+
recordCacheMiss() { this.metrics.cacheMisses++; }
|
|
1457
1228
|
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1229
|
+
collectMetrics() {
|
|
1230
|
+
// Replaced optional chaining with `&&` for compatibility
|
|
1231
|
+
if (performance && performance.memory) {
|
|
1232
|
+
this.metrics.memoryUsage.push({
|
|
1233
|
+
used: performance.memory.usedJSHeapSize,
|
|
1234
|
+
total: performance.memory.totalJSHeapSize,
|
|
1235
|
+
limit: performance.memory.jsHeapSizeLimit,
|
|
1236
|
+
timestamp: Date.now()
|
|
1237
|
+
});
|
|
1238
|
+
if (this.metrics.memoryUsage.length > 60) this.metrics.memoryUsage.shift();
|
|
1461
1239
|
}
|
|
1462
|
-
|
|
1463
|
-
return output;
|
|
1464
1240
|
}
|
|
1465
1241
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1242
|
+
getStats() {
|
|
1243
|
+
const opsPerSec = this.metrics.operations.filter(op => Date.now() - op.timestamp < 1000).length;
|
|
1244
|
+
const totalLatency = this.metrics.latencies.reduce((a, b) => a + b, 0);
|
|
1245
|
+
const avgLatency = this.metrics.latencies.length > 0 ? totalLatency / this.metrics.latencies.length : 0;
|
|
1246
|
+
const totalCacheOps = this.metrics.cacheHits + this.metrics.cacheMisses;
|
|
1247
|
+
const cacheHitRate = totalCacheOps > 0 ? (this.metrics.cacheHits / totalCacheOps) * 100 : 0;
|
|
1248
|
+
|
|
1249
|
+
// Replaced `.at(-1)` with classic index access for compatibility
|
|
1250
|
+
const latestMemory = this.metrics.memoryUsage.length > 0 ? this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1] : null;
|
|
1251
|
+
const memoryUsageMB = latestMemory ? latestMemory.used / (1024 * 1024) : 0;
|
|
1470
1252
|
|
|
1471
1253
|
return {
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
_compressed: this._compressed,
|
|
1477
|
-
_encrypted: this._encrypted,
|
|
1478
|
-
attachments: this.attachments,
|
|
1479
|
-
packedData: this.packedData,
|
|
1254
|
+
opsPerSec,
|
|
1255
|
+
avgLatency: avgLatency.toFixed(2),
|
|
1256
|
+
cacheHitRate: cacheHitRate.toFixed(1),
|
|
1257
|
+
memoryUsageMB: memoryUsageMB.toFixed(2)
|
|
1480
1258
|
};
|
|
1481
1259
|
}
|
|
1482
1260
|
|
|
1483
|
-
|
|
1484
|
-
|
|
1261
|
+
getOptimizationTips() {
|
|
1262
|
+
const tips = [];
|
|
1263
|
+
const stats = this.getStats();
|
|
1264
|
+
|
|
1265
|
+
if (stats.avgLatency > 100) {
|
|
1266
|
+
tips.push('High average latency detected. Consider enabling compression and indexing frequently queried fields.');
|
|
1267
|
+
}
|
|
1268
|
+
if (stats.cacheHitRate < 50 && (this.metrics.cacheHits + this.metrics.cacheMisses) > 20) {
|
|
1269
|
+
tips.push('Low cache hit rate. Consider increasing cache size or optimizing query patterns.');
|
|
1270
|
+
}
|
|
1271
|
+
if (this.metrics.memoryUsage.length > 10) {
|
|
1272
|
+
const recent = this.metrics.memoryUsage.slice(-10);
|
|
1273
|
+
const trend = recent[recent.length - 1].used - recent[0].used;
|
|
1274
|
+
if (trend > 10 * 1024 * 1024) { // > 10MB increase
|
|
1275
|
+
tips.push('Memory usage is increasing rapidly. Check for memory leaks or consider batch processing.');
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return tips.length > 0 ? tips : ['Performance is optimal. No issues detected.'];
|
|
1485
1279
|
}
|
|
1486
1280
|
}
|
|
1487
1281
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1282
|
+
// ========================
|
|
1283
|
+
// Collection Class
|
|
1284
|
+
// ========================
|
|
1285
|
+
|
|
1286
|
+
class Collection {
|
|
1287
|
+
constructor(name, database) {
|
|
1288
|
+
this.name = name;
|
|
1289
|
+
this.database = database;
|
|
1290
|
+
this.db = null;
|
|
1291
|
+
this.metadata = null;
|
|
1292
|
+
this.settings = database.settings;
|
|
1293
|
+
this.indexedDB = new IndexedDBUtility();
|
|
1294
|
+
this.opfs = new OPFSUtility();
|
|
1295
|
+
this.cleanupInterval = null;
|
|
1296
|
+
this.events = new Map();
|
|
1297
|
+
this.queryCache = new Map();
|
|
1298
|
+
this.cacheTimeout = 60000;
|
|
1299
|
+
this.performanceMonitor = database.performanceMonitor;
|
|
1494
1300
|
}
|
|
1495
1301
|
|
|
1496
|
-
init() {
|
|
1497
|
-
this.
|
|
1498
|
-
this.
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1302
|
+
async init() {
|
|
1303
|
+
const dbName = `${this.database.name}_${this.name}`;
|
|
1304
|
+
this.db = await this.indexedDB.openDatabase(dbName, 1, (db, oldVersion) => {
|
|
1305
|
+
if (oldVersion < 1 && !db.objectStoreNames.contains('documents')) {
|
|
1306
|
+
const store = db.createObjectStore('documents', { keyPath: '_id' });
|
|
1307
|
+
store.createIndex('modified', '_modified', { unique: false });
|
|
1308
|
+
}
|
|
1309
|
+
// Future index creation logic would go here during version bumps
|
|
1310
|
+
});
|
|
1503
1311
|
|
|
1504
|
-
|
|
1505
|
-
|
|
1312
|
+
const metadataData = this.database.metadata.collections[this.name];
|
|
1313
|
+
this.metadata = new CollectionMetadata(this.name, metadataData);
|
|
1506
1314
|
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
return Infinity;
|
|
1315
|
+
if (this.settings.freeSpaceEvery > 0) {
|
|
1316
|
+
this.cleanupInterval = setInterval(() => this.freeSpace(), this.settings.freeSpaceEvery);
|
|
1510
1317
|
}
|
|
1511
|
-
|
|
1512
|
-
throw new Error("Invalid freeSpaceEvery value. It must be 0, Infinity, or above 1000.");
|
|
1513
|
-
}
|
|
1514
|
-
if (value >= 1000 && value < 10000) {
|
|
1515
|
-
console.warn("Warning: freeSpaceEvery value is between 1000 and 10000, which may lead to frequent freeing.");
|
|
1516
|
-
}
|
|
1517
|
-
return value;
|
|
1318
|
+
return this;
|
|
1518
1319
|
}
|
|
1519
1320
|
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
return settings ? settings : {};
|
|
1523
|
-
}
|
|
1321
|
+
async add(documentData, options = {}) {
|
|
1322
|
+
await this.trigger('beforeAdd', documentData);
|
|
1524
1323
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1324
|
+
const doc = new Document({ data: documentData, _id: options.id }, {
|
|
1325
|
+
encrypted: options.encrypted || false,
|
|
1326
|
+
compressed: options.compressed !== false,
|
|
1327
|
+
permanent: options.permanent || false,
|
|
1328
|
+
password: options.password
|
|
1329
|
+
});
|
|
1528
1330
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1331
|
+
const attachments = options.attachments;
|
|
1332
|
+
if (attachments && attachments.length > 0) {
|
|
1333
|
+
const preparedAttachments = await Promise.all(
|
|
1334
|
+
attachments.map(att => (att instanceof File || att instanceof Blob) ?
|
|
1335
|
+
OPFSUtility.prepareAttachment(att, att.name) :
|
|
1336
|
+
Promise.resolve(att))
|
|
1337
|
+
);
|
|
1338
|
+
doc._attachments = await this.opfs.saveAttachments(this.database.name, this.name, doc._id, preparedAttachments);
|
|
1339
|
+
}
|
|
1533
1340
|
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1341
|
+
await doc.pack();
|
|
1342
|
+
const dbOutput = doc.databaseOutput();
|
|
1343
|
+
await this.indexedDB.add(this.db, 'documents', dbOutput);
|
|
1537
1344
|
|
|
1538
|
-
|
|
1539
|
-
this.
|
|
1540
|
-
this.
|
|
1541
|
-
}
|
|
1345
|
+
const sizeKB = dbOutput.packedData.byteLength / 1024;
|
|
1346
|
+
this.metadata.addDocument(doc._id, sizeKB, doc._permanent, doc._attachments.length);
|
|
1347
|
+
this.database.metadata.setCollection(this.metadata);
|
|
1542
1348
|
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
this.
|
|
1349
|
+
await this.checkSpaceLimit();
|
|
1350
|
+
await this.trigger('afterAdd', doc);
|
|
1351
|
+
this.queryCache.clear();
|
|
1352
|
+
return doc._id;
|
|
1546
1353
|
}
|
|
1547
1354
|
|
|
1548
|
-
|
|
1549
|
-
this.
|
|
1550
|
-
this._saveSettings();
|
|
1551
|
-
}
|
|
1355
|
+
async get(docId, options = {}) {
|
|
1356
|
+
await this.trigger('beforeGet', docId);
|
|
1552
1357
|
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1358
|
+
const stored = await this.indexedDB.get(this.db, 'documents', docId);
|
|
1359
|
+
if (!stored) {
|
|
1360
|
+
throw new LacertaDBError(`Document with id '${docId}' not found.`, 'DOCUMENT_NOT_FOUND');
|
|
1361
|
+
}
|
|
1556
1362
|
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1363
|
+
const doc = new Document(stored, {
|
|
1364
|
+
password: options.password,
|
|
1365
|
+
encrypted: stored._encrypted,
|
|
1366
|
+
compressed: stored._compressed
|
|
1367
|
+
});
|
|
1560
1368
|
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1369
|
+
if (stored.packedData) {
|
|
1370
|
+
await doc.unpack();
|
|
1371
|
+
}
|
|
1564
1372
|
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1373
|
+
if (options.includeAttachments && doc._attachments.length > 0) {
|
|
1374
|
+
doc.data._attachments = await this.opfs.getAttachments(doc._attachments);
|
|
1375
|
+
}
|
|
1568
1376
|
|
|
1569
|
-
|
|
1570
|
-
return
|
|
1377
|
+
await this.trigger('afterGet', doc);
|
|
1378
|
+
return doc.objectOutput(options.includeAttachments);
|
|
1571
1379
|
}
|
|
1572
|
-
}
|
|
1573
1380
|
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
get observer() {
|
|
1588
|
-
return this._observer;
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
async createIndex(fieldPath, options = {}) {
|
|
1592
|
-
const indexName = options.name || fieldPath.replace(/\./g, '_');
|
|
1593
|
-
const newVersion = this.database.db.version + 1;
|
|
1594
|
-
this.database.db.close();
|
|
1595
|
-
|
|
1596
|
-
this.database.db = await IndexedDBUtility.openDatabase(
|
|
1597
|
-
this.database.name,
|
|
1598
|
-
newVersion,
|
|
1599
|
-
(db) => {
|
|
1600
|
-
if (db.objectStoreNames.contains(this.name)) {
|
|
1601
|
-
const tx = db.transaction([this.name]);
|
|
1602
|
-
const store = tx.objectStore(this.name);
|
|
1603
|
-
if (!store.indexNames.contains(indexName)) {
|
|
1604
|
-
store.createIndex(indexName, fieldPath, {
|
|
1605
|
-
unique: options.unique || false,
|
|
1606
|
-
multiEntry: options.multiEntry || false
|
|
1607
|
-
});
|
|
1608
|
-
}
|
|
1381
|
+
async getAll(options = {}) {
|
|
1382
|
+
const stored = await this.indexedDB.getAll(this.db, 'documents', undefined, options.limit);
|
|
1383
|
+
return Promise.all(stored.map(async docData => {
|
|
1384
|
+
try {
|
|
1385
|
+
const doc = new Document(docData, {
|
|
1386
|
+
password: options.password,
|
|
1387
|
+
encrypted: docData._encrypted,
|
|
1388
|
+
compressed: docData._compressed
|
|
1389
|
+
});
|
|
1390
|
+
if (docData.packedData) {
|
|
1391
|
+
await doc.unpack();
|
|
1609
1392
|
}
|
|
1393
|
+
return doc.objectOutput();
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
console.error(`Failed to unpack document ${docData._id}:`, error);
|
|
1396
|
+
return null;
|
|
1610
1397
|
}
|
|
1611
|
-
);
|
|
1398
|
+
})).then(docs => docs.filter(Boolean));
|
|
1612
1399
|
}
|
|
1613
1400
|
|
|
1614
|
-
async
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
if (
|
|
1619
|
-
|
|
1620
|
-
this._freeSpaceInterval = setInterval(
|
|
1621
|
-
this._freeSpaceHandler,
|
|
1622
|
-
this.settingsData.freeSpaceEvery
|
|
1623
|
-
);
|
|
1401
|
+
async update(docId, updates, options = {}) {
|
|
1402
|
+
await this.trigger('beforeUpdate', { docId, updates });
|
|
1403
|
+
|
|
1404
|
+
const stored = await this.indexedDB.get(this.db, 'documents', docId);
|
|
1405
|
+
if (!stored) {
|
|
1406
|
+
throw new LacertaDBError(`Document with id '${docId}' not found for update.`, 'DOCUMENT_NOT_FOUND');
|
|
1624
1407
|
}
|
|
1625
|
-
}
|
|
1626
1408
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
}
|
|
1409
|
+
const existingDoc = new Document(stored, { password: options.password });
|
|
1410
|
+
if (stored.packedData) await existingDoc.unpack();
|
|
1630
1411
|
|
|
1631
|
-
|
|
1632
|
-
return this.metadataData.documentSizes || {};
|
|
1633
|
-
}
|
|
1412
|
+
const updatedData = { ...existingDoc.data, ...updates };
|
|
1634
1413
|
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1414
|
+
// Replaced `??` with ternary operator for compatibility
|
|
1415
|
+
const doc = new Document({
|
|
1416
|
+
_id: docId,
|
|
1417
|
+
_created: stored._created,
|
|
1418
|
+
data: updatedData
|
|
1419
|
+
}, {
|
|
1420
|
+
encrypted: options.encrypted !== undefined ? options.encrypted : stored._encrypted,
|
|
1421
|
+
compressed: options.compressed !== undefined ? options.compressed : stored._compressed,
|
|
1422
|
+
permanent: options.permanent !== undefined ? options.permanent : stored._permanent,
|
|
1423
|
+
password: options.password
|
|
1424
|
+
});
|
|
1425
|
+
doc._modified = Date.now();
|
|
1426
|
+
|
|
1427
|
+
const attachments = options.attachments;
|
|
1428
|
+
if (attachments && attachments.length > 0) {
|
|
1429
|
+
await this.opfs.deleteAttachments(this.database.name, this.name, docId);
|
|
1430
|
+
const preparedAttachments = await Promise.all(
|
|
1431
|
+
attachments.map(att => (att instanceof File || att instanceof Blob) ?
|
|
1432
|
+
OPFSUtility.prepareAttachment(att, att.name) :
|
|
1433
|
+
Promise.resolve(att))
|
|
1434
|
+
);
|
|
1435
|
+
doc._attachments = await this.opfs.saveAttachments(this.database.name, this.name, doc._id, preparedAttachments);
|
|
1436
|
+
} else {
|
|
1437
|
+
doc._attachments = stored._attachments;
|
|
1438
|
+
}
|
|
1638
1439
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1440
|
+
await doc.pack();
|
|
1441
|
+
const dbOutput = doc.databaseOutput();
|
|
1442
|
+
await this.indexedDB.put(this.db, 'documents', dbOutput);
|
|
1642
1443
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1444
|
+
const sizeKB = dbOutput.packedData.byteLength / 1024;
|
|
1445
|
+
this.metadata.updateDocument(doc._id, sizeKB, doc._permanent, doc._attachments.length);
|
|
1446
|
+
this.database.metadata.setCollection(this.metadata);
|
|
1646
1447
|
|
|
1647
|
-
|
|
1648
|
-
|
|
1448
|
+
await this.trigger('afterUpdate', doc);
|
|
1449
|
+
this.queryCache.clear();
|
|
1450
|
+
return doc._id;
|
|
1649
1451
|
}
|
|
1650
1452
|
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
var sizes = this.sizes;
|
|
1654
|
-
var modifications = this.modifications;
|
|
1655
|
-
var permanents = this.permanents;
|
|
1656
|
-
var attachments = this.attachments;
|
|
1657
|
-
var metadata = new Array(keys.length);
|
|
1658
|
-
var i = 0;
|
|
1659
|
-
for(const key of keys){
|
|
1660
|
-
metadata[i++] = {
|
|
1661
|
-
id: key,
|
|
1662
|
-
size: sizes[key],
|
|
1663
|
-
modified: modifications[key],
|
|
1664
|
-
permanent: permanents[key],
|
|
1665
|
-
attachment: attachments[key]
|
|
1666
|
-
};
|
|
1667
|
-
}
|
|
1453
|
+
async delete(docId) {
|
|
1454
|
+
await this.trigger('beforeDelete', docId);
|
|
1668
1455
|
|
|
1669
|
-
|
|
1670
|
-
|
|
1456
|
+
const doc = await this.indexedDB.get(this.db, 'documents', docId);
|
|
1457
|
+
if (!doc) throw new LacertaDBError('Document not found for deletion', 'DOCUMENT_NOT_FOUND');
|
|
1458
|
+
if (doc._permanent) throw new LacertaDBError('Cannot delete a permanent document', 'INVALID_OPERATION');
|
|
1671
1459
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1460
|
+
await this.indexedDB.delete(this.db, 'documents', docId);
|
|
1461
|
+
const attachments = doc._attachments;
|
|
1462
|
+
if (attachments && attachments.length > 0) {
|
|
1463
|
+
await this.opfs.deleteAttachments(this.database.name, this.name, docId);
|
|
1464
|
+
}
|
|
1675
1465
|
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
}
|
|
1466
|
+
this.metadata.removeDocument(docId);
|
|
1467
|
+
this.database.metadata.setCollection(this.metadata);
|
|
1679
1468
|
|
|
1680
|
-
|
|
1681
|
-
this.
|
|
1469
|
+
await this.trigger('afterDelete', docId);
|
|
1470
|
+
this.queryCache.clear();
|
|
1682
1471
|
}
|
|
1683
1472
|
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1473
|
+
async query(filter = {}, options = {}) {
|
|
1474
|
+
const startTime = performance.now();
|
|
1475
|
+
const cacheKey = base64.encode(serializer.serialize({ filter, options }));
|
|
1476
|
+
const cached = this.queryCache.get(cacheKey);
|
|
1687
1477
|
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1478
|
+
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
|
|
1479
|
+
if (this.performanceMonitor) this.performanceMonitor.recordCacheHit();
|
|
1480
|
+
return cached.data;
|
|
1481
|
+
}
|
|
1482
|
+
if (this.performanceMonitor) this.performanceMonitor.recordCacheMiss();
|
|
1691
1483
|
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1484
|
+
let results = await this.getAll(options);
|
|
1485
|
+
if (Object.keys(filter).length > 0) {
|
|
1486
|
+
results = results.filter(doc => queryEngine.evaluate(doc, filter));
|
|
1487
|
+
}
|
|
1695
1488
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1489
|
+
if (options.sort) results = aggregationPipeline.stages.$sort(results, options.sort);
|
|
1490
|
+
if (options.skip) results = aggregationPipeline.stages.$skip(results, options.skip);
|
|
1491
|
+
if (options.limit) results = aggregationPipeline.stages.$limit(results, options.limit);
|
|
1492
|
+
if (options.projection) results = aggregationPipeline.stages.$project(results, options.projection);
|
|
1493
|
+
|
|
1494
|
+
if (this.performanceMonitor) this.performanceMonitor.recordOperation('query', performance.now() - startTime);
|
|
1700
1495
|
|
|
1701
|
-
|
|
1702
|
-
|
|
1496
|
+
this.queryCache.set(cacheKey, { data: results, timestamp: Date.now() });
|
|
1497
|
+
if (this.queryCache.size > 100) {
|
|
1498
|
+
this.queryCache.delete(this.queryCache.keys().next().value);
|
|
1499
|
+
}
|
|
1500
|
+
return results;
|
|
1703
1501
|
}
|
|
1704
1502
|
|
|
1705
|
-
|
|
1706
|
-
|
|
1503
|
+
async aggregate(pipeline) {
|
|
1504
|
+
const startTime = performance.now();
|
|
1505
|
+
const docs = await this.getAll();
|
|
1506
|
+
const result = await aggregationPipeline.execute(docs, pipeline, this.database);
|
|
1507
|
+
if (this.performanceMonitor) this.performanceMonitor.recordOperation('aggregate', performance.now() - startTime);
|
|
1508
|
+
return result;
|
|
1707
1509
|
}
|
|
1708
1510
|
|
|
1709
|
-
|
|
1710
|
-
|
|
1511
|
+
async batchAdd(documents, options) {
|
|
1512
|
+
const startTime = performance.now();
|
|
1513
|
+
const results = await Promise.all(documents.map(doc =>
|
|
1514
|
+
this.add(doc, options)
|
|
1515
|
+
.then(id => ({ success: true, id }))
|
|
1516
|
+
.catch(error => ({ success: false, error: error.message }))
|
|
1517
|
+
));
|
|
1518
|
+
if (this.performanceMonitor) this.performanceMonitor.recordOperation('batchAdd', performance.now() - startTime);
|
|
1519
|
+
return results;
|
|
1711
1520
|
}
|
|
1712
1521
|
|
|
1713
|
-
|
|
1714
|
-
return
|
|
1522
|
+
batchUpdate(updates, options) {
|
|
1523
|
+
return Promise.all(updates.map(update =>
|
|
1524
|
+
this.update(update.id, update.data, options)
|
|
1525
|
+
.then(id => ({ success: true, id }))
|
|
1526
|
+
.catch(error => ({ success: false, id: update.id, error: error.message }))
|
|
1527
|
+
));
|
|
1715
1528
|
}
|
|
1716
1529
|
|
|
1717
|
-
|
|
1718
|
-
return
|
|
1530
|
+
batchDelete(ids) {
|
|
1531
|
+
return Promise.all(ids.map(id =>
|
|
1532
|
+
this.delete(id)
|
|
1533
|
+
.then(() => ({ success: true, id }))
|
|
1534
|
+
.catch(error => ({ success: false, id, error: error.message }))
|
|
1535
|
+
));
|
|
1719
1536
|
}
|
|
1720
1537
|
|
|
1721
|
-
|
|
1722
|
-
|
|
1538
|
+
async clear() {
|
|
1539
|
+
await this.indexedDB.clear(this.db, 'documents');
|
|
1540
|
+
this.metadata = new CollectionMetadata(this.name);
|
|
1541
|
+
this.database.metadata.setCollection(this.metadata);
|
|
1542
|
+
this.queryCache.clear();
|
|
1723
1543
|
}
|
|
1724
1544
|
|
|
1725
|
-
|
|
1726
|
-
|
|
1545
|
+
async checkSpaceLimit() {
|
|
1546
|
+
if (this.settings.sizeLimitKB !== Infinity && this.metadata.sizeKB > this.settings.bufferLimitKB) {
|
|
1547
|
+
await this.freeSpace();
|
|
1548
|
+
}
|
|
1727
1549
|
}
|
|
1728
1550
|
|
|
1729
|
-
|
|
1730
|
-
|
|
1551
|
+
async freeSpace() {
|
|
1552
|
+
const targetSize = this.settings.bufferLimitKB * 0.8;
|
|
1553
|
+
while (this.metadata.sizeKB > targetSize) {
|
|
1554
|
+
const oldestDocs = this.metadata.getOldestNonPermanentDocuments(10);
|
|
1555
|
+
if (oldestDocs.length === 0) break;
|
|
1556
|
+
await this.batchDelete(oldestDocs);
|
|
1557
|
+
}
|
|
1731
1558
|
}
|
|
1732
1559
|
|
|
1733
|
-
|
|
1734
|
-
|
|
1560
|
+
on(event, callback) {
|
|
1561
|
+
if (!this.events.has(event)) this.events.set(event, []);
|
|
1562
|
+
this.events.get(event).push(callback);
|
|
1735
1563
|
}
|
|
1736
1564
|
|
|
1737
|
-
|
|
1738
|
-
|
|
1565
|
+
off(event, callback) {
|
|
1566
|
+
if (!this.events.has(event)) return;
|
|
1567
|
+
const listeners = this.events.get(event).filter(cb => cb !== callback);
|
|
1568
|
+
this.events.set(event, listeners);
|
|
1739
1569
|
}
|
|
1740
1570
|
|
|
1741
|
-
async
|
|
1742
|
-
if(this.
|
|
1743
|
-
|
|
1571
|
+
async trigger(event, data) {
|
|
1572
|
+
if (!this.events.has(event)) return;
|
|
1573
|
+
for (const callback of this.events.get(event)) {
|
|
1574
|
+
await callback(data);
|
|
1744
1575
|
}
|
|
1745
1576
|
}
|
|
1746
1577
|
|
|
1747
|
-
|
|
1748
|
-
return await this._transactionManager.execute(async () => {
|
|
1749
|
-
this.observer._emit('beforeAdd', documentData);
|
|
1750
|
-
|
|
1751
|
-
const document = new Document(documentData, encryptionKey);
|
|
1752
|
-
|
|
1753
|
-
const attachmentPaths = [];
|
|
1754
|
-
if (Document.hasAttachments(documentData)) {
|
|
1755
|
-
const attachments = documentData._attachments || documentData.attachments;
|
|
1756
|
-
for (let i = 0; i < attachments.length; i++) {
|
|
1757
|
-
attachmentPaths.push(
|
|
1758
|
-
`${this.database.name}/${this.name}/${document._id}/${i}`
|
|
1759
|
-
);
|
|
1760
|
-
}
|
|
1761
|
-
document._attachments = attachmentPaths;
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
const docData = await document.databaseOutput();
|
|
1765
|
-
const docId = docData._id;
|
|
1766
|
-
const isPermanent = docData._permanent || false;
|
|
1767
|
-
const docSizeKB = docData.packedData.byteLength / 1024;
|
|
1768
|
-
let isNewDocument = !(docId in this.metadataData.documentSizes);
|
|
1769
|
-
|
|
1770
|
-
try {
|
|
1771
|
-
await IndexedDBUtility.performTransaction(
|
|
1772
|
-
this.database.db,
|
|
1773
|
-
this.name,
|
|
1774
|
-
'readwrite',
|
|
1775
|
-
(store) => {
|
|
1776
|
-
if (isNewDocument) {
|
|
1777
|
-
return IndexedDBUtility.add(store, docData);
|
|
1778
|
-
} else {
|
|
1779
|
-
return IndexedDBUtility.put(store, docData);
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
);
|
|
1783
|
-
|
|
1784
|
-
if (attachmentPaths.length > 0) {
|
|
1785
|
-
const attachments = documentData._attachments || documentData.attachments;
|
|
1786
|
-
await OPFSUtility.saveAttachments(
|
|
1787
|
-
this.database.name,
|
|
1788
|
-
this.name,
|
|
1789
|
-
document._id,
|
|
1790
|
-
attachments
|
|
1791
|
-
);
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
this.metadata.updateDocument(
|
|
1795
|
-
docId,
|
|
1796
|
-
docSizeKB,
|
|
1797
|
-
isPermanent,
|
|
1798
|
-
attachmentPaths.length
|
|
1799
|
-
);
|
|
1578
|
+
clearCache() { this.queryCache.clear(); }
|
|
1800
1579
|
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
this.name,
|
|
1809
|
-
document._id
|
|
1810
|
-
);
|
|
1811
|
-
} catch (e) {
|
|
1812
|
-
// Ignore cleanup errors
|
|
1813
|
-
}
|
|
1814
|
-
}
|
|
1815
|
-
throw error;
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
await this._maybeFreeSpace();
|
|
1580
|
+
destroy() {
|
|
1581
|
+
clearInterval(this.cleanupInterval);
|
|
1582
|
+
if (this.db) {
|
|
1583
|
+
this.db.close();
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1819
1587
|
|
|
1820
|
-
|
|
1588
|
+
// ========================
|
|
1589
|
+
// Database Class
|
|
1590
|
+
// ========================
|
|
1821
1591
|
|
|
1822
|
-
|
|
1823
|
-
|
|
1592
|
+
class Database {
|
|
1593
|
+
constructor(name, performanceMonitor) {
|
|
1594
|
+
this.name = name;
|
|
1595
|
+
this.collections = new Map();
|
|
1596
|
+
this.metadata = null;
|
|
1597
|
+
this.settings = null;
|
|
1598
|
+
this.quickStore = null;
|
|
1599
|
+
this.performanceMonitor = performanceMonitor;
|
|
1824
1600
|
}
|
|
1825
1601
|
|
|
1826
|
-
async
|
|
1827
|
-
this.
|
|
1602
|
+
async init() {
|
|
1603
|
+
this.metadata = DatabaseMetadata.load(this.name);
|
|
1604
|
+
this.settings = Settings.load(this.name);
|
|
1605
|
+
this.quickStore = new QuickStore(this.name);
|
|
1606
|
+
return this;
|
|
1607
|
+
}
|
|
1828
1608
|
|
|
1829
|
-
|
|
1830
|
-
|
|
1609
|
+
async createCollection(name, options) {
|
|
1610
|
+
if (this.collections.has(name)) {
|
|
1611
|
+
throw new LacertaDBError(`Collection '${name}' already exists.`, 'COLLECTION_EXISTS');
|
|
1831
1612
|
}
|
|
1832
1613
|
|
|
1833
|
-
const
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
'readonly',
|
|
1837
|
-
(store) => {
|
|
1838
|
-
return IndexedDBUtility.get(store, docId);
|
|
1839
|
-
}
|
|
1840
|
-
);
|
|
1841
|
-
|
|
1842
|
-
if (docData) {
|
|
1843
|
-
let document;
|
|
1844
|
-
if (Document.isEncrypted(docData)) {
|
|
1845
|
-
if (encryptionKey) {
|
|
1846
|
-
document = new Document(docData, encryptionKey);
|
|
1847
|
-
} else {
|
|
1848
|
-
return false;
|
|
1849
|
-
}
|
|
1850
|
-
} else {
|
|
1851
|
-
document = new Document(docData);
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
const output = await document.objectOutput(includeAttachments);
|
|
1855
|
-
|
|
1856
|
-
this.observer._emit('afterGet', output);
|
|
1614
|
+
const collection = new Collection(name, this);
|
|
1615
|
+
await collection.init();
|
|
1616
|
+
this.collections.set(name, collection);
|
|
1857
1617
|
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
return false;
|
|
1618
|
+
if (!this.metadata.collections[name]) {
|
|
1619
|
+
this.metadata.setCollection(new CollectionMetadata(name));
|
|
1861
1620
|
}
|
|
1621
|
+
return collection;
|
|
1862
1622
|
}
|
|
1863
1623
|
|
|
1864
|
-
async
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
if (existingIds.length === 0) {
|
|
1869
|
-
return results;
|
|
1624
|
+
async getCollection(name) {
|
|
1625
|
+
if (this.collections.has(name)) {
|
|
1626
|
+
return this.collections.get(name);
|
|
1870
1627
|
}
|
|
1628
|
+
if (this.metadata.collections[name]) {
|
|
1629
|
+
const collection = new Collection(name, this);
|
|
1630
|
+
await collection.init();
|
|
1631
|
+
this.collections.set(name, collection);
|
|
1632
|
+
return collection;
|
|
1633
|
+
}
|
|
1634
|
+
throw new LacertaDBError(`Collection '${name}' not found.`, 'COLLECTION_NOT_FOUND');
|
|
1635
|
+
}
|
|
1871
1636
|
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
this.name
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1637
|
+
async dropCollection(name) {
|
|
1638
|
+
if (this.collections.has(name)) {
|
|
1639
|
+
const collection = this.collections.get(name);
|
|
1640
|
+
await collection.clear();
|
|
1641
|
+
collection.destroy();
|
|
1642
|
+
this.collections.delete(name);
|
|
1643
|
+
}
|
|
1879
1644
|
|
|
1880
|
-
|
|
1881
|
-
if (docData) {
|
|
1882
|
-
let document;
|
|
1883
|
-
if (Document.isEncrypted(docData)) {
|
|
1884
|
-
if (encryptionKey) {
|
|
1885
|
-
document = new Document(docData, encryptionKey);
|
|
1886
|
-
} else {
|
|
1887
|
-
continue;
|
|
1888
|
-
}
|
|
1889
|
-
} else {
|
|
1890
|
-
document = new Document(docData);
|
|
1891
|
-
}
|
|
1645
|
+
this.metadata.removeCollection(name);
|
|
1892
1646
|
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1647
|
+
const dbName = `${this.name}_${name}`;
|
|
1648
|
+
await new Promise((resolve, reject) => {
|
|
1649
|
+
const deleteReq = indexedDB.deleteDatabase(dbName);
|
|
1650
|
+
deleteReq.onsuccess = resolve;
|
|
1651
|
+
deleteReq.onerror = reject;
|
|
1652
|
+
deleteReq.onblocked = () => console.warn(`Deletion of '${dbName}' is blocked.`);
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1899
1655
|
|
|
1900
|
-
|
|
1656
|
+
listCollections() {
|
|
1657
|
+
return Object.keys(this.metadata.collections);
|
|
1901
1658
|
}
|
|
1902
1659
|
|
|
1903
|
-
|
|
1904
|
-
|
|
1660
|
+
getStats() {
|
|
1661
|
+
return {
|
|
1662
|
+
name: this.name,
|
|
1663
|
+
totalSizeKB: this.metadata.totalSizeKB,
|
|
1664
|
+
totalDocuments: this.metadata.totalLength,
|
|
1665
|
+
collections: Object.entries(this.metadata.collections).map(([name, data]) => ({
|
|
1666
|
+
name,
|
|
1667
|
+
sizeKB: data.sizeKB,
|
|
1668
|
+
documents: data.length,
|
|
1669
|
+
createdAt: new Date(data.createdAt).toISOString(),
|
|
1670
|
+
modifiedAt: new Date(data.modifiedAt).toISOString()
|
|
1671
|
+
}))
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1905
1674
|
|
|
1906
|
-
|
|
1907
|
-
if (isPermanent && !force) {
|
|
1908
|
-
return false;
|
|
1909
|
-
}
|
|
1675
|
+
updateSettings(newSettings) { this.settings.updateSettings(newSettings); }
|
|
1910
1676
|
|
|
1911
|
-
|
|
1677
|
+
async export(format = 'json', password = null) {
|
|
1678
|
+
const data = {
|
|
1679
|
+
version: '4.0.3',
|
|
1680
|
+
database: this.name,
|
|
1681
|
+
timestamp: Date.now(),
|
|
1682
|
+
collections: {}
|
|
1683
|
+
};
|
|
1912
1684
|
|
|
1913
|
-
|
|
1914
|
-
|
|
1685
|
+
for (const collName of this.listCollections()) {
|
|
1686
|
+
const collection = await this.getCollection(collName);
|
|
1687
|
+
data.collections[collName] = await collection.getAll({ password });
|
|
1915
1688
|
}
|
|
1916
1689
|
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
this.database.name,
|
|
1921
|
-
this.name,
|
|
1922
|
-
docId
|
|
1923
|
-
);
|
|
1690
|
+
if (format === 'json') {
|
|
1691
|
+
const serialized = serializer.serialize(data);
|
|
1692
|
+
return base64.encode(serialized);
|
|
1924
1693
|
}
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
}
|
|
1933
|
-
);
|
|
1934
|
-
|
|
1935
|
-
this.metadata.deleteDocument(docId);
|
|
1936
|
-
|
|
1937
|
-
await this.database.metadata.saveMetadata();
|
|
1938
|
-
|
|
1939
|
-
this.observer._emit('afterDelete', docId);
|
|
1940
|
-
|
|
1941
|
-
return true;
|
|
1694
|
+
if (format === 'encrypted' && password) {
|
|
1695
|
+
const encryption = new BrowserEncryptionUtility();
|
|
1696
|
+
const serializedData = serializer.serialize(data);
|
|
1697
|
+
const encrypted = await encryption.encrypt(serializedData, password);
|
|
1698
|
+
return base64.encode(encrypted);
|
|
1699
|
+
}
|
|
1700
|
+
throw new LacertaDBError(`Unsupported export format: ${format}`, 'INVALID_FORMAT');
|
|
1942
1701
|
}
|
|
1943
1702
|
|
|
1944
|
-
async
|
|
1945
|
-
let
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1703
|
+
async import(data, format = 'json', password = null) {
|
|
1704
|
+
let parsed;
|
|
1705
|
+
try {
|
|
1706
|
+
const decoded = base64.decode(data);
|
|
1707
|
+
if (format === 'encrypted' && password) {
|
|
1708
|
+
const encryption = new BrowserEncryptionUtility();
|
|
1709
|
+
const decrypted = await encryption.decrypt(decoded, password);
|
|
1710
|
+
parsed = serializer.deserialize(decrypted);
|
|
1952
1711
|
} else {
|
|
1953
|
-
|
|
1712
|
+
parsed = serializer.deserialize(decoded);
|
|
1954
1713
|
}
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
size = currentSize - spaceToFree;
|
|
1714
|
+
} catch (e) {
|
|
1715
|
+
throw new LacertaDBError('Failed to parse import data', 'IMPORT_PARSE_FAILED', e);
|
|
1958
1716
|
}
|
|
1959
1717
|
|
|
1960
|
-
const
|
|
1961
|
-
|
|
1962
|
-
.
|
|
1963
|
-
|
|
1964
|
-
let totalFreed = 0;
|
|
1965
|
-
|
|
1966
|
-
for (const [docId] of docEntries) {
|
|
1967
|
-
if(this.sizeKB > size){
|
|
1968
|
-
const docSize = this.metadataData.documentSizes[docId];
|
|
1969
|
-
totalFreed += docSize;
|
|
1970
|
-
await this.deleteDocument(docId, true);
|
|
1971
|
-
if (totalFreed >= spaceToFree) {
|
|
1972
|
-
break;
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1718
|
+
for (const collName in parsed.collections) {
|
|
1719
|
+
const docs = parsed.collections[collName];
|
|
1720
|
+
const collection = await this.createCollection(collName).catch(() => this.getCollection(collName));
|
|
1721
|
+
await collection.batchAdd(docs);
|
|
1975
1722
|
}
|
|
1976
1723
|
|
|
1977
|
-
|
|
1724
|
+
const docCount = Object.values(parsed.collections).reduce((sum, docs) => sum + docs.length, 0);
|
|
1725
|
+
return {
|
|
1726
|
+
collections: Object.keys(parsed.collections).length,
|
|
1727
|
+
documents: docCount
|
|
1728
|
+
};
|
|
1978
1729
|
}
|
|
1979
1730
|
|
|
1980
|
-
async
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
} = options;
|
|
1988
|
-
|
|
1989
|
-
// FIXED: Collect raw document data first, then process async operations after transaction
|
|
1990
|
-
const rawDocuments = [];
|
|
1991
|
-
|
|
1992
|
-
await IndexedDBUtility.performTransaction(
|
|
1993
|
-
this.database.db,
|
|
1994
|
-
this.name,
|
|
1995
|
-
'readonly',
|
|
1996
|
-
async (store) => {
|
|
1997
|
-
let source = store;
|
|
1998
|
-
|
|
1999
|
-
if (index && store.indexNames.contains(index)) {
|
|
2000
|
-
source = store.index(index);
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
const request = orderBy
|
|
2004
|
-
? source.openCursor(null, orderBy === 'asc' ? 'next' : 'prev')
|
|
2005
|
-
: source.openCursor();
|
|
1731
|
+
async clearAll() {
|
|
1732
|
+
await Promise.all([...this.collections.keys()].map(name => this.dropCollection(name)));
|
|
1733
|
+
this.collections.clear();
|
|
1734
|
+
this.metadata = new DatabaseMetadata(this.name);
|
|
1735
|
+
this.metadata.save();
|
|
1736
|
+
this.quickStore.clear();
|
|
1737
|
+
}
|
|
2006
1738
|
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
1739
|
+
destroy() {
|
|
1740
|
+
this.collections.forEach(collection => collection.destroy());
|
|
1741
|
+
this.collections.clear();
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
2010
1744
|
|
|
2011
|
-
|
|
2012
|
-
|
|
1745
|
+
// ========================
|
|
1746
|
+
// Main LacertaDB Class
|
|
1747
|
+
// ========================
|
|
2013
1748
|
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
1749
|
+
export class LacertaDB {
|
|
1750
|
+
constructor() {
|
|
1751
|
+
this.databases = new Map();
|
|
1752
|
+
this.performanceMonitor = new PerformanceMonitor();
|
|
1753
|
+
}
|
|
2018
1754
|
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
1755
|
+
async getDatabase(name) {
|
|
1756
|
+
if (!this.databases.has(name)) {
|
|
1757
|
+
const db = new Database(name, this.performanceMonitor);
|
|
1758
|
+
await db.init();
|
|
1759
|
+
this.databases.set(name, db);
|
|
1760
|
+
}
|
|
1761
|
+
return this.databases.get(name);
|
|
1762
|
+
}
|
|
2024
1763
|
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
1764
|
+
async dropDatabase(name) {
|
|
1765
|
+
if (this.databases.has(name)) {
|
|
1766
|
+
const db = this.databases.get(name);
|
|
1767
|
+
await db.clearAll();
|
|
1768
|
+
db.destroy();
|
|
1769
|
+
this.databases.delete(name);
|
|
1770
|
+
}
|
|
2032
1771
|
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
);
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
} else {
|
|
2049
|
-
document = new Document(docData);
|
|
1772
|
+
['metadata', 'settings', 'version'].forEach(suffix => {
|
|
1773
|
+
localStorage.removeItem(`lacertadb_${name}_${suffix}`);
|
|
1774
|
+
});
|
|
1775
|
+
const quickStore = new QuickStore(name);
|
|
1776
|
+
quickStore.clear();
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
listDatabases() {
|
|
1780
|
+
const dbNames = new Set();
|
|
1781
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
1782
|
+
const key = localStorage.key(i);
|
|
1783
|
+
// Replaced optional chaining with `&&` for compatibility
|
|
1784
|
+
if (key && key.startsWith('lacertadb_')) {
|
|
1785
|
+
const dbName = key.split('_')[1];
|
|
1786
|
+
dbNames.add(dbName);
|
|
2050
1787
|
}
|
|
1788
|
+
}
|
|
1789
|
+
return [...dbNames];
|
|
1790
|
+
}
|
|
2051
1791
|
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
1792
|
+
async createBackup(password = null) {
|
|
1793
|
+
const backup = {
|
|
1794
|
+
version: '4.0.3',
|
|
1795
|
+
timestamp: Date.now(),
|
|
1796
|
+
databases: {}
|
|
1797
|
+
};
|
|
2056
1798
|
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
1799
|
+
for (const dbName of this.listDatabases()) {
|
|
1800
|
+
const db = await this.getDatabase(dbName);
|
|
1801
|
+
const exported = await db.export('json');
|
|
1802
|
+
const decoded = base64.decode(exported);
|
|
1803
|
+
backup.databases[dbName] = serializer.deserialize(decoded);
|
|
1804
|
+
}
|
|
2061
1805
|
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
1806
|
+
const serializedBackup = serializer.serialize(backup);
|
|
1807
|
+
if (password) {
|
|
1808
|
+
const encryption = new BrowserEncryptionUtility();
|
|
1809
|
+
const encrypted = await encryption.encrypt(serializedBackup, password);
|
|
1810
|
+
return base64.encode(encrypted);
|
|
1811
|
+
}
|
|
1812
|
+
return base64.encode(serializedBackup);
|
|
1813
|
+
}
|
|
2067
1814
|
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
1815
|
+
async restoreBackup(backupData, password = null) {
|
|
1816
|
+
let backup;
|
|
1817
|
+
try {
|
|
1818
|
+
let decodedData = base64.decode(backupData);
|
|
1819
|
+
if (password) {
|
|
1820
|
+
const encryption = new BrowserEncryptionUtility();
|
|
1821
|
+
const decrypted = await encryption.decrypt(decodedData, password);
|
|
1822
|
+
backup = serializer.deserialize(decrypted);
|
|
1823
|
+
} else {
|
|
1824
|
+
backup = serializer.deserialize(decodedData);
|
|
2071
1825
|
}
|
|
2072
|
-
|
|
2073
|
-
|
|
1826
|
+
} catch (e) {
|
|
1827
|
+
throw new LacertaDBError('Failed to parse backup data', 'BACKUP_PARSE_FAILED', e);
|
|
2074
1828
|
}
|
|
2075
1829
|
|
|
2076
|
-
|
|
2077
|
-
|
|
1830
|
+
const results = { databases: 0, collections: 0, documents: 0 };
|
|
1831
|
+
for (const [dbName, dbData] of Object.entries(backup.databases)) {
|
|
1832
|
+
const db = await this.getDatabase(dbName);
|
|
1833
|
+
const encodedDbData = base64.encode(serializer.serialize(dbData));
|
|
1834
|
+
const importResult = await db.import(encodedDbData);
|
|
2078
1835
|
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
this._freeSpaceInterval = null;
|
|
2083
|
-
this._freeSpaceHandler = null;
|
|
1836
|
+
results.databases++;
|
|
1837
|
+
results.collections += importResult.collections;
|
|
1838
|
+
results.documents += importResult.documents;
|
|
2084
1839
|
}
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
toString() {
|
|
2088
|
-
return `[Collection: ${this.name} | Database: ${this.database.name} | Size: ${this.sizeKB.toFixed(2)}KB | Documents: ${this.length}]`;
|
|
1840
|
+
return results;
|
|
2089
1841
|
}
|
|
2090
1842
|
}
|
|
2091
1843
|
|
|
1844
|
+
// Export all major components for advanced usage
|
|
1845
|
+
export {
|
|
1846
|
+
Database,
|
|
1847
|
+
Collection,
|
|
1848
|
+
Document,
|
|
1849
|
+
MigrationManager,
|
|
1850
|
+
PerformanceMonitor,
|
|
1851
|
+
LacertaDBError,
|
|
1852
|
+
OPFSUtility
|
|
1853
|
+
};
|