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.
- package/CLAUDE.md +170 -28
- package/README.md +2 -2
- package/package.json +13 -9
- package/packages/cli/dist/bin.js +33126 -30055
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +33062 -29908
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +7 -6
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +81 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -143
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +26554 -23400
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +64 -25
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +338 -198
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +177 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +163 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +87 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +92 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +200 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +41 -0
- package/types/core/src/middleware.d.ts +330 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +268 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +366 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +146 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +341 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +242 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +402 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +183 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +77 -0
- package/types/orm/src/adapters/mysql.d.ts +67 -0
- package/types/orm/src/adapters/odbc.d.ts +94 -0
- package/types/orm/src/adapters/postgres.d.ts +86 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +68 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +427 -0
- package/types/orm/src/cachedDatabase.d.ts +190 -0
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +655 -0
- package/types/orm/src/databaseResult.d.ts +109 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +241 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +43 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +193 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +258 -0
- package/types/orm/src/types.d.ts +148 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
- 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");
|
|
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
|
-
|
|
272
|
-
|
|
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
|
|
339
|
+
return compare(field, COMPARATORS[op], operand);
|
|
283
340
|
}
|
|
284
341
|
if (op === "$eq") {
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
573
|
+
this.#limit = Math.trunc(n);
|
|
446
574
|
return this;
|
|
447
575
|
}
|
|
448
576
|
|
|
449
577
|
skip(n: number): this {
|
|
450
|
-
this
|
|
578
|
+
this.#skip = Math.trunc(n);
|
|
451
579
|
return this;
|
|
452
580
|
}
|
|
453
581
|
|
|
454
|
-
|
|
455
|
-
let sql = `SELECT doc FROM ${this
|
|
456
|
-
if (this.
|
|
457
|
-
const order = this
|
|
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
|
|
463
|
-
sql += ` LIMIT ${Math.trunc(this
|
|
464
|
-
if (this
|
|
465
|
-
} else if (this
|
|
466
|
-
sql += ` LIMIT -1 OFFSET ${Math.trunc(this
|
|
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
|
-
/**
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
/**
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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
|
|
504
|
-
this.
|
|
505
|
-
`CREATE TABLE IF NOT EXISTS ${this
|
|
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
|
|
524
|
-
.prepare(`INSERT INTO ${this
|
|
525
|
-
.run(idKey(doc._id),
|
|
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.
|
|
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),
|
|
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
|
|
558
|
-
.prepare(`SELECT count(*) AS c FROM ${this
|
|
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
|
|
565
|
-
.prepare(`SELECT count(*) AS c FROM ${this
|
|
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
|
-
|
|
719
|
+
#matchingRows(filter?: Record<string, unknown> | null): { _id: string; doc: string }[] {
|
|
581
720
|
const { where, params } = compileFilter(filter ?? {});
|
|
582
|
-
return this
|
|
583
|
-
.prepare(`SELECT _id, doc FROM ${this
|
|
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
|
-
|
|
726
|
+
#firstMatch(filter?: Record<string, unknown> | null): { _id: string; doc: string } | null {
|
|
588
727
|
const { where, params } = compileFilter(filter ?? {});
|
|
589
|
-
const row = this
|
|
590
|
-
.prepare(`SELECT _id, doc FROM ${this
|
|
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
|
-
|
|
596
|
-
this
|
|
597
|
-
.prepare(`UPDATE ${this
|
|
598
|
-
.run(idKey(newDoc._id),
|
|
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
|
-
|
|
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
|
|
758
|
+
): Promise<UpdateResult> {
|
|
759
|
+
const row = this.#firstMatch(filter);
|
|
621
760
|
if (!row) {
|
|
622
|
-
if (options?.upsert) return this
|
|
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
|
|
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
|
|
637
|
-
if (rows.length === 0 && options?.upsert) return this
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
685
|
-
.prepare(`DELETE FROM ${this
|
|
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.
|
|
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
|
-
*
|
|
762
|
-
*
|
|
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
|
-
|
|
766
|
-
|
|
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
|
-
*
|
|
796
|
-
*
|
|
797
|
-
* SqliteCollection
|
|
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 |
|
|
968
|
+
export async function getCollection(name: string): Promise<SqliteCollection | unknown> {
|
|
800
969
|
if (isServerless()) {
|
|
801
970
|
return getDb().getCollection(name);
|
|
802
971
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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). */
|