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
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tina4 synchronous MongoDB transport —
|
|
2
|
+
* Tina4 synchronous MongoDB transport — ONE persistent client per target.
|
|
3
3
|
*
|
|
4
|
-
* The session-handler interface is synchronous
|
|
5
|
-
* so
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The session-handler interface is synchronous but every Mongo client is async,
|
|
5
|
+
* so this rides the shared sync-over-async bridge (syncBridge): a Worker thread
|
|
6
|
+
* keeps its own event loop and holds the connection, the caller blocks in
|
|
7
|
+
* Atomics.wait until the worker answers through a SharedArrayBuffer.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* WHY IT CHANGED. Each command used to run in a short-lived `node -e` child, so
|
|
10
|
+
* every session read or write paid a process spawn AND a `require("mongodb")`
|
|
11
|
+
* AND a fresh connection handshake. Measured on this machine that was p50 230ms
|
|
12
|
+
* per session write+read — by far the worst of the four backends (Valkey on the
|
|
13
|
+
* same bridge is p50 10.5ms), and the same class of defect that made the Valkey
|
|
14
|
+
* session tests flaky. The client is now created once and reused.
|
|
15
|
+
*
|
|
16
|
+
* Two transports are kept, mirroring the Python master's pymongo-first design:
|
|
17
|
+
*
|
|
18
|
+
* 1. The official `mongodb` driver when it resolves (the normal path —
|
|
10
19
|
* @tina4/orm depends on it, so it is present in any app that uses the ORM).
|
|
11
|
-
* 2. A raw MongoDB wire-protocol (OP_MSG) fallback over node:net with a
|
|
12
|
-
* BSON codec — zero dependencies.
|
|
20
|
+
* 2. A raw MongoDB wire-protocol (OP_MSG) fallback over node:net with a
|
|
21
|
+
* minimal BSON codec — zero dependencies.
|
|
13
22
|
*
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
16
|
-
* CommandNotFound
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* match. Here the command name comes first and `$db` last (matching the Python
|
|
20
|
-
* master), the BSON codec is little-endian and handles every type the commands use,
|
|
21
|
-
* and the OP_MSG response is decoded properly (cursor.firstBatch for find, `ok` for
|
|
22
|
-
* writes).
|
|
23
|
+
* The raw path's hard-won details are preserved exactly: the command name comes
|
|
24
|
+
* FIRST and `$db` LAST (putting `$db` first made the server read it as the
|
|
25
|
+
* command name and answer CommandNotFound), the BSON codec is little-endian and
|
|
26
|
+
* encodes arrays/booleans/doubles properly, and the OP_MSG response is decoded
|
|
27
|
+
* rather than regex-matched against binary.
|
|
23
28
|
*/
|
|
24
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
getBridge,
|
|
31
|
+
STATUS_ERROR,
|
|
32
|
+
STATUS_NIL,
|
|
33
|
+
STATUS_OK,
|
|
34
|
+
STATUS_TRANSPORT,
|
|
35
|
+
} from "./syncBridge.js";
|
|
25
36
|
|
|
26
37
|
export interface MongoTarget {
|
|
27
38
|
host: string;
|
|
@@ -30,23 +41,271 @@ export interface MongoTarget {
|
|
|
30
41
|
collection: string;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Command args: `filter` (always), plus `data`/`expires_at`/`last_accessed` for
|
|
46
|
+
* an update.
|
|
47
|
+
*
|
|
48
|
+
* `expires_at` is an ABSOLUTE epoch-seconds deadline computed at write time. It
|
|
49
|
+
* is what makes the session actually expire: this handler previously stored no
|
|
50
|
+
* expiry at all, created no TTL index, and never consulted anything on read, so
|
|
51
|
+
* MongoDB sessions lived forever.
|
|
52
|
+
*/
|
|
34
53
|
export interface MongoCommandArgs {
|
|
35
54
|
filter: Record<string, unknown>;
|
|
36
55
|
data?: unknown;
|
|
56
|
+
expires_at?: number;
|
|
37
57
|
last_accessed?: number;
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
/**
|
|
41
|
-
*
|
|
61
|
+
* Server-selection budget for the driver. Kept BELOW the bridge's reply timeout
|
|
62
|
+
* so an unreachable server is reported as a Mongo connection failure with a real
|
|
63
|
+
* message, rather than surfacing as the caller's generic reply timeout.
|
|
64
|
+
*/
|
|
65
|
+
const SERVER_SELECTION_MS = 3000;
|
|
66
|
+
|
|
67
|
+
const MONGO_WORKER = `
|
|
68
|
+
const net = require("node:net");
|
|
69
|
+
const target = workerData.target;
|
|
70
|
+
const host = target.host;
|
|
71
|
+
const port = target.port;
|
|
72
|
+
const database = target.database;
|
|
73
|
+
const collection = target.collection;
|
|
74
|
+
|
|
75
|
+
// TINA4_MONGO_FORCE_RAW selects the zero-dep wire-protocol path even where the
|
|
76
|
+
// driver IS installed. Without it the raw fallback can never be exercised on a
|
|
77
|
+
// machine that has mongodb (which is every machine using @tina4/orm), so the
|
|
78
|
+
// zero-dependency path would ship untested. This selects a REAL code path
|
|
79
|
+
// against a REAL server - it is not a stub.
|
|
80
|
+
let hasDriver = false;
|
|
81
|
+
if (!workerData.forceRaw) {
|
|
82
|
+
try { require.resolve("mongodb"); hasDriver = true; } catch (e) {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── driver path (preferred — matches the Python master's pymongo-first) ──
|
|
86
|
+
// The client is created ONCE and reused. Creating and closing one per command
|
|
87
|
+
// was the whole cost: a driver load plus a handshake on every session read.
|
|
88
|
+
let client = null;
|
|
89
|
+
let coll = null;
|
|
90
|
+
|
|
91
|
+
async function driverCall(command, args) {
|
|
92
|
+
try {
|
|
93
|
+
if (!coll) {
|
|
94
|
+
const { MongoClient } = require("mongodb");
|
|
95
|
+
client = new MongoClient("mongodb://" + host + ":" + port, { serverSelectionTimeoutMS: ${SERVER_SELECTION_MS} });
|
|
96
|
+
await client.connect();
|
|
97
|
+
coll = client.db(database).collection(collection);
|
|
98
|
+
}
|
|
99
|
+
if (command === "find") {
|
|
100
|
+
const doc = await coll.findOne(args.filter);
|
|
101
|
+
// A genuine miss is NOT a failure - it is "no session yet".
|
|
102
|
+
if (!doc) { __reply(${STATUS_NIL}, null); return; }
|
|
103
|
+
__reply(${STATUS_OK}, JSON.stringify(doc));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (command === "update") {
|
|
107
|
+
await coll.updateOne(
|
|
108
|
+
args.filter,
|
|
109
|
+
{ $set: { data: args.data, expires_at: args.expires_at, last_accessed: args.last_accessed } },
|
|
110
|
+
{ upsert: true },
|
|
111
|
+
);
|
|
112
|
+
} else {
|
|
113
|
+
await coll.deleteOne(args.filter);
|
|
114
|
+
}
|
|
115
|
+
__reply(${STATUS_OK}, "__OK__");
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Drop the client so the NEXT command reconnects - a transient blip must
|
|
118
|
+
// not poison the channel forever.
|
|
119
|
+
try { if (client) await client.close(); } catch (e) {}
|
|
120
|
+
client = null;
|
|
121
|
+
coll = null;
|
|
122
|
+
__reply(${STATUS_TRANSPORT}, String((err && err.message) || err));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── raw OP_MSG fallback (zero-dep, mirrors the Python master codec) ──
|
|
127
|
+
function encElem(key, value) {
|
|
128
|
+
const ck = Buffer.concat([Buffer.from(key, "utf-8"), Buffer.from([0])]);
|
|
129
|
+
if (value === null || value === undefined) return Buffer.concat([Buffer.from([0x0a]), ck]);
|
|
130
|
+
if (typeof value === "boolean") return Buffer.concat([Buffer.from([0x08]), ck, Buffer.from([value ? 1 : 0])]);
|
|
131
|
+
if (typeof value === "number") {
|
|
132
|
+
if (Number.isInteger(value) && value >= -2147483648 && value <= 2147483647) {
|
|
133
|
+
const b = Buffer.alloc(4); b.writeInt32LE(value, 0);
|
|
134
|
+
return Buffer.concat([Buffer.from([0x10]), ck, b]);
|
|
135
|
+
}
|
|
136
|
+
if (Number.isInteger(value)) {
|
|
137
|
+
const b = Buffer.alloc(8); b.writeBigInt64LE(BigInt(value), 0);
|
|
138
|
+
return Buffer.concat([Buffer.from([0x12]), ck, b]);
|
|
139
|
+
}
|
|
140
|
+
const b = Buffer.alloc(8); b.writeDoubleLE(value, 0);
|
|
141
|
+
return Buffer.concat([Buffer.from([0x01]), ck, b]);
|
|
142
|
+
}
|
|
143
|
+
if (typeof value === "string") {
|
|
144
|
+
const s = Buffer.from(value, "utf-8");
|
|
145
|
+
const len = Buffer.alloc(4); len.writeInt32LE(s.length + 1, 0);
|
|
146
|
+
return Buffer.concat([Buffer.from([0x02]), ck, len, s, Buffer.from([0])]);
|
|
147
|
+
}
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
const indexed = {};
|
|
150
|
+
value.forEach((v, i) => { indexed[String(i)] = v; });
|
|
151
|
+
return Buffer.concat([Buffer.from([0x04]), ck, encDoc(indexed)]);
|
|
152
|
+
}
|
|
153
|
+
if (typeof value === "object") return Buffer.concat([Buffer.from([0x03]), ck, encDoc(value)]);
|
|
154
|
+
const s = Buffer.from(String(value), "utf-8");
|
|
155
|
+
const len = Buffer.alloc(4); len.writeInt32LE(s.length + 1, 0);
|
|
156
|
+
return Buffer.concat([Buffer.from([0x02]), ck, len, s, Buffer.from([0])]);
|
|
157
|
+
}
|
|
158
|
+
function encDoc(obj) {
|
|
159
|
+
let body = Buffer.alloc(0);
|
|
160
|
+
for (const k of Object.keys(obj)) body = Buffer.concat([body, encElem(k, obj[k])]);
|
|
161
|
+
body = Buffer.concat([body, Buffer.from([0])]);
|
|
162
|
+
const out = Buffer.alloc(4 + body.length);
|
|
163
|
+
out.writeInt32LE(out.length, 0);
|
|
164
|
+
body.copy(out, 4);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
function decDoc(buf, pos) {
|
|
168
|
+
const docLen = buf.readInt32LE(pos.i); pos.i += 4;
|
|
169
|
+
const end = pos.i + docLen - 5;
|
|
170
|
+
const doc = {};
|
|
171
|
+
while (pos.i < end) {
|
|
172
|
+
const type = buf[pos.i]; pos.i += 1;
|
|
173
|
+
const keyEnd = buf.indexOf(0, pos.i);
|
|
174
|
+
const key = buf.toString("utf-8", pos.i, keyEnd);
|
|
175
|
+
pos.i = keyEnd + 1;
|
|
176
|
+
doc[key] = decVal(buf, pos, type, end);
|
|
177
|
+
}
|
|
178
|
+
pos.i += 1;
|
|
179
|
+
return doc;
|
|
180
|
+
}
|
|
181
|
+
function decVal(buf, pos, type, end) {
|
|
182
|
+
if (type === 0x01) { const v = buf.readDoubleLE(pos.i); pos.i += 8; return v; }
|
|
183
|
+
if (type === 0x02) { const len = buf.readInt32LE(pos.i); pos.i += 4; const v = buf.toString("utf-8", pos.i, pos.i + len - 1); pos.i += len; return v; }
|
|
184
|
+
if (type === 0x03) return decDoc(buf, pos);
|
|
185
|
+
if (type === 0x04) { const d = decDoc(buf, pos); return Object.keys(d).map((k) => d[k]); }
|
|
186
|
+
if (type === 0x05) { const len = buf.readInt32LE(pos.i); pos.i += 4; pos.i += 1; const v = buf.subarray(pos.i, pos.i + len); pos.i += len; return v; }
|
|
187
|
+
if (type === 0x07) { const v = buf.toString("hex", pos.i, pos.i + 12); pos.i += 12; return v; }
|
|
188
|
+
if (type === 0x08) { const v = buf[pos.i] !== 0; pos.i += 1; return v; }
|
|
189
|
+
if (type === 0x09) { const v = Number(buf.readBigInt64LE(pos.i)); pos.i += 8; return v; }
|
|
190
|
+
if (type === 0x0a) return null;
|
|
191
|
+
if (type === 0x10) { const v = buf.readInt32LE(pos.i); pos.i += 4; return v; }
|
|
192
|
+
if (type === 0x11) { const v = Number(buf.readBigUInt64LE(pos.i)); pos.i += 8; return v; }
|
|
193
|
+
if (type === 0x12) { const v = Number(buf.readBigInt64LE(pos.i)); pos.i += 8; return v; }
|
|
194
|
+
// Unknown type: cannot size it — stop at the document boundary to avoid a loop.
|
|
195
|
+
pos.i = end;
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let sock = null;
|
|
200
|
+
let buffer = Buffer.alloc(0);
|
|
201
|
+
let pending = null; // { command }
|
|
202
|
+
let requestId = 0;
|
|
203
|
+
|
|
204
|
+
function settle(status, payload) {
|
|
205
|
+
if (!pending) return;
|
|
206
|
+
pending = null;
|
|
207
|
+
__reply(status, payload);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function drop(err) {
|
|
211
|
+
try { if (sock) sock.destroy(); } catch (e) {}
|
|
212
|
+
sock = null;
|
|
213
|
+
buffer = Buffer.alloc(0);
|
|
214
|
+
settle(${STATUS_TRANSPORT}, err || "connection lost");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function onData(chunk) {
|
|
218
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
219
|
+
if (!pending) return;
|
|
220
|
+
if (buffer.length < 4) return;
|
|
221
|
+
const msgLen = buffer.readInt32LE(0);
|
|
222
|
+
if (buffer.length < msgLen) return; // OP_MSG split across TCP chunks
|
|
223
|
+
|
|
224
|
+
const frame = buffer.subarray(0, msgLen);
|
|
225
|
+
buffer = buffer.subarray(msgLen);
|
|
226
|
+
|
|
227
|
+
let doc;
|
|
228
|
+
// 16 header + 4 flagBits + 1 section kind = 21
|
|
229
|
+
try { doc = decDoc(frame, { i: 21 }); } catch (e) { settle(${STATUS_TRANSPORT}, "decode error: " + e.message); return; }
|
|
230
|
+
|
|
231
|
+
const ok = doc && (doc.ok === 1 || doc.ok === 1.0);
|
|
232
|
+
if (!ok) { settle(${STATUS_ERROR}, "command error: " + ((doc && doc.errmsg) || JSON.stringify(doc))); return; }
|
|
233
|
+
|
|
234
|
+
if (pending.command === "find") {
|
|
235
|
+
const batch = (doc.cursor && doc.cursor.firstBatch) || [];
|
|
236
|
+
if (!batch.length) { settle(${STATUS_NIL}, null); return; }
|
|
237
|
+
settle(${STATUS_OK}, JSON.stringify(batch[0]));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
settle(${STATUS_OK}, "__OK__");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function buildMessage(command, args) {
|
|
244
|
+
let cmdDoc;
|
|
245
|
+
// The command name FIRST and $db LAST. Reversed, the server reads $db as the
|
|
246
|
+
// command name and answers CommandNotFound.
|
|
247
|
+
if (command === "find") {
|
|
248
|
+
cmdDoc = { find: collection, filter: args.filter, limit: 1, "$db": database };
|
|
249
|
+
} else if (command === "update") {
|
|
250
|
+
const u = { _id: args.filter._id, data: args.data, expires_at: args.expires_at, last_accessed: args.last_accessed };
|
|
251
|
+
cmdDoc = { update: collection, updates: [{ q: args.filter, u: u, upsert: true }], "$db": database };
|
|
252
|
+
} else {
|
|
253
|
+
cmdDoc = { delete: collection, deletes: [{ q: args.filter, limit: 1 }], "$db": database };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const bodyDoc = encDoc(cmdDoc);
|
|
257
|
+
const section = Buffer.concat([Buffer.from([0]), bodyDoc]); // section kind 0 = body
|
|
258
|
+
const flags = Buffer.alloc(4); // flagBits = 0
|
|
259
|
+
const payload = Buffer.concat([flags, section]);
|
|
260
|
+
const header = Buffer.alloc(16);
|
|
261
|
+
requestId += 1;
|
|
262
|
+
header.writeInt32LE(16 + payload.length, 0); // messageLength
|
|
263
|
+
header.writeInt32LE(requestId, 4); // requestID
|
|
264
|
+
header.writeInt32LE(0, 8); // responseTo
|
|
265
|
+
header.writeInt32LE(2013, 12); // opCode = OP_MSG
|
|
266
|
+
return Buffer.concat([header, payload]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function connect(then) {
|
|
270
|
+
const s = net.createConnection({ host: host, port: port });
|
|
271
|
+
s.setNoDelay(true);
|
|
272
|
+
const connectTimer = setTimeout(() => {
|
|
273
|
+
if (sock !== s) { try { s.destroy(); } catch (e) {} drop("connect timed out"); }
|
|
274
|
+
}, ${SERVER_SELECTION_MS});
|
|
275
|
+
s.on("data", onData);
|
|
276
|
+
s.on("error", (e) => { clearTimeout(connectTimer); drop(e.message); });
|
|
277
|
+
s.on("close", () => { clearTimeout(connectTimer); if (sock === s) drop("connection closed"); });
|
|
278
|
+
s.on("connect", () => { clearTimeout(connectTimer); sock = s; then(); });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function rawCall(command, args) {
|
|
282
|
+
// Set pending BEFORE connecting: a connect error otherwise arrives with
|
|
283
|
+
// nothing in flight and the caller blocks for its whole reply timeout.
|
|
284
|
+
pending = { command: command };
|
|
285
|
+
const write = () => {
|
|
286
|
+
try { sock.write(buildMessage(command, args)); } catch (e) { drop(e.message); }
|
|
287
|
+
};
|
|
288
|
+
if (sock) write(); else { try { connect(write); } catch (e) { drop(e.message); } }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
parentPort.on("message", (msg) => {
|
|
292
|
+
if (hasDriver) void driverCall(msg.command, msg.args);
|
|
293
|
+
else rawCall(msg.command, msg.args);
|
|
294
|
+
});
|
|
295
|
+
`;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Run a session Mongo command synchronously.
|
|
42
299
|
*
|
|
43
300
|
* - command "find" -> the matched document as a JSON string, or "__EMPTY__".
|
|
44
301
|
* - command "update" -> "__OK__" (upsert of `{_id, data, last_accessed}`).
|
|
45
302
|
* - command "delete" -> "__OK__".
|
|
46
303
|
*
|
|
47
|
-
* THROWS `<label> command failed: ...` on a transport failure (server
|
|
48
|
-
* timeout) OR a Mongo command error (`ok != 1`), so the Session
|
|
49
|
-
* log-loud + degrade (or re-throw under strict mode).
|
|
304
|
+
* THROWS `<label> command failed: ...` on a transport failure (server
|
|
305
|
+
* unreachable, timeout) OR a Mongo command error (`ok != 1`), so the Session
|
|
306
|
+
* boundary can log-loud + degrade (or re-throw under strict mode). A genuine
|
|
307
|
+
* miss is NOT a failure and comes back as "__EMPTY__" — collapsing the two is
|
|
308
|
+
* how a dead backend silently logs every user out.
|
|
50
309
|
*/
|
|
51
310
|
export function mongoCommandSync(
|
|
52
311
|
target: MongoTarget,
|
|
@@ -54,185 +313,17 @@ export function mongoCommandSync(
|
|
|
54
313
|
args: MongoCommandArgs,
|
|
55
314
|
label = "MongoDB",
|
|
56
315
|
): string {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const args = ${JSON.stringify(args)};
|
|
65
|
-
|
|
66
|
-
// ── driver path (preferred — matches the Python master's pymongo-first) ──
|
|
67
|
-
let hasDriver = false;
|
|
68
|
-
try { require.resolve("mongodb"); hasDriver = true; } catch (e) {}
|
|
69
|
-
|
|
70
|
-
if (hasDriver) {
|
|
71
|
-
(async () => {
|
|
72
|
-
let client;
|
|
73
|
-
try {
|
|
74
|
-
const { MongoClient } = require("mongodb");
|
|
75
|
-
client = new MongoClient("mongodb://" + host + ":" + port, { serverSelectionTimeoutMS: 4000 });
|
|
76
|
-
await client.connect();
|
|
77
|
-
const coll = client.db(database).collection(collection);
|
|
78
|
-
let out;
|
|
79
|
-
if (command === "find") {
|
|
80
|
-
const doc = await coll.findOne(args.filter);
|
|
81
|
-
out = doc ? JSON.stringify(doc) : "__EMPTY__";
|
|
82
|
-
} else if (command === "update") {
|
|
83
|
-
await coll.updateOne(args.filter, { $set: { data: args.data, last_accessed: args.last_accessed } }, { upsert: true });
|
|
84
|
-
out = "__OK__";
|
|
85
|
-
} else {
|
|
86
|
-
await coll.deleteOne(args.filter);
|
|
87
|
-
out = "__OK__";
|
|
88
|
-
}
|
|
89
|
-
await client.close();
|
|
90
|
-
process.stdout.write(out, () => process.exit(0));
|
|
91
|
-
} catch (err) {
|
|
92
|
-
try { if (client) await client.close(); } catch (e) {}
|
|
93
|
-
process.stderr.write(String((err && err.message) || err));
|
|
94
|
-
process.exit(1);
|
|
95
|
-
}
|
|
96
|
-
})();
|
|
97
|
-
} else {
|
|
316
|
+
const key = `mongo:${target.host}:${target.port}:${target.database}:${target.collection}`;
|
|
317
|
+
const forceRaw = process.env.TINA4_MONGO_FORCE_RAW ? 1 : 0;
|
|
318
|
+
const { status, payload } = getBridge(
|
|
319
|
+
`${key}:${forceRaw ? "raw" : "drv"}`,
|
|
320
|
+
MONGO_WORKER,
|
|
321
|
+
{ target, forceRaw },
|
|
322
|
+
).call({ command, args }, label);
|
|
98
323
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (value === null || value === undefined) return Buffer.concat([Buffer.from([0x0a]), ck]);
|
|
103
|
-
if (typeof value === "boolean") return Buffer.concat([Buffer.from([0x08]), ck, Buffer.from([value ? 1 : 0])]);
|
|
104
|
-
if (typeof value === "number") {
|
|
105
|
-
if (Number.isInteger(value) && value >= -2147483648 && value <= 2147483647) {
|
|
106
|
-
const b = Buffer.alloc(4); b.writeInt32LE(value, 0);
|
|
107
|
-
return Buffer.concat([Buffer.from([0x10]), ck, b]);
|
|
108
|
-
}
|
|
109
|
-
if (Number.isInteger(value)) {
|
|
110
|
-
const b = Buffer.alloc(8); b.writeBigInt64LE(BigInt(value), 0);
|
|
111
|
-
return Buffer.concat([Buffer.from([0x12]), ck, b]);
|
|
112
|
-
}
|
|
113
|
-
const b = Buffer.alloc(8); b.writeDoubleLE(value, 0);
|
|
114
|
-
return Buffer.concat([Buffer.from([0x01]), ck, b]);
|
|
115
|
-
}
|
|
116
|
-
if (typeof value === "string") {
|
|
117
|
-
const s = Buffer.from(value, "utf-8");
|
|
118
|
-
const len = Buffer.alloc(4); len.writeInt32LE(s.length + 1, 0);
|
|
119
|
-
return Buffer.concat([Buffer.from([0x02]), ck, len, s, Buffer.from([0])]);
|
|
120
|
-
}
|
|
121
|
-
if (Array.isArray(value)) {
|
|
122
|
-
const indexed = {};
|
|
123
|
-
value.forEach((v, i) => { indexed[String(i)] = v; });
|
|
124
|
-
return Buffer.concat([Buffer.from([0x04]), ck, encDoc(indexed)]);
|
|
125
|
-
}
|
|
126
|
-
if (typeof value === "object") return Buffer.concat([Buffer.from([0x03]), ck, encDoc(value)]);
|
|
127
|
-
const s = Buffer.from(String(value), "utf-8");
|
|
128
|
-
const len = Buffer.alloc(4); len.writeInt32LE(s.length + 1, 0);
|
|
129
|
-
return Buffer.concat([Buffer.from([0x02]), ck, len, s, Buffer.from([0])]);
|
|
130
|
-
}
|
|
131
|
-
function encDoc(obj) {
|
|
132
|
-
let body = Buffer.alloc(0);
|
|
133
|
-
for (const k of Object.keys(obj)) body = Buffer.concat([body, encElem(k, obj[k])]);
|
|
134
|
-
body = Buffer.concat([body, Buffer.from([0])]);
|
|
135
|
-
const out = Buffer.alloc(4 + body.length);
|
|
136
|
-
out.writeInt32LE(out.length, 0);
|
|
137
|
-
body.copy(out, 4);
|
|
138
|
-
return out;
|
|
139
|
-
}
|
|
140
|
-
function decDoc(buf, pos) {
|
|
141
|
-
const docLen = buf.readInt32LE(pos.i); pos.i += 4;
|
|
142
|
-
const end = pos.i + docLen - 5;
|
|
143
|
-
const doc = {};
|
|
144
|
-
while (pos.i < end) {
|
|
145
|
-
const type = buf[pos.i]; pos.i += 1;
|
|
146
|
-
const keyEnd = buf.indexOf(0, pos.i);
|
|
147
|
-
const key = buf.toString("utf-8", pos.i, keyEnd);
|
|
148
|
-
pos.i = keyEnd + 1;
|
|
149
|
-
doc[key] = decVal(buf, pos, type, end);
|
|
150
|
-
}
|
|
151
|
-
pos.i += 1;
|
|
152
|
-
return doc;
|
|
153
|
-
}
|
|
154
|
-
function decVal(buf, pos, type, end) {
|
|
155
|
-
if (type === 0x01) { const v = buf.readDoubleLE(pos.i); pos.i += 8; return v; }
|
|
156
|
-
if (type === 0x02) { const len = buf.readInt32LE(pos.i); pos.i += 4; const v = buf.toString("utf-8", pos.i, pos.i + len - 1); pos.i += len; return v; }
|
|
157
|
-
if (type === 0x03) return decDoc(buf, pos);
|
|
158
|
-
if (type === 0x04) { const d = decDoc(buf, pos); return Object.keys(d).map((k) => d[k]); }
|
|
159
|
-
if (type === 0x05) { const len = buf.readInt32LE(pos.i); pos.i += 4; pos.i += 1; const v = buf.subarray(pos.i, pos.i + len); pos.i += len; return v; }
|
|
160
|
-
if (type === 0x07) { const v = buf.toString("hex", pos.i, pos.i + 12); pos.i += 12; return v; }
|
|
161
|
-
if (type === 0x08) { const v = buf[pos.i] !== 0; pos.i += 1; return v; }
|
|
162
|
-
if (type === 0x09) { const v = Number(buf.readBigInt64LE(pos.i)); pos.i += 8; return v; }
|
|
163
|
-
if (type === 0x0a) return null;
|
|
164
|
-
if (type === 0x10) { const v = buf.readInt32LE(pos.i); pos.i += 4; return v; }
|
|
165
|
-
if (type === 0x11) { const v = Number(buf.readBigUInt64LE(pos.i)); pos.i += 8; return v; }
|
|
166
|
-
if (type === 0x12) { const v = Number(buf.readBigInt64LE(pos.i)); pos.i += 8; return v; }
|
|
167
|
-
// Unknown type: cannot size it — stop at the document boundary to avoid a loop.
|
|
168
|
-
pos.i = end;
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
let cmdDoc;
|
|
173
|
-
if (command === "find") {
|
|
174
|
-
cmdDoc = { find: collection, filter: args.filter, limit: 1, "$db": database };
|
|
175
|
-
} else if (command === "update") {
|
|
176
|
-
const u = { _id: args.filter._id, data: args.data, last_accessed: args.last_accessed };
|
|
177
|
-
cmdDoc = { update: collection, updates: [{ q: args.filter, u: u, upsert: true }], "$db": database };
|
|
178
|
-
} else {
|
|
179
|
-
cmdDoc = { delete: collection, deletes: [{ q: args.filter, limit: 1 }], "$db": database };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const bodyDoc = encDoc(cmdDoc);
|
|
183
|
-
const section = Buffer.concat([Buffer.from([0]), bodyDoc]); // section kind 0 = body
|
|
184
|
-
const flags = Buffer.alloc(4); // flagBits = 0
|
|
185
|
-
const payload = Buffer.concat([flags, section]);
|
|
186
|
-
const header = Buffer.alloc(16);
|
|
187
|
-
header.writeInt32LE(16 + payload.length, 0); // messageLength
|
|
188
|
-
header.writeInt32LE(1, 4); // requestID
|
|
189
|
-
header.writeInt32LE(0, 8); // responseTo
|
|
190
|
-
header.writeInt32LE(2013, 12); // opCode = OP_MSG
|
|
191
|
-
const message = Buffer.concat([header, payload]);
|
|
192
|
-
|
|
193
|
-
const sock = net.createConnection({ host, port });
|
|
194
|
-
sock.setNoDelay(true);
|
|
195
|
-
let buffer = Buffer.alloc(0);
|
|
196
|
-
let done = false;
|
|
197
|
-
const timer = setTimeout(() => { if (!done) { done = true; try { sock.destroy(); } catch (e) {} process.stderr.write("timeout"); process.exitCode = 1; } }, 5000);
|
|
198
|
-
function emit(s) {
|
|
199
|
-
if (done) return; done = true; clearTimeout(timer);
|
|
200
|
-
process.stdout.write(s, () => { try { sock.destroy(); } catch (e) {} });
|
|
201
|
-
}
|
|
202
|
-
function fail(msg) {
|
|
203
|
-
if (done) return; done = true; clearTimeout(timer);
|
|
204
|
-
try { sock.destroy(); } catch (e) {}
|
|
205
|
-
process.stderr.write(msg || ""); process.exitCode = 1;
|
|
206
|
-
}
|
|
207
|
-
sock.on("connect", () => sock.write(message));
|
|
208
|
-
sock.on("data", (chunk) => {
|
|
209
|
-
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
210
|
-
if (buffer.length < 4) return;
|
|
211
|
-
const msgLen = buffer.readInt32LE(0);
|
|
212
|
-
if (buffer.length < msgLen) return;
|
|
213
|
-
let doc;
|
|
214
|
-
try { doc = decDoc(buffer, { i: 21 }); } catch (e) { fail("decode error: " + e.message); return; }
|
|
215
|
-
const ok = doc && (doc.ok === 1 || doc.ok === 1.0);
|
|
216
|
-
if (!ok) { fail("command error: " + ((doc && doc.errmsg) || JSON.stringify(doc))); return; }
|
|
217
|
-
if (command === "find") {
|
|
218
|
-
const batch = (doc.cursor && doc.cursor.firstBatch) || [];
|
|
219
|
-
emit(batch.length ? JSON.stringify(batch[0]) : "__EMPTY__");
|
|
220
|
-
} else {
|
|
221
|
-
emit("__OK__");
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
sock.on("error", (err) => fail(err.message));
|
|
225
|
-
sock.on("close", () => { if (!done) fail("connection closed before reply"); });
|
|
226
|
-
}
|
|
227
|
-
`;
|
|
228
|
-
|
|
229
|
-
try {
|
|
230
|
-
return execFileSync(process.execPath, ["-e", script], {
|
|
231
|
-
encoding: "utf-8",
|
|
232
|
-
timeout: 8000,
|
|
233
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
234
|
-
});
|
|
235
|
-
} catch (err) {
|
|
236
|
-
throw new Error(`${label} command failed: ${(err as Error).message}`);
|
|
324
|
+
if (status === STATUS_NIL) return "__EMPTY__"; // genuine miss, not an error
|
|
325
|
+
if (status === STATUS_TRANSPORT || status === STATUS_ERROR) {
|
|
326
|
+
throw new Error(`${label} command failed: ${payload}`);
|
|
237
327
|
}
|
|
328
|
+
return payload;
|
|
238
329
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* TINA4_SESSION_MONGO_URI (overrides host/port if set)
|
|
11
11
|
* TINA4_SESSION_MONGO_USERNAME (optional)
|
|
12
12
|
* TINA4_SESSION_MONGO_PASSWORD (optional)
|
|
13
|
-
* TINA4_SESSION_MONGO_DB (default: "
|
|
13
|
+
* TINA4_SESSION_MONGO_DB (default: "tina4")
|
|
14
14
|
* TINA4_SESSION_MONGO_COLLECTION (default: "sessions")
|
|
15
15
|
*/
|
|
16
16
|
import type { SessionHandler } from "../session.js";
|
|
@@ -57,8 +57,18 @@ export class MongoSessionHandler implements SessionHandler {
|
|
|
57
57
|
private password: string;
|
|
58
58
|
private database: string;
|
|
59
59
|
private collection: string;
|
|
60
|
+
private hostExplicit: boolean;
|
|
61
|
+
private portExplicit: boolean;
|
|
62
|
+
private uriExplicit: boolean;
|
|
60
63
|
|
|
61
64
|
constructor(config?: MongoSessionConfig) {
|
|
65
|
+
// WHICH VALUES THE CALLER GAVE US EXPLICITLY, recorded because a URI from the
|
|
66
|
+
// ENVIRONMENT must never override an argument the caller passed by hand. See
|
|
67
|
+
// target() for the precedence and the defect this fixes.
|
|
68
|
+
this.hostExplicit = config?.host !== undefined;
|
|
69
|
+
this.portExplicit = config?.port !== undefined;
|
|
70
|
+
this.uriExplicit = config?.uri !== undefined;
|
|
71
|
+
|
|
62
72
|
this.host = config?.host
|
|
63
73
|
?? process.env.TINA4_SESSION_MONGO_HOST
|
|
64
74
|
?? "127.0.0.1";
|
|
@@ -77,23 +87,67 @@ export class MongoSessionHandler implements SessionHandler {
|
|
|
77
87
|
this.password = config?.password
|
|
78
88
|
?? process.env.TINA4_SESSION_MONGO_PASSWORD
|
|
79
89
|
?? "";
|
|
90
|
+
// "tina4" is the default in tina4-python, tina4-php and tina4-ruby. Node was
|
|
91
|
+
// the outlier at "tina4_sessions", so the SAME .env put Node's sessions in a
|
|
92
|
+
// different database from the other three - identical configuration, different
|
|
93
|
+
// observable outcome (the ADR-0024 failure mode). Breaking on purpose: see the
|
|
94
|
+
// migration note in the commit. No fallback read is offered; a session store is
|
|
95
|
+
// ephemeral by definition, so the impact self-heals within one TTL.
|
|
80
96
|
this.database = config?.database
|
|
81
97
|
?? process.env.TINA4_SESSION_MONGO_DB
|
|
82
|
-
?? "
|
|
98
|
+
?? "tina4";
|
|
83
99
|
this.collection = config?.collection
|
|
84
100
|
?? process.env.TINA4_SESSION_MONGO_COLLECTION
|
|
85
101
|
?? "sessions";
|
|
86
102
|
}
|
|
87
103
|
|
|
88
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the effective host/port (honours a configured mongodb:// URI).
|
|
106
|
+
*
|
|
107
|
+
* PRECEDENCE, and it runs the way every other resolver in Tina4 runs -
|
|
108
|
+
* EXPLICIT CONFIGURATION BEATS THE ENVIRONMENT:
|
|
109
|
+
*
|
|
110
|
+
* 1. an explicitly passed `uri` - the caller named a complete address
|
|
111
|
+
* 2. an explicitly passed host/port - per field, and an ENV uri may not touch them
|
|
112
|
+
* 3. TINA4_SESSION_MONGO_URI / _URL - the ambient address
|
|
113
|
+
* 4. TINA4_SESSION_MONGO_HOST/_PORT - ambient parts
|
|
114
|
+
* 5. 127.0.0.1:27017
|
|
115
|
+
*
|
|
116
|
+
* THE DEFECT THIS FIXES, measured 2026-08-05 on the lab host: the URI won
|
|
117
|
+
* UNCONDITIONALLY, including over an argument the caller had just passed by
|
|
118
|
+
* hand. With TINA4_SESSION_MONGO_URI=mongodb://127.0.0.1:27017/tina4_node
|
|
119
|
+
* exported - an entirely ordinary deployment setting -
|
|
120
|
+
*
|
|
121
|
+
* new MongoSessionHandler({ host: "127.0.0.1", port: 59999 })
|
|
122
|
+
*
|
|
123
|
+
* resolved to 127.0.0.1:27017. The handler dialled a DIFFERENT SERVER from the
|
|
124
|
+
* one it was told to use, said nothing, and a read against it came back null:
|
|
125
|
+
* indistinguishable from a genuine miss. So an app that points a handler at one
|
|
126
|
+
* Mongo while the environment names another writes its sessions to the wrong
|
|
127
|
+
* server, and the backend-failure policy cannot fire because nothing failed.
|
|
128
|
+
*
|
|
129
|
+
* It also made two suites report a framework contract as broken - the
|
|
130
|
+
* unreachable-server-must-throw cases in sessionHandlers and
|
|
131
|
+
* sessionMongoRawProtocol never reached the dead port at all, so they measured
|
|
132
|
+
* a live server and got a miss. Those cases were RIGHT; this was the bug they
|
|
133
|
+
* were catching.
|
|
134
|
+
*
|
|
135
|
+
* Same class as the TINA4_QUEUE_URL precedence inversion fixed in PHP's
|
|
136
|
+
* Queue::resolveMongoConfig earlier the same day: environment quietly beating
|
|
137
|
+
* an explicit argument.
|
|
138
|
+
*/
|
|
89
139
|
private target(): MongoTarget {
|
|
90
140
|
let host = this.host;
|
|
91
141
|
let port = this.port;
|
|
92
|
-
|
|
142
|
+
// An ENV-supplied uri may fill in only what the caller did NOT pin. An
|
|
143
|
+
// explicitly passed uri is the caller's own choice and still wins outright.
|
|
144
|
+
const uriMayOverrideHost = this.uriExplicit || !this.hostExplicit;
|
|
145
|
+
const uriMayOverridePort = this.uriExplicit || !this.portExplicit;
|
|
146
|
+
if (this.uri && (uriMayOverrideHost || uriMayOverridePort)) {
|
|
93
147
|
const match = this.uri.match(/mongodb:\/\/(?:[^@/]+@)?([^/:]+):?(\d+)?/);
|
|
94
148
|
if (match) {
|
|
95
|
-
host = match[1];
|
|
96
|
-
port = match[2] ? parseInt(match[2], 10) : 27017;
|
|
149
|
+
if (uriMayOverrideHost) host = match[1];
|
|
150
|
+
if (uriMayOverridePort) port = match[2] ? parseInt(match[2], 10) : 27017;
|
|
97
151
|
}
|
|
98
152
|
}
|
|
99
153
|
return { host, port, database: this.database, collection: this.collection };
|
|
@@ -103,7 +157,21 @@ export class MongoSessionHandler implements SessionHandler {
|
|
|
103
157
|
const result = mongoCommandSync(this.target(), "find", { filter: { _id: sessionId } });
|
|
104
158
|
if (!result || result === "__EMPTY__") return null; // genuine miss
|
|
105
159
|
try {
|
|
106
|
-
const doc = JSON.parse(result) as { data?: unknown };
|
|
160
|
+
const doc = JSON.parse(result) as { data?: unknown; expires_at?: number };
|
|
161
|
+
|
|
162
|
+
// Expiry is an ABSOLUTE deadline stamped at write time, and an absent or
|
|
163
|
+
// zero stamp means "never expires" - so it is guarded OUT of the
|
|
164
|
+
// comparison, never fed INTO it. Before this, read() consulted NOTHING:
|
|
165
|
+
// no stamp was stored, no TTL index was created, and write() took the ttl
|
|
166
|
+
// as `_ttl` and discarded it, so a mongodb-backed session never expired at
|
|
167
|
+
// all. Measured: write with ttl=1, sleep 3s, read still returned the data
|
|
168
|
+
// while file/redis/valkey/memcached/database all returned null.
|
|
169
|
+
const expiresAt = Number(doc?.expires_at ?? 0);
|
|
170
|
+
if (expiresAt > 0 && Date.now() / 1000 > expiresAt) {
|
|
171
|
+
this.destroy(sessionId);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
107
175
|
const data = doc?.data;
|
|
108
176
|
// Stored as a nested document (parity with the Python master); read returns it.
|
|
109
177
|
return data && typeof data === "object" ? (data as SessionData) : null;
|
|
@@ -112,10 +180,22 @@ export class MongoSessionHandler implements SessionHandler {
|
|
|
112
180
|
}
|
|
113
181
|
}
|
|
114
182
|
|
|
115
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Write session data.
|
|
185
|
+
*
|
|
186
|
+
* The ttl is consumed HERE, at write time, and baked into an absolute deadline,
|
|
187
|
+
* so nothing at read time needs to know what the ttl was. The parameter used to
|
|
188
|
+
* be named `_ttl` and thrown away.
|
|
189
|
+
*
|
|
190
|
+
* @param sessionId - the session id
|
|
191
|
+
* @param data - the payload to store
|
|
192
|
+
* @param ttl - lifetime in seconds; 0 or less means never expires
|
|
193
|
+
*/
|
|
194
|
+
write(sessionId: string, data: SessionData, ttl: number = 0): void {
|
|
116
195
|
mongoCommandSync(this.target(), "update", {
|
|
117
196
|
filter: { _id: sessionId },
|
|
118
197
|
data,
|
|
198
|
+
expires_at: ttl > 0 ? Math.floor(Date.now() / 1000) + ttl : 0,
|
|
119
199
|
last_accessed: Date.now() / 1000,
|
|
120
200
|
});
|
|
121
201
|
}
|