@semics-tech/mongolite 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +54 -3
  2. package/dist/adapters/node-sqlite.d.ts +4 -0
  3. package/dist/adapters/node-sqlite.js +80 -35
  4. package/dist/adapters/node-sqlite.js.map +1 -1
  5. package/dist/bin/mongolite-debug.d.ts +6 -1
  6. package/dist/bin/mongolite-debug.js +6 -113
  7. package/dist/bin/mongolite-debug.js.map +1 -1
  8. package/dist/bin/mongolite.d.ts +13 -0
  9. package/dist/bin/mongolite.js +172 -0
  10. package/dist/bin/mongolite.js.map +1 -0
  11. package/dist/cjs/adapters/node-sqlite.js +84 -35
  12. package/dist/cjs/bin/mongolite-debug.js +6 -149
  13. package/dist/cjs/bin/mongolite.js +207 -0
  14. package/dist/cjs/cli/discover.js +156 -0
  15. package/dist/cjs/cli/explorer.js +929 -0
  16. package/dist/cjs/cli/filter-builder.js +276 -0
  17. package/dist/cjs/cli/format.js +161 -0
  18. package/dist/cjs/cli/schema.js +211 -0
  19. package/dist/cjs/cli/ui.js +431 -0
  20. package/dist/cjs/collection.js +53 -14
  21. package/dist/cjs/cursors/findCursor.js +108 -19
  22. package/dist/cjs/db.js +87 -40
  23. package/dist/cjs/utils/connection-registry.js +122 -0
  24. package/dist/cli/discover.d.ts +40 -0
  25. package/dist/cli/discover.js +146 -0
  26. package/dist/cli/discover.js.map +1 -0
  27. package/dist/cli/explorer.d.ts +59 -0
  28. package/dist/cli/explorer.js +923 -0
  29. package/dist/cli/explorer.js.map +1 -0
  30. package/dist/cli/filter-builder.d.ts +52 -0
  31. package/dist/cli/filter-builder.js +268 -0
  32. package/dist/cli/filter-builder.js.map +1 -0
  33. package/dist/cli/format.d.ts +29 -0
  34. package/dist/cli/format.js +153 -0
  35. package/dist/cli/format.js.map +1 -0
  36. package/dist/cli/schema.d.ts +58 -0
  37. package/dist/cli/schema.js +205 -0
  38. package/dist/cli/schema.js.map +1 -0
  39. package/dist/cli/ui.d.ts +71 -0
  40. package/dist/cli/ui.js +379 -0
  41. package/dist/cli/ui.js.map +1 -0
  42. package/dist/collection.d.ts +25 -0
  43. package/dist/collection.js +53 -14
  44. package/dist/collection.js.map +1 -1
  45. package/dist/cursors/findCursor.d.ts +70 -0
  46. package/dist/cursors/findCursor.js +108 -19
  47. package/dist/cursors/findCursor.js.map +1 -1
  48. package/dist/db.d.ts +23 -0
  49. package/dist/db.js +85 -39
  50. package/dist/db.js.map +1 -1
  51. package/dist/utils/connection-registry.d.ts +74 -0
  52. package/dist/utils/connection-registry.js +117 -0
  53. package/dist/utils/connection-registry.js.map +1 -0
  54. package/package.json +2 -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 = `json_type(json_extract(${elementPrefix}, ${arrayPath})) = 'array'`;
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(json_extract(${elementPrefix}, ${arrayPath})) WHERE json_each.value IN (${placeholders}))`;
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(json_extract(${elementPrefix}, ${arrayPath})) WHERE json_each.value IN (${placeholders}))`;
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
- (json_type(json_extract(${elementPrefix}, ${arrayPath})) != 'array' AND ${nonArrayCondition})
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 = `json_type(json_extract(data, ${arrayPath})) = 'array'`;
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(json_extract(data, ${arrayPath})) WHERE ${condition})`;
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(json_extract(data, ${arrayPath})) WHERE json_each.value = ?)`;
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(json_extract(data, ${arrayPath})) WHERE ${condition})`;
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(json_extract(data, ${arrayPath})) WHERE json_each.value = ?)`;
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(json_extract(data, ${arrayPath})) as array_elements
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.buildWhereClause(filter, params);
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}"`);
@@ -702,6 +779,25 @@ class FindCursor {
702
779
  * @returns A promise that resolves to an array of documents.
703
780
  */
704
781
  async toArray() {
782
+ const { sql: finalSql, params: finalParams } = this.toSQL();
783
+ // Handle malformed json in the database
784
+ const rows = await this.db.all(finalSql, finalParams);
785
+ return rows.map((row) => {
786
+ const parsedData = safeJsonParse(row.data, `findCursor toArray for document ${row._id}`);
787
+ const restoredData = restoreDates(parsedData);
788
+ const doc = { _id: row._id, ...restoredData };
789
+ return this.applyProjection(doc);
790
+ });
791
+ }
792
+ /**
793
+ * Returns the SQL this cursor will execute, along with its bound parameters.
794
+ *
795
+ * Useful for debugging and for tools that want to show how a MongoDB-style
796
+ * filter was translated. Reflects any `sort`, `limit` and `skip` applied so
797
+ * far; `project` is applied in JavaScript after the rows come back and so
798
+ * does not appear here.
799
+ */
800
+ toSQL() {
705
801
  let finalSql = this.queryParts.sql;
706
802
  const finalParams = [...this.queryParts.params];
707
803
  if (this.sortCriteria) {
@@ -728,14 +824,7 @@ class FindCursor {
728
824
  finalSql += ` OFFSET ?`;
729
825
  finalParams.push(this.skipCount);
730
826
  }
731
- // Handle malformed json in the database
732
- const rows = await this.db.all(finalSql, finalParams);
733
- return rows.map((row) => {
734
- const parsedData = safeJsonParse(row.data, `findCursor toArray for document ${row._id}`);
735
- const restoredData = restoreDates(parsedData);
736
- const doc = { _id: row._id, ...restoredData };
737
- return this.applyProjection(doc);
738
- });
827
+ return { sql: finalSql, params: finalParams };
739
828
  }
740
829
  /**
741
830
  * Executes the query and returns the first matching document.
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 = false;
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 || true;
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
- const options = {
49
- readonly: this.readOnly,
50
- verbose: this.verbose ? console.log : undefined,
51
- };
52
- this.db = new better_sqlite3_1.default(this.filePath, options);
53
- // Enable Write-Ahead Logging if requested
54
- if (this.WAL && !this.readOnly) {
55
- this.db.pragma('journal_mode = WAL');
56
- }
57
- // Register regexp UDF for $regex operator support.
58
- // WARNING: Patterns are compiled via JavaScript RegExp. User-supplied patterns that are
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
- // Register regexp_flags UDF for $regex with $options support
72
- this.db.function('regexp_flags', { deterministic: true }, (pattern, flags, value) => {
73
- if (typeof pattern !== 'string' || value === null || value === undefined)
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
- catch {
80
- return 0;
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
- if (this.verbose) {
84
- console.log(`SQLite database opened: ${this.filePath}`);
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
- this.db.close();
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(`SQLite database closed: ${this.filePath}`);
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
+ }
@@ -0,0 +1,40 @@
1
+ import type { IDatabaseAdapter } from '../db.js';
2
+ export interface DatabaseCandidate {
3
+ /** Absolute path to the file. */
4
+ path: string;
5
+ /** Path relative to the search root, for display. */
6
+ relativePath: string;
7
+ sizeBytes: number;
8
+ modified: Date;
9
+ }
10
+ export interface FindDatabasesOptions {
11
+ /** How many directory levels below the root to search. Default 2. */
12
+ depth?: number;
13
+ /** Stop after this many files. Default 50. */
14
+ limit?: number;
15
+ }
16
+ /**
17
+ * Walk `root` looking for SQLite-looking files, skipping the usual noisy
18
+ * directories. Results are newest-first, since the file you just wrote is
19
+ * almost always the one you want.
20
+ */
21
+ export declare function findDatabaseFiles(root?: string, options?: FindDatabasesOptions): DatabaseCandidate[];
22
+ export declare function formatBytes(bytes: number): string;
23
+ export declare function formatRelativeTime(date: Date, now?: Date): string;
24
+ export interface CollectionInfo {
25
+ name: string;
26
+ /** Number of documents (rows) in the collection. */
27
+ count: number;
28
+ /** False for tables that are not shaped like a MongoLite collection. */
29
+ isMongoLite: boolean;
30
+ }
31
+ /**
32
+ * List the tables in the database, flagging which ones look like MongoLite
33
+ * collections (an `_id` column plus a `data` column). Foreign tables are still
34
+ * reported so a user pointed at the wrong file gets a useful message rather
35
+ * than an empty list.
36
+ */
37
+ export declare function listCollectionInfo(db: IDatabaseAdapter): Promise<CollectionInfo[]>;
38
+ /** Resolve a user-supplied database path, allowing `:memory:` and `~` shorthand. */
39
+ export declare function resolveDatabasePath(input: string): string;
40
+ export declare function databaseExists(dbPath: string): boolean;