tina4-nodejs 3.13.94 → 3.13.96
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 +158 -30
- package/README.md +1 -1
- package/package.json +3 -1
- package/packages/cli/dist/bin.js +30911 -28444
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +30810 -28261
- package/packages/core/public/css/tina4.min.css +1 -1
- 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/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +6 -7
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +294 -106
- 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 +1 -1
- 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 +34 -16
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +886 -421
- package/packages/core/src/session.ts +244 -27
- 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 -208
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -147
- 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/orm/dist/index.js +22717 -20168
- 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 +60 -24
- package/packages/orm/src/autoCrud.ts +12 -10
- 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 +241 -197
- package/packages/orm/src/databaseResult.ts +51 -28
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -6
- package/packages/orm/src/migration.ts +44 -11
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +47 -6
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +21 -77
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- package/types/core/src/ai.d.ts +1 -1
- package/types/core/src/auth.d.ts +28 -5
- package/types/core/src/background.d.ts +3 -3
- package/types/core/src/cache.d.ts +15 -12
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/dotenv.d.ts +38 -16
- package/types/core/src/index.d.ts +6 -9
- package/types/core/src/logger.d.ts +93 -16
- package/types/core/src/messenger.d.ts +47 -6
- package/types/core/src/metrics.d.ts +25 -61
- package/types/core/src/middleware.d.ts +134 -11
- package/types/core/src/queue.d.ts +54 -5
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
- package/types/core/src/router.d.ts +14 -3
- package/types/core/src/server.d.ts +15 -4
- package/types/core/src/session.d.ts +87 -2
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
- package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
- 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/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +28 -5
- package/types/core/src/websocket.d.ts +26 -0
- package/types/orm/src/adapters/firebird.d.ts +55 -10
- package/types/orm/src/adapters/mongodb.d.ts +2 -2
- package/types/orm/src/adapters/mssql.d.ts +18 -11
- package/types/orm/src/adapters/mysql.d.ts +11 -10
- package/types/orm/src/adapters/odbc.d.ts +9 -12
- package/types/orm/src/adapters/postgres.d.ts +11 -10
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +15 -3
- package/types/orm/src/baseModel.d.ts +45 -9
- package/types/orm/src/cachedDatabase.d.ts +18 -5
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +78 -28
- package/types/orm/src/databaseResult.d.ts +29 -15
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +102 -43
- package/types/orm/src/index.d.ts +6 -4
- package/types/orm/src/migration.d.ts +4 -3
- package/types/orm/src/queryBuilder.d.ts +23 -3
- package/types/orm/src/sqlTranslator.d.ts +126 -2
- package/types/orm/src/types.d.ts +21 -38
- package/packages/core/src/scss.ts +0 -623
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
- package/types/core/src/scss.d.ts +0 -19
- package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
|
@@ -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
|
}
|
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tina4 synchronous RESP transport — shared by every Redis/Valkey session handler.
|
|
3
3
|
*
|
|
4
|
-
* The session-handler interface is synchronous
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The session-handler interface is synchronous but node:net is async-only, so
|
|
5
|
+
* this delegates to syncSocket, which owns ONE persistent connection per target
|
|
6
|
+
* behind a worker thread. See that file for why: the previous implementation ran
|
|
7
|
+
* every command in a short-lived `node -e` child, paying a process spawn AND a
|
|
8
|
+
* fresh TCP connection per command (p50 41ms, p99 487ms), and its long tail
|
|
9
|
+
* tripped the child's own deadline — the cause of the sessionHandlers flake.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* GET/SET/DEL), so "end" never fires — the child waited out its timeout, exited
|
|
12
|
-
* non-zero, and execFileSync re-threw it as a "transport failure". EVERY command
|
|
13
|
-
* failed against a reachable server. Here the child parses replies INCREMENTALLY on
|
|
14
|
-
* the "data" event (the same proven RESP parser cache.ts's RespClient uses): it
|
|
15
|
-
* reads exactly the replies it sent commands for (AUTH? + SELECT? + the command),
|
|
16
|
-
* then returns the LAST (command) reply.
|
|
11
|
+
* This module stays as the RESP-shaped seam the Redis/Valkey handlers call, so
|
|
12
|
+
* neither has to know how the synchronous transport is implemented.
|
|
17
13
|
*/
|
|
18
|
-
import {
|
|
19
|
-
import { childFailureError } from "./childError.js";
|
|
14
|
+
import { syncCommand } from "./syncSocket.js";
|
|
20
15
|
|
|
21
16
|
export interface RespTarget {
|
|
22
17
|
host: string;
|
|
@@ -37,139 +32,13 @@ export interface RespTarget {
|
|
|
37
32
|
* `<label> error: ...`. A rejected handshake is a transport failure, not a
|
|
38
33
|
* result, so it is surfaced ahead of the command reply.
|
|
39
34
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* The miss/failure split is the whole contract: collapsing them is how a dead
|
|
36
|
+
* backend silently logs every user out instead of surfacing an outage.
|
|
42
37
|
*/
|
|
43
38
|
export function respCommandSync(target: RespTarget, args: string[], label = "Redis"): string {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const script = `
|
|
50
|
-
const net = require("node:net");
|
|
51
|
-
const host = ${JSON.stringify(host)};
|
|
52
|
-
const port = ${port};
|
|
53
|
-
const password = ${JSON.stringify(password)};
|
|
54
|
-
const db = ${db};
|
|
55
|
-
const args = ${JSON.stringify(args)};
|
|
56
|
-
// Replies to consume = AUTH? + SELECT? + the command. The LAST is our result.
|
|
57
|
-
const expected = (password ? 1 : 0) + (db !== 0 ? 1 : 0) + 1;
|
|
58
|
-
|
|
59
|
-
function encode(a) {
|
|
60
|
-
let c = "*" + a.length + "\\r\\n";
|
|
61
|
-
for (const s of a) c += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
62
|
-
return c;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Parse one RESP value at offset. Returns { value, next } or null if more bytes
|
|
66
|
-
// are needed (so a bulk string split across TCP chunks is handled correctly).
|
|
67
|
-
function parse(buf, off) {
|
|
68
|
-
if (off >= buf.length) return null;
|
|
69
|
-
const type = buf[off];
|
|
70
|
-
const crlf = buf.indexOf("\\r\\n", off + 1, "utf-8");
|
|
71
|
-
if (crlf === -1) return null;
|
|
72
|
-
const line = buf.toString("utf-8", off + 1, crlf);
|
|
73
|
-
const after = crlf + 2;
|
|
74
|
-
if (type === 0x2b) return { value: line, next: after }; // '+' simple string
|
|
75
|
-
if (type === 0x3a) return { value: line, next: after }; // ':' integer
|
|
76
|
-
if (type === 0x2d) return { value: { __err: line }, next: after }; // '-' error
|
|
77
|
-
if (type === 0x24) { // '$' bulk string
|
|
78
|
-
const len = parseInt(line, 10);
|
|
79
|
-
if (len === -1) return { value: null, next: after };
|
|
80
|
-
if (after + len + 2 > buf.length) return null;
|
|
81
|
-
return { value: buf.toString("utf-8", after, after + len), next: after + len + 2 };
|
|
82
|
-
}
|
|
83
|
-
if (type === 0x2a) { // '*' array
|
|
84
|
-
const count = parseInt(line, 10);
|
|
85
|
-
if (count === -1) return { value: null, next: after };
|
|
86
|
-
const arr = [];
|
|
87
|
-
let pos = after;
|
|
88
|
-
for (let i = 0; i < count; i++) {
|
|
89
|
-
const el = parse(buf, pos);
|
|
90
|
-
if (!el) return null;
|
|
91
|
-
arr.push(el.value);
|
|
92
|
-
pos = el.next;
|
|
93
|
-
}
|
|
94
|
-
return { value: arr, next: pos };
|
|
95
|
-
}
|
|
96
|
-
return { value: line, next: after };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const sock = net.createConnection({ host, port });
|
|
100
|
-
sock.setNoDelay(true);
|
|
101
|
-
let buffer = Buffer.alloc(0);
|
|
102
|
-
const replies = [];
|
|
103
|
-
let done = false;
|
|
104
|
-
const timer = setTimeout(() => { if (!done) { done = true; try { sock.destroy(); } catch (e) {} process.stderr.write("timeout"); process.exitCode = 1; } }, 3000);
|
|
105
|
-
|
|
106
|
-
function emit(s) {
|
|
107
|
-
if (done) return;
|
|
108
|
-
done = true;
|
|
109
|
-
clearTimeout(timer);
|
|
110
|
-
// The write callback guarantees stdout is flushed before exit (a bare
|
|
111
|
-
// process.exit can truncate piped stdout).
|
|
112
|
-
process.stdout.write(s, () => { try { sock.destroy(); } catch (e) {} });
|
|
113
|
-
}
|
|
114
|
-
function fail(msg) {
|
|
115
|
-
if (done) return;
|
|
116
|
-
done = true;
|
|
117
|
-
clearTimeout(timer);
|
|
118
|
-
try { sock.destroy(); } catch (e) {}
|
|
119
|
-
process.stderr.write(msg || "");
|
|
120
|
-
process.exitCode = 1;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
sock.on("connect", () => {
|
|
124
|
-
let cmds = "";
|
|
125
|
-
if (password) cmds += encode(["AUTH", password]);
|
|
126
|
-
if (db !== 0) cmds += encode(["SELECT", String(db)]);
|
|
127
|
-
cmds += encode(args);
|
|
128
|
-
sock.write(cmds);
|
|
129
|
-
});
|
|
130
|
-
sock.on("data", (chunk) => {
|
|
131
|
-
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
132
|
-
while (true) {
|
|
133
|
-
const p = parse(buffer, 0);
|
|
134
|
-
if (!p) break;
|
|
135
|
-
buffer = buffer.subarray(p.next);
|
|
136
|
-
replies.push(p.value);
|
|
137
|
-
if (replies.length < expected) continue;
|
|
138
|
-
// A rejected AUTH/SELECT is a transport failure — surface it first.
|
|
139
|
-
for (let i = 0; i < expected - 1; i++) {
|
|
140
|
-
const r = replies[i];
|
|
141
|
-
if (r && typeof r === "object" && r.__err !== undefined) { emit("__ERR__" + r.__err); return; }
|
|
142
|
-
}
|
|
143
|
-
const result = replies[expected - 1];
|
|
144
|
-
if (result && typeof result === "object" && result.__err !== undefined) emit("__ERR__" + result.__err);
|
|
145
|
-
else if (result === null || result === undefined) emit("__NULL__");
|
|
146
|
-
else emit(String(result));
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
sock.on("error", (err) => fail(err.message));
|
|
151
|
-
sock.on("close", () => { if (!done) fail("connection closed before reply"); });
|
|
152
|
-
`;
|
|
153
|
-
|
|
154
|
-
let result: string;
|
|
155
|
-
try {
|
|
156
|
-
result = execFileSync(process.execPath, ["-e", script], {
|
|
157
|
-
encoding: "utf-8",
|
|
158
|
-
timeout: 5000,
|
|
159
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
160
|
-
});
|
|
161
|
-
} catch (err) {
|
|
162
|
-
// Non-zero exit = socket error / timeout / closed connection: a transport
|
|
163
|
-
// FAILURE, not a key miss. Surface it so the Session boundary logs + degrades
|
|
164
|
-
// (or re-throws under strict mode).
|
|
165
|
-
//
|
|
166
|
-
// Report the CHILD's stderr, not execFileSync's message -- that message
|
|
167
|
-
// embeds the whole generated script and buries the actual reason.
|
|
168
|
-
throw childFailureError(label, err);
|
|
169
|
-
}
|
|
170
|
-
if (result === "__NULL__") return ""; // genuine key miss
|
|
171
|
-
if (result.startsWith("__ERR__")) {
|
|
172
|
-
throw new Error(`${label} error: ${result.slice("__ERR__".length)}`);
|
|
173
|
-
}
|
|
174
|
-
return result;
|
|
39
|
+
return syncCommand(
|
|
40
|
+
{ host: target.host, port: target.port, password: target.password, db: target.db },
|
|
41
|
+
args,
|
|
42
|
+
label,
|
|
43
|
+
);
|
|
175
44
|
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 synchronous SQL transport for the database session backend.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. The SessionHandler interface is SYNCHRONOUS and every SQL
|
|
5
|
+
* driver Node offers for a networked engine (pg, mysql2, tedious,
|
|
6
|
+
* node-firebird) is async-only. That mismatch was previously "resolved" by
|
|
7
|
+
* refusing every engine except SQLite: resolveDbPath() THREW on any non-sqlite
|
|
8
|
+
* TINA4_DATABASE_URL. So an app developed on SQLite and deployed on PostgreSQL
|
|
9
|
+
* did not start, in the one subsystem that decides whether anybody is logged in.
|
|
10
|
+
*
|
|
11
|
+
* The mismatch is not a reason to refuse an engine, because the fix already
|
|
12
|
+
* existed: syncBridge.ts. A Worker thread keeps its own event loop, so it can
|
|
13
|
+
* hold a long-lived driver connection and do ordinary async I/O, while the
|
|
14
|
+
* caller blocks in Atomics.wait until the worker writes its reply into a
|
|
15
|
+
* SharedArrayBuffer. RESP (Redis/Valkey), memcached and MongoDB have all ridden
|
|
16
|
+
* that bridge for months. This is a fifth consumer of a proven mechanism, not a
|
|
17
|
+
* new mechanism.
|
|
18
|
+
*
|
|
19
|
+
* SQLITE DOES NOT COME THROUGH HERE. `node:sqlite` is already synchronous, so
|
|
20
|
+
* routing it through a worker would add a thread hop and a JSON round-trip to
|
|
21
|
+
* the one engine that needs neither. DatabaseSessionHandler drives it directly.
|
|
22
|
+
*
|
|
23
|
+
* WHY THE DRIVERS ARE REQUIRED INSIDE THE WORKER rather than reaching for the
|
|
24
|
+
* ORM adapters: exactly the reason mongoClient.ts requires "mongodb" itself.
|
|
25
|
+
* The worker body is an eval'd CJS string, so a bare `require` is the one module
|
|
26
|
+
* resolution that works identically under tsx, under plain node, from source and
|
|
27
|
+
* from a built dist. @tina4/orm already declares pg / mysql2 / tedious as
|
|
28
|
+
* optional dependencies, so nothing new is installed for this - core stays
|
|
29
|
+
* zero-dependency.
|
|
30
|
+
*
|
|
31
|
+
* The SQL itself is NOT built here. The handler builds engine-neutral SQL with
|
|
32
|
+
* `?` placeholders - the same statement text as the Python master - and this
|
|
33
|
+
* transport rewrites the placeholders into the dialect the driver wants. Only
|
|
34
|
+
* the placeholder style and the CREATE TABLE types differ per engine.
|
|
35
|
+
*/
|
|
36
|
+
import { createRequire } from "node:module";
|
|
37
|
+
import { getBridge, STATUS_OK, STATUS_TRANSPORT } from "./syncBridge.js";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The SQL engines the database session backend speaks.
|
|
41
|
+
*
|
|
42
|
+
* This IS the invariant: it is the engine set of the ORM Database layer minus
|
|
43
|
+
* the two non-SQL entries (mongodb has its own session backend, odbc has no
|
|
44
|
+
* session story in any of the four frameworks). Naming it once means the
|
|
45
|
+
* refusal message and the dispatch can never disagree about what is supported.
|
|
46
|
+
*/
|
|
47
|
+
export const SQL_SESSION_ENGINES = ["sqlite", "postgres", "mysql", "mssql", "firebird"] as const;
|
|
48
|
+
|
|
49
|
+
export type SqlSessionEngine = (typeof SQL_SESSION_ENGINES)[number];
|
|
50
|
+
|
|
51
|
+
/** The engines that need the bridge - everything except already-sync SQLite. */
|
|
52
|
+
export type BridgedEngine = Exclude<SqlSessionEngine, "sqlite">;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A connection target for the worker.
|
|
56
|
+
*
|
|
57
|
+
* A PLAIN object, deliberately - never a `DatabaseUrl`. That class carries a
|
|
58
|
+
* cleartext password and its own docblock forbids persisting it across a
|
|
59
|
+
* structured-clone boundary (test/databaseUrlRedaction.test.ts enforces it).
|
|
60
|
+
* The worker genuinely needs credentials to authenticate, so it gets the fields
|
|
61
|
+
* it needs and nothing that renders itself.
|
|
62
|
+
*/
|
|
63
|
+
export interface SqlTarget {
|
|
64
|
+
engine: BridgedEngine;
|
|
65
|
+
host: string;
|
|
66
|
+
port: number;
|
|
67
|
+
database: string;
|
|
68
|
+
username: string | null;
|
|
69
|
+
password: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Connect budget, kept BELOW the bridge's 5s reply timeout on purpose. An
|
|
74
|
+
* unreachable server then surfaces as a real driver message ("ECONNREFUSED
|
|
75
|
+
* 127.0.0.1:5432") instead of the caller's generic "timed out after 5000ms",
|
|
76
|
+
* which says nothing about what is actually wrong. Same reasoning, same number
|
|
77
|
+
* as mongoClient's SERVER_SELECTION_MS.
|
|
78
|
+
*/
|
|
79
|
+
const CONNECT_TIMEOUT_MS = 3000;
|
|
80
|
+
|
|
81
|
+
/** The driver each engine needs. All are already optional deps of @tina4/orm. */
|
|
82
|
+
const DRIVER_PACKAGE: Record<BridgedEngine, string> = {
|
|
83
|
+
postgres: "pg",
|
|
84
|
+
mysql: "mysql2",
|
|
85
|
+
mssql: "tedious",
|
|
86
|
+
firebird: "node-firebird",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the driver to an ABSOLUTE path, on the main thread, before the worker
|
|
93
|
+
* starts.
|
|
94
|
+
*
|
|
95
|
+
* A bare `require("pg")` inside the worker would resolve from the process
|
|
96
|
+
* WORKING DIRECTORY (an eval'd worker has no real filename to resolve from), so
|
|
97
|
+
* a server started from anywhere other than the project root would fail to find
|
|
98
|
+
* a driver that is installed. Resolving from THIS module instead walks up from
|
|
99
|
+
* the framework's own location, which is correct in the monorepo and in an
|
|
100
|
+
* installed app alike.
|
|
101
|
+
*
|
|
102
|
+
* @throws Error naming the missing package and how to install it.
|
|
103
|
+
*/
|
|
104
|
+
function driverPath(engine: BridgedEngine): string {
|
|
105
|
+
const packageName = DRIVER_PACKAGE[engine];
|
|
106
|
+
try {
|
|
107
|
+
return requireFromHere.resolve(packageName);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`The "database" session backend on ${engine} requires the "${packageName}" package. `
|
|
111
|
+
+ `Install it with: npm install ${packageName}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const SQL_WORKER = `
|
|
117
|
+
const target = workerData.target;
|
|
118
|
+
const engine = target.engine;
|
|
119
|
+
const driver = require(workerData.driverPath);
|
|
120
|
+
|
|
121
|
+
let client = null;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Rewrite the handler's neutral \`?\` placeholders into the driver's dialect.
|
|
125
|
+
* mysql2 and node-firebird already take \`?\`, so they are left alone.
|
|
126
|
+
*/
|
|
127
|
+
function convert(sql) {
|
|
128
|
+
if (engine === "postgres") { let n = 0; return sql.replace(/\\?/g, () => "$" + (++n)); }
|
|
129
|
+
if (engine === "mssql") { let n = 0; return sql.replace(/\\?/g, () => "@p" + (n++)); }
|
|
130
|
+
return sql;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function connect() {
|
|
134
|
+
if (engine === "postgres") {
|
|
135
|
+
const Client = driver.Client || (driver.default && driver.default.Client);
|
|
136
|
+
const c = new Client({
|
|
137
|
+
host: target.host,
|
|
138
|
+
port: target.port,
|
|
139
|
+
user: target.username === null ? undefined : target.username,
|
|
140
|
+
password: target.password === null ? undefined : target.password,
|
|
141
|
+
database: target.database,
|
|
142
|
+
connectionTimeoutMillis: ${CONNECT_TIMEOUT_MS},
|
|
143
|
+
});
|
|
144
|
+
await c.connect();
|
|
145
|
+
return c;
|
|
146
|
+
}
|
|
147
|
+
if (engine === "mysql") {
|
|
148
|
+
const c = driver.createConnection({
|
|
149
|
+
host: target.host,
|
|
150
|
+
port: target.port,
|
|
151
|
+
user: target.username === null ? undefined : target.username,
|
|
152
|
+
password: target.password === null ? undefined : target.password,
|
|
153
|
+
database: target.database,
|
|
154
|
+
connectTimeout: ${CONNECT_TIMEOUT_MS},
|
|
155
|
+
});
|
|
156
|
+
await new Promise((resolve, reject) => c.connect((err) => (err ? reject(err) : resolve())));
|
|
157
|
+
return c;
|
|
158
|
+
}
|
|
159
|
+
if (engine === "mssql") {
|
|
160
|
+
const c = new driver.Connection({
|
|
161
|
+
server: target.host,
|
|
162
|
+
authentication: {
|
|
163
|
+
type: "default",
|
|
164
|
+
options: { userName: target.username, password: target.password },
|
|
165
|
+
},
|
|
166
|
+
options: {
|
|
167
|
+
database: target.database,
|
|
168
|
+
port: target.port,
|
|
169
|
+
trustServerCertificate: true,
|
|
170
|
+
encrypt: false,
|
|
171
|
+
connectTimeout: ${CONNECT_TIMEOUT_MS},
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
await new Promise((resolve, reject) => {
|
|
175
|
+
c.on("connect", (err) => (err ? reject(err) : resolve()));
|
|
176
|
+
c.connect();
|
|
177
|
+
});
|
|
178
|
+
return c;
|
|
179
|
+
}
|
|
180
|
+
if (engine === "firebird") {
|
|
181
|
+
return await new Promise((resolve, reject) => {
|
|
182
|
+
driver.attach(
|
|
183
|
+
{
|
|
184
|
+
host: target.host,
|
|
185
|
+
port: target.port,
|
|
186
|
+
database: target.database,
|
|
187
|
+
user: target.username,
|
|
188
|
+
password: target.password,
|
|
189
|
+
},
|
|
190
|
+
(err, db) => (err ? reject(err) : resolve(db)),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
throw new Error("unsupported session SQL engine: " + engine);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function runMssql(sql, params) {
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
const rows = [];
|
|
200
|
+
const request = new driver.Request(convert(sql), (err) => (err ? reject(err) : resolve(rows)));
|
|
201
|
+
params.forEach((value, i) => {
|
|
202
|
+
// EVERY number binds as Float, never Int. tedious' Int is 32-bit and an
|
|
203
|
+
// expiry stamp is epoch SECONDS - an integral value above 2147483647
|
|
204
|
+
// (2038-01-19) would overflow and be stored as garbage, silently.
|
|
205
|
+
if (typeof value === "number") request.addParameter("p" + i, driver.TYPES.Float, value);
|
|
206
|
+
else if (value === null || value === undefined) request.addParameter("p" + i, driver.TYPES.NVarChar, null);
|
|
207
|
+
// length: Infinity means NVARCHAR(MAX). Without it tedious caps the
|
|
208
|
+
// parameter at 4000 characters and a large session TRUNCATES on write.
|
|
209
|
+
else request.addParameter("p" + i, driver.TYPES.NVarChar, String(value), { length: Infinity });
|
|
210
|
+
});
|
|
211
|
+
request.on("row", (columns) => {
|
|
212
|
+
const row = {};
|
|
213
|
+
columns.forEach((column) => { row[column.metadata.colName] = column.value; });
|
|
214
|
+
rows.push(row);
|
|
215
|
+
});
|
|
216
|
+
client.execSql(request);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function run(sql, params) {
|
|
221
|
+
if (!client) client = await connect();
|
|
222
|
+
if (engine === "postgres") {
|
|
223
|
+
const result = await client.query(convert(sql), params);
|
|
224
|
+
return result.rows || [];
|
|
225
|
+
}
|
|
226
|
+
if (engine === "mysql") {
|
|
227
|
+
const results = await new Promise((resolve, reject) => {
|
|
228
|
+
client.query(sql, params, (err, rows) => (err ? reject(err) : resolve(rows)));
|
|
229
|
+
});
|
|
230
|
+
return Array.isArray(results) ? results : [];
|
|
231
|
+
}
|
|
232
|
+
if (engine === "mssql") return await runMssql(sql, params);
|
|
233
|
+
const rows = await new Promise((resolve, reject) => {
|
|
234
|
+
client.query(sql, params, (err, result) => (err ? reject(err) : resolve(result)));
|
|
235
|
+
});
|
|
236
|
+
return Array.isArray(rows) ? rows : [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
parentPort.on("message", (message) => {
|
|
240
|
+
void (async () => {
|
|
241
|
+
try {
|
|
242
|
+
const rows = await run(message.sql, message.params || []);
|
|
243
|
+
__reply(${STATUS_OK}, JSON.stringify(rows));
|
|
244
|
+
} catch (err) {
|
|
245
|
+
// Drop the connection so the NEXT command reconnects. A transient blip
|
|
246
|
+
// must not poison the channel for the life of the process.
|
|
247
|
+
try {
|
|
248
|
+
if (client) {
|
|
249
|
+
if (typeof client.end === "function") client.end();
|
|
250
|
+
else if (typeof client.close === "function") client.close();
|
|
251
|
+
else if (typeof client.detach === "function") client.detach();
|
|
252
|
+
}
|
|
253
|
+
} catch (ignored) {}
|
|
254
|
+
client = null;
|
|
255
|
+
__reply(${STATUS_TRANSPORT}, String((err && err.message) || err));
|
|
256
|
+
}
|
|
257
|
+
})();
|
|
258
|
+
});
|
|
259
|
+
`;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Run one SQL statement synchronously against a networked engine.
|
|
263
|
+
*
|
|
264
|
+
* @returns the result rows - always an array, empty for a write or DDL.
|
|
265
|
+
* @throws Error on ANY driver failure (server unreachable, bad credentials,
|
|
266
|
+
* SQL error). It is never swallowed into an empty result: for a session
|
|
267
|
+
* store, "the database is down" and "no session yet" must stay
|
|
268
|
+
* distinguishable, or a dead backend silently logs every user out.
|
|
269
|
+
*/
|
|
270
|
+
export function sqlCommandSync(
|
|
271
|
+
target: SqlTarget,
|
|
272
|
+
sql: string,
|
|
273
|
+
params: unknown[] = [],
|
|
274
|
+
label = "Database session",
|
|
275
|
+
): Record<string, unknown>[] {
|
|
276
|
+
const key = `sql:${target.engine}:${target.host}:${target.port}:${target.database}:${target.username ?? ""}`;
|
|
277
|
+
const { status, payload } = getBridge(key, SQL_WORKER, {
|
|
278
|
+
target,
|
|
279
|
+
driverPath: driverPath(target.engine),
|
|
280
|
+
}).call({ sql, params }, label);
|
|
281
|
+
|
|
282
|
+
if (status !== STATUS_OK) {
|
|
283
|
+
throw new Error(`${label} command failed: ${payload}`);
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
return JSON.parse(payload) as Record<string, unknown>[];
|
|
287
|
+
} catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
}
|