tina4-nodejs 3.13.92 → 3.13.95

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 (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +33062 -29908
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/devMailbox.ts +20 -44
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +7 -6
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +81 -13
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +6 -9
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * import { getCollection, ObjectId } from "@tina4/orm";
8
8
  *
9
- * const orders = getCollection("orders"); // SqliteCollection when no Mongo configured
9
+ * const orders = await getCollection("orders"); // SqliteCollection when no Mongo configured
10
10
  * const { insertedId } = await orders.insertOne({ customer_id: 1, total: 9.99 });
11
11
  * for (const o of await orders.find({ customer_id: { $in: [1, 2] } }).sort("created_at", -1).limit(10).toArray()) {
12
12
  * // ...
@@ -21,6 +21,11 @@
21
21
  * production runs serverless in local dev with no code change - only the backend
22
22
  * differs.
23
23
  *
24
+ * A configured URI with NO driver installed throws `DocStoreDriverMissing`
25
+ * (ADR-0033). It does NOT quietly use the local SQLite store, and it no longer
26
+ * surfaces a bare ERR_MODULE_NOT_FOUND that names an npm package rather than
27
+ * the framework decision that led there.
28
+ *
24
29
  * Design (the SQLite backend):
25
30
  * - Each collection is a table `(_id TEXT PRIMARY KEY, doc TEXT)`; `doc` is JSON.
26
31
  * - Query filters are pushed down to SQL over `json_extract(doc, '$.field')`
@@ -205,6 +210,57 @@ function extract(field: string): string {
205
210
  return `json_extract(doc, '${jsonPath(field)}')`;
206
211
  }
207
212
 
213
+ /** A rowset over the field: one row per element of an array, one for a scalar. */
214
+ function jsonEach(field: string): string {
215
+ return `json_each(doc, '${jsonPath(field)}')`;
216
+ }
217
+
218
+ /**
219
+ * True when the field is an ARRAY and any element satisfies `condition`.
220
+ *
221
+ * MongoDB's rule for an array-valued field is that a condition matches when ANY
222
+ * ELEMENT matches it. json_each yields one row per element, so EXISTS is the
223
+ * direct translation.
224
+ *
225
+ * The `= 'array'` guard is load-bearing: json_each over an OBJECT iterates its
226
+ * VALUES, and Mongo never matches an object field against one of its values -
227
+ * {obj: "x"} must NOT match {obj: {city: "x"}}.
228
+ */
229
+ function anyElement(field: string, condition: string): string {
230
+ return `(${typeOf(field)} = 'array' AND EXISTS (SELECT 1 FROM ${jsonEach(field)} WHERE ${condition}))`;
231
+ }
232
+
233
+ /** Compile `field == operand` under Mongo's array rule. */
234
+ function equality(field: string, operand: unknown): CompiledFilter {
235
+ const ex = extract(field);
236
+ if (operand === null || operand === undefined) return { where: `${ex} IS NULL`, params: [] };
237
+ // An array or plain object operand compares against the WHOLE value, never
238
+ // element-wise: {tags: ["x","y"]} is exact-array equality.
239
+ const composite =
240
+ Array.isArray(operand) ||
241
+ (typeof operand === "object" && !(operand instanceof ObjectId) && !(operand instanceof Date));
242
+ if (composite) return { where: `${ex} = ?`, params: [bind(operand)] };
243
+ return {
244
+ where: `(${ex} = ? OR ${anyElement(field, "value = ?")})`,
245
+ params: [bind(operand), bind(operand)],
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Compile `field OP operand` under Mongo's array rule.
251
+ *
252
+ * The `<> 'array'` guard on the scalar branch removes a measured FALSE POSITIVE:
253
+ * json_extract of an array returns its JSON TEXT, and SQLite sorts any text above
254
+ * any number, so {nums: {$gt: 9}} matched [1,2,3].
255
+ */
256
+ function compare(field: string, sqlOp: string, operand: unknown): CompiledFilter {
257
+ const ex = extract(field);
258
+ return {
259
+ where: `((${typeOf(field)} <> 'array' AND ${ex} ${sqlOp} ?) OR ${anyElement(field, `value ${sqlOp} ?`)})`,
260
+ params: [bind(operand), bind(operand)],
261
+ };
262
+ }
263
+
208
264
  function typeOf(field: string): string {
209
265
  return `json_type(doc, '${jsonPath(field)}')`;
210
266
  }
@@ -265,11 +321,12 @@ export function compileFilter(query?: Record<string, unknown> | null): CompiledF
265
321
  clauses.push(compiled.where);
266
322
  params.push(...compiled.params);
267
323
  }
268
- } else if (value === null) {
269
- clauses.push(`${extract(key)} IS NULL`);
270
324
  } else {
271
- clauses.push(`${extract(key)} = ?`);
272
- params.push(bind(value));
325
+ // equality - the same helper $eq uses, so the array rule applies whether
326
+ // the filter reads {tags: "x"} or {tags: {$eq: "x"}}
327
+ const compiled = equality(key, value);
328
+ clauses.push(compiled.where);
329
+ params.push(...compiled.params);
273
330
  }
274
331
  }
275
332
 
@@ -279,27 +336,37 @@ export function compileFilter(query?: Record<string, unknown> | null): CompiledF
279
336
  function compileOp(field: string, op: string, operand: unknown): CompiledFilter {
280
337
  const ex = extract(field);
281
338
  if (op in COMPARATORS) {
282
- return { where: `${ex} ${COMPARATORS[op]} ?`, params: [bind(operand)] };
339
+ return compare(field, COMPARATORS[op], operand);
283
340
  }
284
341
  if (op === "$eq") {
285
- if (operand === null) return { where: `${ex} IS NULL`, params: [] };
286
- return { where: `${ex} = ?`, params: [bind(operand)] };
342
+ return equality(field, operand);
287
343
  }
288
344
  if (op === "$ne") {
289
345
  if (operand === null) return { where: `${ex} IS NOT NULL`, params: [] };
290
- return { where: `(${ex} <> ? OR ${ex} IS NULL)`, params: [bind(operand)] };
346
+ const eq = equality(field, operand);
347
+ // A MISSING field satisfies $ne in Mongo, and SQL's NOT(NULL) is NULL rather
348
+ // than true - so the IS NULL arm is required, not decoration.
349
+ return { where: `(NOT (${eq.where}) OR ${ex} IS NULL)`, params: eq.params };
291
350
  }
292
351
  if (op === "$in") {
293
352
  const items = Array.isArray(operand) ? operand : [];
294
353
  if (items.length === 0) return { where: "0", params: [] };
295
354
  const placeholders = items.map(() => "?").join(",");
296
- return { where: `${ex} IN (${placeholders})`, params: items.map(bind) };
355
+ const bound = items.map(bind);
356
+ return {
357
+ where: `(${ex} IN (${placeholders}) OR ${anyElement(field, `value IN (${placeholders})`)})`,
358
+ params: [...bound, ...bound],
359
+ };
297
360
  }
298
361
  if (op === "$nin") {
299
362
  const items = Array.isArray(operand) ? operand : [];
300
363
  if (items.length === 0) return { where: "1", params: [] };
301
364
  const placeholders = items.map(() => "?").join(",");
302
- return { where: `(${ex} NOT IN (${placeholders}) OR ${ex} IS NULL)`, params: items.map(bind) };
365
+ const bound = items.map(bind);
366
+ return {
367
+ where: `(NOT (${ex} IN (${placeholders}) OR ${anyElement(field, `value IN (${placeholders})`)}) OR ${ex} IS NULL)`,
368
+ params: [...bound, ...bound],
369
+ };
303
370
  }
304
371
  if (op === "$exists") {
305
372
  // json_type is NULL when the path is absent; present-but-null still has a type.
@@ -310,7 +377,10 @@ function compileOp(field: string, op: string, operand: unknown): CompiledFilter
310
377
  if (operand !== null && typeof operand === "object") {
311
378
  pattern = (operand as Record<string, unknown>).$regex ?? "";
312
379
  }
313
- return { where: `${ex} REGEXP ?`, params: [String(pattern)] };
380
+ return {
381
+ where: `((${typeOf(field)} <> 'array' AND ${ex} REGEXP ?) OR ${anyElement(field, "value REGEXP ?")})`,
382
+ params: [String(pattern), String(pattern)],
383
+ };
314
384
  }
315
385
  throw new Error(`DocStore: unsupported query operator '${op}'`);
316
386
  }
@@ -417,73 +487,151 @@ export interface DeleteResult {
417
487
  deletedCount: number;
418
488
  }
419
489
 
490
+ /**
491
+ * Decode a stored JSON document (rehydrating ObjectId/Date), with an optional
492
+ * projection. Module-level because it is a PURE function of its inputs - it was
493
+ * a public `load` method on the collection only so the Cursor could reach it,
494
+ * which ADR-0025 corollary 1 forbids.
495
+ */
496
+ function loadDoc(docText: string, projection?: Record<string, unknown> | null): Record<string, unknown> {
497
+ const doc = decodeValue(JSON.parse(docText)) as Record<string, unknown>;
498
+ return projection ? project(doc, projection) : doc;
499
+ }
500
+
501
+ /** Encode a document for storage. Pure, for the same reason as loadDoc. */
502
+ function dumpDoc(document: Record<string, unknown>): string {
503
+ return JSON.stringify(encodeValue(document));
504
+ }
505
+
420
506
  // ── Cursor ───────────────────────────────────────────────────────────────────
421
507
 
422
508
  /** Lazy result cursor. Builds and runs SQL only when materialised (toArray). */
423
- export class Cursor {
424
- private _sort: [string, number][] = [];
425
- private _limit: number | null = null;
426
- private _skip = 0;
509
+ /** The three sort spellings a real FindCursor accepts. */
510
+ export type SortSpec =
511
+ | string
512
+ | [string, number][]
513
+ | Record<string, number>
514
+ | Map<string, number>;
515
+
516
+ /**
517
+ * Normalise the driver's three sort spellings to a list of [key, direction].
518
+ *
519
+ * ADR-0036. A real `FindCursor.sort()` accepts a key plus a direction, a list
520
+ * of `[key, direction]` pairs, OR an object/Map - and the driver is the shape
521
+ * this fallback imitates (ADR-0025). The object form used to throw
522
+ * `TypeError: keyOrList is not iterable` here. Measured 2026-08-04 against a
523
+ * real MongoDB: the object spelling worked on the driver and threw on the
524
+ * fallback, in three of the four frameworks.
525
+ */
526
+ export function sortSpec(keyOrList: SortSpec, direction = 1): [string, number][] {
527
+ if (typeof keyOrList === "string") return [[keyOrList, direction]];
528
+ if (keyOrList instanceof Map) return [...keyOrList.entries()];
529
+ if (Array.isArray(keyOrList)) return keyOrList.map(([k, d]) => [k, d]);
530
+ return Object.entries(keyOrList).map(([k, d]) => [k, d]);
531
+ }
427
532
 
533
+ export class Cursor {
534
+ #sort: [string, number][] = [];
535
+ #limit: number | null = null;
536
+ #skip = 0;
537
+
538
+ readonly #conn: DatabaseSync;
539
+ readonly #quoted: string;
540
+ readonly #where: string;
541
+ readonly #params: unknown[];
542
+ readonly #projection?: Record<string, unknown> | null;
543
+
544
+ /**
545
+ * The cursor receives WHAT IT NEEDS, not the collection it came from.
546
+ *
547
+ * It used to hold the collection and reach back for `connection`, `quoted` and
548
+ * `load` - which is the only reason those three were public. ADR-0025
549
+ * corollary 1: anything the fallback needs internally is private, and a real
550
+ * FindCursor exposes none of them. Handing over the two values and calling the
551
+ * module-level loader removes the back-reference AND the public surface.
552
+ */
428
553
  constructor(
429
- private readonly collection: SqliteCollection,
430
- private readonly where: string,
431
- private readonly params: unknown[],
432
- private readonly projection?: Record<string, unknown> | null,
433
- ) {}
434
-
435
- sort(keyOrList: string | [string, number][], direction = 1): this {
436
- if (typeof keyOrList === "string") {
437
- this._sort.push([keyOrList, direction]);
438
- } else {
439
- for (const [k, d] of keyOrList) this._sort.push([k, d]);
440
- }
554
+ conn: DatabaseSync,
555
+ quoted: string,
556
+ where: string,
557
+ params: unknown[],
558
+ projection?: Record<string, unknown> | null,
559
+ ) {
560
+ this.#conn = conn;
561
+ this.#quoted = quoted;
562
+ this.#where = where;
563
+ this.#params = params;
564
+ this.#projection = projection;
565
+ }
566
+
567
+ sort(keyOrList: SortSpec, direction = 1): this {
568
+ for (const pair of sortSpec(keyOrList, direction)) this.#sort.push(pair);
441
569
  return this;
442
570
  }
443
571
 
444
572
  limit(n: number): this {
445
- this._limit = Math.trunc(n);
573
+ this.#limit = Math.trunc(n);
446
574
  return this;
447
575
  }
448
576
 
449
577
  skip(n: number): this {
450
- this._skip = Math.trunc(n);
578
+ this.#skip = Math.trunc(n);
451
579
  return this;
452
580
  }
453
581
 
454
- private buildSql(): string {
455
- let sql = `SELECT doc FROM ${this.collection.quoted} WHERE ${this.where}`;
456
- if (this._sort.length) {
457
- const order = this._sort
582
+ #buildSql(): string {
583
+ let sql = `SELECT doc FROM ${this.#quoted} WHERE ${this.#where}`;
584
+ if (this.#sort.length) {
585
+ const order = this.#sort
458
586
  .map(([k, d]) => `${extract(k)} ${d < 0 ? "DESC" : "ASC"}`)
459
587
  .join(", ");
460
588
  sql += ` ORDER BY ${order}`;
461
589
  }
462
- if (this._limit !== null) {
463
- sql += ` LIMIT ${Math.trunc(this._limit)}`;
464
- if (this._skip) sql += ` OFFSET ${Math.trunc(this._skip)}`;
465
- } else if (this._skip) {
466
- sql += ` LIMIT -1 OFFSET ${Math.trunc(this._skip)}`;
590
+ if (this.#limit !== null) {
591
+ sql += ` LIMIT ${Math.trunc(this.#limit)}`;
592
+ if (this.#skip) sql += ` OFFSET ${Math.trunc(this.#skip)}`;
593
+ } else if (this.#skip) {
594
+ sql += ` LIMIT -1 OFFSET ${Math.trunc(this.#skip)}`;
467
595
  }
468
596
  return sql;
469
597
  }
470
598
 
471
- /** Materialise the cursor into an array of decoded documents. */
472
- toArray(): Record<string, unknown>[] {
473
- const rows = this.collection.connection
474
- .prepare(this.buildSql())
475
- .all(...(this.params as never[])) as { doc: string }[];
476
- return rows.map((r) => this.collection.load(r.doc, this.projection));
599
+ /**
600
+ * Materialise the cursor into an array of decoded documents.
601
+ *
602
+ * ASYNC because the driver's FindCursor.toArray() is async (ADR-0025 clause
603
+ * 3). The work underneath is synchronous - node:sqlite has no async API - but
604
+ * the SHAPE is what a call site sees, and a shape that changes with the
605
+ * provider is the defect this fixes.
606
+ */
607
+ async toArray(): Promise<Record<string, unknown>[]> {
608
+ const rows = this.#conn
609
+ .prepare(this.#buildSql())
610
+ .all(...(this.#params as never[])) as { doc: string }[];
611
+ return rows.map((r) => loadDoc(r.doc, this.#projection));
477
612
  }
478
613
 
479
- /** Alias for toArray() (pymongo's to_list / driver's toArray). */
480
- toList(length?: number): Record<string, unknown>[] {
481
- const out = this.toArray();
482
- return length === undefined ? out : out.slice(0, length);
483
- }
484
-
485
- [Symbol.iterator](): Iterator<Record<string, unknown>> {
486
- return this.toArray()[Symbol.iterator]();
614
+ /**
615
+ * Async iteration, matching the driver.
616
+ *
617
+ * NOTE: there is deliberately no [Symbol.iterator] here. A real FindCursor
618
+ * has ONLY Symbol.asyncIterator, so `for (const doc of cursor)` is a
619
+ * fallback-only spelling - it works locally and throws "is not iterable" the
620
+ * moment TINA4_MONGO_URI is set. Use `for await (const doc of cursor)`.
621
+ *
622
+ * toList() is gone for the same reason: the driver's FindCursor has no such
623
+ * method.
624
+ *
625
+ * ADR-0035 restored the uniform spellings in ruby and php through a
626
+ * delegator, and deliberately did NOT do so here. A delegator can only supply
627
+ * a method that is POSSIBLE on the real provider, and a synchronous iterator
628
+ * is not: a FindCursor is async-only. Adding one back on the fallback alone
629
+ * would recreate ADR-0025's worst measured defect - identical source changing
630
+ * TYPE, with a truthy Promise passing `if (doc)` for a document that does not
631
+ * exist. That is ADR-0025 corollary 3, which ADR-0035 keeps.
632
+ */
633
+ async *[Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
634
+ for (const doc of await this.toArray()) yield doc;
487
635
  }
488
636
  }
489
637
 
@@ -491,49 +639,40 @@ export class Cursor {
491
639
 
492
640
  /** A SQLite-backed collection exposing the everyday MongoDB driver API. */
493
641
  export class SqliteCollection {
494
- readonly quoted: string;
642
+ readonly #quoted: string;
643
+ readonly #conn: DatabaseSync;
644
+ readonly #name: string;
495
645
 
496
- constructor(
497
- readonly connection: DatabaseSync,
498
- private readonly name: string,
499
- ) {
646
+ constructor(conn: DatabaseSync, name: string) {
647
+ this.#conn = conn;
648
+ this.#name = name;
500
649
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
501
650
  throw new Error(`DocStore: invalid collection name '${name}'`);
502
651
  }
503
- this.quoted = `"${name}"`;
504
- this.connection.exec(
505
- `CREATE TABLE IF NOT EXISTS ${this.quoted} (_id TEXT PRIMARY KEY, doc TEXT NOT NULL)`,
652
+ this.#quoted = `"${name}"`;
653
+ this.#conn.exec(
654
+ `CREATE TABLE IF NOT EXISTS ${this.#quoted} (_id TEXT PRIMARY KEY, doc TEXT NOT NULL)`,
506
655
  );
507
656
  }
508
657
 
509
- private dump(document: Record<string, unknown>): string {
510
- return JSON.stringify(encodeValue(document));
511
- }
512
-
513
- /** Decode a stored JSON document (rehydrating ObjectId/Date), with optional projection. */
514
- load(docText: string, projection?: Record<string, unknown> | null): Record<string, unknown> {
515
- const doc = decodeValue(JSON.parse(docText)) as Record<string, unknown>;
516
- return projection ? project(doc, projection) : doc;
517
- }
518
-
519
658
  // -- writes --
520
- insertOne(document: Record<string, unknown>): InsertOneResult {
659
+ async insertOne(document: Record<string, unknown>): Promise<InsertOneResult> {
521
660
  const doc = { ...document };
522
661
  if (!("_id" in doc)) doc._id = new ObjectId();
523
- this.connection
524
- .prepare(`INSERT INTO ${this.quoted} (_id, doc) VALUES (?, ?)`)
525
- .run(idKey(doc._id), this.dump(doc));
662
+ this.#conn
663
+ .prepare(`INSERT INTO ${this.#quoted} (_id, doc) VALUES (?, ?)`)
664
+ .run(idKey(doc._id), dumpDoc(doc));
526
665
  return { acknowledged: true, insertedId: doc._id };
527
666
  }
528
667
 
529
- insertMany(documents: Record<string, unknown>[]): InsertManyResult {
668
+ async insertMany(documents: Record<string, unknown>[]): Promise<InsertManyResult> {
530
669
  const ids: unknown[] = [];
531
- const stmt = this.connection.prepare(`INSERT INTO ${this.quoted} (_id, doc) VALUES (?, ?)`);
670
+ const stmt = this.#conn.prepare(`INSERT INTO ${this.#quoted} (_id, doc) VALUES (?, ?)`);
532
671
  for (const document of documents) {
533
672
  const doc = { ...document };
534
673
  if (!("_id" in doc)) doc._id = new ObjectId();
535
674
  ids.push(doc._id);
536
- stmt.run(idKey(doc._id), this.dump(doc));
675
+ stmt.run(idKey(doc._id), dumpDoc(doc));
537
676
  }
538
677
  return { acknowledged: true, insertedIds: ids };
539
678
  }
@@ -541,35 +680,35 @@ export class SqliteCollection {
541
680
  // -- reads --
542
681
  find(filter?: Record<string, unknown> | null, projection?: Record<string, unknown> | null): Cursor {
543
682
  const { where, params } = compileFilter(filter ?? {});
544
- return new Cursor(this, where, params, projection);
683
+ return new Cursor(this.#conn, this.#quoted, where, params, projection);
545
684
  }
546
685
 
547
- findOne(
686
+ async findOne(
548
687
  filter?: Record<string, unknown> | null,
549
688
  projection?: Record<string, unknown> | null,
550
- ): Record<string, unknown> | null {
551
- const results = this.find(filter, projection).limit(1).toArray();
689
+ ): Promise<Record<string, unknown> | null> {
690
+ const results = await this.find(filter, projection).limit(1).toArray();
552
691
  return results.length ? results[0] : null;
553
692
  }
554
693
 
555
- countDocuments(filter?: Record<string, unknown> | null): number {
694
+ async countDocuments(filter?: Record<string, unknown> | null): Promise<number> {
556
695
  const { where, params } = compileFilter(filter ?? {});
557
- const row = this.connection
558
- .prepare(`SELECT count(*) AS c FROM ${this.quoted} WHERE ${where}`)
696
+ const row = this.#conn
697
+ .prepare(`SELECT count(*) AS c FROM ${this.#quoted} WHERE ${where}`)
559
698
  .get(...(params as never[])) as { c: number | bigint };
560
699
  return Number(row.c);
561
700
  }
562
701
 
563
- estimatedDocumentCount(): number {
564
- const row = this.connection
565
- .prepare(`SELECT count(*) AS c FROM ${this.quoted}`)
702
+ async estimatedDocumentCount(): Promise<number> {
703
+ const row = this.#conn
704
+ .prepare(`SELECT count(*) AS c FROM ${this.#quoted}`)
566
705
  .get() as { c: number | bigint };
567
706
  return Number(row.c);
568
707
  }
569
708
 
570
- distinct(key: string, filter?: Record<string, unknown> | null): unknown[] {
709
+ async distinct(key: string, filter?: Record<string, unknown> | null): Promise<unknown[]> {
571
710
  const seen: unknown[] = [];
572
- for (const doc of this.find(filter).toArray()) {
711
+ for (const doc of await this.find(filter).toArray()) {
573
712
  const v = doc[key];
574
713
  if (!seen.some((s) => valuesEqual(s, v))) seen.push(v);
575
714
  }
@@ -577,28 +716,28 @@ export class SqliteCollection {
577
716
  }
578
717
 
579
718
  // -- updates (filter pushed to SQL; mutation applied per matched doc) --
580
- private matchingRows(filter?: Record<string, unknown> | null): { _id: string; doc: string }[] {
719
+ #matchingRows(filter?: Record<string, unknown> | null): { _id: string; doc: string }[] {
581
720
  const { where, params } = compileFilter(filter ?? {});
582
- return this.connection
583
- .prepare(`SELECT _id, doc FROM ${this.quoted} WHERE ${where}`)
721
+ return this.#conn
722
+ .prepare(`SELECT _id, doc FROM ${this.#quoted} WHERE ${where}`)
584
723
  .all(...(params as never[])) as { _id: string; doc: string }[];
585
724
  }
586
725
 
587
- private firstMatch(filter?: Record<string, unknown> | null): { _id: string; doc: string } | null {
726
+ #firstMatch(filter?: Record<string, unknown> | null): { _id: string; doc: string } | null {
588
727
  const { where, params } = compileFilter(filter ?? {});
589
- const row = this.connection
590
- .prepare(`SELECT _id, doc FROM ${this.quoted} WHERE ${where} LIMIT 1`)
728
+ const row = this.#conn
729
+ .prepare(`SELECT _id, doc FROM ${this.#quoted} WHERE ${where} LIMIT 1`)
591
730
  .get(...(params as never[])) as { _id: string; doc: string } | undefined;
592
731
  return row ?? null;
593
732
  }
594
733
 
595
- private writeBack(oldId: string, newDoc: Record<string, unknown>): void {
596
- this.connection
597
- .prepare(`UPDATE ${this.quoted} SET _id = ?, doc = ? WHERE _id = ?`)
598
- .run(idKey(newDoc._id), this.dump(newDoc), oldId);
734
+ #writeBack(oldId: string, newDoc: Record<string, unknown>): void {
735
+ this.#conn
736
+ .prepare(`UPDATE ${this.#quoted} SET _id = ?, doc = ? WHERE _id = ?`)
737
+ .run(idKey(newDoc._id), dumpDoc(newDoc), oldId);
599
738
  }
600
739
 
601
- private doUpsert(filter: Record<string, unknown> | null | undefined, update: Record<string, unknown>): UpdateResult {
740
+ async #doUpsert(filter: Record<string, unknown> | null | undefined, update: Record<string, unknown>): Promise<UpdateResult> {
602
741
  // Seed a document from the filter's equality terms, then apply the update.
603
742
  const seed: Record<string, unknown> = {};
604
743
  for (const [k, v] of Object.entries(filter ?? {})) {
@@ -608,56 +747,56 @@ export class SqliteCollection {
608
747
  }
609
748
  const doc = applyUpdate(seed, update);
610
749
  if (!("_id" in doc)) doc._id = new ObjectId();
611
- this.insertOne(doc);
750
+ await this.insertOne(doc);
612
751
  return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: doc._id };
613
752
  }
614
753
 
615
- updateOne(
754
+ async updateOne(
616
755
  filter: Record<string, unknown> | null | undefined,
617
756
  update: Record<string, unknown>,
618
757
  options?: { upsert?: boolean },
619
- ): UpdateResult {
620
- const row = this.firstMatch(filter);
758
+ ): Promise<UpdateResult> {
759
+ const row = this.#firstMatch(filter);
621
760
  if (!row) {
622
- if (options?.upsert) return this.doUpsert(filter, update);
761
+ if (options?.upsert) return this.#doUpsert(filter, update);
623
762
  return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
624
763
  }
625
764
  const doc = decodeValue(JSON.parse(row.doc)) as Record<string, unknown>;
626
765
  const newDoc = applyUpdate(doc, update);
627
- this.writeBack(row._id, newDoc);
766
+ this.#writeBack(row._id, newDoc);
628
767
  return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
629
768
  }
630
769
 
631
- updateMany(
770
+ async updateMany(
632
771
  filter: Record<string, unknown> | null | undefined,
633
772
  update: Record<string, unknown>,
634
773
  options?: { upsert?: boolean },
635
- ): UpdateResult {
636
- const rows = this.matchingRows(filter);
637
- if (rows.length === 0 && options?.upsert) return this.doUpsert(filter, update);
774
+ ): Promise<UpdateResult> {
775
+ const rows = this.#matchingRows(filter);
776
+ if (rows.length === 0 && options?.upsert) return this.#doUpsert(filter, update);
638
777
  let matched = 0;
639
778
  let modified = 0;
640
779
  for (const row of rows) {
641
780
  matched += 1;
642
781
  const doc = decodeValue(JSON.parse(row.doc)) as Record<string, unknown>;
643
782
  const newDoc = applyUpdate(doc, update);
644
- this.writeBack(row._id, newDoc);
783
+ this.#writeBack(row._id, newDoc);
645
784
  modified += 1;
646
785
  }
647
786
  return { acknowledged: true, matchedCount: matched, modifiedCount: modified, upsertedId: null };
648
787
  }
649
788
 
650
- replaceOne(
789
+ async replaceOne(
651
790
  filter: Record<string, unknown> | null | undefined,
652
791
  replacement: Record<string, unknown>,
653
792
  options?: { upsert?: boolean },
654
- ): UpdateResult {
655
- const row = this.firstMatch(filter);
793
+ ): Promise<UpdateResult> {
794
+ const row = this.#firstMatch(filter);
656
795
  if (!row) {
657
796
  if (options?.upsert) {
658
797
  const doc = { ...replacement };
659
798
  if (!("_id" in doc)) doc._id = new ObjectId();
660
- this.insertOne(doc);
799
+ await this.insertOne(doc);
661
800
  return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: doc._id };
662
801
  }
663
802
  return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
@@ -667,28 +806,28 @@ export class SqliteCollection {
667
806
  const existing = decodeValue(JSON.parse(row.doc)) as Record<string, unknown>;
668
807
  doc._id = existing._id;
669
808
  }
670
- this.writeBack(row._id, doc);
809
+ this.#writeBack(row._id, doc);
671
810
  return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
672
811
  }
673
812
 
674
813
  // -- deletes --
675
- deleteOne(filter?: Record<string, unknown> | null): DeleteResult {
676
- const row = this.firstMatch(filter);
814
+ async deleteOne(filter?: Record<string, unknown> | null): Promise<DeleteResult> {
815
+ const row = this.#firstMatch(filter);
677
816
  if (!row) return { acknowledged: true, deletedCount: 0 };
678
- this.connection.prepare(`DELETE FROM ${this.quoted} WHERE _id = ?`).run(row._id);
817
+ this.#conn.prepare(`DELETE FROM ${this.#quoted} WHERE _id = ?`).run(row._id);
679
818
  return { acknowledged: true, deletedCount: 1 };
680
819
  }
681
820
 
682
- deleteMany(filter?: Record<string, unknown> | null): DeleteResult {
821
+ async deleteMany(filter?: Record<string, unknown> | null): Promise<DeleteResult> {
683
822
  const { where, params } = compileFilter(filter ?? {});
684
- const result = this.connection
685
- .prepare(`DELETE FROM ${this.quoted} WHERE ${where}`)
823
+ const result = this.#conn
824
+ .prepare(`DELETE FROM ${this.#quoted} WHERE ${where}`)
686
825
  .run(...(params as never[]));
687
826
  return { acknowledged: true, deletedCount: Number(result.changes) };
688
827
  }
689
828
 
690
- drop(): void {
691
- this.connection.exec(`DROP TABLE IF EXISTS ${this.quoted}`);
829
+ async drop(): Promise<void> {
830
+ this.#conn.exec(`DROP TABLE IF EXISTS ${this.#quoted}`);
692
831
  }
693
832
  }
694
833
 
@@ -758,16 +897,41 @@ export class SqliteDatabase {
758
897
  }
759
898
 
760
899
  /**
761
- * The configured Mongo URI, reusing the app-wide queue/session env vars.
762
- * Canonical TINA4_SESSION_MONGO_URI; TINA4_SESSION_MONGO_URL is a legacy alias.
900
+ * A Mongo URI is configured but the MongoDB driver is not installed.
901
+ *
902
+ * ADR-0024 rule 3, settled for DocStore by ADR-0033: a provider that cannot
903
+ * honour an operation must RAISE, naming the provider and what is missing.
904
+ * Node already threw here, but with a bare ERR_MODULE_NOT_FOUND that named an
905
+ * npm package and not the framework decision that led there - so the outcome
906
+ * was loud but undocumented, and different from the other three frameworks.
907
+ */
908
+ export class DocStoreDriverMissing extends Error {
909
+ constructor(message: string) {
910
+ super(message);
911
+ this.name = "DocStoreDriverMissing";
912
+ }
913
+ }
914
+
915
+ /**
916
+ * The env var that supplied the URI, or "" when none did.
917
+ *
918
+ * Named separately so an error can tell the operator WHICH variable to unset
919
+ * without ever printing its value - a Mongo URI routinely carries
920
+ * `user:password@`.
921
+ *
922
+ * Canonical TINA4_MONGO_URI, then the session-layer TINA4_SESSION_MONGO_URI;
923
+ * TINA4_SESSION_MONGO_URL is a legacy alias.
763
924
  */
925
+ const MONGO_URI_VARS = ["TINA4_MONGO_URI", "TINA4_SESSION_MONGO_URI", "TINA4_SESSION_MONGO_URL"] as const;
926
+
927
+ function mongoUriSource(): string {
928
+ return MONGO_URI_VARS.find((name) => (process.env[name] ?? "").trim() !== "") ?? "";
929
+ }
930
+
931
+ /** The configured Mongo URI, reusing the app-wide queue/session env vars. */
764
932
  function mongoUri(): string {
765
- return (
766
- process.env.TINA4_MONGO_URI ||
767
- process.env.TINA4_SESSION_MONGO_URI ||
768
- process.env.TINA4_SESSION_MONGO_URL ||
769
- ""
770
- ).trim();
933
+ const source = mongoUriSource();
934
+ return source ? (process.env[source] ?? "").trim() : "";
771
935
  }
772
936
 
773
937
  /** True when no Mongo is configured, so the SQLite fallback is in effect. */
@@ -792,22 +956,99 @@ function getDb(): SqliteDatabase {
792
956
  * `mongodb` driver is installed); otherwise a `SqliteCollection` backed by the
793
957
  * local SQLite file. Same call sites either way - only the backend differs.
794
958
  *
795
- * The real-Mongo path is async (the driver connects lazily), so this returns a
796
- * Promise when Mongo is configured. In serverless mode it returns a
797
- * SqliteCollection synchronously (the common local-dev case).
959
+ * ALWAYS async, on BOTH providers (ADR-0025 clause 3).
960
+ *
961
+ * It used to return a SqliteCollection SYNCHRONOUSLY in serverless mode and a
962
+ * Promise on the real-Mongo path. That made identical source change TYPE when
963
+ * TINA4_MONGO_URI was set, and a Promise is always truthy - so un-awaited code
964
+ * read a real document locally and a thenable in production, and `if (doc)`
965
+ * succeeded for a document that did not exist. The driver cannot become sync,
966
+ * so the fallback becomes async.
798
967
  */
799
- export function getCollection(name: string): SqliteCollection | Promise<unknown> {
968
+ export async function getCollection(name: string): Promise<SqliteCollection | unknown> {
800
969
  if (isServerless()) {
801
970
  return getDb().getCollection(name);
802
971
  }
803
- return (async () => {
804
- const { MongoClient } = await import("mongodb");
805
- const uri = mongoUri();
806
- const dbName = process.env.TINA4_MONGO_DB || process.env.TINA4_SESSION_MONGO_DB || "tina4";
807
- const client = new MongoClient(uri);
808
- await client.connect();
809
- return client.db(dbName).collection(name);
810
- })();
972
+ const dbName = process.env.TINA4_MONGO_DB || process.env.TINA4_SESSION_MONGO_DB || "tina4";
973
+ const { db } = await mongoConnection(mongoUri(), dbName);
974
+ return db.collection(name);
975
+ }
976
+
977
+ /** One connected client per (uri, database), keyed so a reconfigure gets its own. */
978
+ const mongoClients = new Map<string, Promise<{ client: any; db: any }>>();
979
+
980
+ /**
981
+ * Return the shared client for this (uri, database), connecting once.
982
+ *
983
+ * MEASURED 2026-08-03 against a real MongoDB: getCollection() used to construct
984
+ * a `new MongoClient` on EVERY call and never close it, so 20 calls left 40
985
+ * server connections open and the count grew without bound. It was invisible in
986
+ * development because the SQLite fallback has no connections at all - a resource
987
+ * leak that only exists AFTER the swap to the real provider.
988
+ *
989
+ * The map holds the in-flight PROMISE rather than the resolved client. Caching
990
+ * the resolved value would leave a check-then-act window in which two
991
+ * concurrent callers both miss the cache and both build a client - which is the
992
+ * same leak, just rarer and harder to see. A failed connect is evicted so a
993
+ * transient outage cannot poison the entry for the life of the process.
994
+ *
995
+ * A missing driver is reported at provider RESOLUTION - the import, before the
996
+ * connect - so no network I/O is needed to establish it.
997
+ */
998
+ async function importMongoDriver(): Promise<any> {
999
+ try {
1000
+ return await import("mongodb");
1001
+ } catch (error: any) {
1002
+ // Only a genuinely UNRESOLVABLE `mongodb` becomes the documented error.
1003
+ // Anything else - the driver present but one of ITS imports failing, a
1004
+ // syntax error, a native binding problem - is re-thrown untouched, because
1005
+ // relabelling it would send the operator to install a package they already
1006
+ // have.
1007
+ const unresolvable =
1008
+ error?.code === "ERR_MODULE_NOT_FOUND" && String(error?.message ?? "").includes("'mongodb'");
1009
+ if (!unresolvable) throw error;
1010
+
1011
+ const source = mongoUriSource() || "TINA4_MONGO_URI";
1012
+ throw new DocStoreDriverMissing(
1013
+ `Tina4 DocStore: ${source} is set, so the MongoDB provider is selected, but its ` +
1014
+ `driver is not installed (npm package 'mongodb'). Install it with ` +
1015
+ `\`npm install mongodb\`, or unset ${source} to use the local SQLite store.`,
1016
+ );
1017
+ }
1018
+ }
1019
+
1020
+ function mongoConnection(uri: string, dbName: string): Promise<{ client: any; db: any }> {
1021
+ const key = `${uri}\u0000${dbName}`;
1022
+ let pending = mongoClients.get(key);
1023
+ if (!pending) {
1024
+ pending = (async () => {
1025
+ const { MongoClient } = await importMongoDriver();
1026
+ const client = new MongoClient(uri);
1027
+ await client.connect();
1028
+ return { client, db: client.db(dbName) };
1029
+ })();
1030
+ mongoClients.set(key, pending);
1031
+ pending.catch(() => mongoClients.delete(key));
1032
+ }
1033
+ return pending;
1034
+ }
1035
+
1036
+ /**
1037
+ * Close every DocStore connection: the SQLite store and all Mongo clients.
1038
+ *
1039
+ * A pooled client keeps the event loop alive, so a script or test that touches
1040
+ * the real provider needs a way to let the process end on its own.
1041
+ */
1042
+ export async function closeDocStore(): Promise<void> {
1043
+ const pending = [...mongoClients.values()];
1044
+ mongoClients.clear();
1045
+ await Promise.allSettled(
1046
+ pending.map(async (p) => {
1047
+ const { client } = await p;
1048
+ await client.close();
1049
+ }),
1050
+ );
1051
+ resetDefaultStore();
811
1052
  }
812
1053
 
813
1054
  /** Drop the cached default SQLite store (test helper). */