@semics-tech/mongolite 1.0.0 → 1.2.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.
Files changed (44) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +78 -2
  3. package/dist/adapters/node-sqlite.d.ts +65 -0
  4. package/dist/adapters/node-sqlite.js +241 -0
  5. package/dist/adapters/node-sqlite.js.map +1 -0
  6. package/dist/better-sqlite3.d.ts +49 -0
  7. package/dist/better-sqlite3.js +60 -0
  8. package/dist/better-sqlite3.js.map +1 -0
  9. package/dist/bin/mongolite-debug.js +0 -0
  10. package/dist/cjs/adapters/browser.js +106 -0
  11. package/dist/cjs/adapters/cloudflare.js +97 -0
  12. package/dist/cjs/adapters/node-sqlite.js +248 -0
  13. package/dist/cjs/better-sqlite3.js +80 -0
  14. package/dist/cjs/bin/mongolite-debug.js +152 -0
  15. package/dist/cjs/changeStream.js +341 -0
  16. package/dist/cjs/cloudflare.js +59 -0
  17. package/dist/cjs/collection.js +1748 -0
  18. package/dist/cjs/cursors/findCursor.js +837 -0
  19. package/dist/cjs/db.js +307 -0
  20. package/dist/cjs/debugger/query-debugger.js +297 -0
  21. package/dist/cjs/index.js +65 -0
  22. package/dist/cjs/mongo-client.js +58 -0
  23. package/dist/cjs/package.json +3 -0
  24. package/dist/cjs/types.js +2 -0
  25. package/dist/cjs/utils/connection-registry.js +122 -0
  26. package/dist/cjs/utils/indexing.js +45 -0
  27. package/dist/cjs/utils/projection.js +101 -0
  28. package/dist/cjs/utils/queryOperators.js +142 -0
  29. package/dist/collection.d.ts +25 -0
  30. package/dist/collection.js +53 -14
  31. package/dist/collection.js.map +1 -1
  32. package/dist/cursors/findCursor.d.ts +58 -0
  33. package/dist/cursors/findCursor.js +88 -11
  34. package/dist/cursors/findCursor.js.map +1 -1
  35. package/dist/db.d.ts +23 -0
  36. package/dist/db.js +85 -39
  37. package/dist/db.js.map +1 -1
  38. package/dist/index.d.ts +5 -2
  39. package/dist/index.js +6 -3
  40. package/dist/index.js.map +1 -1
  41. package/dist/utils/connection-registry.d.ts +74 -0
  42. package/dist/utils/connection-registry.js +117 -0
  43. package/dist/utils/connection-registry.js.map +1 -0
  44. package/package.json +20 -10
