tina4-nodejs 3.13.94 → 3.13.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CLAUDE.md +157 -28
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/packages/cli/dist/bin.js +32418 -29638
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +32364 -29501
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/dispatchPipeline.ts +285 -0
  14. package/packages/core/src/dotenv.ts +185 -40
  15. package/packages/core/src/index.ts +5 -4
  16. package/packages/core/src/logger.ts +257 -36
  17. package/packages/core/src/mcp.ts +1 -1
  18. package/packages/core/src/messenger.ts +9 -13
  19. package/packages/core/src/metrics.ts +199 -961
  20. package/packages/core/src/middleware.ts +390 -123
  21. package/packages/core/src/queue.ts +188 -32
  22. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  23. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  24. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  25. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  26. package/packages/core/src/rateLimiter.ts +10 -5
  27. package/packages/core/src/request.ts +6 -9
  28. package/packages/core/src/response.ts +46 -1
  29. package/packages/core/src/router.ts +29 -4
  30. package/packages/core/src/server.ts +751 -414
  31. package/packages/core/src/session.ts +244 -27
  32. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  33. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  34. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  36. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  37. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  38. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  39. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  40. package/packages/core/src/testClient.ts +18 -5
  41. package/packages/core/src/trustedProxy.ts +249 -0
  42. package/packages/core/src/types.ts +29 -5
  43. package/packages/core/src/websocket.ts +66 -0
  44. package/packages/orm/dist/index.js +22367 -19504
  45. package/packages/orm/src/adapters/firebird.ts +183 -56
  46. package/packages/orm/src/adapters/mongodb.ts +25 -4
  47. package/packages/orm/src/adapters/mssql.ts +114 -29
  48. package/packages/orm/src/adapters/mysql.ts +103 -40
  49. package/packages/orm/src/adapters/odbc.ts +44 -21
  50. package/packages/orm/src/adapters/postgres.ts +118 -26
  51. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  52. package/packages/orm/src/adapters/sqlite.ts +60 -24
  53. package/packages/orm/src/baseModel.ts +135 -40
  54. package/packages/orm/src/cachedDatabase.ts +43 -19
  55. package/packages/orm/src/connectTimeout.ts +265 -0
  56. package/packages/orm/src/database.ts +237 -197
  57. package/packages/orm/src/databaseResult.ts +65 -13
  58. package/packages/orm/src/databaseUrl.ts +484 -0
  59. package/packages/orm/src/docstore.ts +386 -145
  60. package/packages/orm/src/index.ts +13 -3
  61. package/packages/orm/src/migration.ts +18 -3
  62. package/packages/orm/src/queryBuilder.ts +38 -4
  63. package/packages/orm/src/sqlTranslator.ts +310 -4
  64. package/packages/orm/src/types.ts +15 -4
  65. package/types/core/src/ai.d.ts +1 -1
  66. package/types/core/src/auth.d.ts +28 -5
  67. package/types/core/src/background.d.ts +3 -3
  68. package/types/core/src/cache.d.ts +15 -12
  69. package/types/core/src/dispatchPipeline.d.ts +117 -0
  70. package/types/core/src/dotenv.d.ts +38 -16
  71. package/types/core/src/index.d.ts +5 -6
  72. package/types/core/src/logger.d.ts +93 -16
  73. package/types/core/src/messenger.d.ts +2 -2
  74. package/types/core/src/metrics.d.ts +25 -61
  75. package/types/core/src/middleware.d.ts +134 -11
  76. package/types/core/src/queue.d.ts +54 -5
  77. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  78. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  79. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  80. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  81. package/types/core/src/router.d.ts +14 -3
  82. package/types/core/src/server.d.ts +15 -0
  83. package/types/core/src/session.d.ts +87 -2
  84. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  85. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  86. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  87. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  88. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  89. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  90. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  91. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  92. package/types/core/src/trustedProxy.d.ts +44 -0
  93. package/types/core/src/types.d.ts +28 -5
  94. package/types/core/src/websocket.d.ts +26 -0
  95. package/types/orm/src/adapters/firebird.d.ts +55 -10
  96. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  97. package/types/orm/src/adapters/mssql.d.ts +18 -11
  98. package/types/orm/src/adapters/mysql.d.ts +11 -10
  99. package/types/orm/src/adapters/odbc.d.ts +9 -12
  100. package/types/orm/src/adapters/postgres.d.ts +11 -10
  101. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  102. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  103. package/types/orm/src/baseModel.d.ts +45 -9
  104. package/types/orm/src/cachedDatabase.d.ts +18 -5
  105. package/types/orm/src/connectTimeout.d.ts +100 -0
  106. package/types/orm/src/database.d.ts +72 -26
  107. package/types/orm/src/databaseResult.d.ts +24 -0
  108. package/types/orm/src/databaseUrl.d.ts +125 -0
  109. package/types/orm/src/docstore.d.ts +102 -43
  110. package/types/orm/src/index.d.ts +5 -2
  111. package/types/orm/src/queryBuilder.d.ts +23 -3
  112. package/types/orm/src/sqlTranslator.d.ts +126 -2
  113. package/types/orm/src/types.d.ts +14 -4
  114. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  115. 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: "tina4_sessions")
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
- ?? "tina4_sessions";
98
+ ?? "tina4";
83
99
  this.collection = config?.collection
84
100
  ?? process.env.TINA4_SESSION_MONGO_COLLECTION
85
101
  ?? "sessions";
86
102
  }
87
103
 
88
- /** Resolve the effective host/port (honours a configured mongodb:// URI). */
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
- if (this.uri) {
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
- write(sessionId: string, data: SessionData, _ttl: number): void {
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, but node:net is async-only, so a
5
- * command runs in a short-lived `node -e` child (execFileSync) that blocks the
6
- * caller until it exits. This module is the ONE correct implementation of that
7
- * child; the Redis/Valkey handlers delegate here instead of inlining their own.
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
- * The bug this replaces: the old child parsed the reply on the socket "end" event.
10
- * Redis/Valkey keep the connection OPEN after a reply (they never half-close for a
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 { execFileSync } from "node:child_process";
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
- * Session values are JSON strings (they start with `{`), so they never collide
41
- * with the `__NULL__` / `__ERR__` sentinels the child uses on stdout.
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
- const host = target.host;
45
- const port = target.port;
46
- const password = target.password ?? "";
47
- const db = target.db ?? 0;
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
+ }