@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,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MongoLite = void 0;
4
+ const collection_js_1 = require("./collection.js");
5
+ /**
6
+ * Core MongoLite client. Accepts any `IDatabaseAdapter` implementation.
7
+ *
8
+ * This class is intentionally free of `better-sqlite3` dependencies so that it
9
+ * can be bundled for Cloudflare Workers / Durable Objects. For Node.js use, prefer
10
+ * importing `MongoLite` from the package root, which adds convenient constructor
11
+ * overloads for file paths and options objects.
12
+ */
13
+ class MongoLite {
14
+ constructor(adapter, options = {}) {
15
+ this.db = adapter;
16
+ this.options = options;
17
+ }
18
+ /**
19
+ * Connects to the database.
20
+ * For most custom adapters (e.g. Cloudflare) this is a no-op.
21
+ */
22
+ async connect() {
23
+ return this.db.connect();
24
+ }
25
+ /**
26
+ * Returns the underlying database adapter instance.
27
+ */
28
+ get database() {
29
+ return this.db;
30
+ }
31
+ /**
32
+ * Closes the database connection.
33
+ * For most custom adapters (e.g. Cloudflare) this is a no-op.
34
+ */
35
+ async close() {
36
+ return this.db.close();
37
+ }
38
+ /**
39
+ * Lists all collections (tables) in the database.
40
+ * @returns An object with a `toArray` method that resolves to an array of collection names.
41
+ */
42
+ listCollections() {
43
+ return {
44
+ toArray: async () => {
45
+ const result = await this.db.all(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`);
46
+ return result.map((row) => row.name);
47
+ },
48
+ };
49
+ }
50
+ /**
51
+ * Returns a typed collection handle.
52
+ * @param name The collection (table) name.
53
+ */
54
+ collection(name) {
55
+ return new collection_js_1.MongoLiteCollection(this.db, name, this.options);
56
+ }
57
+ }
58
+ exports.MongoLite = MongoLite;
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractRawIndexColumns = extractRawIndexColumns;
4
+ function extractRawIndexColumns(sql) {
5
+ const match = sql.match(/\(([\s\S]+)\)/); // allow multiline matching
6
+ if (!match)
7
+ return [];
8
+ const colsPart = match[1];
9
+ // Split on commas not inside parentheses
10
+ const columns = [];
11
+ let current = '';
12
+ let parens = 0;
13
+ for (let i = 0; i < colsPart.length; i++) {
14
+ const char = colsPart[i];
15
+ if (char === '(')
16
+ parens++;
17
+ if (char === ')')
18
+ parens--;
19
+ if (char === ',' && parens === 0) {
20
+ columns.push(current.trim());
21
+ current = '';
22
+ }
23
+ else {
24
+ current += char;
25
+ }
26
+ }
27
+ if (current.trim())
28
+ columns.push(current.trim());
29
+ return columns.map((col) => {
30
+ // Extract direction (ASC or DESC)
31
+ const directionMatch = col.match(/\s+(ASC|DESC)\s*$/i);
32
+ const direction = directionMatch ? directionMatch[1].toUpperCase() : null;
33
+ // Remove direction from column string
34
+ const colWithoutDirection = directionMatch
35
+ ? col.slice(0, directionMatch.index).trim()
36
+ : col.trim();
37
+ // Check for json_extract and pull out path
38
+ const jsonMatch = colWithoutDirection.match(/json_extract\s*\(\s*[^,]+,\s*'([^']+)'\s*\)/i);
39
+ let column = jsonMatch
40
+ ? jsonMatch[1] // the JSON path like '$.age'
41
+ : colWithoutDirection; // fallback to raw column like testCol
42
+ column = column.replace('$.', ''); // Remove leading $ if present
43
+ return { column, direction };
44
+ });
45
+ }
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveProjectionMode = resolveProjectionMode;
4
+ exports.applyProjectionToDocument = applyProjectionToDocument;
5
+ /**
6
+ * Determines whether a projection is an inclusion or exclusion projection, matching MongoDB's
7
+ * rule: any explicit non-`_id` inclusion (1/true) puts it in inclusion mode; otherwise any
8
+ * explicit non-`_id` exclusion (0/false) puts it in exclusion mode; with only `_id` specified
9
+ * (or nothing), `_id: 0`/`false` means exclusion, everything else defaults to inclusion.
10
+ */
11
+ function resolveProjectionMode(projection) {
12
+ let hasExplicitInclusion = false;
13
+ let hasExplicitExclusion = false;
14
+ for (const key of Object.keys(projection)) {
15
+ if (key === '_id')
16
+ continue;
17
+ const value = projection[key];
18
+ if (value === 1 || value === true) {
19
+ hasExplicitInclusion = true;
20
+ break;
21
+ }
22
+ if (value === 0 || value === false) {
23
+ hasExplicitExclusion = true;
24
+ }
25
+ }
26
+ if (hasExplicitInclusion)
27
+ return 'include';
28
+ if (hasExplicitExclusion)
29
+ return 'exclude';
30
+ return projection._id === 0 || projection._id === false ? 'exclude' : 'include';
31
+ }
32
+ /**
33
+ * Applies a MongoDB-style projection to a single document, matching real MongoDB find()
34
+ * projection semantics: an empty/missing projection means no restriction, inclusion mode
35
+ * copies only listed fields (plus `_id` unless explicitly excluded), and exclusion mode copies
36
+ * everything except the listed fields. Supports dot-notation nested paths in both modes.
37
+ */
38
+ function applyProjectionToDocument(doc, projection) {
39
+ if (!projection || Object.keys(projection).length === 0)
40
+ return doc;
41
+ const mode = resolveProjectionMode(projection);
42
+ const projectedDoc = {};
43
+ if (mode === 'include') {
44
+ for (const key in projection) {
45
+ const value = projection[key];
46
+ if (value !== 1 && value !== true)
47
+ continue;
48
+ if (key.includes('.')) {
49
+ const path = key.split('.');
50
+ let current = doc;
51
+ let target = projectedDoc;
52
+ for (let i = 0; i < path.length - 1; i++) {
53
+ const segment = path[i];
54
+ if (current[segment] === undefined || current[segment] === null)
55
+ break;
56
+ if (target[segment] === undefined) {
57
+ target[segment] = {};
58
+ }
59
+ current = current[segment];
60
+ target = target[segment];
61
+ }
62
+ const lastSegment = path[path.length - 1];
63
+ if (current && current[lastSegment] !== undefined) {
64
+ target[lastSegment] = current[lastSegment];
65
+ }
66
+ }
67
+ else if (key in doc) {
68
+ projectedDoc[key] = doc[key];
69
+ }
70
+ }
71
+ if (projection._id !== 0 && projection._id !== false && '_id' in doc) {
72
+ projectedDoc._id = doc._id;
73
+ }
74
+ }
75
+ else {
76
+ Object.assign(projectedDoc, doc);
77
+ for (const key in projection) {
78
+ const value = projection[key];
79
+ if (value !== 0 && value !== false)
80
+ continue;
81
+ if (key.includes('.')) {
82
+ const path = key.split('.');
83
+ let current = projectedDoc;
84
+ for (let i = 0; i < path.length - 1; i++) {
85
+ const segment = path[i];
86
+ if (current[segment] === undefined)
87
+ break;
88
+ current = current[segment];
89
+ }
90
+ const lastSegment = path[path.length - 1];
91
+ if (current && current[lastSegment] !== undefined) {
92
+ delete current[lastSegment];
93
+ }
94
+ }
95
+ else {
96
+ delete projectedDoc[key];
97
+ }
98
+ }
99
+ }
100
+ return projectedDoc;
101
+ }
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SUPPORTED_QUERY_OPERATORS = void 0;
4
+ exports.evaluateOperator = evaluateOperator;
5
+ exports.evaluateOperators = evaluateOperators;
6
+ /**
7
+ * Shared query-operator registry used by in-memory ($match) filtering. Kept in sync with the
8
+ * operator set `buildComparisonCondition` (src/cursors/findCursor.ts) supports for SQL-generated
9
+ * find() filtering, so the two engines can't silently drift apart on what an operator means —
10
+ * see tests/operator-coverage.test.ts, which runs the same filter through both engines.
11
+ */
12
+ exports.SUPPORTED_QUERY_OPERATORS = [
13
+ '$eq',
14
+ '$ne',
15
+ '$gt',
16
+ '$gte',
17
+ '$lt',
18
+ '$lte',
19
+ '$in',
20
+ '$nin',
21
+ '$all',
22
+ '$exists',
23
+ '$not',
24
+ '$regex',
25
+ '$size',
26
+ '$type',
27
+ '$mod',
28
+ ];
29
+ function matchesBsonType(value, bsonType) {
30
+ switch (bsonType) {
31
+ case 1:
32
+ case 'double':
33
+ case 'number':
34
+ return typeof value === 'number';
35
+ case 2:
36
+ case 'string':
37
+ return typeof value === 'string';
38
+ case 3:
39
+ case 'object':
40
+ return (typeof value === 'object' &&
41
+ value !== null &&
42
+ !Array.isArray(value) &&
43
+ !(value instanceof Date));
44
+ case 4:
45
+ case 'array':
46
+ return Array.isArray(value);
47
+ case 8:
48
+ case 'bool':
49
+ case 'boolean':
50
+ return typeof value === 'boolean';
51
+ case 9:
52
+ case 'date':
53
+ return value instanceof Date;
54
+ case 10:
55
+ case 'null':
56
+ return value === null;
57
+ case 16:
58
+ case 'int':
59
+ case 18:
60
+ case 'long':
61
+ return typeof value === 'number' && Number.isInteger(value);
62
+ default:
63
+ return false;
64
+ }
65
+ }
66
+ /**
67
+ * Evaluates a single query operator against a document's field value. Used for in-memory
68
+ * filtering (e.g. aggregation `$match`) — the SQL-generating side (`buildComparisonCondition`)
69
+ * emits SQL fragments instead of booleans and stays separate, but should support the same
70
+ * operator set.
71
+ */
72
+ function evaluateOperator(op, docValue, opValue, regexOptions) {
73
+ switch (op) {
74
+ case '$eq':
75
+ return JSON.stringify(docValue) === JSON.stringify(opValue);
76
+ case '$ne':
77
+ return JSON.stringify(docValue) !== JSON.stringify(opValue);
78
+ case '$gt':
79
+ return docValue > opValue;
80
+ case '$gte':
81
+ return docValue >= opValue;
82
+ case '$lt':
83
+ return docValue < opValue;
84
+ case '$lte':
85
+ return docValue <= opValue;
86
+ case '$in':
87
+ if (!Array.isArray(opValue))
88
+ return false;
89
+ if (Array.isArray(docValue)) {
90
+ return docValue.some((el) => opValue.some((v) => JSON.stringify(el) === JSON.stringify(v)));
91
+ }
92
+ return opValue.some((v) => JSON.stringify(v) === JSON.stringify(docValue));
93
+ case '$nin':
94
+ if (!Array.isArray(opValue))
95
+ return false;
96
+ if (Array.isArray(docValue)) {
97
+ return !docValue.some((el) => opValue.some((v) => JSON.stringify(el) === JSON.stringify(v)));
98
+ }
99
+ return !opValue.some((v) => JSON.stringify(v) === JSON.stringify(docValue));
100
+ case '$all':
101
+ if (!Array.isArray(opValue) || !Array.isArray(docValue))
102
+ return false;
103
+ return opValue.every((item) => docValue.some((el) => JSON.stringify(el) === JSON.stringify(item)));
104
+ case '$exists':
105
+ return opValue ? docValue !== undefined : docValue === undefined;
106
+ case '$not':
107
+ return !evaluateOperators(docValue, opValue);
108
+ case '$regex': {
109
+ const pattern = opValue instanceof RegExp
110
+ ? opValue
111
+ : new RegExp(opValue, typeof regexOptions === 'string' ? regexOptions : undefined);
112
+ return pattern.test(String(docValue));
113
+ }
114
+ case '$size':
115
+ return Array.isArray(docValue) && docValue.length === opValue;
116
+ case '$type': {
117
+ const types = Array.isArray(opValue) ? opValue : [opValue];
118
+ return types.some((t) => matchesBsonType(docValue, t));
119
+ }
120
+ case '$mod': {
121
+ if (!Array.isArray(opValue) || opValue.length !== 2 || typeof docValue !== 'number')
122
+ return false;
123
+ const [divisor, remainder] = opValue;
124
+ return Math.trunc(docValue) % divisor === remainder;
125
+ }
126
+ case '$options':
127
+ return true; // consumed alongside $regex, not an independent predicate
128
+ default:
129
+ return false; // unsupported operator — conservative: never matches, rather than silently no-op
130
+ }
131
+ }
132
+ /** Evaluates every operator in `ops` against `docValue`, ANDing the results together. */
133
+ function evaluateOperators(docValue, ops) {
134
+ const regexOptions = ops['$options'];
135
+ for (const [op, opValue] of Object.entries(ops)) {
136
+ if (op === '$options')
137
+ continue;
138
+ if (!evaluateOperator(op, docValue, opValue, regexOptions))
139
+ return false;
140
+ }
141
+ return true;
142
+ }
@@ -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.
@@ -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)['buildWhereClause'](filter, paramsForSelect);
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' && value !== null && '$each' in value && Array.isArray(value.$each)
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)['buildWhereClause'](filter, paramsForSelect);
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' && value !== null && '$each' in value && Array.isArray(value.$each)
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)['buildWhereClause'](filter, paramsForDelete);
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)['buildWhereClause'](filter, paramsForDelete);
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)['buildWhereClause'](filter, paramsForCount);
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] = nums.length > 0 ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
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] = vals.length > 0 ? vals.reduce((a, b) => (a < b ? a : b)) : null;
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] = vals.length > 0 ? vals.reduce((a, b) => (a > b ? a : b)) : null;
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' && value !== null && !(value instanceof RegExp) && !(value instanceof Date)) {
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) {