@@ -0,0 +1,837 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FindCursor = void 0;
4
+ const projection_js_1 = require("../utils/projection.js");
5
+ /**
6
+ * Matches compiled clauses that invoke SQLite's json1 functions, which are the
7
+ * ones that throw on malformed JSON. See `buildGuardedWhereClause`.
8
+ */
9
+ const JSON1_FUNCTION_PATTERN = /\bjson_(?:extract|each)\s*\(/i;
10
+ /**
11
+ * Safely parses JSON data with fallback mechanisms for malformed JSON.
12
+ * @param jsonString The JSON string to parse
13
+ * @param context Optional context for error messages and recovery
14
+ * @returns The parsed object or a fallback object
15
+ */
16
+ function safeJsonParse(jsonString, context) {
17
+ if (!jsonString || typeof jsonString !== 'string') {
18
+ console.warn(`Invalid JSON string${context ? ` in ${context}` : ''}: not a string or empty`);
19
+ return {};
20
+ }
21
+ try {
22
+ return JSON.parse(jsonString);
23
+ }
24
+ catch (error) {
25
+ // Log the error for debugging
26
+ console.error(`JSON parse error${context ? ` in ${context}` : ''}: ${error instanceof Error ? error.message : 'Unknown error'}`);
27
+ console.error(`Malformed JSON string: ${jsonString.substring(0, 500)}${jsonString.length > 500 ? '...' : ''}`);
28
+ // Attempt to recover from common JSON corruption issues
29
+ try {
30
+ // Try to fix common escaping issues
31
+ let fixedJson = jsonString;
32
+ // Fix double-escaped quotes
33
+ fixedJson = fixedJson.replace(/\\"/g, '"');
34
+ // Fix improperly escaped backslashes
35
+ fixedJson = fixedJson.replace(/\\\\/g, '\\');
36
+ // Try parsing the fixed JSON
37
+ const recovered = JSON.parse(fixedJson);
38
+ console.warn(`Successfully recovered malformed JSON${context ? ` in ${context}` : ''}`);
39
+ return recovered;
40
+ }
41
+ catch (recoveryError) {
42
+ console.error(`JSON recovery failed${context ? ` in ${context}` : ''}: ${recoveryError instanceof Error ? recoveryError.message : 'Unknown error'}`);
43
+ // Last resort: return an empty object with a special marker
44
+ return {
45
+ __mongoLiteCorrupted: true,
46
+ __originalData: jsonString,
47
+ __error: error instanceof Error ? error.message : 'Unknown JSON parse error',
48
+ };
49
+ }
50
+ }
51
+ }
52
+ /**
53
+ * Helper function to convert JavaScript values to SQLite-compatible values.
54
+ * This ensures that boolean values are converted to 0/1 integers that SQLite can handle.
55
+ * Date objects are converted to ISO strings for SQLite storage and comparison.
56
+ * @param value The value to convert
57
+ * @returns A SQLite-compatible value
58
+ */
59
+ function toSQLiteValue(value) {
60
+ if (typeof value === 'boolean') {
61
+ return value ? 1 : 0;
62
+ }
63
+ if (value instanceof Date) {
64
+ return value.toISOString();
65
+ }
66
+ return value;
67
+ }
68
+ /**
69
+ * Returns true for values that json_extract() returns as a nested JSON structure
70
+ * (plain objects/arrays) rather than as an unwrapped scalar. Date instances are
71
+ * scalars from SQLite's point of view (stored as ISO strings), not nested objects.
72
+ */
73
+ function isPlainObjectOrArray(value) {
74
+ return typeof value === 'object' && value !== null && !(value instanceof Date);
75
+ }
76
+ /**
77
+ * Builds a comparable value for a `json_extract(...) = ?` condition against an
78
+ * array element. json_extract() unwraps scalar leaf values (strings, numbers,
79
+ * booleans, ISO date strings) but returns nested objects/arrays as JSON text, so
80
+ * only those need to be JSON-stringified for comparison.
81
+ */
82
+ function toJsonExtractComparableValue(value) {
83
+ if (isPlainObjectOrArray(value)) {
84
+ return JSON.stringify(value);
85
+ }
86
+ return toSQLiteValue(value);
87
+ }
88
+ /**
89
+ * Helper function to recursively restore Date objects from JSON-parsed data.
90
+ * Converts ISO date strings back to Date objects.
91
+ * @param obj The object to process
92
+ * @returns The object with Date strings converted back to Date objects
93
+ */
94
+ function restoreDates(obj) {
95
+ if (obj === null || obj === undefined) {
96
+ return obj;
97
+ }
98
+ if (typeof obj === 'string') {
99
+ // Check if string is an ISO date string
100
+ const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
101
+ if (isoDateRegex.test(obj)) {
102
+ const date = new Date(obj);
103
+ // Verify it's a valid date and the string represents the same date
104
+ if (!isNaN(date.getTime()) && date.toISOString() === obj) {
105
+ return date;
106
+ }
107
+ }
108
+ return obj;
109
+ }
110
+ if (Array.isArray(obj)) {
111
+ return obj.map((item) => restoreDates(item));
112
+ }
113
+ if (typeof obj === 'object') {
114
+ const result = {};
115
+ for (const [key, value] of Object.entries(obj)) {
116
+ result[key] = restoreDates(value);
117
+ }
118
+ return result;
119
+ }
120
+ return obj;
121
+ }
122
+ /**
123
+ * Represents a cursor for find operations, allowing chaining of limit, skip, and sort.
124
+ */
125
+ class FindCursor {
126
+ constructor(db, collectionName, initialFilter, options = {}) {
127
+ this.db = db;
128
+ this.collectionName = collectionName;
129
+ this.options = options;
130
+ this.limitCount = null;
131
+ this.skipCount = null;
132
+ this.sortCriteria = null;
133
+ this.projectionFields = null;
134
+ this.queryParts = this.buildSelectQuery(initialFilter);
135
+ }
136
+ parseJsonPath(path) {
137
+ return `'$.${path.replace(/\./g, '.')}'`;
138
+ }
139
+ /**
140
+ * Helper method to build $in and $nin conditions for both array and non-array fields
141
+ */
142
+ buildArrayComparisonCondition(field, value, operator, params, elementPrefix = 'data') {
143
+ const jsonPath = elementPrefix === 'data'
144
+ ? `json_extract(${elementPrefix}, ${this.parseJsonPath(field)})`
145
+ : `json_extract(${elementPrefix}.value, '$.${field}')`;
146
+ // For Date values, use simple comparison without json_each to avoid JSON serialization issues
147
+ const hasDate = value.some((v) => v instanceof Date);
148
+ if (hasDate) {
149
+ const operator_symbol = operator === '$in' ? '=' : '!=';
150
+ const joiner = operator === '$in' ? ' OR ' : ' AND ';
151
+ const conditions = value.map(() => `${jsonPath} ${operator_symbol} ?`).join(joiner);
152
+ params.push(...value.map((v) => toSQLiteValue(v)));
153
+ if (operator === '$in') {
154
+ return `(${conditions})`;
155
+ }
156
+ else {
157
+ // For $nin with non-array fields, also include NULL
158
+ return `(${jsonPath} IS NULL OR ${conditions})`;
159
+ }
160
+ }
161
+ // For non-Date values, handle both array and non-array fields
162
+ const arrayPath = this.parseJsonPath(field);
163
+ const isArrayCheck = this.buildIsArrayCheck(elementPrefix, arrayPath);
164
+ const eachArg = this.buildJsonEachArg(elementPrefix, arrayPath);
165
+ const placeholders = value.map(() => '?').join(',');
166
+ let arrayCondition;
167
+ let nonArrayCondition;
168
+ if (operator === '$in') {
169
+ arrayCondition = `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE json_each.value IN (${placeholders}))`;
170
+ nonArrayCondition = `(${jsonPath} IN (${placeholders}))`;
171
+ }
172
+ else {
173
+ // $nin
174
+ arrayCondition = `NOT EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE json_each.value IN (${placeholders}))`;
175
+ nonArrayCondition = `(${jsonPath} NOT IN (${placeholders}) OR ${jsonPath} IS NULL)`;
176
+ }
177
+ // Push parameters for both array and non-array conditions
178
+ params.push(...value.map((v) => toSQLiteValue(v))); // for array condition
179
+ params.push(...value.map((v) => toSQLiteValue(v))); // for non-array condition
180
+ return `(
181
+ (${isArrayCheck} AND ${arrayCondition}) OR
182
+ (${this.buildIsNotArrayCheck(elementPrefix, arrayPath)} AND ${nonArrayCondition})
183
+ )`;
184
+ }
185
+ /**
186
+ * SQL testing whether the value at `arrayPath` is a JSON array.
187
+ *
188
+ * Uses `json_type`'s two-argument form. The one-argument form —
189
+ * `json_type(json_extract(root, path))` — re-parses the *extracted value* as
190
+ * JSON, which raises `malformed JSON` for any plain string field: for
191
+ * `{"role":"admin"}` `json_extract` yields the bare text `admin`, and
192
+ * `json_type('admin')` is not valid JSON. That made `$in`/`$nin`/`$all`/
193
+ * `$elemMatch` throw on every non-array field, on healthy data.
194
+ *
195
+ * The two-argument form also classifies correctly where the one-argument form
196
+ * lied: a string field holding `"[1,2]"` reported as `array` rather than `text`.
197
+ */
198
+ buildIsArrayCheck(root, arrayPath) {
199
+ return `(json_valid(${root}) AND json_type(${root}, ${arrayPath}) = 'array')`;
200
+ }
201
+ /**
202
+ * Negation of {@link buildIsArrayCheck} — but not just `!=`. When `arrayPath` doesn't exist
203
+ * in the document, `json_type` returns SQL NULL, and `NULL != 'array'` is itself NULL (falsy),
204
+ * not TRUE. That silently dropped missing-field documents from the "not array" branch of
205
+ * $in/$nin/$all, even though they're exactly the case `IS NULL` handling downstream expects to
206
+ * see. `IS NOT` treats NULL as distinct from 'array', so missing fields correctly count as
207
+ * "not an array" here.
208
+ */
209
+ buildIsNotArrayCheck(root, arrayPath) {
210
+ return `(json_valid(${root}) AND json_type(${root}, ${arrayPath}) IS NOT 'array')`;
211
+ }
212
+ /**
213
+ * Builds a safe argument for `json_each(...)`.
214
+ *
215
+ * SQLite evaluates a table-valued function inside a correlated subquery even
216
+ * when a preceding `AND` term (such as the is-array check) is false, so
217
+ * guarding *outside* the call isn't enough — `json_each('admin')` still runs
218
+ * and throws. The guard therefore has to live inside the argument: anything
219
+ * that isn't a valid JSON array becomes an empty array, which yields no rows.
220
+ */
221
+ buildJsonEachArg(root, arrayPath) {
222
+ return `CASE WHEN json_valid(${root}) AND json_type(${root}, ${arrayPath}) = 'array' THEN json_extract(${root}, ${arrayPath}) ELSE '[]' END`;
223
+ }
224
+ /**
225
+ * Helper method to generate a comparison condition
226
+ */
227
+ buildComparisonCondition(field, operator, value, params, elementPrefix = 'data') {
228
+ // Handle _id specially as it's a column, not in the JSON
229
+ if (field === '_id' && elementPrefix === 'data') {
230
+ switch (operator) {
231
+ case '$eq':
232
+ params.push(toSQLiteValue(value));
233
+ return '_id = ?';
234
+ case '$ne':
235
+ params.push(toSQLiteValue(value));
236
+ return '_id <> ?';
237
+ case '$in':
238
+ if (Array.isArray(value) && value.length > 0) {
239
+ // Convert each value to SQLite-compatible type
240
+ params.push(...value.map((v) => toSQLiteValue(v)));
241
+ return `_id IN (${value.map(() => '?').join(',')})`;
242
+ }
243
+ return '1=0'; // Empty array, nothing matches
244
+ case '$nin':
245
+ if (Array.isArray(value) && value.length > 0) {
246
+ // Convert each value to SQLite-compatible type
247
+ params.push(...value.map((v) => toSQLiteValue(v)));
248
+ return `_id NOT IN (${value.map(() => '?').join(',')})`;
249
+ }
250
+ return '1=1'; // Empty array, everything matches
251
+ default:
252
+ // Handle other operators that might be applied to _id
253
+ return '1=0'; // Unsupported operator for _id
254
+ }
255
+ }
256
+ // For JSON fields
257
+ const jsonPath = elementPrefix === 'data'
258
+ ? `json_extract(${elementPrefix}, ${this.parseJsonPath(field)})`
259
+ : `json_extract(${elementPrefix}.value, '$.${field}')`;
260
+ switch (operator) {
261
+ case '$eq':
262
+ if (value === null) {
263
+ return `${jsonPath} IS NULL`;
264
+ }
265
+ // Handle Date objects with exact equality
266
+ if (value instanceof Date) {
267
+ params.push(toSQLiteValue(value));
268
+ return `${jsonPath} = ?`;
269
+ }
270
+ // Handle string matching for equality and array matching as par mongodb client
271
+ params.push(toSQLiteValue(value));
272
+ params.push(toSQLiteValue(value));
273
+ return `(${jsonPath} = ? OR ${jsonPath} LIKE '%' || ? || '%')`; // For string matching
274
+ case '$ne':
275
+ if (value === null) {
276
+ return `${jsonPath} IS NOT NULL`;
277
+ }
278
+ params.push(toSQLiteValue(value));
279
+ // In MongoDB, $ne matches documents where the field doesn't exist OR has a different value
280
+ // SQLite's NULL != ? returns NULL (falsy), so we need to explicitly include NULL
281
+ return `(${jsonPath} IS NULL OR ${jsonPath} != ?)`;
282
+ case '$gt':
283
+ params.push(toSQLiteValue(value));
284
+ return `${jsonPath} > ?`;
285
+ case '$gte':
286
+ params.push(toSQLiteValue(value));
287
+ return `${jsonPath} >= ?`;
288
+ case '$lt':
289
+ params.push(toSQLiteValue(value));
290
+ return `${jsonPath} < ?`;
291
+ case '$lte':
292
+ params.push(toSQLiteValue(value));
293
+ return `${jsonPath} <= ?`;
294
+ case '$exists':
295
+ return value ? `${jsonPath} IS NOT NULL` : `${jsonPath} IS NULL`;
296
+ case '$in':
297
+ if (Array.isArray(value) && value.length > 0) {
298
+ return this.buildArrayComparisonCondition(field, value, '$in', params, elementPrefix);
299
+ }
300
+ return '1=0'; // Empty array, nothing matches
301
+ case '$nin':
302
+ if (Array.isArray(value) && value.length > 0) {
303
+ return this.buildArrayComparisonCondition(field, value, '$nin', params, elementPrefix);
304
+ }
305
+ return '1=1'; // Empty array, everything matches
306
+ case '$options':
307
+ // Consumed by $regex handling in buildWhereClause; treat standalone $options as unsupported
308
+ return '1=0';
309
+ case '$regex': {
310
+ // When called from buildComparisonCondition directly (no $options context),
311
+ // extract flags from a RegExp value if provided
312
+ let src;
313
+ let flags = '';
314
+ if (value instanceof RegExp) {
315
+ src = value.source;
316
+ flags = value.flags;
317
+ }
318
+ else if (typeof value === 'string') {
319
+ src = value;
320
+ }
321
+ else {
322
+ return '1=0';
323
+ }
324
+ const textExpr = `CAST(${jsonPath} AS TEXT)`;
325
+ if (flags) {
326
+ params.push(src, flags);
327
+ return `regexp_flags(?, ?, ${textExpr}) = 1`;
328
+ }
329
+ else {
330
+ params.push(src);
331
+ return `regexp(?, ${textExpr}) = 1`;
332
+ }
333
+ }
334
+ case '$size':
335
+ // Matches arrays with exactly N elements
336
+ params.push(value);
337
+ return `json_array_length(${jsonPath}) = ?`;
338
+ case '$type': {
339
+ // Map BSON type numbers/names to SQLite json_type() values
340
+ const mapBsonType = (bsonType) => {
341
+ const typeMap = {
342
+ 1: ['real', 'integer'],
343
+ 2: ['text'],
344
+ 3: ['object'],
345
+ 4: ['array'],
346
+ 8: ['true', 'false'],
347
+ 9: ['text'],
348
+ 10: ['null'],
349
+ 16: ['integer'],
350
+ 18: ['integer'],
351
+ double: ['real', 'integer'],
352
+ string: ['text'],
353
+ object: ['object'],
354
+ array: ['array'],
355
+ bool: ['true', 'false'],
356
+ boolean: ['true', 'false'],
357
+ date: ['text'],
358
+ null: ['null'],
359
+ int: ['integer'],
360
+ long: ['integer'],
361
+ number: ['real', 'integer'],
362
+ };
363
+ return typeMap[bsonType] ?? [];
364
+ };
365
+ const typeArgs = Array.isArray(value) ? value : [value];
366
+ const allSqlTypes = typeArgs.flatMap((t) => mapBsonType(t));
367
+ const uniqueTypes = [...new Set(allSqlTypes)];
368
+ if (uniqueTypes.length === 0)
369
+ return '1=0';
370
+ // Use json_type(json, path) form which correctly identifies type of JSON values
371
+ const typePath = elementPrefix === 'data'
372
+ ? `json_type(${elementPrefix}, '$.${field}')`
373
+ : `json_type(${elementPrefix}.value, '$.${field}')`;
374
+ const typeConds = uniqueTypes.map(() => `${typePath} = ?`).join(' OR ');
375
+ params.push(...uniqueTypes);
376
+ return uniqueTypes.length === 1 ? `${typePath} = ?` : `(${typeConds})`;
377
+ }
378
+ case '$mod': {
379
+ // Matches where field % divisor === remainder
380
+ if (Array.isArray(value) && value.length === 2) {
381
+ params.push(value[0], value[1]);
382
+ return `(CAST(${jsonPath} AS INTEGER) % ? = ?)`;
383
+ }
384
+ return '1=0';
385
+ }
386
+ default:
387
+ return '1=0'; // Unsupported operator
388
+ }
389
+ }
390
+ /**
391
+ * Builds a regex condition with optional flags from $options.
392
+ */
393
+ buildRegexCondition(field, value, options, params, elementPrefix = 'data') {
394
+ let src;
395
+ let flags = typeof options === 'string' ? options : '';
396
+ if (value instanceof RegExp) {
397
+ src = value.source;
398
+ flags = value.flags || flags;
399
+ }
400
+ else if (typeof value === 'string') {
401
+ src = value;
402
+ }
403
+ else {
404
+ return '1=0';
405
+ }
406
+ const jsonPath = elementPrefix === 'data'
407
+ ? `json_extract(${elementPrefix}, ${this.parseJsonPath(field)})`
408
+ : `json_extract(${elementPrefix}.value, '$.${field}')`;
409
+ const textExpr = `CAST(${jsonPath} AS TEXT)`;
410
+ if (flags) {
411
+ params.push(src, flags);
412
+ return `regexp_flags(?, ?, ${textExpr}) = 1`;
413
+ }
414
+ else {
415
+ params.push(src);
416
+ return `regexp(?, ${textExpr}) = 1`;
417
+ }
418
+ }
419
+ /**
420
+ * Builds a subquery for array operations like $all and $elemMatch
421
+ */
422
+ buildArraySubquery(field, operator, value, params) {
423
+ const arrayPath = this.parseJsonPath(field);
424
+ const arrayTypeCheck = this.buildIsArrayCheck('data', arrayPath);
425
+ const eachArg = this.buildJsonEachArg('data', arrayPath);
426
+ if (operator === '$all') {
427
+ if (Array.isArray(value) && value.length > 0) {
428
+ const allConditions = value
429
+ .map((item) => {
430
+ if (typeof item === 'object' &&
431
+ item !== null &&
432
+ !Array.isArray(item) &&
433
+ !(item instanceof Date)) {
434
+ // For object elements, we need to check if any array element matches all properties
435
+ const objectConditions = [];
436
+ for (const [key, val] of Object.entries(item)) {
437
+ // For nested objects in arrays, we need to match the full object
438
+ params.push(toJsonExtractComparableValue(val));
439
+ objectConditions.push(`json_extract(json_each.value, '$.${key}') = ?`);
440
+ }
441
+ const condition = objectConditions.join(' AND ');
442
+ return `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE ${condition})`;
443
+ }
444
+ else {
445
+ // For simple values, use direct comparison
446
+ params.push(toSQLiteValue(item));
447
+ return `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE json_each.value = ?)`;
448
+ }
449
+ })
450
+ .join(' AND ');
451
+ return `(${arrayTypeCheck} AND ${allConditions})`;
452
+ }
453
+ return '1=0'; // Empty $all array, nothing matches
454
+ }
455
+ else if (operator === '$in') {
456
+ // Handle $in with array values that may contain objects or scalars
457
+ if (Array.isArray(value) && value.length > 0) {
458
+ const hasObjects = value.some((v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date));
459
+ if (hasObjects) {
460
+ // When $in has objects, match if any array element matches any of the objects
461
+ const orConditions = value
462
+ .map((item) => {
463
+ if (typeof item === 'object' &&
464
+ item !== null &&
465
+ !Array.isArray(item) &&
466
+ !(item instanceof Date)) {
467
+ // For object elements in $in, check if array element matches all properties
468
+ const objectConditions = [];
469
+ for (const [key, val] of Object.entries(item)) {
470
+ params.push(toJsonExtractComparableValue(val));
471
+ objectConditions.push(`json_extract(json_each.value, '$.${key}') = ?`);
472
+ }
473
+ const condition = objectConditions.join(' AND ');
474
+ return `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE ${condition})`;
475
+ }
476
+ else {
477
+ // For scalar values
478
+ params.push(toSQLiteValue(item));
479
+ return `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE json_each.value = ?)`;
480
+ }
481
+ })
482
+ .join(' OR ');
483
+ return `(${arrayTypeCheck} AND (${orConditions}))`;
484
+ }
485
+ else {
486
+ // All scalar values - use the existing logic for scalar comparison
487
+ return this.buildArrayComparisonCondition(field, value, '$in', params);
488
+ }
489
+ }
490
+ return '1=0'; // Empty $in array, nothing matches
491
+ }
492
+ else if (operator === '$elemMatch') {
493
+ let elemMatchSubquery = `EXISTS (
494
+ SELECT 1
495
+ FROM json_each(${eachArg}) as array_elements
496
+ WHERE `;
497
+ if (typeof value === 'object' && value !== null) {
498
+ const conditions = this.buildObjectConditions(value, params, 'array_elements');
499
+ elemMatchSubquery += conditions;
500
+ }
501
+ else {
502
+ // Simple equality check for the entire element
503
+ params.push(toSQLiteValue(value));
504
+ elemMatchSubquery += `array_elements.value = ?`;
505
+ }
506
+ elemMatchSubquery += ')';
507
+ return `${arrayTypeCheck} AND ${elemMatchSubquery}`;
508
+ }
509
+ return '1=0'; // Unsupported array operator
510
+ }
511
+ /**
512
+ * Builds conditions for an object with potentially nested operators
513
+ */
514
+ buildObjectConditions(obj, params, elementPrefix = 'data') {
515
+ const conditions = [];
516
+ for (const [key, value] of Object.entries(obj)) {
517
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
518
+ // Check if this is an operator object
519
+ const operatorKeys = Object.keys(value);
520
+ const isOperatorObject = operatorKeys.every((k) => k.startsWith('$'));
521
+ if (isOperatorObject) {
522
+ // This is an object with operators like { age: { $gt: 30 } }
523
+ const opConditions = operatorKeys.map((op) => this.buildComparisonCondition(key, op, value[op], params, elementPrefix));
524
+ conditions.push(`(${opConditions.join(' AND ')})`);
525
+ }
526
+ else {
527
+ // This is a nested object, handle recursively
528
+ // In this implementation, we'd need to handle dot notation
529
+ // which might require more complex logic
530
+ conditions.push('1=0'); // Placeholder for nested object handling
531
+ }
532
+ }
533
+ else {
534
+ // Simple equality
535
+ conditions.push(this.buildComparisonCondition(key, '$eq', value, params, elementPrefix));
536
+ }
537
+ }
538
+ return conditions.length > 0 ? conditions.join(' AND ') : '1=1';
539
+ }
540
+ /**
541
+ * Builds the SQL condition(s) for a set of operators applied to one field, e.g.
542
+ * `{ age: { $gt: 30, $lt: 50 } }`. Recurses for `$not`, so a negated operator set on the same
543
+ * field (`{ age: { $not: { $gt: 30 } } }`) gets wrapped in `NOT (...)` rather than falling
544
+ * through to buildComparisonCondition's unsupported-operator default.
545
+ */
546
+ buildFieldOperatorConditions(field, ops, params) {
547
+ const regexOptions = ops['$options'];
548
+ return Object.entries(ops)
549
+ .filter(([op]) => op !== '$options') // $options is consumed alongside $regex
550
+ .map(([op, opValue]) => {
551
+ if (op === '$not') {
552
+ return `NOT (${this.buildFieldOperatorConditions(field, opValue, params)})`;
553
+ }
554
+ if (op === '$elemMatch' || op === '$all' || op === '$in') {
555
+ // Check if $in has objects that need array subquery handling
556
+ if (op === '$in' &&
557
+ Array.isArray(opValue) &&
558
+ opValue.some((v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date))) {
559
+ return this.buildArraySubquery(field, op, opValue, params);
560
+ }
561
+ if (op === '$elemMatch' || op === '$all') {
562
+ return this.buildArraySubquery(field, op, opValue, params);
563
+ }
564
+ }
565
+ if (op === '$regex') {
566
+ return this.buildRegexCondition(field, opValue, regexOptions, params);
567
+ }
568
+ return this.buildComparisonCondition(field, op, opValue, params);
569
+ })
570
+ .join(' AND ');
571
+ }
572
+ buildWhereClause(filter, params) {
573
+ const conditions = [];
574
+ for (const key of Object.keys(filter)) {
575
+ const value = filter[key];
576
+ // Handle logical operators
577
+ if (key === '$and' && filter.$and) {
578
+ const andConditions = filter.$and
579
+ .map((subFilter) => `(${this.buildWhereClause(subFilter, params)})`)
580
+ .join(' AND ');
581
+ conditions.push(`(${andConditions})`);
582
+ }
583
+ else if (key === '$or' && filter.$or) {
584
+ const orConditions = filter.$or
585
+ .map((subFilter) => `(${this.buildWhereClause(subFilter, params)})`)
586
+ .join(' OR ');
587
+ conditions.push(`(${orConditions})`);
588
+ }
589
+ else if (key === '$nor' && filter.$nor) {
590
+ const norConditions = filter.$nor
591
+ .map((subFilter) => `(${this.buildWhereClause(subFilter, params)})`)
592
+ .join(' OR ');
593
+ conditions.push(`NOT (${norConditions})`);
594
+ }
595
+ else if (key === '$not' && filter.$not) {
596
+ // Create a nested filter with the $not contents
597
+ const nestedFilter = {
598
+ [Object.keys(filter.$not)[0]]: filter.$not[Object.keys(filter.$not)[0]],
599
+ };
600
+ const notClause = this.buildWhereClause(nestedFilter, params);
601
+ conditions.push(`NOT (${notClause})`);
602
+ }
603
+ // Handle text search
604
+ else if (key === '$text' && filter.$text) {
605
+ const textSearch = filter.$text;
606
+ const search = textSearch.$search;
607
+ if (typeof search === 'string' && search.trim() !== '') {
608
+ conditions.push(`data LIKE ?`);
609
+ params.push(`%${search}%`); // Simple LIKE search
610
+ }
611
+ else {
612
+ conditions.push('1=0'); // No valid search term, nothing matches
613
+ }
614
+ }
615
+ // Handle field existence checks
616
+ else if (key === '$exists' && filter.$exists) {
617
+ const existsConditions = Object.entries(filter.$exists)
618
+ .map(([field, exists]) => this.buildComparisonCondition(field, '$exists', exists, params))
619
+ .join(' AND ');
620
+ conditions.push(`(${existsConditions})`);
621
+ }
622
+ // Handle array operations
623
+ else if ((key === '$all' || key === '$elemMatch') && filter[key]) {
624
+ const arrayConditions = Object.entries(filter[key])
625
+ .map(([field, value]) => this.buildArraySubquery(field, key, value, params))
626
+ .join(' AND ');
627
+ conditions.push(`(${arrayConditions})`);
628
+ }
629
+ // Handle regular field conditions
630
+ else if (!key.startsWith('$')) {
631
+ // It's a field path
632
+ if (key === '_id') {
633
+ if (typeof value === 'string') {
634
+ conditions.push(this.buildComparisonCondition('_id', '$eq', value, params));
635
+ }
636
+ else if (typeof value === 'object' && value !== null) {
637
+ // Handle operators on _id
638
+ const opConditions = Object.entries(value)
639
+ .map(([op, opValue]) => this.buildComparisonCondition('_id', op, opValue, params))
640
+ .join(' AND ');
641
+ conditions.push(`(${opConditions})`);
642
+ }
643
+ }
644
+ else {
645
+ // Regular field with JSON path
646
+ if (value instanceof Date) {
647
+ // Handle Date objects for simple equality
648
+ const jsonPath = this.parseJsonPath(key);
649
+ conditions.push(`json_extract(data, ${jsonPath}) = ?`);
650
+ params.push(toSQLiteValue(value));
651
+ }
652
+ else if (value instanceof RegExp) {
653
+ // Support bare RegExp values: { field: /pattern/i }
654
+ conditions.push(this.buildRegexCondition(key, value, value.flags, params));
655
+ }
656
+ else if (typeof value === 'object' && value !== null) {
657
+ // Check if any key is an operator
658
+ const hasOperators = Object.keys(value).some((k) => k.startsWith('$'));
659
+ if (hasOperators) {
660
+ conditions.push(`(${this.buildFieldOperatorConditions(key, value, params)})`);
661
+ }
662
+ else {
663
+ // This is an object equality check (exact match)
664
+ const jsonPath = this.parseJsonPath(key);
665
+ conditions.push(`json_extract(data, ${jsonPath}) = ?`);
666
+ params.push(JSON.stringify(value));
667
+ }
668
+ }
669
+ else {
670
+ // Simple equality check
671
+ conditions.push(this.buildComparisonCondition(key, '$eq', value, params));
672
+ }
673
+ }
674
+ }
675
+ }
676
+ return conditions.length > 0 ? conditions.join(' AND ') : '1=1';
677
+ }
678
+ /**
679
+ * Wraps a compiled WHERE clause so that a single corrupted row can't take out
680
+ * every filtered query on the collection.
681
+ *
682
+ * SQLite's json1 functions (`json_extract`, `json_each`) raise
683
+ * `SqliteError: malformed JSON` as soon as they evaluate a row whose `data`
684
+ * column isn't valid JSON — including rows that would never have matched the
685
+ * filter anyway. One bad document therefore fails *all* filtered reads against
686
+ * that collection, not just the ones that would have returned it.
687
+ *
688
+ * Prefixing with `json_valid(data)` lets SQLite short-circuit those rows before
689
+ * any json1 function touches them, so corruption degrades to "this document is
690
+ * missing from results" rather than "this collection is unqueryable". Use
691
+ * {@link MongoLiteCollection.findInvalidJson} to find and repair the offenders.
692
+ *
693
+ * Applied only at the top level, and only when json1 functions are genuinely
694
+ * present, both deliberately:
695
+ * - `$not`/`$nor` compile their sub-clauses into `NOT (...)`. A guard nested
696
+ * inside one would invert to `NOT (0 AND …)` — making corrupt rows *match* a
697
+ * negated filter.
698
+ * - An empty filter compiles to `1=1`, touches no json1 function, and so can't
699
+ * throw. Leaving it unguarded keeps `deleteMany({})` able to purge corrupt
700
+ * rows, which is the escape hatch for cleaning them up.
701
+ */
702
+ buildGuardedWhereClause(filter, params) {
703
+ const whereClause = this.buildWhereClause(filter, params);
704
+ if (!JSON1_FUNCTION_PATTERN.test(whereClause)) {
705
+ return whereClause;
706
+ }
707
+ return `json_valid(data) AND (${whereClause})`;
708
+ }
709
+ buildSelectQuery(filter) {
710
+ const params = [];
711
+ const whereClause = this.buildGuardedWhereClause(filter, params);
712
+ const sql = `SELECT _id, data FROM "${this.collectionName}" WHERE ${whereClause}`;
713
+ if (this.options.verbose) {
714
+ console.log(`Building SQL query for collection "${this.collectionName}"`);
715
+ console.log(`Filter: ${JSON.stringify(filter)}`);
716
+ console.log(`Where Clause: ${whereClause}`);
717
+ console.log(`SQL Query: ${sql}`);
718
+ console.log(`Parameters: ${JSON.stringify(params)}`);
719
+ }
720
+ return { sql, params };
721
+ }
722
+ /**
723
+ * Specifies the maximum number of documents the cursor will return.
724
+ * @param count The number of documents to limit to.
725
+ * @returns The `FindCursor` instance for chaining.
726
+ */
727
+ limit(count) {
728
+ if (count < 0)
729
+ throw new Error('Limit must be a non-negative number.');
730
+ this.limitCount = count;
731
+ return this;
732
+ }
733
+ /**
734
+ * Specifies the number of documents to skip.
735
+ * @param count The number of documents to skip.
736
+ * @returns The `FindCursor` instance for chaining.
737
+ */
738
+ skip(count) {
739
+ if (count < 0)
740
+ throw new Error('Skip must be a non-negative number.');
741
+ this.skipCount = count;
742
+ return this;
743
+ }
744
+ /**
745
+ * Specifies the sorting order for the documents.
746
+ * @param sortCriteria An object defining sort order (e.g., `{ age: -1, name: 1 }`).
747
+ * @returns The `FindCursor` instance for chaining.
748
+ */
749
+ sort(sortCriteria) {
750
+ this.sortCriteria = sortCriteria;
751
+ return this;
752
+ }
753
+ /**
754
+ * Specifies the fields to return (projection).
755
+ * @param projection An object where keys are field names and values are 1 (include) or 0 (exclude).
756
+ * `_id` is included by default unless explicitly excluded.
757
+ * @returns The `FindCursor` instance for chaining.
758
+ * @remarks When using projections, the returned documents may have undefined fields at runtime,
759
+ * though the TypeScript type will still be `T`. Consumers should be aware that projected-out
760
+ * fields will be undefined.
761
+ */
762
+ project(projection) {
763
+ this.projectionFields = projection;
764
+ return this;
765
+ }
766
+ /**
767
+ * Applies projection to a document, returning only the specified fields.
768
+ * @param doc The full document to apply projection to
769
+ * @returns The projected document. When a projection is applied, some fields may be undefined
770
+ * at runtime despite the return type being `T`. This trade-off provides better developer
771
+ * experience for the common case (no projection) while maintaining type compatibility.
772
+ * @private
773
+ */
774
+ applyProjection(doc) {
775
+ return (0, projection_js_1.applyProjectionToDocument)(doc, this.projectionFields);
776
+ }
777
+ /**
778
+ * Executes the query and returns all matching documents as an array.
779
+ * @returns A promise that resolves to an array of documents.
780
+ */
781
+ async toArray() {
782
+ let finalSql = this.queryParts.sql;
783
+ const finalParams = [...this.queryParts.params];
784
+ if (this.sortCriteria) {
785
+ const sortClauses = Object.entries(this.sortCriteria).map(([field, order]) => {
786
+ if (field === '_id') {
787
+ return `_id ${order === 1 ? 'ASC' : 'DESC'}`;
788
+ }
789
+ return `json_extract(data, ${this.parseJsonPath(field)}) ${order === 1 ? 'ASC' : 'DESC'}`;
790
+ });
791
+ if (sortClauses.length > 0) {
792
+ finalSql += ` ORDER BY ${sortClauses.join(', ')}`;
793
+ }
794
+ }
795
+ if (this.limitCount !== null) {
796
+ finalSql += ` LIMIT ?`;
797
+ finalParams.push(this.limitCount);
798
+ }
799
+ if (this.skipCount !== null) {
800
+ if (this.limitCount === null) {
801
+ // SQLite requires a LIMIT if OFFSET is used.
802
+ // Use a very large number if no limit is specified.
803
+ finalSql += ` LIMIT -1`; // Or a large number like 999999999
804
+ }
805
+ finalSql += ` OFFSET ?`;
806
+ finalParams.push(this.skipCount);
807
+ }
808
+ // Handle malformed json in the database
809
+ const rows = await this.db.all(finalSql, finalParams);
810
+ return rows.map((row) => {
811
+ const parsedData = safeJsonParse(row.data, `findCursor toArray for document ${row._id}`);
812
+ const restoredData = restoreDates(parsedData);
813
+ const doc = { _id: row._id, ...restoredData };
814
+ return this.applyProjection(doc);
815
+ });
816
+ }
817
+ /**
818
+ * Executes the query and returns the first matching document.
819
+ * @returns A promise that resolves to the first matching document or null if no matches are found.
820
+ * @throws Error if the query fails.
821
+ */
822
+ async first() {
823
+ const results = await this.limit(1).toArray();
824
+ return results.length > 0 ? results[0] : null;
825
+ }
826
+ /**
827
+ * Executes the query and returns a count of matching documents.
828
+ * @returns A promise that resolves to the count of matching documents.
829
+ */
830
+ async count() {
831
+ const whereClause = this.buildWhereClause({}, []);
832
+ const countSql = `SELECT COUNT(*) as count FROM "${this.collectionName}" WHERE ${whereClause}`;
833
+ const result = await this.db.get(countSql);
834
+ return result?.count ?? 0; // Return 0 if result is undefined
835
+ }
836
+ }
837
+ exports.FindCursor = FindCursor;