@semics-tech/mongolite 1.1.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.
- package/README.md +40 -2
- package/dist/adapters/node-sqlite.d.ts +4 -0
- package/dist/adapters/node-sqlite.js +80 -35
- package/dist/adapters/node-sqlite.js.map +1 -1
- package/dist/cjs/adapters/node-sqlite.js +84 -35
- package/dist/cjs/collection.js +53 -14
- package/dist/cjs/cursors/findCursor.js +88 -11
- package/dist/cjs/db.js +87 -40
- package/dist/cjs/utils/connection-registry.js +122 -0
- package/dist/collection.d.ts +25 -0
- package/dist/collection.js +53 -14
- package/dist/collection.js.map +1 -1
- package/dist/cursors/findCursor.d.ts +58 -0
- package/dist/cursors/findCursor.js +88 -11
- package/dist/cursors/findCursor.js.map +1 -1
- package/dist/db.d.ts +23 -0
- package/dist/db.js +85 -39
- package/dist/db.js.map +1 -1
- package/dist/utils/connection-registry.d.ts +74 -0
- package/dist/utils/connection-registry.js +117 -0
- package/dist/utils/connection-registry.js.map +1 -0
- package/package.json +1 -1
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FindCursor = void 0;
|
|
4
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;
|
|
5
10
|
/**
|
|
6
11
|
* Safely parses JSON data with fallback mechanisms for malformed JSON.
|
|
7
12
|
* @param jsonString The JSON string to parse
|
|
@@ -155,17 +160,18 @@ class FindCursor {
|
|
|
155
160
|
}
|
|
156
161
|
// For non-Date values, handle both array and non-array fields
|
|
157
162
|
const arrayPath = this.parseJsonPath(field);
|
|
158
|
-
const isArrayCheck =
|
|
163
|
+
const isArrayCheck = this.buildIsArrayCheck(elementPrefix, arrayPath);
|
|
164
|
+
const eachArg = this.buildJsonEachArg(elementPrefix, arrayPath);
|
|
159
165
|
const placeholders = value.map(() => '?').join(',');
|
|
160
166
|
let arrayCondition;
|
|
161
167
|
let nonArrayCondition;
|
|
162
168
|
if (operator === '$in') {
|
|
163
|
-
arrayCondition = `EXISTS (SELECT 1 FROM json_each(
|
|
169
|
+
arrayCondition = `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE json_each.value IN (${placeholders}))`;
|
|
164
170
|
nonArrayCondition = `(${jsonPath} IN (${placeholders}))`;
|
|
165
171
|
}
|
|
166
172
|
else {
|
|
167
173
|
// $nin
|
|
168
|
-
arrayCondition = `NOT EXISTS (SELECT 1 FROM json_each(
|
|
174
|
+
arrayCondition = `NOT EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE json_each.value IN (${placeholders}))`;
|
|
169
175
|
nonArrayCondition = `(${jsonPath} NOT IN (${placeholders}) OR ${jsonPath} IS NULL)`;
|
|
170
176
|
}
|
|
171
177
|
// Push parameters for both array and non-array conditions
|
|
@@ -173,9 +179,48 @@ class FindCursor {
|
|
|
173
179
|
params.push(...value.map((v) => toSQLiteValue(v))); // for non-array condition
|
|
174
180
|
return `(
|
|
175
181
|
(${isArrayCheck} AND ${arrayCondition}) OR
|
|
176
|
-
(
|
|
182
|
+
(${this.buildIsNotArrayCheck(elementPrefix, arrayPath)} AND ${nonArrayCondition})
|
|
177
183
|
)`;
|
|
178
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
|
+
}
|
|
179
224
|
/**
|
|
180
225
|
* Helper method to generate a comparison condition
|
|
181
226
|
*/
|
|
@@ -376,7 +421,8 @@ class FindCursor {
|
|
|
376
421
|
*/
|
|
377
422
|
buildArraySubquery(field, operator, value, params) {
|
|
378
423
|
const arrayPath = this.parseJsonPath(field);
|
|
379
|
-
const arrayTypeCheck =
|
|
424
|
+
const arrayTypeCheck = this.buildIsArrayCheck('data', arrayPath);
|
|
425
|
+
const eachArg = this.buildJsonEachArg('data', arrayPath);
|
|
380
426
|
if (operator === '$all') {
|
|
381
427
|
if (Array.isArray(value) && value.length > 0) {
|
|
382
428
|
const allConditions = value
|
|
@@ -393,12 +439,12 @@ class FindCursor {
|
|
|
393
439
|
objectConditions.push(`json_extract(json_each.value, '$.${key}') = ?`);
|
|
394
440
|
}
|
|
395
441
|
const condition = objectConditions.join(' AND ');
|
|
396
|
-
return `EXISTS (SELECT 1 FROM json_each(
|
|
442
|
+
return `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE ${condition})`;
|
|
397
443
|
}
|
|
398
444
|
else {
|
|
399
445
|
// For simple values, use direct comparison
|
|
400
446
|
params.push(toSQLiteValue(item));
|
|
401
|
-
return `EXISTS (SELECT 1 FROM json_each(
|
|
447
|
+
return `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE json_each.value = ?)`;
|
|
402
448
|
}
|
|
403
449
|
})
|
|
404
450
|
.join(' AND ');
|
|
@@ -425,12 +471,12 @@ class FindCursor {
|
|
|
425
471
|
objectConditions.push(`json_extract(json_each.value, '$.${key}') = ?`);
|
|
426
472
|
}
|
|
427
473
|
const condition = objectConditions.join(' AND ');
|
|
428
|
-
return `EXISTS (SELECT 1 FROM json_each(
|
|
474
|
+
return `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE ${condition})`;
|
|
429
475
|
}
|
|
430
476
|
else {
|
|
431
477
|
// For scalar values
|
|
432
478
|
params.push(toSQLiteValue(item));
|
|
433
|
-
return `EXISTS (SELECT 1 FROM json_each(
|
|
479
|
+
return `EXISTS (SELECT 1 FROM json_each(${eachArg}) WHERE json_each.value = ?)`;
|
|
434
480
|
}
|
|
435
481
|
})
|
|
436
482
|
.join(' OR ');
|
|
@@ -446,7 +492,7 @@ class FindCursor {
|
|
|
446
492
|
else if (operator === '$elemMatch') {
|
|
447
493
|
let elemMatchSubquery = `EXISTS (
|
|
448
494
|
SELECT 1
|
|
449
|
-
FROM json_each(
|
|
495
|
+
FROM json_each(${eachArg}) as array_elements
|
|
450
496
|
WHERE `;
|
|
451
497
|
if (typeof value === 'object' && value !== null) {
|
|
452
498
|
const conditions = this.buildObjectConditions(value, params, 'array_elements');
|
|
@@ -629,9 +675,40 @@ class FindCursor {
|
|
|
629
675
|
}
|
|
630
676
|
return conditions.length > 0 ? conditions.join(' AND ') : '1=1';
|
|
631
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
|
+
}
|
|
632
709
|
buildSelectQuery(filter) {
|
|
633
710
|
const params = [];
|
|
634
|
-
const whereClause = this.
|
|
711
|
+
const whereClause = this.buildGuardedWhereClause(filter, params);
|
|
635
712
|
const sql = `SELECT _id, data FROM "${this.collectionName}" WHERE ${whereClause}`;
|
|
636
713
|
if (this.options.verbose) {
|
|
637
714
|
console.log(`Building SQL query for collection "${this.collectionName}"`);
|
package/dist/cjs/db.js
CHANGED
|
@@ -3,8 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.SQLiteDB = void 0;
|
|
6
|
+
exports.SQLiteDB = exports.DEFAULT_BUSY_TIMEOUT_MS = void 0;
|
|
7
|
+
exports.sharedConnectionCount = sharedConnectionCount;
|
|
7
8
|
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const connection_registry_js_1 = require("./utils/connection-registry.js");
|
|
11
|
+
Object.defineProperty(exports, "DEFAULT_BUSY_TIMEOUT_MS", { enumerable: true, get: function () { return connection_registry_js_1.DEFAULT_BUSY_TIMEOUT_MS; } });
|
|
12
|
+
/** Shared `better-sqlite3` handles for this process, keyed by connection identity. */
|
|
13
|
+
const sharedConnections = new connection_registry_js_1.ConnectionRegistry();
|
|
14
|
+
/** Exposed for tests and diagnostics. */
|
|
15
|
+
function sharedConnectionCount() {
|
|
16
|
+
return sharedConnections.size;
|
|
17
|
+
}
|
|
8
18
|
/**
|
|
9
19
|
* SQLiteDB class provides a wrapper around the better-sqlite3 library
|
|
10
20
|
* to simplify database operations.
|
|
@@ -18,18 +28,34 @@ class SQLiteDB {
|
|
|
18
28
|
constructor(dbPathOrOptions) {
|
|
19
29
|
this.db = null;
|
|
20
30
|
this.openPromise = null;
|
|
31
|
+
let shared;
|
|
21
32
|
if (typeof dbPathOrOptions === 'string') {
|
|
22
33
|
this.filePath = dbPathOrOptions;
|
|
23
34
|
this.verbose = false;
|
|
24
35
|
this.readOnly = false;
|
|
25
|
-
this.WAL =
|
|
36
|
+
this.WAL = true;
|
|
37
|
+
this.busyTimeout = connection_registry_js_1.DEFAULT_BUSY_TIMEOUT_MS;
|
|
38
|
+
shared = false;
|
|
26
39
|
}
|
|
27
40
|
else {
|
|
28
41
|
this.filePath = dbPathOrOptions.filePath;
|
|
29
42
|
this.verbose = dbPathOrOptions.verbose || false;
|
|
30
43
|
this.readOnly = dbPathOrOptions.readOnly || false;
|
|
31
|
-
this.WAL = dbPathOrOptions.WAL
|
|
44
|
+
this.WAL = dbPathOrOptions.WAL ?? true;
|
|
45
|
+
this.busyTimeout = dbPathOrOptions.busyTimeout ?? connection_registry_js_1.DEFAULT_BUSY_TIMEOUT_MS;
|
|
46
|
+
shared = dbPathOrOptions.shared ?? false;
|
|
32
47
|
}
|
|
48
|
+
this.shareKey =
|
|
49
|
+
shared && (0, connection_registry_js_1.isShareablePath)(this.filePath)
|
|
50
|
+
? (0, connection_registry_js_1.buildConnectionKey)({
|
|
51
|
+
backend: 'better-sqlite3',
|
|
52
|
+
filePath: node_path_1.default.resolve(this.filePath),
|
|
53
|
+
readOnly: this.readOnly,
|
|
54
|
+
WAL: this.WAL,
|
|
55
|
+
busyTimeout: this.busyTimeout,
|
|
56
|
+
verbose: this.verbose,
|
|
57
|
+
})
|
|
58
|
+
: null;
|
|
33
59
|
}
|
|
34
60
|
/**
|
|
35
61
|
* Opens the database connection if it's not already open.
|
|
@@ -45,44 +71,57 @@ class SQLiteDB {
|
|
|
45
71
|
}
|
|
46
72
|
this.openPromise = new Promise((resolve, reject) => {
|
|
47
73
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// not validated for catastrophic backtracking (ReDoS) could block the event loop.
|
|
60
|
-
// Avoid using untrusted/unvalidated patterns with $regex in security-sensitive contexts.
|
|
61
|
-
this.db.function('regexp', { deterministic: true }, (pattern, value) => {
|
|
62
|
-
if (typeof pattern !== 'string' || value === null || value === undefined)
|
|
63
|
-
return 0;
|
|
64
|
-
try {
|
|
65
|
-
return new RegExp(pattern).test(String(value)) ? 1 : 0;
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return 0;
|
|
74
|
+
// Only runs on a genuinely new connection — when sharing, an existing
|
|
75
|
+
// handle already has its pragmas and UDFs applied.
|
|
76
|
+
const openConnection = () => {
|
|
77
|
+
const options = {
|
|
78
|
+
readonly: this.readOnly,
|
|
79
|
+
verbose: this.verbose ? console.log : undefined,
|
|
80
|
+
};
|
|
81
|
+
const db = new better_sqlite3_1.default(this.filePath, options);
|
|
82
|
+
// Enable Write-Ahead Logging if requested
|
|
83
|
+
if (this.WAL && !this.readOnly) {
|
|
84
|
+
db.pragma('journal_mode = WAL');
|
|
69
85
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return 0;
|
|
75
|
-
try {
|
|
76
|
-
const f = typeof flags === 'string' ? flags : '';
|
|
77
|
-
return new RegExp(pattern, f).test(String(value)) ? 1 : 0;
|
|
86
|
+
// Wait rather than immediately throwing SQLITE_BUSY when another
|
|
87
|
+
// connection (often another process) holds a lock.
|
|
88
|
+
if (this.busyTimeout > 0) {
|
|
89
|
+
db.pragma(`busy_timeout = ${this.busyTimeout}`);
|
|
78
90
|
}
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
// Register regexp UDF for $regex operator support.
|
|
92
|
+
// WARNING: Patterns are compiled via JavaScript RegExp. User-supplied patterns that are
|
|
93
|
+
// not validated for catastrophic backtracking (ReDoS) could block the event loop.
|
|
94
|
+
// Avoid using untrusted/unvalidated patterns with $regex in security-sensitive contexts.
|
|
95
|
+
db.function('regexp', { deterministic: true }, (pattern, value) => {
|
|
96
|
+
if (typeof pattern !== 'string' || value === null || value === undefined)
|
|
97
|
+
return 0;
|
|
98
|
+
try {
|
|
99
|
+
return new RegExp(pattern).test(String(value)) ? 1 : 0;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
// Register regexp_flags UDF for $regex with $options support
|
|
106
|
+
db.function('regexp_flags', { deterministic: true }, (pattern, flags, value) => {
|
|
107
|
+
if (typeof pattern !== 'string' || value === null || value === undefined)
|
|
108
|
+
return 0;
|
|
109
|
+
try {
|
|
110
|
+
const f = typeof flags === 'string' ? flags : '';
|
|
111
|
+
return new RegExp(pattern, f).test(String(value)) ? 1 : 0;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (this.verbose) {
|
|
118
|
+
console.log(`SQLite database opened: ${this.filePath}`);
|
|
81
119
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
120
|
+
return db;
|
|
121
|
+
};
|
|
122
|
+
this.db = this.shareKey
|
|
123
|
+
? sharedConnections.acquire(this.shareKey, openConnection)
|
|
124
|
+
: openConnection();
|
|
86
125
|
resolve();
|
|
87
126
|
}
|
|
88
127
|
catch (err) {
|
|
@@ -191,9 +230,17 @@ class SQLiteDB {
|
|
|
191
230
|
}
|
|
192
231
|
if (this.db) {
|
|
193
232
|
try {
|
|
194
|
-
|
|
233
|
+
// When shared, only the last holder actually closes the handle.
|
|
234
|
+
const isLastHolder = this.shareKey
|
|
235
|
+
? sharedConnections.release(this.shareKey, this.db)
|
|
236
|
+
: true;
|
|
237
|
+
if (isLastHolder) {
|
|
238
|
+
this.db.close();
|
|
239
|
+
}
|
|
195
240
|
if (this.verbose) {
|
|
196
|
-
console.log(
|
|
241
|
+
console.log(isLastHolder
|
|
242
|
+
? `SQLite database closed: ${this.filePath}`
|
|
243
|
+
: `SQLite database released (still in use): ${this.filePath}`);
|
|
197
244
|
}
|
|
198
245
|
}
|
|
199
246
|
catch (err) {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Process-wide registry of open SQLite handles, keyed by connection identity.
|
|
4
|
+
*
|
|
5
|
+
* Consumers frequently end up with several `MongoLite` instances pointing at the
|
|
6
|
+
* same database file — a module-level client here, a background job there, each
|
|
7
|
+
* calling `new MongoLite(path)` independently. Every one of those opens its own
|
|
8
|
+
* SQLite connection, which turns same-process reads/writes into needless lock
|
|
9
|
+
* contention and forces each consumer to hand-roll its own instance cache.
|
|
10
|
+
*
|
|
11
|
+
* When `shared` is enabled, adapters acquire their underlying handle through this
|
|
12
|
+
* registry instead of opening one directly, so N adapter instances against the
|
|
13
|
+
* same identity share a single connection. Each adapter still tracks its own
|
|
14
|
+
* open/closed state; the handle is only really closed once the last holder
|
|
15
|
+
* releases it (reference counting), so one consumer calling `close()` can't pull
|
|
16
|
+
* the connection out from under another.
|
|
17
|
+
*
|
|
18
|
+
* Note this only dedupes connections *within a process*. Separate processes (e.g.
|
|
19
|
+
* worker threads spawned as child processes, or a job runner forking workers)
|
|
20
|
+
* each get their own registry and therefore their own connection — cross-process
|
|
21
|
+
* contention is handled by WAL mode and `busy_timeout`, not by sharing.
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.ConnectionRegistry = exports.DEFAULT_BUSY_TIMEOUT_MS = void 0;
|
|
25
|
+
exports.buildConnectionKey = buildConnectionKey;
|
|
26
|
+
exports.isShareablePath = isShareablePath;
|
|
27
|
+
/**
|
|
28
|
+
* Default `busy_timeout`, in milliseconds, applied to new connections.
|
|
29
|
+
*
|
|
30
|
+
* Lives here rather than in `db.ts` so the `node:sqlite` adapter can read it
|
|
31
|
+
* without importing that module — `db.ts` pulls in `better-sqlite3`, which is an
|
|
32
|
+
* optional dependency and may not be installed.
|
|
33
|
+
*/
|
|
34
|
+
exports.DEFAULT_BUSY_TIMEOUT_MS = 5000;
|
|
35
|
+
class ConnectionRegistry {
|
|
36
|
+
constructor() {
|
|
37
|
+
this.entries = new Map();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Returns the handle registered for `key`, opening one via `open()` on first
|
|
41
|
+
* use. Increments the reference count for existing entries.
|
|
42
|
+
*/
|
|
43
|
+
acquire(key, open) {
|
|
44
|
+
const existing = this.entries.get(key);
|
|
45
|
+
if (existing) {
|
|
46
|
+
existing.refCount++;
|
|
47
|
+
return existing.handle;
|
|
48
|
+
}
|
|
49
|
+
const handle = open();
|
|
50
|
+
this.entries.set(key, { handle, refCount: 1 });
|
|
51
|
+
return handle;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Drops one reference to `key`.
|
|
55
|
+
* @returns `true` when the caller held the last reference and is therefore
|
|
56
|
+
* responsible for closing the handle; `false` while other holders remain.
|
|
57
|
+
*/
|
|
58
|
+
release(key, handle) {
|
|
59
|
+
const entry = this.entries.get(key);
|
|
60
|
+
// Untracked handle (never shared, or already fully released) — the caller
|
|
61
|
+
// owns it outright and should close it.
|
|
62
|
+
if (!entry || entry.handle !== handle) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
entry.refCount--;
|
|
66
|
+
if (entry.refCount > 0) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
this.entries.delete(key);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
/** Live reference count for `key`; 0 when nothing holds it. */
|
|
73
|
+
refCount(key) {
|
|
74
|
+
return this.entries.get(key)?.refCount ?? 0;
|
|
75
|
+
}
|
|
76
|
+
/** Number of distinct shared handles currently open. */
|
|
77
|
+
get size() {
|
|
78
|
+
return this.entries.size;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Forgets every entry without closing the handles.
|
|
82
|
+
* Intended for test isolation, not for production teardown.
|
|
83
|
+
*/
|
|
84
|
+
clear() {
|
|
85
|
+
this.entries.clear();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.ConnectionRegistry = ConnectionRegistry;
|
|
89
|
+
/**
|
|
90
|
+
* Builds a stable registry key. Connections may only be shared when every
|
|
91
|
+
* setting that affects the handle's behaviour matches, so each of these is part
|
|
92
|
+
* of the identity rather than just the file path.
|
|
93
|
+
*/
|
|
94
|
+
function buildConnectionKey(parts) {
|
|
95
|
+
return JSON.stringify([
|
|
96
|
+
parts.backend,
|
|
97
|
+
parts.filePath,
|
|
98
|
+
parts.readOnly,
|
|
99
|
+
parts.WAL,
|
|
100
|
+
parts.busyTimeout,
|
|
101
|
+
parts.verbose,
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether a database path is eligible for sharing.
|
|
106
|
+
*
|
|
107
|
+
* `:memory:` databases are private to their connection — two `:memory:` opens are
|
|
108
|
+
* two unrelated databases, so sharing them would silently merge state that
|
|
109
|
+
* callers expect to be isolated. Anonymous temp databases (empty path) behave the
|
|
110
|
+
* same way.
|
|
111
|
+
*/
|
|
112
|
+
function isShareablePath(filePath) {
|
|
113
|
+
const normalised = filePath.trim().toLowerCase();
|
|
114
|
+
if (normalised === '' || normalised === ':memory:') {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
// file: URIs opt into sharing only when not explicitly in-memory.
|
|
118
|
+
if (normalised.startsWith('file:') && normalised.includes('mode=memory')) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
package/dist/collection.d.ts
CHANGED
|
@@ -151,6 +151,31 @@ export declare class MongoLiteCollection<T extends DocumentWithId> {
|
|
|
151
151
|
* @returns {Promise<number>} The count of matching documents.
|
|
152
152
|
*/
|
|
153
153
|
countDocuments(filter?: Filter<T>): Promise<number>;
|
|
154
|
+
/**
|
|
155
|
+
* Finds rows whose stored `data` column is not valid JSON.
|
|
156
|
+
*
|
|
157
|
+
* Filtered queries deliberately skip these rows — see
|
|
158
|
+
* `FindCursor.buildGuardedWhereClause` — so that one corrupted document can't
|
|
159
|
+
* make the whole collection unqueryable. That keeps reads working, but it also
|
|
160
|
+
* means such rows quietly stop appearing in results. This is the way to find
|
|
161
|
+
* them so they can be repaired or deleted (`deleteOne({ _id })` works on them,
|
|
162
|
+
* since `_id` lookups don't go through json1 functions).
|
|
163
|
+
*
|
|
164
|
+
* Intended for integrity checks and ops tooling rather than hot paths: it scans
|
|
165
|
+
* the table and returns the raw column text, not parsed documents.
|
|
166
|
+
*
|
|
167
|
+
* @returns The `_id` and raw stored text of every invalid row.
|
|
168
|
+
*/
|
|
169
|
+
findInvalidJson(): Promise<{
|
|
170
|
+
_id: string;
|
|
171
|
+
data: string;
|
|
172
|
+
}[]>;
|
|
173
|
+
/**
|
|
174
|
+
* Counts rows whose stored `data` column is not valid JSON.
|
|
175
|
+
* Cheap enough to run on a schedule as a health check.
|
|
176
|
+
* @see findInvalidJson
|
|
177
|
+
*/
|
|
178
|
+
countInvalidJson(): Promise<number>;
|
|
154
179
|
/**
|
|
155
180
|
* Returns the number of documents in the collection.
|
|
156
181
|
* This is an estimate and may not reflect the exact count in concurrent environments.
|
package/dist/collection.js
CHANGED
|
@@ -349,7 +349,7 @@ export class MongoLiteCollection {
|
|
|
349
349
|
await this.ensureTable();
|
|
350
350
|
// Find the document first (only one)
|
|
351
351
|
const paramsForSelect = [];
|
|
352
|
-
const whereClause = new FindCursor(this.db, this.name, filter)['
|
|
352
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildGuardedWhereClause'](filter, paramsForSelect);
|
|
353
353
|
const selectSql = `SELECT _id, data FROM "${this.name}" WHERE ${whereClause} LIMIT 1`;
|
|
354
354
|
const rowToUpdate = await this.db.get(selectSql, paramsForSelect);
|
|
355
355
|
if (!rowToUpdate && !options.upsert) {
|
|
@@ -478,7 +478,10 @@ export class MongoLiteCollection {
|
|
|
478
478
|
const value = opArgs[path];
|
|
479
479
|
const currentValue = this.getNestedValue(currentDoc, path);
|
|
480
480
|
const arr = Array.isArray(currentValue) ? currentValue : [];
|
|
481
|
-
const items = typeof value === 'object' &&
|
|
481
|
+
const items = typeof value === 'object' &&
|
|
482
|
+
value !== null &&
|
|
483
|
+
'$each' in value &&
|
|
484
|
+
Array.isArray(value.$each)
|
|
482
485
|
? value.$each
|
|
483
486
|
: [value];
|
|
484
487
|
let changed = false;
|
|
@@ -614,7 +617,7 @@ export class MongoLiteCollection {
|
|
|
614
617
|
async updateMany(filter, update) {
|
|
615
618
|
await this.ensureTable();
|
|
616
619
|
const paramsForSelect = [];
|
|
617
|
-
const whereClause = new FindCursor(this.db, this.name, filter)['
|
|
620
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildGuardedWhereClause'](filter, paramsForSelect);
|
|
618
621
|
const selectSql = `SELECT _id, data FROM "${this.name}" WHERE ${whereClause}`;
|
|
619
622
|
const rowsToUpdate = await this.db.all(selectSql, paramsForSelect);
|
|
620
623
|
if (rowsToUpdate.length === 0) {
|
|
@@ -712,7 +715,10 @@ export class MongoLiteCollection {
|
|
|
712
715
|
const value = opArgs[path];
|
|
713
716
|
const currentValue = this.getNestedValue(currentDoc, path);
|
|
714
717
|
const arr = Array.isArray(currentValue) ? currentValue : [];
|
|
715
|
-
const items = typeof value === 'object' &&
|
|
718
|
+
const items = typeof value === 'object' &&
|
|
719
|
+
value !== null &&
|
|
720
|
+
'$each' in value &&
|
|
721
|
+
Array.isArray(value.$each)
|
|
716
722
|
? value.$each
|
|
717
723
|
: [value];
|
|
718
724
|
let changed = false;
|
|
@@ -1036,7 +1042,7 @@ export class MongoLiteCollection {
|
|
|
1036
1042
|
async deleteOne(filter) {
|
|
1037
1043
|
await this.ensureTable();
|
|
1038
1044
|
const paramsForDelete = [];
|
|
1039
|
-
const whereClause = new FindCursor(this.db, this.name, filter)['
|
|
1045
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildGuardedWhereClause'](filter, paramsForDelete);
|
|
1040
1046
|
// Get the number of documents that would be deleted
|
|
1041
1047
|
const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause} LIMIT 1`;
|
|
1042
1048
|
const countResult = await this.db.get(countSql, paramsForDelete);
|
|
@@ -1065,7 +1071,7 @@ export class MongoLiteCollection {
|
|
|
1065
1071
|
async deleteMany(filter) {
|
|
1066
1072
|
await this.ensureTable();
|
|
1067
1073
|
const paramsForDelete = [];
|
|
1068
|
-
const whereClause = new FindCursor(this.db, this.name, filter)['
|
|
1074
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildGuardedWhereClause'](filter, paramsForDelete);
|
|
1069
1075
|
// Get the number of documents that would be deleted
|
|
1070
1076
|
const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause}`;
|
|
1071
1077
|
const countResult = await this.db.get(countSql, paramsForDelete);
|
|
@@ -1091,11 +1097,40 @@ export class MongoLiteCollection {
|
|
|
1091
1097
|
async countDocuments(filter = {}) {
|
|
1092
1098
|
await this.ensureTable();
|
|
1093
1099
|
const paramsForCount = [];
|
|
1094
|
-
const whereClause = new FindCursor(this.db, this.name, filter)['
|
|
1100
|
+
const whereClause = new FindCursor(this.db, this.name, filter)['buildGuardedWhereClause'](filter, paramsForCount);
|
|
1095
1101
|
const countSql = `SELECT COUNT(*) as count FROM "${this.name}" WHERE ${whereClause}`;
|
|
1096
1102
|
const result = await this.db.get(countSql, paramsForCount);
|
|
1097
1103
|
return result?.count || 0;
|
|
1098
1104
|
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Finds rows whose stored `data` column is not valid JSON.
|
|
1107
|
+
*
|
|
1108
|
+
* Filtered queries deliberately skip these rows — see
|
|
1109
|
+
* `FindCursor.buildGuardedWhereClause` — so that one corrupted document can't
|
|
1110
|
+
* make the whole collection unqueryable. That keeps reads working, but it also
|
|
1111
|
+
* means such rows quietly stop appearing in results. This is the way to find
|
|
1112
|
+
* them so they can be repaired or deleted (`deleteOne({ _id })` works on them,
|
|
1113
|
+
* since `_id` lookups don't go through json1 functions).
|
|
1114
|
+
*
|
|
1115
|
+
* Intended for integrity checks and ops tooling rather than hot paths: it scans
|
|
1116
|
+
* the table and returns the raw column text, not parsed documents.
|
|
1117
|
+
*
|
|
1118
|
+
* @returns The `_id` and raw stored text of every invalid row.
|
|
1119
|
+
*/
|
|
1120
|
+
async findInvalidJson() {
|
|
1121
|
+
await this.ensureTable();
|
|
1122
|
+
return this.db.all(`SELECT _id, data FROM "${this.name}" WHERE json_valid(data) = 0`);
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Counts rows whose stored `data` column is not valid JSON.
|
|
1126
|
+
* Cheap enough to run on a schedule as a health check.
|
|
1127
|
+
* @see findInvalidJson
|
|
1128
|
+
*/
|
|
1129
|
+
async countInvalidJson() {
|
|
1130
|
+
await this.ensureTable();
|
|
1131
|
+
const result = await this.db.get(`SELECT COUNT(*) as count FROM "${this.name}" WHERE json_valid(data) = 0`);
|
|
1132
|
+
return result?.count || 0;
|
|
1133
|
+
}
|
|
1099
1134
|
/**
|
|
1100
1135
|
* Returns the number of documents in the collection.
|
|
1101
1136
|
* This is an estimate and may not reflect the exact count in concurrent environments.
|
|
@@ -1124,9 +1159,7 @@ export class MongoLiteCollection {
|
|
|
1124
1159
|
return null;
|
|
1125
1160
|
}
|
|
1126
1161
|
// 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;
|
|
1162
|
+
const updateFilter = existingId !== undefined && existingId !== null ? { _id: existingId } : filter;
|
|
1130
1163
|
const updateResult = await this.updateOne(updateFilter, update, { upsert });
|
|
1131
1164
|
if (returnDocument === 'after') {
|
|
1132
1165
|
const targetId = updateResult.upsertedId ?? existingId;
|
|
@@ -1487,7 +1520,8 @@ export class MongoLiteCollection {
|
|
|
1487
1520
|
const nums = groupDocs
|
|
1488
1521
|
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1489
1522
|
.filter((v) => typeof v === 'number');
|
|
1490
|
-
groupResult[field] =
|
|
1523
|
+
groupResult[field] =
|
|
1524
|
+
nums.length > 0 ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
|
|
1491
1525
|
}
|
|
1492
1526
|
break;
|
|
1493
1527
|
}
|
|
@@ -1497,7 +1531,8 @@ export class MongoLiteCollection {
|
|
|
1497
1531
|
const vals = groupDocs
|
|
1498
1532
|
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1499
1533
|
.filter((v) => v !== undefined && v !== null);
|
|
1500
|
-
groupResult[field] =
|
|
1534
|
+
groupResult[field] =
|
|
1535
|
+
vals.length > 0 ? vals.reduce((a, b) => (a < b ? a : b)) : null;
|
|
1501
1536
|
}
|
|
1502
1537
|
break;
|
|
1503
1538
|
}
|
|
@@ -1507,7 +1542,8 @@ export class MongoLiteCollection {
|
|
|
1507
1542
|
const vals = groupDocs
|
|
1508
1543
|
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1509
1544
|
.filter((v) => v !== undefined && v !== null);
|
|
1510
|
-
groupResult[field] =
|
|
1545
|
+
groupResult[field] =
|
|
1546
|
+
vals.length > 0 ? vals.reduce((a, b) => (a > b ? a : b)) : null;
|
|
1511
1547
|
}
|
|
1512
1548
|
break;
|
|
1513
1549
|
}
|
|
@@ -1603,7 +1639,10 @@ export class MongoLiteCollection {
|
|
|
1603
1639
|
if (key.startsWith('$'))
|
|
1604
1640
|
continue;
|
|
1605
1641
|
const docValue = this.getNestedValue(doc, key);
|
|
1606
|
-
if (typeof value === 'object' &&
|
|
1642
|
+
if (typeof value === 'object' &&
|
|
1643
|
+
value !== null &&
|
|
1644
|
+
!(value instanceof RegExp) &&
|
|
1645
|
+
!(value instanceof Date)) {
|
|
1607
1646
|
const ops = value;
|
|
1608
1647
|
const hasOps = Object.keys(ops).some((k) => k.startsWith('$'));
|
|
1609
1648
|
if (hasOps) {
|