@semics-tech/mongolite 1.0.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/LICENSE +21 -0
- package/README.md +179 -0
- package/dist/adapters/browser.d.ts +97 -0
- package/dist/adapters/browser.js +103 -0
- package/dist/adapters/browser.js.map +1 -0
- package/dist/adapters/cloudflare.d.ts +106 -0
- package/dist/adapters/cloudflare.js +94 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/bin/mongolite-debug.d.ts +2 -0
- package/dist/bin/mongolite-debug.js +115 -0
- package/dist/bin/mongolite-debug.js.map +1 -0
- package/dist/changeStream.d.ts +114 -0
- package/dist/changeStream.js +338 -0
- package/dist/changeStream.js.map +1 -0
- package/dist/cloudflare.d.ts +39 -0
- package/dist/cloudflare.js +39 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/collection.d.ts +274 -0
- package/dist/collection.js +1706 -0
- package/dist/collection.js.map +1 -0
- package/dist/cursors/findCursor.d.ts +101 -0
- package/dist/cursors/findCursor.js +757 -0
- package/dist/cursors/findCursor.js.map +1 -0
- package/dist/db.d.ts +116 -0
- package/dist/db.js +254 -0
- package/dist/db.js.map +1 -0
- package/dist/debugger/query-debugger.d.ts +22 -0
- package/dist/debugger/query-debugger.js +258 -0
- package/dist/debugger/query-debugger.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/mongo-client.d.ts +56 -0
- package/dist/mongo-client.js +55 -0
- package/dist/mongo-client.js.map +1 -0
- package/dist/types.d.ts +318 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/indexing.d.ts +4 -0
- package/dist/utils/indexing.js +43 -0
- package/dist/utils/indexing.js.map +1 -0
- package/dist/utils/projection.d.ts +16 -0
- package/dist/utils/projection.js +98 -0
- package/dist/utils/projection.js.map +1 -0
- package/dist/utils/queryOperators.d.ts +16 -0
- package/dist/utils/queryOperators.js +138 -0
- package/dist/utils/queryOperators.js.map +1 -0
- package/package.json +92 -0
|
@@ -0,0 +1,1706 @@
|
|
|
1
|
+
import { ObjectId } from 'bson';
|
|
2
|
+
import { FindCursor } from './cursors/findCursor.js';
|
|
3
|
+
import { extractRawIndexColumns } from './utils/indexing.js';
|
|
4
|
+
import { ChangeStream } from './changeStream.js';
|
|
5
|
+
import { applyProjectionToDocument, resolveProjectionMode } from './utils/projection.js';
|
|
6
|
+
import { evaluateOperators } from './utils/queryOperators.js';
|
|
7
|
+
/**
|
|
8
|
+
* Safely stringifies JSON data with validation and error handling.
|
|
9
|
+
* Prevents storing malformed JSON that could cause parsing issues later.
|
|
10
|
+
* @param data The data to stringify
|
|
11
|
+
* @param context Optional context for error messages
|
|
12
|
+
* @returns The JSON string
|
|
13
|
+
* @throws Error if the data cannot be safely stringified
|
|
14
|
+
*/
|
|
15
|
+
function safeJsonStringify(data, context) {
|
|
16
|
+
try {
|
|
17
|
+
// First, validate that the data is JSON-serializable by attempting to stringify it
|
|
18
|
+
const jsonString = JSON.stringify(data);
|
|
19
|
+
// Verify that the stringified data can be parsed back (round-trip test)
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(jsonString);
|
|
22
|
+
// Basic validation that the round-trip preserved the data structure
|
|
23
|
+
if (typeof data === 'object' && data !== null && typeof parsed !== 'object') {
|
|
24
|
+
throw new Error('Round-trip JSON validation failed: type mismatch');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (parseError) {
|
|
28
|
+
throw new Error(`Round-trip JSON validation failed: ${parseError instanceof Error ? parseError.message : 'Unknown parse error'}`);
|
|
29
|
+
}
|
|
30
|
+
return jsonString;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const contextMsg = context ? ` in ${context}` : '';
|
|
34
|
+
if (error instanceof Error) {
|
|
35
|
+
throw new Error(`Failed to safely stringify JSON data${contextMsg}: ${error.message}`);
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Failed to safely stringify JSON data${contextMsg}: Unknown error`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Safely parses JSON data with fallback mechanisms for malformed JSON.
|
|
42
|
+
* @param jsonString The JSON string to parse
|
|
43
|
+
* @param context Optional context for error messages and recovery
|
|
44
|
+
* @returns The parsed object or a fallback object
|
|
45
|
+
*/
|
|
46
|
+
function safeJsonParse(jsonString, context) {
|
|
47
|
+
if (!jsonString || typeof jsonString !== 'string') {
|
|
48
|
+
console.warn(`Invalid JSON string${context ? ` in ${context}` : ''}: not a string or empty`);
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(jsonString);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
// Log the error for debugging
|
|
56
|
+
console.error(`JSON parse error${context ? ` in ${context}` : ''}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
57
|
+
console.error(`Malformed JSON string: ${jsonString.substring(0, 500)}${jsonString.length > 500 ? '...' : ''}`);
|
|
58
|
+
// Attempt to recover from common JSON corruption issues
|
|
59
|
+
try {
|
|
60
|
+
// Try to fix common escaping issues
|
|
61
|
+
let fixedJson = jsonString;
|
|
62
|
+
// Fix double-escaped quotes
|
|
63
|
+
fixedJson = fixedJson.replace(/\\"/g, '"');
|
|
64
|
+
// Fix improperly escaped backslashes
|
|
65
|
+
fixedJson = fixedJson.replace(/\\\\/g, '\\');
|
|
66
|
+
// Try parsing the fixed JSON
|
|
67
|
+
const recovered = JSON.parse(fixedJson);
|
|
68
|
+
console.warn(`Successfully recovered malformed JSON${context ? ` in ${context}` : ''}`);
|
|
69
|
+
return recovered;
|
|
70
|
+
}
|
|
71
|
+
catch (recoveryError) {
|
|
72
|
+
console.error(`JSON recovery failed${context ? ` in ${context}` : ''}: ${recoveryError instanceof Error ? recoveryError.message : 'Unknown error'}`);
|
|
73
|
+
// Last resort: return an empty object with a special marker
|
|
74
|
+
return {
|
|
75
|
+
__mongoLiteCorrupted: true,
|
|
76
|
+
__originalData: jsonString,
|
|
77
|
+
__error: error instanceof Error ? error.message : 'Unknown JSON parse error',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Validates that a document is safe to store (no functions, circular references, etc.)
|
|
84
|
+
* @param doc The document to validate
|
|
85
|
+
* @param path Current path for error reporting
|
|
86
|
+
* @throws Error if the document contains invalid data
|
|
87
|
+
*/
|
|
88
|
+
function validateDocumentForStorage(doc, path = 'root') {
|
|
89
|
+
if (doc === null || doc === undefined) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (typeof doc === 'function') {
|
|
93
|
+
throw new Error(`Document validation failed: Functions are not allowed in documents (found at ${path})`);
|
|
94
|
+
}
|
|
95
|
+
if (typeof doc === 'symbol') {
|
|
96
|
+
throw new Error(`Document validation failed: Symbols are not allowed in documents (found at ${path})`);
|
|
97
|
+
}
|
|
98
|
+
if (typeof doc === 'bigint') {
|
|
99
|
+
throw new Error(`Document validation failed: BigInt values are not supported, use regular numbers or strings (found at ${path})`);
|
|
100
|
+
}
|
|
101
|
+
if (doc instanceof RegExp) {
|
|
102
|
+
throw new Error(`Document validation failed: RegExp objects are not supported in documents (found at ${path})`);
|
|
103
|
+
}
|
|
104
|
+
if (Array.isArray(doc)) {
|
|
105
|
+
doc.forEach((item, index) => {
|
|
106
|
+
validateDocumentForStorage(item, `${path}[${index}]`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
else if (typeof doc === 'object') {
|
|
110
|
+
// Check for circular references by maintaining a Set of visited objects
|
|
111
|
+
const visited = new Set();
|
|
112
|
+
function checkCircular(obj, currentPath) {
|
|
113
|
+
if (visited.has(obj)) {
|
|
114
|
+
throw new Error(`Document validation failed: Circular reference detected at ${currentPath}`);
|
|
115
|
+
}
|
|
116
|
+
visited.add(obj);
|
|
117
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
118
|
+
if (typeof value === 'object' && value !== null) {
|
|
119
|
+
checkCircular(value, `${currentPath}.${key}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
visited.delete(obj);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
checkCircular(doc, path);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error instanceof Error && error.message.includes('Circular reference')) {
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Validate each property
|
|
133
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
134
|
+
validateDocumentForStorage(value, `${path}.${key}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* MongoLiteCollection provides methods to interact with a specific SQLite table
|
|
140
|
+
* as if it were a MongoDB collection.
|
|
141
|
+
*/
|
|
142
|
+
export class MongoLiteCollection {
|
|
143
|
+
constructor(db, name, options = {}) {
|
|
144
|
+
this.db = db;
|
|
145
|
+
this.name = name;
|
|
146
|
+
this.options = options;
|
|
147
|
+
this.ensureTable().catch((err) => {
|
|
148
|
+
// This error should be handled or logged appropriately.
|
|
149
|
+
// For now, console.error is used. In a real app, a more robust
|
|
150
|
+
// error handling mechanism would be needed, potentially failing
|
|
151
|
+
// the collection initialization or notifying the user.
|
|
152
|
+
console.error(`Failed to ensure table ${this.name} exists:`, err);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Ensures the SQLite table for this collection exists, creating it if necessary.
|
|
157
|
+
* The table will have an `_id` column (indexed) and a `data` column for JSON.
|
|
158
|
+
* @private
|
|
159
|
+
*/
|
|
160
|
+
async ensureTable() {
|
|
161
|
+
// Using " " around table name to handle names with special characters or keywords
|
|
162
|
+
const createTableSQL = `
|
|
163
|
+
CREATE TABLE IF NOT EXISTS "${this.name}" (
|
|
164
|
+
_id TEXT PRIMARY KEY,
|
|
165
|
+
data TEXT
|
|
166
|
+
);
|
|
167
|
+
`;
|
|
168
|
+
// It's generally good practice to create an index on _id, but TEXT PRIMARY KEY often implies an index.
|
|
169
|
+
// Explicitly creating an index can be done if performance dictates.
|
|
170
|
+
// const createIndexSQL = `CREATE UNIQUE INDEX IF NOT EXISTS idx_${this.name}__id ON "${this.name}"(_id);`;
|
|
171
|
+
try {
|
|
172
|
+
await this.db.exec(createTableSQL);
|
|
173
|
+
// await this.db.exec(createIndexSQL); // If explicit index is desired
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
console.error(`Error ensuring table "${this.name}":`, error);
|
|
177
|
+
throw error; // Re-throw to allow caller to handle
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Inserts a single document into the collection.
|
|
182
|
+
* If `_id` is not provided, a UUID will be generated.
|
|
183
|
+
* @param doc The document to insert.
|
|
184
|
+
* @returns {Promise<InsertOneResult>} An object containing the outcome of the insert operation.
|
|
185
|
+
*/
|
|
186
|
+
async insertOne(doc) {
|
|
187
|
+
await this.ensureTable(); // Ensure table exists before insert
|
|
188
|
+
const docId = doc._id || new ObjectId().toString();
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
190
|
+
const { _id, ...dataToStore } = { ...doc, _id: docId }; // Ensure _id is part of the internal structure
|
|
191
|
+
// Validate the document before storing
|
|
192
|
+
try {
|
|
193
|
+
validateDocumentForStorage(dataToStore);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
throw new Error(`Cannot insert document: ${error instanceof Error ? error.message : 'Unknown validation error'}`);
|
|
197
|
+
}
|
|
198
|
+
const jsonData = safeJsonStringify(dataToStore, `insertOne for document ${docId}`);
|
|
199
|
+
const sql = `INSERT INTO "${this.name}" (_id, data) VALUES (?, ?)`;
|
|
200
|
+
try {
|
|
201
|
+
await this.db.run(sql, [docId, jsonData]);
|
|
202
|
+
return { acknowledged: true, insertedId: docId };
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
// Handle potential errors, e.g., unique constraint violation if _id already exists
|
|
206
|
+
console.error(`Error inserting document into ${this.name}:`, error);
|
|
207
|
+
// Check for unique constraint error (SQLite specific error code)
|
|
208
|
+
if (error.code === 'SQLITE_CONSTRAINT') {
|
|
209
|
+
throw new Error(`Duplicate _id: ${docId}`);
|
|
210
|
+
}
|
|
211
|
+
// Handle SQLITE_BUSY
|
|
212
|
+
if (error.code === 'SQLITE_BUSY') {
|
|
213
|
+
return this.retryWithBackoff(() => this.insertOne(doc), `insert for ${docId}`);
|
|
214
|
+
}
|
|
215
|
+
throw error; // Re-throw other errors
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Inserts multiple documents into the collection.
|
|
220
|
+
* If any document does not have an `_id`, a UUID will be generated.
|
|
221
|
+
* Uses batch insert with transactions for improved performance.
|
|
222
|
+
* @param docs An array of documents to insert.
|
|
223
|
+
* @returns {Promise<InsertManyResult>} An object containing the outcome of all insert operations.
|
|
224
|
+
* */
|
|
225
|
+
async insertMany(docs) {
|
|
226
|
+
await this.ensureTable(); // Ensure table exists before insert
|
|
227
|
+
if (docs.length === 0) {
|
|
228
|
+
return { acknowledged: true, insertedCount: 0, insertedIds: {} };
|
|
229
|
+
}
|
|
230
|
+
const insertedIds = {};
|
|
231
|
+
const batchSize = 500; // Process in batches to avoid memory issues with very large datasets
|
|
232
|
+
let insertedCount = 0;
|
|
233
|
+
let index = 0;
|
|
234
|
+
// Process documents in batches
|
|
235
|
+
for (let i = 0; i < docs.length; i += batchSize) {
|
|
236
|
+
const batch = docs.slice(i, i + batchSize);
|
|
237
|
+
const batchResults = await this.insertBatch(batch);
|
|
238
|
+
batchResults.forEach((res) => {
|
|
239
|
+
insertedIds[index++] = res.insertedId;
|
|
240
|
+
});
|
|
241
|
+
insertedCount += batchResults.length;
|
|
242
|
+
}
|
|
243
|
+
return { acknowledged: true, insertedCount, insertedIds };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Inserts a batch of documents using a single transaction for optimal performance.
|
|
247
|
+
* @private
|
|
248
|
+
*/
|
|
249
|
+
async insertBatch(docs) {
|
|
250
|
+
const results = [];
|
|
251
|
+
// Prepare all documents and their data upfront
|
|
252
|
+
const preparedDocs = docs.map((doc) => {
|
|
253
|
+
const docId = doc._id || new ObjectId().toString();
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
255
|
+
const { _id, ...dataToStore } = { ...doc, _id: docId };
|
|
256
|
+
// Validate the document before storing
|
|
257
|
+
try {
|
|
258
|
+
validateDocumentForStorage(dataToStore);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
throw new Error(`Cannot insert document ${docId}: ${error instanceof Error ? error.message : 'Unknown validation error'}`);
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
id: docId,
|
|
265
|
+
data: safeJsonStringify(dataToStore, `insertBatch for document ${docId}`),
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
const insertBatchOperation = async () => {
|
|
269
|
+
// Begin transaction
|
|
270
|
+
await this.db.exec('BEGIN TRANSACTION');
|
|
271
|
+
try {
|
|
272
|
+
const sql = `INSERT INTO "${this.name}" (_id, data) VALUES (?, ?)`;
|
|
273
|
+
// Use a prepared statement for better performance
|
|
274
|
+
for (const preparedDoc of preparedDocs) {
|
|
275
|
+
await this.db.run(sql, [preparedDoc.id, preparedDoc.data]);
|
|
276
|
+
results.push({ acknowledged: true, insertedId: preparedDoc.id });
|
|
277
|
+
}
|
|
278
|
+
// Commit the transaction
|
|
279
|
+
await this.db.exec('COMMIT');
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
// Rollback on any error
|
|
283
|
+
try {
|
|
284
|
+
await this.db.exec('ROLLBACK');
|
|
285
|
+
}
|
|
286
|
+
catch (rollbackError) {
|
|
287
|
+
console.error(`Error during rollback in ${this.name}:`, rollbackError);
|
|
288
|
+
}
|
|
289
|
+
// Clear results since transaction failed
|
|
290
|
+
results.length = 0;
|
|
291
|
+
// Handle specific error types
|
|
292
|
+
if (error.code === 'SQLITE_CONSTRAINT') {
|
|
293
|
+
// Find which document caused the constraint violation
|
|
294
|
+
const errorMessage = error.message;
|
|
295
|
+
const duplicateId = preparedDocs.find((doc) => errorMessage.includes(doc.id))?.id || 'unknown';
|
|
296
|
+
throw new Error(`Duplicate _id: ${duplicateId}`);
|
|
297
|
+
}
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
await insertBatchOperation();
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error.code === 'SQLITE_BUSY') {
|
|
306
|
+
// Retry the entire batch with backoff
|
|
307
|
+
await this.retryWithBackoff(insertBatchOperation, `batch insert of ${docs.length} documents`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return results;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Finds a single document matching the filter.
|
|
317
|
+
* @param filter The query criteria.
|
|
318
|
+
* @param projection Optional. Specifies the fields to return.
|
|
319
|
+
* @returns {Promise<T | null>} The found document or `null`.
|
|
320
|
+
* @remarks When using a projection, some fields may be undefined at runtime despite the
|
|
321
|
+
* return type being `T`. This provides better ergonomics for the common case where no
|
|
322
|
+
* projection is used.
|
|
323
|
+
*/
|
|
324
|
+
async findOne(filter, projection) {
|
|
325
|
+
await this.ensureTable();
|
|
326
|
+
const cursor = this.find(filter).limit(1);
|
|
327
|
+
if (projection) {
|
|
328
|
+
cursor.project(projection);
|
|
329
|
+
}
|
|
330
|
+
const results = await cursor.toArray();
|
|
331
|
+
return results.length > 0 ? results[0] : null;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Finds multiple documents matching the filter and returns a cursor.
|
|
335
|
+
* @param filter The query criteria.
|
|
336
|
+
* @returns {FindCursor<T>} A `FindCursor` instance.
|
|
337
|
+
*/
|
|
338
|
+
find(filter = {}) {
|
|
339
|
+
// ensureTable is called by operations on the cursor or by constructor
|
|
340
|
+
return new FindCursor(this.db, this.name, filter, this.options);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Updates a single document matching the filter.
|
|
344
|
+
* @param filter The selection criteria for the update.
|
|
345
|
+
* @param update The modifications to apply.
|
|
346
|
+
* @returns {Promise<UpdateResult>} An object describing the outcome.
|
|
347
|
+
*/
|
|
348
|
+
async updateOne(filter, update, options = {}) {
|
|
349
|
+
await this.ensureTable();
|
|
350
|
+
// Find the document first (only one)
|
|
351
|
+
const paramsForSelect = [];
|
|
352
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForSelect);
|
|
353
|
+
const selectSql = `SELECT _id, data FROM "${this.name}" WHERE ${whereClause} LIMIT 1`;
|
|
354
|
+
const rowToUpdate = await this.db.get(selectSql, paramsForSelect);
|
|
355
|
+
if (!rowToUpdate && !options.upsert) {
|
|
356
|
+
// If no document found and upsert is not requested, return no changes
|
|
357
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
|
|
358
|
+
}
|
|
359
|
+
// If no document found and upsert is requested, create a new document
|
|
360
|
+
if (!rowToUpdate && options.upsert) {
|
|
361
|
+
// Check if _id is a valid ObjectId or provided
|
|
362
|
+
if (update.$set?._id && !ObjectId.isValid(update.$set._id)) {
|
|
363
|
+
throw new Error(`_id must be a valid ObjectId or not provided for upsert in ${this.name}`);
|
|
364
|
+
}
|
|
365
|
+
// Generate a new _id if not provided
|
|
366
|
+
// If _id is provided in $set, use it; otherwise, generate a new ObjectId
|
|
367
|
+
const newDocId = update.$set?._id || new ObjectId().toString();
|
|
368
|
+
const newDoc = { _id: newDocId, ...update.$set }; // Assuming $set is used for upsert
|
|
369
|
+
// Ensure _id is set
|
|
370
|
+
const jsonData = safeJsonStringify(newDoc, `upsert for document ${newDocId}`);
|
|
371
|
+
const insertSql = `INSERT INTO "${this.name}" (_id, data) VALUES (?, ?)`;
|
|
372
|
+
try {
|
|
373
|
+
await this.db.run(insertSql, [newDocId, jsonData]);
|
|
374
|
+
return {
|
|
375
|
+
acknowledged: true,
|
|
376
|
+
matchedCount: 0,
|
|
377
|
+
modifiedCount: 0,
|
|
378
|
+
upsertedId: newDocId,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
console.error(`Error inserting document during upsert in ${this.name}:`, error);
|
|
383
|
+
if (error.code === 'SQLITE_CONSTRAINT') {
|
|
384
|
+
throw new Error(`Duplicate _id during upsert: ${newDocId}`);
|
|
385
|
+
}
|
|
386
|
+
throw error; // Re-throw other errors
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (!rowToUpdate) {
|
|
390
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
|
|
391
|
+
}
|
|
392
|
+
const currentDoc = safeJsonParse(rowToUpdate.data, `updateOne for document ${rowToUpdate._id}`);
|
|
393
|
+
let modified = false;
|
|
394
|
+
// Process update operators
|
|
395
|
+
for (const operator in update) {
|
|
396
|
+
const opArgs = update[operator];
|
|
397
|
+
if (!opArgs)
|
|
398
|
+
continue;
|
|
399
|
+
switch (operator) {
|
|
400
|
+
case '$set':
|
|
401
|
+
for (const path in opArgs) {
|
|
402
|
+
const value = opArgs[path];
|
|
403
|
+
this.setNestedValue(currentDoc, path, value);
|
|
404
|
+
modified = true;
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
case '$unset':
|
|
408
|
+
for (const path in opArgs) {
|
|
409
|
+
this.unsetNestedValue(currentDoc, path);
|
|
410
|
+
modified = true;
|
|
411
|
+
}
|
|
412
|
+
break;
|
|
413
|
+
case '$inc':
|
|
414
|
+
for (const path in opArgs) {
|
|
415
|
+
const value = opArgs[path];
|
|
416
|
+
if (typeof value === 'number') {
|
|
417
|
+
const currentValue = this.getNestedValue(currentDoc, path) || 0;
|
|
418
|
+
if (typeof currentValue === 'number') {
|
|
419
|
+
this.setNestedValue(currentDoc, path, currentValue + value);
|
|
420
|
+
modified = true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
break;
|
|
425
|
+
case '$push':
|
|
426
|
+
for (const path in opArgs) {
|
|
427
|
+
const value = opArgs[path];
|
|
428
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
429
|
+
if (Array.isArray(currentValue)) {
|
|
430
|
+
if (typeof value === 'object' && value !== null && '$each' in value) {
|
|
431
|
+
if (Array.isArray(value.$each)) {
|
|
432
|
+
currentValue.push(...value.$each);
|
|
433
|
+
modified = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
currentValue.push(value);
|
|
438
|
+
modified = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
else if (currentValue === undefined) {
|
|
442
|
+
// If the field doesn't exist, create it as an array
|
|
443
|
+
if (typeof value === 'object' && value !== null && '$each' in value) {
|
|
444
|
+
if (Array.isArray(value.$each)) {
|
|
445
|
+
this.setNestedValue(currentDoc, path, [...value.$each]);
|
|
446
|
+
modified = true;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
this.setNestedValue(currentDoc, path, [value]);
|
|
451
|
+
modified = true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
case '$pull':
|
|
457
|
+
for (const path in opArgs) {
|
|
458
|
+
const value = opArgs[path];
|
|
459
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
460
|
+
if (Array.isArray(currentValue)) {
|
|
461
|
+
// Simple equality pull
|
|
462
|
+
const newArray = currentValue.filter((item) => {
|
|
463
|
+
if (typeof item === 'object' && typeof value === 'object') {
|
|
464
|
+
// For objects, do a deep comparison (simplified)
|
|
465
|
+
return JSON.stringify(item) !== JSON.stringify(value);
|
|
466
|
+
}
|
|
467
|
+
return item !== value;
|
|
468
|
+
});
|
|
469
|
+
if (newArray.length !== currentValue.length) {
|
|
470
|
+
this.setNestedValue(currentDoc, path, newArray);
|
|
471
|
+
modified = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
case '$addToSet':
|
|
477
|
+
for (const path in opArgs) {
|
|
478
|
+
const value = opArgs[path];
|
|
479
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
480
|
+
const arr = Array.isArray(currentValue) ? currentValue : [];
|
|
481
|
+
const items = typeof value === 'object' && value !== null && '$each' in value && Array.isArray(value.$each)
|
|
482
|
+
? value.$each
|
|
483
|
+
: [value];
|
|
484
|
+
let changed = false;
|
|
485
|
+
for (const item of items) {
|
|
486
|
+
const alreadyPresent = arr.some((el) => JSON.stringify(el) === JSON.stringify(item));
|
|
487
|
+
if (!alreadyPresent) {
|
|
488
|
+
arr.push(item);
|
|
489
|
+
changed = true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (changed || !Array.isArray(currentValue)) {
|
|
493
|
+
this.setNestedValue(currentDoc, path, arr);
|
|
494
|
+
modified = true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
498
|
+
case '$pop':
|
|
499
|
+
for (const path in opArgs) {
|
|
500
|
+
const value = opArgs[path];
|
|
501
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
502
|
+
if (Array.isArray(currentValue) && currentValue.length > 0) {
|
|
503
|
+
if (value === 1) {
|
|
504
|
+
currentValue.pop();
|
|
505
|
+
}
|
|
506
|
+
else if (value === -1) {
|
|
507
|
+
currentValue.shift();
|
|
508
|
+
}
|
|
509
|
+
this.setNestedValue(currentDoc, path, currentValue);
|
|
510
|
+
modified = true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
break;
|
|
514
|
+
case '$mul':
|
|
515
|
+
for (const path in opArgs) {
|
|
516
|
+
const value = opArgs[path];
|
|
517
|
+
if (typeof value === 'number') {
|
|
518
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
519
|
+
const numericCurrent = typeof currentValue === 'number' ? currentValue : 0;
|
|
520
|
+
this.setNestedValue(currentDoc, path, numericCurrent * value);
|
|
521
|
+
modified = true;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
case '$min':
|
|
526
|
+
for (const path in opArgs) {
|
|
527
|
+
const value = opArgs[path];
|
|
528
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
529
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
530
|
+
if (currentValue === undefined || value < currentValue) {
|
|
531
|
+
this.setNestedValue(currentDoc, path, value);
|
|
532
|
+
modified = true;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
case '$max':
|
|
537
|
+
for (const path in opArgs) {
|
|
538
|
+
const value = opArgs[path];
|
|
539
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
540
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
541
|
+
if (currentValue === undefined || value > currentValue) {
|
|
542
|
+
this.setNestedValue(currentDoc, path, value);
|
|
543
|
+
modified = true;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
case '$currentDate':
|
|
548
|
+
for (const path in opArgs) {
|
|
549
|
+
const value = opArgs[path];
|
|
550
|
+
if (value === true) {
|
|
551
|
+
// Default: store as ISO string date
|
|
552
|
+
this.setNestedValue(currentDoc, path, new Date().toISOString());
|
|
553
|
+
modified = true;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (value && typeof value === 'object' && '$type' in value) {
|
|
557
|
+
const typeValue = value.$type;
|
|
558
|
+
if (typeValue === 'date') {
|
|
559
|
+
this.setNestedValue(currentDoc, path, new Date().toISOString());
|
|
560
|
+
modified = true;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (typeValue === 'timestamp') {
|
|
564
|
+
// Represent timestamp as a numeric UNIX epoch in milliseconds
|
|
565
|
+
this.setNestedValue(currentDoc, path, Date.now());
|
|
566
|
+
modified = true;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
throw new Error(`Unsupported $currentDate $type value for path "${path}": ${JSON.stringify(typeValue)}`);
|
|
570
|
+
}
|
|
571
|
+
throw new Error(`Unsupported $currentDate value for path "${path}": ${JSON.stringify(value)}`);
|
|
572
|
+
}
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (modified) {
|
|
577
|
+
// Update the document in SQLite
|
|
578
|
+
const updateSql = `UPDATE "${this.name}" SET data = ? WHERE _id = ?`;
|
|
579
|
+
const updateParams = [
|
|
580
|
+
safeJsonStringify(currentDoc, `updateOne for document ${rowToUpdate._id}`),
|
|
581
|
+
rowToUpdate._id,
|
|
582
|
+
];
|
|
583
|
+
try {
|
|
584
|
+
await this.db.run(updateSql, updateParams);
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (error.code === 'SQLITE_BUSY') {
|
|
588
|
+
await this.retryWithBackoff(async () => this.db.run(updateSql, updateParams), `update for ${rowToUpdate._id}`);
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
acknowledged: true,
|
|
596
|
+
matchedCount: 1,
|
|
597
|
+
modifiedCount: 1, // Since we found and modified exactly one document
|
|
598
|
+
upsertedId: null,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
acknowledged: true,
|
|
603
|
+
matchedCount: 1,
|
|
604
|
+
modifiedCount: 0, // No changes were made
|
|
605
|
+
upsertedId: null,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Updates multiple documents matching the filter.
|
|
610
|
+
* @param filter The selection criteria for the update.
|
|
611
|
+
* @param update The modifications to apply.
|
|
612
|
+
* @returns {Promise<UpdateResult>} An object describing the outcome.
|
|
613
|
+
*/
|
|
614
|
+
async updateMany(filter, update) {
|
|
615
|
+
await this.ensureTable();
|
|
616
|
+
const paramsForSelect = [];
|
|
617
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForSelect);
|
|
618
|
+
const selectSql = `SELECT _id, data FROM "${this.name}" WHERE ${whereClause}`;
|
|
619
|
+
const rowsToUpdate = await this.db.all(selectSql, paramsForSelect);
|
|
620
|
+
if (rowsToUpdate.length === 0) {
|
|
621
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
|
|
622
|
+
}
|
|
623
|
+
let modifiedCount = 0;
|
|
624
|
+
const updatedIds = [];
|
|
625
|
+
for (const rowToUpdate of rowsToUpdate) {
|
|
626
|
+
const currentDoc = safeJsonParse(rowToUpdate.data, `updateMany for document ${rowToUpdate._id}`);
|
|
627
|
+
let modified = false;
|
|
628
|
+
// Process update operators
|
|
629
|
+
for (const operator in update) {
|
|
630
|
+
const opArgs = update[operator];
|
|
631
|
+
if (!opArgs)
|
|
632
|
+
continue;
|
|
633
|
+
switch (operator) {
|
|
634
|
+
case '$set':
|
|
635
|
+
for (const path in opArgs) {
|
|
636
|
+
const value = opArgs[path];
|
|
637
|
+
this.setNestedValue(currentDoc, path, value);
|
|
638
|
+
modified = true;
|
|
639
|
+
}
|
|
640
|
+
break;
|
|
641
|
+
case '$unset':
|
|
642
|
+
for (const path in opArgs) {
|
|
643
|
+
this.unsetNestedValue(currentDoc, path);
|
|
644
|
+
modified = true;
|
|
645
|
+
}
|
|
646
|
+
break;
|
|
647
|
+
case '$inc':
|
|
648
|
+
for (const path in opArgs) {
|
|
649
|
+
const value = opArgs[path];
|
|
650
|
+
if (typeof value === 'number') {
|
|
651
|
+
const currentValue = this.getNestedValue(currentDoc, path) || 0;
|
|
652
|
+
if (typeof currentValue === 'number') {
|
|
653
|
+
this.setNestedValue(currentDoc, path, currentValue + value);
|
|
654
|
+
modified = true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
break;
|
|
659
|
+
case '$push':
|
|
660
|
+
for (const path in opArgs) {
|
|
661
|
+
const value = opArgs[path];
|
|
662
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
663
|
+
if (Array.isArray(currentValue)) {
|
|
664
|
+
if (typeof value === 'object' && value !== null && '$each' in value) {
|
|
665
|
+
if (Array.isArray(value.$each)) {
|
|
666
|
+
currentValue.push(...value.$each);
|
|
667
|
+
modified = true;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
currentValue.push(value);
|
|
672
|
+
modified = true;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
else if (currentValue === undefined) {
|
|
676
|
+
// If the field doesn't exist, create it as an array
|
|
677
|
+
if (typeof value === 'object' && value !== null && '$each' in value) {
|
|
678
|
+
if (Array.isArray(value.$each)) {
|
|
679
|
+
this.setNestedValue(currentDoc, path, [...value.$each]);
|
|
680
|
+
modified = true;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
this.setNestedValue(currentDoc, path, [value]);
|
|
685
|
+
modified = true;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
break;
|
|
690
|
+
case '$pull':
|
|
691
|
+
for (const path in opArgs) {
|
|
692
|
+
const value = opArgs[path];
|
|
693
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
694
|
+
if (Array.isArray(currentValue)) {
|
|
695
|
+
// Simple equality pull
|
|
696
|
+
const newArray = currentValue.filter((item) => {
|
|
697
|
+
if (typeof item === 'object' && typeof value === 'object') {
|
|
698
|
+
// For objects, do a deep comparison (simplified)
|
|
699
|
+
return JSON.stringify(item) !== JSON.stringify(value);
|
|
700
|
+
}
|
|
701
|
+
return item !== value;
|
|
702
|
+
});
|
|
703
|
+
if (newArray.length !== currentValue.length) {
|
|
704
|
+
this.setNestedValue(currentDoc, path, newArray);
|
|
705
|
+
modified = true;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
break;
|
|
710
|
+
case '$addToSet':
|
|
711
|
+
for (const path in opArgs) {
|
|
712
|
+
const value = opArgs[path];
|
|
713
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
714
|
+
const arr = Array.isArray(currentValue) ? currentValue : [];
|
|
715
|
+
const items = typeof value === 'object' && value !== null && '$each' in value && Array.isArray(value.$each)
|
|
716
|
+
? value.$each
|
|
717
|
+
: [value];
|
|
718
|
+
let changed = false;
|
|
719
|
+
for (const item of items) {
|
|
720
|
+
const alreadyPresent = arr.some((el) => JSON.stringify(el) === JSON.stringify(item));
|
|
721
|
+
if (!alreadyPresent) {
|
|
722
|
+
arr.push(item);
|
|
723
|
+
changed = true;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (changed || !Array.isArray(currentValue)) {
|
|
727
|
+
this.setNestedValue(currentDoc, path, arr);
|
|
728
|
+
modified = true;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
break;
|
|
732
|
+
case '$pop':
|
|
733
|
+
for (const path in opArgs) {
|
|
734
|
+
const value = opArgs[path];
|
|
735
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
736
|
+
if (Array.isArray(currentValue) && currentValue.length > 0) {
|
|
737
|
+
if (value === 1) {
|
|
738
|
+
currentValue.pop();
|
|
739
|
+
}
|
|
740
|
+
else if (value === -1) {
|
|
741
|
+
currentValue.shift();
|
|
742
|
+
}
|
|
743
|
+
this.setNestedValue(currentDoc, path, currentValue);
|
|
744
|
+
modified = true;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
break;
|
|
748
|
+
case '$mul':
|
|
749
|
+
for (const path in opArgs) {
|
|
750
|
+
const value = opArgs[path];
|
|
751
|
+
if (typeof value === 'number') {
|
|
752
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
753
|
+
const numericCurrent = typeof currentValue === 'number' ? currentValue : 0;
|
|
754
|
+
this.setNestedValue(currentDoc, path, numericCurrent * value);
|
|
755
|
+
modified = true;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
break;
|
|
759
|
+
case '$min':
|
|
760
|
+
for (const path in opArgs) {
|
|
761
|
+
const value = opArgs[path];
|
|
762
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
763
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
764
|
+
if (currentValue === undefined || value < currentValue) {
|
|
765
|
+
this.setNestedValue(currentDoc, path, value);
|
|
766
|
+
modified = true;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
break;
|
|
770
|
+
case '$max':
|
|
771
|
+
for (const path in opArgs) {
|
|
772
|
+
const value = opArgs[path];
|
|
773
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
774
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
775
|
+
if (currentValue === undefined || value > currentValue) {
|
|
776
|
+
this.setNestedValue(currentDoc, path, value);
|
|
777
|
+
modified = true;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
break;
|
|
781
|
+
case '$currentDate':
|
|
782
|
+
for (const path in opArgs) {
|
|
783
|
+
this.setNestedValue(currentDoc, path, new Date().toISOString());
|
|
784
|
+
modified = true;
|
|
785
|
+
}
|
|
786
|
+
break;
|
|
787
|
+
default:
|
|
788
|
+
throw new Error(`Unsupported update operator: ${operator}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (modified) {
|
|
792
|
+
// Update the document in SQLite
|
|
793
|
+
const updateSql = `UPDATE "${this.name}" SET data = ? WHERE _id = ?`;
|
|
794
|
+
const updateParams = [
|
|
795
|
+
safeJsonStringify(currentDoc, `updateMany for document ${rowToUpdate._id}`),
|
|
796
|
+
rowToUpdate._id,
|
|
797
|
+
];
|
|
798
|
+
try {
|
|
799
|
+
await this.db.run(updateSql, updateParams);
|
|
800
|
+
modifiedCount++;
|
|
801
|
+
updatedIds.push(rowToUpdate._id);
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
if (error.code === 'SQLITE_BUSY') {
|
|
805
|
+
await this.retryWithBackoff(async () => {
|
|
806
|
+
await this.db.run(updateSql, updateParams);
|
|
807
|
+
modifiedCount++;
|
|
808
|
+
updatedIds.push(rowToUpdate._id);
|
|
809
|
+
}, `update for ${rowToUpdate._id} in batch operation`);
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
throw error;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
acknowledged: true,
|
|
819
|
+
matchedCount: rowsToUpdate.length,
|
|
820
|
+
modifiedCount: modifiedCount,
|
|
821
|
+
upsertedId: null, // We don't support upsert yet
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Creates an index on the specified field(s) of the collection.
|
|
826
|
+
*
|
|
827
|
+
* @param fieldOrSpec The field or specification to create the index on.
|
|
828
|
+
* @param options Options for creating the index.
|
|
829
|
+
* @returns A promise that resolves to the result of the operation.
|
|
830
|
+
*
|
|
831
|
+
* @example
|
|
832
|
+
* // Create a simple index on the "name" field
|
|
833
|
+
* collection.createIndex({ name: 1 });
|
|
834
|
+
*
|
|
835
|
+
* // Create a unique index on the "email" field
|
|
836
|
+
* collection.createIndex({ email: 1 }, { unique: true });
|
|
837
|
+
*
|
|
838
|
+
* // Create a compound index on multiple fields
|
|
839
|
+
* collection.createIndex({ name: 1, age: -1 });
|
|
840
|
+
*/
|
|
841
|
+
async createIndex(fieldOrSpec, options = {}) {
|
|
842
|
+
await this.ensureTable();
|
|
843
|
+
// Convert string field to index spec format
|
|
844
|
+
const spec = typeof fieldOrSpec === 'string' ? { [fieldOrSpec]: 1 } : fieldOrSpec;
|
|
845
|
+
// Generate index name if not provided
|
|
846
|
+
const indexName = options.name || this.generateIndexName(spec);
|
|
847
|
+
// Build the index fields part for SQLite (field1, field2, ...)
|
|
848
|
+
const indexFields = Object.entries(spec)
|
|
849
|
+
.map(([field, direction]) => {
|
|
850
|
+
// For JSON field paths, we need to use json_extract
|
|
851
|
+
if (field.includes('.') || field !== '_id') {
|
|
852
|
+
const jsonPath = this.parseJsonPath(field);
|
|
853
|
+
return `json_extract(data, ${jsonPath}) ${direction === -1 ? 'DESC' : 'ASC'}`;
|
|
854
|
+
}
|
|
855
|
+
// For _id field, use the column directly
|
|
856
|
+
return `_id ${direction === -1 ? 'DESC' : 'ASC'}`;
|
|
857
|
+
})
|
|
858
|
+
.join(', ');
|
|
859
|
+
// Create the SQL for the index
|
|
860
|
+
const uniqueClause = options.unique ? 'UNIQUE' : '';
|
|
861
|
+
const createIndexSQL = `CREATE ${uniqueClause} INDEX IF NOT EXISTS "${indexName}" ON "${this.name}" (${indexFields})`;
|
|
862
|
+
try {
|
|
863
|
+
if (this.options.verbose) {
|
|
864
|
+
console.log(`Creating index ${indexName} on collection ${this.name}`);
|
|
865
|
+
}
|
|
866
|
+
await this.db.exec(createIndexSQL);
|
|
867
|
+
return { acknowledged: true, name: indexName };
|
|
868
|
+
}
|
|
869
|
+
catch (error) {
|
|
870
|
+
console.error(`Error creating index ${indexName} on collection ${this.name}:`, error);
|
|
871
|
+
throw error;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Generate a MongoDB-like index name from an index specification.
|
|
876
|
+
* Format: collectionName_field1_1_field2_-1
|
|
877
|
+
*/
|
|
878
|
+
generateIndexName(spec) {
|
|
879
|
+
const nameParts = Object.entries(spec).map(([field, direction]) => `${field.replace(/\./g, '_')}_${direction}`);
|
|
880
|
+
return `${this.name}_${nameParts.join('_')}`;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Lists all indexes on the collection.
|
|
884
|
+
*
|
|
885
|
+
* @returns A promise that resolves to an object with a toArray method
|
|
886
|
+
* that returns the list of indexes.
|
|
887
|
+
*/
|
|
888
|
+
listIndexes() {
|
|
889
|
+
return {
|
|
890
|
+
toArray: async () => {
|
|
891
|
+
await this.ensureTable();
|
|
892
|
+
try {
|
|
893
|
+
// Query SQLite master table for indexes on this collection
|
|
894
|
+
const indexes = await this.db.all(`SELECT name, sql FROM sqlite_master
|
|
895
|
+
WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_autoindex%'`, [this.name]);
|
|
896
|
+
const results = [];
|
|
897
|
+
// Parse the SQL to determine indexed fields and options
|
|
898
|
+
for (const index of indexes) {
|
|
899
|
+
const indexInfo = {
|
|
900
|
+
name: index.name,
|
|
901
|
+
key: {},
|
|
902
|
+
};
|
|
903
|
+
// Parse the CREATE INDEX statement to extract field information
|
|
904
|
+
// Example: CREATE UNIQUE INDEX "users_email_1" ON "users" (json_extract(data, '$.email') ASC)
|
|
905
|
+
const sqlLower = index.sql.toLowerCase();
|
|
906
|
+
indexInfo.unique = sqlLower.includes('unique index');
|
|
907
|
+
// Extract the fields from the SQL
|
|
908
|
+
const rawColumns = extractRawIndexColumns(index.sql);
|
|
909
|
+
for (const col of rawColumns) {
|
|
910
|
+
// Use the column name as the key, and direction as value
|
|
911
|
+
indexInfo.key[col.column] = col.direction === 'DESC' ? -1 : 1;
|
|
912
|
+
}
|
|
913
|
+
results.push(indexInfo);
|
|
914
|
+
}
|
|
915
|
+
return results;
|
|
916
|
+
}
|
|
917
|
+
catch (error) {
|
|
918
|
+
console.error(`Error listing indexes for collection ${this.name}:`, error);
|
|
919
|
+
throw error;
|
|
920
|
+
}
|
|
921
|
+
},
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Drops a specified index from the collection.
|
|
926
|
+
*
|
|
927
|
+
* @param indexName The name of the index to drop.
|
|
928
|
+
* @returns A promise that resolves to the result of the operation.
|
|
929
|
+
*/
|
|
930
|
+
async dropIndex(indexName) {
|
|
931
|
+
await this.ensureTable();
|
|
932
|
+
try {
|
|
933
|
+
if (this.options.verbose) {
|
|
934
|
+
console.log(`Dropping index ${indexName} from collection ${this.name}`);
|
|
935
|
+
}
|
|
936
|
+
await this.db.exec(`DROP INDEX IF EXISTS "${indexName}"`);
|
|
937
|
+
return { acknowledged: true, name: indexName };
|
|
938
|
+
}
|
|
939
|
+
catch (error) {
|
|
940
|
+
console.error(`Error dropping index ${indexName} from collection ${this.name}:`, error);
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Drops all indexes from the collection, except for the index on _id.
|
|
946
|
+
*
|
|
947
|
+
* @returns A promise that resolves to the result of the operation.
|
|
948
|
+
*/
|
|
949
|
+
async dropIndexes() {
|
|
950
|
+
await this.ensureTable();
|
|
951
|
+
try {
|
|
952
|
+
const indexes = await this.listIndexes().toArray();
|
|
953
|
+
let droppedCount = 0;
|
|
954
|
+
for (const index of indexes) {
|
|
955
|
+
// Skip primary key index on _id if it exists
|
|
956
|
+
if (index.name === `sqlite_autoindex_${this.name}_1`)
|
|
957
|
+
continue;
|
|
958
|
+
await this.db.exec(`DROP INDEX IF EXISTS "${index.name}"`);
|
|
959
|
+
droppedCount++;
|
|
960
|
+
}
|
|
961
|
+
return { acknowledged: true, droppedCount };
|
|
962
|
+
}
|
|
963
|
+
catch (error) {
|
|
964
|
+
console.error(`Error dropping indexes from collection ${this.name}:`, error);
|
|
965
|
+
throw error;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Prepends '$.' to the path for SQLite JSON functions.
|
|
970
|
+
* @private
|
|
971
|
+
* @param path Field path, e.g., 'address.city'
|
|
972
|
+
* @returns JSON path string for SQLite, e.g., '$.address.city'
|
|
973
|
+
*/
|
|
974
|
+
parseJsonPath(path) {
|
|
975
|
+
// Prepends '$.' to the path for SQLite JSON functions.
|
|
976
|
+
// 'address.city' becomes '$.address.city'
|
|
977
|
+
return `'$.${path}'`;
|
|
978
|
+
}
|
|
979
|
+
// Helper for $set, $inc to handle dot notation
|
|
980
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
981
|
+
setNestedValue(obj, path, value) {
|
|
982
|
+
const keys = path.split('.');
|
|
983
|
+
let current = obj;
|
|
984
|
+
// Navigate to the last parent in the path
|
|
985
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
986
|
+
const key = keys[i];
|
|
987
|
+
// Create nested objects if they don't exist
|
|
988
|
+
if (current[key] === undefined || current[key] === null) {
|
|
989
|
+
current[key] = {};
|
|
990
|
+
}
|
|
991
|
+
else if (typeof current[key] !== 'object') {
|
|
992
|
+
// If it's not an object but we need to go deeper, replace it with an object
|
|
993
|
+
current[key] = {};
|
|
994
|
+
}
|
|
995
|
+
current = current[key];
|
|
996
|
+
}
|
|
997
|
+
// Set the value at the final key
|
|
998
|
+
current[keys[keys.length - 1]] = value;
|
|
999
|
+
}
|
|
1000
|
+
// Helper for $unset
|
|
1001
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1002
|
+
unsetNestedValue(obj, path) {
|
|
1003
|
+
const keys = path.split('.');
|
|
1004
|
+
let current = obj;
|
|
1005
|
+
// Navigate to the last parent in the path
|
|
1006
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
1007
|
+
const key = keys[i];
|
|
1008
|
+
if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {
|
|
1009
|
+
return; // Path doesn't exist, nothing to unset
|
|
1010
|
+
}
|
|
1011
|
+
current = current[key];
|
|
1012
|
+
}
|
|
1013
|
+
// Delete the property at the final key
|
|
1014
|
+
delete current[keys[keys.length - 1]];
|
|
1015
|
+
}
|
|
1016
|
+
// Helper to get nested value
|
|
1017
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1018
|
+
getNestedValue(obj, path) {
|
|
1019
|
+
const keys = path.split('.');
|
|
1020
|
+
let current = obj;
|
|
1021
|
+
// Navigate through the path
|
|
1022
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1023
|
+
const key = keys[i];
|
|
1024
|
+
if (current[key] === undefined) {
|
|
1025
|
+
return undefined; // Path doesn't exist
|
|
1026
|
+
}
|
|
1027
|
+
current = current[key];
|
|
1028
|
+
}
|
|
1029
|
+
return current;
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Deletes a single document matching the filter.
|
|
1033
|
+
* @param filter The criteria to select the document to delete.
|
|
1034
|
+
* @returns {Promise<DeleteResult>} An object describing the outcome.
|
|
1035
|
+
*/
|
|
1036
|
+
async deleteOne(filter) {
|
|
1037
|
+
await this.ensureTable();
|
|
1038
|
+
const paramsForDelete = [];
|
|
1039
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForDelete);
|
|
1040
|
+
// Get the number of documents that would be deleted
|
|
1041
|
+
const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause} LIMIT 1`;
|
|
1042
|
+
const countResult = await this.db.get(countSql, paramsForDelete);
|
|
1043
|
+
const deleteSql = `
|
|
1044
|
+
DELETE FROM "${this.name}"
|
|
1045
|
+
WHERE ROWID IN (SELECT ROWID FROM "${this.name}" WHERE ${whereClause} LIMIT 1);
|
|
1046
|
+
`;
|
|
1047
|
+
try {
|
|
1048
|
+
await this.db.run(deleteSql, paramsForDelete);
|
|
1049
|
+
}
|
|
1050
|
+
catch (error) {
|
|
1051
|
+
if (error.code === 'SQLITE_BUSY') {
|
|
1052
|
+
await this.retryWithBackoff(async () => this.db.run(deleteSql, paramsForDelete), `delete for filter ${JSON.stringify(filter)}`);
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
throw error;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return { acknowledged: true, deletedCount: countResult?.count ? 1 : 0 };
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Deletes multiple documents matching the filter.
|
|
1062
|
+
* @param filter The criteria to select documents to delete.
|
|
1063
|
+
* @returns {Promise<DeleteResult>} An object describing the outcome.
|
|
1064
|
+
*/
|
|
1065
|
+
async deleteMany(filter) {
|
|
1066
|
+
await this.ensureTable();
|
|
1067
|
+
const paramsForDelete = [];
|
|
1068
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForDelete);
|
|
1069
|
+
// Get the number of documents that would be deleted
|
|
1070
|
+
const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause}`;
|
|
1071
|
+
const countResult = await this.db.get(countSql, paramsForDelete);
|
|
1072
|
+
const deleteSql = `DELETE FROM "${this.name}" WHERE ${whereClause}`;
|
|
1073
|
+
try {
|
|
1074
|
+
await this.db.run(deleteSql, paramsForDelete);
|
|
1075
|
+
}
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
if (error.code === 'SQLITE_BUSY') {
|
|
1078
|
+
await this.retryWithBackoff(async () => this.db.run(deleteSql, paramsForDelete), `delete many for filter ${JSON.stringify(filter)}`);
|
|
1079
|
+
}
|
|
1080
|
+
else {
|
|
1081
|
+
throw error;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return { acknowledged: true, deletedCount: countResult ? countResult.count : 0 };
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Counts the number of documents matching the filter.
|
|
1088
|
+
* @param filter The criteria to select documents to count.
|
|
1089
|
+
* @returns {Promise<number>} The count of matching documents.
|
|
1090
|
+
*/
|
|
1091
|
+
async countDocuments(filter = {}) {
|
|
1092
|
+
await this.ensureTable();
|
|
1093
|
+
const paramsForCount = [];
|
|
1094
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildWhereClause'](filter, paramsForCount);
|
|
1095
|
+
const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause}`;
|
|
1096
|
+
const result = await this.db.get(countSql, paramsForCount);
|
|
1097
|
+
return result?.count || 0;
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Returns the number of documents in the collection.
|
|
1101
|
+
* This is an estimate and may not reflect the exact count in concurrent environments.
|
|
1102
|
+
* @returns {Promise<number>} The estimated count.
|
|
1103
|
+
*/
|
|
1104
|
+
async estimatedDocumentCount() {
|
|
1105
|
+
await this.ensureTable();
|
|
1106
|
+
const result = await this.db.get(`SELECT COUNT(*) as count FROM "${this.name}"`);
|
|
1107
|
+
return result?.count || 0;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Finds a single document matching the filter, applies the update, and returns the document.
|
|
1111
|
+
* @param filter The selection criteria.
|
|
1112
|
+
* @param update The modifications to apply.
|
|
1113
|
+
* @param options Options including returnDocument ('before'|'after') and upsert.
|
|
1114
|
+
* @returns {Promise<T | null>} The document before or after the update, or null.
|
|
1115
|
+
*/
|
|
1116
|
+
async findOneAndUpdate(filter, update, options = {}) {
|
|
1117
|
+
await this.ensureTable();
|
|
1118
|
+
const { returnDocument = 'before', upsert = false, projection } = options;
|
|
1119
|
+
// Fetch the target document without projection so we always have the _id
|
|
1120
|
+
const idDocs = await this.find(filter).limit(1).toArray();
|
|
1121
|
+
const existingFullDoc = idDocs.length > 0 ? idDocs[0] : null;
|
|
1122
|
+
const existingId = existingFullDoc?._id;
|
|
1123
|
+
if (!existingFullDoc && !upsert) {
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
// Update by _id when available to ensure we modify the exact document we found
|
|
1127
|
+
const updateFilter = existingId !== undefined && existingId !== null
|
|
1128
|
+
? { _id: existingId }
|
|
1129
|
+
: filter;
|
|
1130
|
+
const updateResult = await this.updateOne(updateFilter, update, { upsert });
|
|
1131
|
+
if (returnDocument === 'after') {
|
|
1132
|
+
const targetId = updateResult.upsertedId ?? existingId;
|
|
1133
|
+
if (!targetId)
|
|
1134
|
+
return null;
|
|
1135
|
+
const afterCursor = this.find({ _id: targetId }).limit(1);
|
|
1136
|
+
if (projection)
|
|
1137
|
+
afterCursor.project(projection);
|
|
1138
|
+
const afterDocs = await afterCursor.toArray();
|
|
1139
|
+
return afterDocs.length > 0 ? afterDocs[0] : null;
|
|
1140
|
+
}
|
|
1141
|
+
// returnDocument === 'before'
|
|
1142
|
+
if (!existingFullDoc) {
|
|
1143
|
+
// Upsert case where no original document existed
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
if (!projection) {
|
|
1147
|
+
return existingFullDoc;
|
|
1148
|
+
}
|
|
1149
|
+
// Apply projection to the located document by _id
|
|
1150
|
+
if (existingId === undefined || existingId === null) {
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
const beforeCursor = this.find({ _id: existingId }).limit(1);
|
|
1154
|
+
beforeCursor.project(projection);
|
|
1155
|
+
const beforeDocs = await beforeCursor.toArray();
|
|
1156
|
+
return beforeDocs.length > 0 ? beforeDocs[0] : null;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Finds a single document matching the filter, deletes it, and returns it.
|
|
1160
|
+
* @param filter The selection criteria.
|
|
1161
|
+
* @param options Options including field projection.
|
|
1162
|
+
* @returns {Promise<T | null>} The deleted document or null.
|
|
1163
|
+
*/
|
|
1164
|
+
async findOneAndDelete(filter, options = {}) {
|
|
1165
|
+
await this.ensureTable();
|
|
1166
|
+
const { projection } = options;
|
|
1167
|
+
// Fetch without projection to always have _id for the delete
|
|
1168
|
+
const idDocs = await this.find(filter).limit(1).toArray();
|
|
1169
|
+
const existingFullDoc = idDocs.length > 0 ? idDocs[0] : null;
|
|
1170
|
+
if (!existingFullDoc) {
|
|
1171
|
+
return null;
|
|
1172
|
+
}
|
|
1173
|
+
await this.deleteOne({ _id: existingFullDoc._id });
|
|
1174
|
+
if (!projection) {
|
|
1175
|
+
return existingFullDoc;
|
|
1176
|
+
}
|
|
1177
|
+
// Apply projection to the in-memory document
|
|
1178
|
+
const projCursor = this.find({ _id: existingFullDoc._id }).limit(1);
|
|
1179
|
+
projCursor.project(projection);
|
|
1180
|
+
// Since we already deleted, apply projection manually from the full doc
|
|
1181
|
+
const fullDocRec = existingFullDoc;
|
|
1182
|
+
return this.applyAggregateProjection(fullDocRec, projection);
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Finds a single document matching the filter and replaces it entirely.
|
|
1186
|
+
* @param filter The selection criteria.
|
|
1187
|
+
* @param replacement The replacement document (replaces all fields except _id).
|
|
1188
|
+
* @param options Options including returnDocument, upsert, and projection.
|
|
1189
|
+
* @returns {Promise<T | null>} The document before or after the replacement, or null.
|
|
1190
|
+
*/
|
|
1191
|
+
async findOneAndReplace(filter, replacement, options = {}) {
|
|
1192
|
+
await this.ensureTable();
|
|
1193
|
+
const { returnDocument = 'before', upsert = false, projection } = options;
|
|
1194
|
+
const existingDoc = await this.findOne(filter);
|
|
1195
|
+
if (!existingDoc && !upsert) {
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
if (existingDoc) {
|
|
1199
|
+
const docId = existingDoc._id;
|
|
1200
|
+
const newData = safeJsonStringify(replacement, `findOneAndReplace for ${docId}`);
|
|
1201
|
+
await this.db.run(`UPDATE "${this.name}" SET data = ? WHERE _id = ?`, [newData, docId]);
|
|
1202
|
+
if (returnDocument === 'after') {
|
|
1203
|
+
const afterCursor = this.find({ _id: docId }).limit(1);
|
|
1204
|
+
if (projection)
|
|
1205
|
+
afterCursor.project(projection);
|
|
1206
|
+
const afterDocs = await afterCursor.toArray();
|
|
1207
|
+
return afterDocs.length > 0 ? afterDocs[0] : null;
|
|
1208
|
+
}
|
|
1209
|
+
if (!projection) {
|
|
1210
|
+
return existingDoc;
|
|
1211
|
+
}
|
|
1212
|
+
// Apply projection to the pre-replacement document
|
|
1213
|
+
return this.applyAggregateProjection(existingDoc, projection);
|
|
1214
|
+
}
|
|
1215
|
+
else {
|
|
1216
|
+
// Upsert: insert the replacement. Carry _id from filter if it's a simple equality.
|
|
1217
|
+
const filterRec = filter;
|
|
1218
|
+
const filterId = filterRec['_id'] !== undefined &&
|
|
1219
|
+
filterRec['_id'] !== null &&
|
|
1220
|
+
typeof filterRec['_id'] !== 'object'
|
|
1221
|
+
? filterRec['_id']
|
|
1222
|
+
: undefined;
|
|
1223
|
+
const docToInsert = filterId
|
|
1224
|
+
? { ...replacement, _id: filterId }
|
|
1225
|
+
: replacement;
|
|
1226
|
+
const result = await this.insertOne(docToInsert);
|
|
1227
|
+
if (returnDocument === 'after') {
|
|
1228
|
+
const afterCursor = this.find({ _id: result.insertedId }).limit(1);
|
|
1229
|
+
if (projection)
|
|
1230
|
+
afterCursor.project(projection);
|
|
1231
|
+
const afterDocs = await afterCursor.toArray();
|
|
1232
|
+
return afterDocs.length > 0 ? afterDocs[0] : null;
|
|
1233
|
+
}
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Replaces a single document matching the filter.
|
|
1239
|
+
* @param filter The selection criteria.
|
|
1240
|
+
* @param replacement The replacement document (replaces all fields except _id).
|
|
1241
|
+
* @param options Options including upsert.
|
|
1242
|
+
* @returns {Promise<UpdateResult>} An object describing the outcome.
|
|
1243
|
+
*/
|
|
1244
|
+
async replaceOne(filter, replacement, options = {}) {
|
|
1245
|
+
await this.ensureTable();
|
|
1246
|
+
const { upsert = false } = options;
|
|
1247
|
+
const existingDoc = await this.findOne(filter);
|
|
1248
|
+
if (!existingDoc) {
|
|
1249
|
+
if (!upsert) {
|
|
1250
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
|
|
1251
|
+
}
|
|
1252
|
+
const result = await this.insertOne(replacement);
|
|
1253
|
+
return {
|
|
1254
|
+
acknowledged: true,
|
|
1255
|
+
matchedCount: 0,
|
|
1256
|
+
modifiedCount: 0,
|
|
1257
|
+
upsertedId: result.insertedId,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
const docId = existingDoc._id;
|
|
1261
|
+
const newData = safeJsonStringify(replacement, `replaceOne for ${docId}`);
|
|
1262
|
+
await this.db.run(`UPDATE "${this.name}" SET data = ? WHERE _id = ?`, [newData, docId]);
|
|
1263
|
+
return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Returns distinct values for a field across all documents matching the filter.
|
|
1267
|
+
* @param field The field to find distinct values for.
|
|
1268
|
+
* @param filter Optional filter to narrow the documents.
|
|
1269
|
+
* @returns {Promise<unknown[]>} An array of distinct values.
|
|
1270
|
+
*/
|
|
1271
|
+
async distinct(field, filter = {}) {
|
|
1272
|
+
await this.ensureTable();
|
|
1273
|
+
const docs = await this.find(filter).toArray();
|
|
1274
|
+
const seen = new Set();
|
|
1275
|
+
const results = [];
|
|
1276
|
+
for (const doc of docs) {
|
|
1277
|
+
const value = this.getNestedValue(doc, field);
|
|
1278
|
+
if (Array.isArray(value)) {
|
|
1279
|
+
for (const item of value) {
|
|
1280
|
+
const key = JSON.stringify(item);
|
|
1281
|
+
if (!seen.has(key)) {
|
|
1282
|
+
seen.add(key);
|
|
1283
|
+
results.push(item);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
else if (value !== undefined) {
|
|
1288
|
+
const key = JSON.stringify(value);
|
|
1289
|
+
if (!seen.has(key)) {
|
|
1290
|
+
seen.add(key);
|
|
1291
|
+
results.push(value);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return results;
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Drops the entire collection (table) from the database.
|
|
1299
|
+
* @returns {Promise<void>}
|
|
1300
|
+
*/
|
|
1301
|
+
async drop() {
|
|
1302
|
+
await this.db.exec(`DROP TABLE IF EXISTS "${this.name}"`);
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Executes an aggregation pipeline on the collection.
|
|
1306
|
+
* Supports: $match, $project, $sort, $limit, $skip, $count, $group, $unwind, $addFields.
|
|
1307
|
+
* @param pipeline An array of pipeline stage documents.
|
|
1308
|
+
* @returns An object with a toArray() method that returns the aggregation result.
|
|
1309
|
+
*/
|
|
1310
|
+
aggregate(pipeline) {
|
|
1311
|
+
return {
|
|
1312
|
+
toArray: async () => {
|
|
1313
|
+
await this.ensureTable();
|
|
1314
|
+
return this.runAggregationPipeline(pipeline);
|
|
1315
|
+
},
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Runs an aggregation pipeline and returns results.
|
|
1320
|
+
* @private
|
|
1321
|
+
*/
|
|
1322
|
+
async runAggregationPipeline(pipeline) {
|
|
1323
|
+
let results = [];
|
|
1324
|
+
// Only push the initial $match down to SQL when it is the very first stage.
|
|
1325
|
+
// Applying a $match that appears after other stages (e.g. after $unwind/$group) too
|
|
1326
|
+
// early would change pipeline semantics.
|
|
1327
|
+
const firstStage = pipeline[0];
|
|
1328
|
+
const firstIsMatch = firstStage !== undefined && '$match' in firstStage;
|
|
1329
|
+
if (firstIsMatch) {
|
|
1330
|
+
const matchFilter = firstStage.$match;
|
|
1331
|
+
const docs = await this.find(matchFilter).toArray();
|
|
1332
|
+
results = docs;
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
const docs = await this.find({}).toArray();
|
|
1336
|
+
results = docs;
|
|
1337
|
+
}
|
|
1338
|
+
for (let i = 0; i < pipeline.length; i++) {
|
|
1339
|
+
const stage = pipeline[i];
|
|
1340
|
+
const stageKey = Object.keys(stage)[0];
|
|
1341
|
+
switch (stageKey) {
|
|
1342
|
+
case '$match': {
|
|
1343
|
+
// Skip the first stage if it was already used for the initial SQL fetch
|
|
1344
|
+
if (i === 0 && firstIsMatch) {
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
const matchFilter = stage['$match'];
|
|
1348
|
+
results = results.filter((doc) => this.matchesFilter(doc, matchFilter));
|
|
1349
|
+
break;
|
|
1350
|
+
}
|
|
1351
|
+
case '$project': {
|
|
1352
|
+
const projection = stage['$project'];
|
|
1353
|
+
results = results.map((doc) => this.applyAggregateProjection(doc, projection));
|
|
1354
|
+
break;
|
|
1355
|
+
}
|
|
1356
|
+
case '$addFields': {
|
|
1357
|
+
const fields = stage['$addFields'];
|
|
1358
|
+
results = results.map((doc) => ({ ...doc, ...fields }));
|
|
1359
|
+
break;
|
|
1360
|
+
}
|
|
1361
|
+
case '$sort': {
|
|
1362
|
+
const sortSpec = stage['$sort'];
|
|
1363
|
+
results = results.sort((a, b) => {
|
|
1364
|
+
for (const [field, order] of Object.entries(sortSpec)) {
|
|
1365
|
+
const aVal = this.getNestedValue(a, field);
|
|
1366
|
+
const bVal = this.getNestedValue(b, field);
|
|
1367
|
+
if (aVal === bVal)
|
|
1368
|
+
continue;
|
|
1369
|
+
if (aVal === null || aVal === undefined)
|
|
1370
|
+
return order;
|
|
1371
|
+
if (bVal === null || bVal === undefined)
|
|
1372
|
+
return -order;
|
|
1373
|
+
const cmp = aVal < bVal ? -1 : 1;
|
|
1374
|
+
return cmp * order;
|
|
1375
|
+
}
|
|
1376
|
+
return 0;
|
|
1377
|
+
});
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
case '$limit': {
|
|
1381
|
+
const limitCount = stage['$limit'];
|
|
1382
|
+
results = results.slice(0, limitCount);
|
|
1383
|
+
break;
|
|
1384
|
+
}
|
|
1385
|
+
case '$skip': {
|
|
1386
|
+
const skipCount = stage['$skip'];
|
|
1387
|
+
results = results.slice(skipCount);
|
|
1388
|
+
break;
|
|
1389
|
+
}
|
|
1390
|
+
case '$count': {
|
|
1391
|
+
const countField = stage['$count'];
|
|
1392
|
+
results = [{ [countField]: results.length }];
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
case '$group': {
|
|
1396
|
+
results = this.applyGroupStage(results, stage['$group']);
|
|
1397
|
+
break;
|
|
1398
|
+
}
|
|
1399
|
+
case '$unwind': {
|
|
1400
|
+
const path = typeof stage['$unwind'] === 'string'
|
|
1401
|
+
? stage['$unwind']
|
|
1402
|
+
: stage['$unwind'].path;
|
|
1403
|
+
const fieldName = path.startsWith('$') ? path.slice(1) : path;
|
|
1404
|
+
const unwound = [];
|
|
1405
|
+
for (const doc of results) {
|
|
1406
|
+
const arrayVal = this.getNestedValue(doc, fieldName);
|
|
1407
|
+
if (Array.isArray(arrayVal)) {
|
|
1408
|
+
for (const item of arrayVal) {
|
|
1409
|
+
const newDoc = { ...doc };
|
|
1410
|
+
this.setNestedValue(newDoc, fieldName, item);
|
|
1411
|
+
unwound.push(newDoc);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
else if (arrayVal !== undefined && arrayVal !== null) {
|
|
1415
|
+
unwound.push(doc);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
results = unwound;
|
|
1419
|
+
break;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
return results;
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Applies a $group pipeline stage.
|
|
1427
|
+
* @private
|
|
1428
|
+
*/
|
|
1429
|
+
applyGroupStage(docs, groupSpec) {
|
|
1430
|
+
const groups = new Map();
|
|
1431
|
+
const idSpec = groupSpec['_id'];
|
|
1432
|
+
for (const doc of docs) {
|
|
1433
|
+
let groupKey;
|
|
1434
|
+
if (idSpec === null) {
|
|
1435
|
+
groupKey = null;
|
|
1436
|
+
}
|
|
1437
|
+
else if (typeof idSpec === 'string' && idSpec.startsWith('$')) {
|
|
1438
|
+
groupKey = this.getNestedValue(doc, idSpec.slice(1));
|
|
1439
|
+
}
|
|
1440
|
+
else if (typeof idSpec === 'object' && idSpec !== null) {
|
|
1441
|
+
const keyObj = {};
|
|
1442
|
+
for (const [k, expr] of Object.entries(idSpec)) {
|
|
1443
|
+
if (typeof expr === 'string' && expr.startsWith('$')) {
|
|
1444
|
+
keyObj[k] = this.getNestedValue(doc, expr.slice(1));
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
keyObj[k] = expr;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
groupKey = keyObj;
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
groupKey = idSpec;
|
|
1454
|
+
}
|
|
1455
|
+
const keyStr = JSON.stringify(groupKey);
|
|
1456
|
+
if (!groups.has(keyStr)) {
|
|
1457
|
+
groups.set(keyStr, []);
|
|
1458
|
+
}
|
|
1459
|
+
groups.get(keyStr).push(doc);
|
|
1460
|
+
}
|
|
1461
|
+
const results = [];
|
|
1462
|
+
for (const [keyStr, groupDocs] of groups) {
|
|
1463
|
+
const groupResult = { _id: JSON.parse(keyStr) };
|
|
1464
|
+
for (const [field, expr] of Object.entries(groupSpec)) {
|
|
1465
|
+
if (field === '_id')
|
|
1466
|
+
continue;
|
|
1467
|
+
if (typeof expr === 'object' && expr !== null) {
|
|
1468
|
+
const aggOp = Object.keys(expr)[0];
|
|
1469
|
+
const aggArg = expr[aggOp];
|
|
1470
|
+
switch (aggOp) {
|
|
1471
|
+
case '$sum': {
|
|
1472
|
+
if (typeof aggArg === 'number') {
|
|
1473
|
+
groupResult[field] = groupDocs.length * aggArg;
|
|
1474
|
+
}
|
|
1475
|
+
else if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1476
|
+
const fieldName = aggArg.slice(1);
|
|
1477
|
+
groupResult[field] = groupDocs.reduce((sum, doc) => {
|
|
1478
|
+
const val = this.getNestedValue(doc, fieldName);
|
|
1479
|
+
return sum + (typeof val === 'number' ? val : 0);
|
|
1480
|
+
}, 0);
|
|
1481
|
+
}
|
|
1482
|
+
break;
|
|
1483
|
+
}
|
|
1484
|
+
case '$avg': {
|
|
1485
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1486
|
+
const fieldName = aggArg.slice(1);
|
|
1487
|
+
const nums = groupDocs
|
|
1488
|
+
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1489
|
+
.filter((v) => typeof v === 'number');
|
|
1490
|
+
groupResult[field] = nums.length > 0 ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
|
|
1491
|
+
}
|
|
1492
|
+
break;
|
|
1493
|
+
}
|
|
1494
|
+
case '$min': {
|
|
1495
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1496
|
+
const fieldName = aggArg.slice(1);
|
|
1497
|
+
const vals = groupDocs
|
|
1498
|
+
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1499
|
+
.filter((v) => v !== undefined && v !== null);
|
|
1500
|
+
groupResult[field] = vals.length > 0 ? vals.reduce((a, b) => (a < b ? a : b)) : null;
|
|
1501
|
+
}
|
|
1502
|
+
break;
|
|
1503
|
+
}
|
|
1504
|
+
case '$max': {
|
|
1505
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1506
|
+
const fieldName = aggArg.slice(1);
|
|
1507
|
+
const vals = groupDocs
|
|
1508
|
+
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1509
|
+
.filter((v) => v !== undefined && v !== null);
|
|
1510
|
+
groupResult[field] = vals.length > 0 ? vals.reduce((a, b) => (a > b ? a : b)) : null;
|
|
1511
|
+
}
|
|
1512
|
+
break;
|
|
1513
|
+
}
|
|
1514
|
+
case '$push': {
|
|
1515
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1516
|
+
const fieldName = aggArg.slice(1);
|
|
1517
|
+
groupResult[field] = groupDocs.map((doc) => this.getNestedValue(doc, fieldName));
|
|
1518
|
+
}
|
|
1519
|
+
else {
|
|
1520
|
+
groupResult[field] = groupDocs.map(() => aggArg);
|
|
1521
|
+
}
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1524
|
+
case '$first': {
|
|
1525
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1526
|
+
const fieldName = aggArg.slice(1);
|
|
1527
|
+
groupResult[field] =
|
|
1528
|
+
groupDocs.length > 0 ? this.getNestedValue(groupDocs[0], fieldName) : null;
|
|
1529
|
+
}
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1532
|
+
case '$last': {
|
|
1533
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1534
|
+
const fieldName = aggArg.slice(1);
|
|
1535
|
+
groupResult[field] =
|
|
1536
|
+
groupDocs.length > 0
|
|
1537
|
+
? this.getNestedValue(groupDocs[groupDocs.length - 1], fieldName)
|
|
1538
|
+
: null;
|
|
1539
|
+
}
|
|
1540
|
+
break;
|
|
1541
|
+
}
|
|
1542
|
+
case '$count': {
|
|
1543
|
+
groupResult[field] = groupDocs.length;
|
|
1544
|
+
break;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
results.push(groupResult);
|
|
1550
|
+
}
|
|
1551
|
+
return results;
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Applies a $project stage to a document.
|
|
1555
|
+
* @private
|
|
1556
|
+
*/
|
|
1557
|
+
applyAggregateProjection(doc, projection) {
|
|
1558
|
+
if (Object.keys(projection).length === 0)
|
|
1559
|
+
return doc;
|
|
1560
|
+
// Computed/renamed fields (string values starting with '$') are aggregation-only semantics
|
|
1561
|
+
// that plain find() projections don't have, so they're layered on top of the shared
|
|
1562
|
+
// inclusion/exclusion mechanics rather than folded into it. They also count as an inclusion
|
|
1563
|
+
// for mode-determination purposes, matching real $project behavior.
|
|
1564
|
+
const modeInput = {};
|
|
1565
|
+
for (const [key, value] of Object.entries(projection)) {
|
|
1566
|
+
modeInput[key] = typeof value === 'string' && value.startsWith('$') ? 1 : value;
|
|
1567
|
+
}
|
|
1568
|
+
const mode = resolveProjectionMode(modeInput);
|
|
1569
|
+
const base = applyProjectionToDocument(doc, projection);
|
|
1570
|
+
if (mode !== 'include')
|
|
1571
|
+
return base;
|
|
1572
|
+
const result = { ...base };
|
|
1573
|
+
for (const [field, val] of Object.entries(projection)) {
|
|
1574
|
+
if (typeof val === 'string' && val.startsWith('$')) {
|
|
1575
|
+
result[field] = this.getNestedValue(doc, val.slice(1));
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return result;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Checks if a document matches a filter (for in-memory aggregation stages).
|
|
1582
|
+
* Uses the same logic as FindCursor but applied in JavaScript.
|
|
1583
|
+
* @private
|
|
1584
|
+
*/
|
|
1585
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1586
|
+
matchesFilter(doc, filter) {
|
|
1587
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
1588
|
+
if (key === '$and' && Array.isArray(value)) {
|
|
1589
|
+
if (!value.every((f) => this.matchesFilter(doc, f)))
|
|
1590
|
+
return false;
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
if (key === '$or' && Array.isArray(value)) {
|
|
1594
|
+
if (!value.some((f) => this.matchesFilter(doc, f)))
|
|
1595
|
+
return false;
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
if (key === '$nor' && Array.isArray(value)) {
|
|
1599
|
+
if (value.some((f) => this.matchesFilter(doc, f)))
|
|
1600
|
+
return false;
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
if (key.startsWith('$'))
|
|
1604
|
+
continue;
|
|
1605
|
+
const docValue = this.getNestedValue(doc, key);
|
|
1606
|
+
if (typeof value === 'object' && value !== null && !(value instanceof RegExp) && !(value instanceof Date)) {
|
|
1607
|
+
const ops = value;
|
|
1608
|
+
const hasOps = Object.keys(ops).some((k) => k.startsWith('$'));
|
|
1609
|
+
if (hasOps) {
|
|
1610
|
+
if (!this.matchesOperators(docValue, ops))
|
|
1611
|
+
return false;
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
// Simple equality (including Date comparison)
|
|
1616
|
+
if (value instanceof Date) {
|
|
1617
|
+
if (!(docValue instanceof Date) || docValue.getTime() !== value.getTime())
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
else if (Array.isArray(docValue)) {
|
|
1621
|
+
if (!docValue.some((el) => JSON.stringify(el) === JSON.stringify(value)))
|
|
1622
|
+
return false;
|
|
1623
|
+
}
|
|
1624
|
+
else if (JSON.stringify(docValue) !== JSON.stringify(value)) {
|
|
1625
|
+
return false;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Checks if a value matches a set of query operators (for in-memory filtering).
|
|
1632
|
+
* @private
|
|
1633
|
+
*/
|
|
1634
|
+
matchesOperators(docValue, ops) {
|
|
1635
|
+
return evaluateOperators(docValue, ops);
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Opens a change stream to watch for changes on this collection.
|
|
1639
|
+
* Returns a ChangeStream that emits events when documents are inserted, updated, or deleted.
|
|
1640
|
+
*
|
|
1641
|
+
* @param options Options for the change stream
|
|
1642
|
+
* @returns A ChangeStream instance
|
|
1643
|
+
*
|
|
1644
|
+
* @example
|
|
1645
|
+
* ```typescript
|
|
1646
|
+
* const changeStream = collection.watch();
|
|
1647
|
+
*
|
|
1648
|
+
* changeStream.on('change', (change) => {
|
|
1649
|
+
* console.log('Change detected:', change);
|
|
1650
|
+
* });
|
|
1651
|
+
*
|
|
1652
|
+
* // Or use async iteration
|
|
1653
|
+
* for await (const change of changeStream) {
|
|
1654
|
+
* console.log('Change detected:', change);
|
|
1655
|
+
* }
|
|
1656
|
+
*
|
|
1657
|
+
* // Close the change stream when done
|
|
1658
|
+
* changeStream.close();
|
|
1659
|
+
* ```
|
|
1660
|
+
*/
|
|
1661
|
+
watch(options = {}) {
|
|
1662
|
+
return new ChangeStream(this.db, this.name, options);
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Retries an operation with exponential backoff when SQLITE_BUSY errors occur.
|
|
1666
|
+
* @param operation The operation function to retry.
|
|
1667
|
+
* @param operationDescription A description of the operation for logging purposes.
|
|
1668
|
+
* @param maxRetries The maximum number of retry attempts (default: 5).
|
|
1669
|
+
* @param initialDelayMs The initial delay before the first retry in milliseconds (default: 100).
|
|
1670
|
+
* @param maxDelayMs The maximum delay between retries in milliseconds (default: 10000).
|
|
1671
|
+
* @returns The result of the operation once successful.
|
|
1672
|
+
* @private
|
|
1673
|
+
*/
|
|
1674
|
+
async retryWithBackoff(operation, operationDescription, maxRetries = 5, initialDelayMs = 100, maxDelayMs = 10000) {
|
|
1675
|
+
let retryCount = 0;
|
|
1676
|
+
let delayMs = initialDelayMs;
|
|
1677
|
+
while (true) {
|
|
1678
|
+
try {
|
|
1679
|
+
if (retryCount > 0) {
|
|
1680
|
+
console.warn(`Retry attempt ${retryCount}/${maxRetries} for ${operationDescription} after ${delayMs}ms delay...`);
|
|
1681
|
+
}
|
|
1682
|
+
return await operation();
|
|
1683
|
+
}
|
|
1684
|
+
catch (error) {
|
|
1685
|
+
if (error.code === 'SQLITE_BUSY' && retryCount < maxRetries) {
|
|
1686
|
+
retryCount++;
|
|
1687
|
+
// Wait using exponential backoff with jitter
|
|
1688
|
+
await new Promise((resolve) => {
|
|
1689
|
+
// Add some randomness (jitter) to prevent multiple clients from retrying simultaneously
|
|
1690
|
+
const jitter = Math.random() * 0.3 + 0.85; // Random factor between 0.85 and 1.15
|
|
1691
|
+
delayMs = Math.min(delayMs * 2 * jitter, maxDelayMs);
|
|
1692
|
+
setTimeout(resolve, delayMs);
|
|
1693
|
+
});
|
|
1694
|
+
// Continue to next iteration to retry
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
// If we've exhausted retries or it's a different error, rethrow
|
|
1698
|
+
if (retryCount >= maxRetries && error.code === 'SQLITE_BUSY') {
|
|
1699
|
+
console.error(`Maximum retries (${maxRetries}) reached for ${operationDescription}. Operation failed.`);
|
|
1700
|
+
}
|
|
1701
|
+
throw error;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
//# sourceMappingURL=collection.js.map
|