tina4-nodejs 3.13.92 → 3.13.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +33062 -29908
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/devMailbox.ts +20 -44
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +7 -6
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +81 -13
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +6 -9
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
@@ -1,21 +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";
14
+ import { syncCommand } from "./syncSocket.js";
19
15
 
20
16
  export interface RespTarget {
21
17
  host: string;
@@ -36,136 +32,13 @@ export interface RespTarget {
36
32
  * `<label> error: ...`. A rejected handshake is a transport failure, not a
37
33
  * result, so it is surfaced ahead of the command reply.
38
34
  *
39
- * Session values are JSON strings (they start with `{`), so they never collide
40
- * 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.
41
37
  */
42
38
  export function respCommandSync(target: RespTarget, args: string[], label = "Redis"): string {
43
- const host = target.host;
44
- const port = target.port;
45
- const password = target.password ?? "";
46
- const db = target.db ?? 0;
47
-
48
- const script = `
49
- const net = require("node:net");
50
- const host = ${JSON.stringify(host)};
51
- const port = ${port};
52
- const password = ${JSON.stringify(password)};
53
- const db = ${db};
54
- const args = ${JSON.stringify(args)};
55
- // Replies to consume = AUTH? + SELECT? + the command. The LAST is our result.
56
- const expected = (password ? 1 : 0) + (db !== 0 ? 1 : 0) + 1;
57
-
58
- function encode(a) {
59
- let c = "*" + a.length + "\\r\\n";
60
- for (const s of a) c += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
61
- return c;
62
- }
63
-
64
- // Parse one RESP value at offset. Returns { value, next } or null if more bytes
65
- // are needed (so a bulk string split across TCP chunks is handled correctly).
66
- function parse(buf, off) {
67
- if (off >= buf.length) return null;
68
- const type = buf[off];
69
- const crlf = buf.indexOf("\\r\\n", off + 1, "utf-8");
70
- if (crlf === -1) return null;
71
- const line = buf.toString("utf-8", off + 1, crlf);
72
- const after = crlf + 2;
73
- if (type === 0x2b) return { value: line, next: after }; // '+' simple string
74
- if (type === 0x3a) return { value: line, next: after }; // ':' integer
75
- if (type === 0x2d) return { value: { __err: line }, next: after }; // '-' error
76
- if (type === 0x24) { // '$' bulk string
77
- const len = parseInt(line, 10);
78
- if (len === -1) return { value: null, next: after };
79
- if (after + len + 2 > buf.length) return null;
80
- return { value: buf.toString("utf-8", after, after + len), next: after + len + 2 };
81
- }
82
- if (type === 0x2a) { // '*' array
83
- const count = parseInt(line, 10);
84
- if (count === -1) return { value: null, next: after };
85
- const arr = [];
86
- let pos = after;
87
- for (let i = 0; i < count; i++) {
88
- const el = parse(buf, pos);
89
- if (!el) return null;
90
- arr.push(el.value);
91
- pos = el.next;
92
- }
93
- return { value: arr, next: pos };
94
- }
95
- return { value: line, next: after };
96
- }
97
-
98
- const sock = net.createConnection({ host, port });
99
- sock.setNoDelay(true);
100
- let buffer = Buffer.alloc(0);
101
- const replies = [];
102
- let done = false;
103
- const timer = setTimeout(() => { if (!done) { done = true; try { sock.destroy(); } catch (e) {} process.stderr.write("timeout"); process.exitCode = 1; } }, 3000);
104
-
105
- function emit(s) {
106
- if (done) return;
107
- done = true;
108
- clearTimeout(timer);
109
- // The write callback guarantees stdout is flushed before exit (a bare
110
- // process.exit can truncate piped stdout).
111
- process.stdout.write(s, () => { try { sock.destroy(); } catch (e) {} });
112
- }
113
- function fail(msg) {
114
- if (done) return;
115
- done = true;
116
- clearTimeout(timer);
117
- try { sock.destroy(); } catch (e) {}
118
- process.stderr.write(msg || "");
119
- process.exitCode = 1;
120
- }
121
-
122
- sock.on("connect", () => {
123
- let cmds = "";
124
- if (password) cmds += encode(["AUTH", password]);
125
- if (db !== 0) cmds += encode(["SELECT", String(db)]);
126
- cmds += encode(args);
127
- sock.write(cmds);
128
- });
129
- sock.on("data", (chunk) => {
130
- buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
131
- while (true) {
132
- const p = parse(buffer, 0);
133
- if (!p) break;
134
- buffer = buffer.subarray(p.next);
135
- replies.push(p.value);
136
- if (replies.length < expected) continue;
137
- // A rejected AUTH/SELECT is a transport failure — surface it first.
138
- for (let i = 0; i < expected - 1; i++) {
139
- const r = replies[i];
140
- if (r && typeof r === "object" && r.__err !== undefined) { emit("__ERR__" + r.__err); return; }
141
- }
142
- const result = replies[expected - 1];
143
- if (result && typeof result === "object" && result.__err !== undefined) emit("__ERR__" + result.__err);
144
- else if (result === null || result === undefined) emit("__NULL__");
145
- else emit(String(result));
146
- return;
147
- }
148
- });
149
- sock.on("error", (err) => fail(err.message));
150
- sock.on("close", () => { if (!done) fail("connection closed before reply"); });
151
- `;
152
-
153
- let result: string;
154
- try {
155
- result = execFileSync(process.execPath, ["-e", script], {
156
- encoding: "utf-8",
157
- timeout: 5000,
158
- stdio: ["pipe", "pipe", "pipe"],
159
- });
160
- } catch (err) {
161
- // Non-zero exit = socket error / timeout / closed connection: a transport
162
- // FAILURE, not a key miss. Surface it so the Session boundary logs + degrades
163
- // (or re-throws under strict mode).
164
- throw new Error(`${label} command failed: ${(err as Error).message}`);
165
- }
166
- if (result === "__NULL__") return ""; // genuine key miss
167
- if (result.startsWith("__ERR__")) {
168
- throw new Error(`${label} error: ${result.slice("__ERR__".length)}`);
169
- }
170
- return result;
39
+ return syncCommand(
40
+ { host: target.host, port: target.port, password: target.password, db: target.db },
41
+ args,
42
+ label,
43
+ );
171
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
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Tina4 sync-over-async bridge — call an ASYNC worker from SYNCHRONOUS code.
3
+ *
4
+ * The SessionHandler interface is synchronous; every backend client Node offers
5
+ * (node:net, the mongodb driver) is async-only. The old resolution was to run
6
+ * each command in a short-lived `node -e` child and block on execFileSync. That
7
+ * works but pays a process spawn — and a fresh connection, and for Mongo a fresh
8
+ * driver load — PER COMMAND. Measured on this machine: spawn min 38ms / p50 41ms
9
+ * / p99 487ms, with a bare TCP connect adding a ~0.5-0.9s tail of its own. That
10
+ * tail tripped the child's deadline under load, which is what made the Valkey
11
+ * session tests flaky with the signature "a value that was just written reads
12
+ * back null" — the write timed out for the caller while still landing on the
13
+ * server.
14
+ *
15
+ * This is the ONE piece of plumbing that replaces it. A Worker thread keeps its
16
+ * own event loop, so it can do ordinary async I/O and hold a long-lived
17
+ * connection. The caller hands it a message with postMessage (delivered on the
18
+ * WORKER's loop, so a blocked main thread cannot deadlock it), then blocks in
19
+ * Atomics.wait until the worker writes a reply into a SharedArrayBuffer and
20
+ * Atomics.notify wakes it.
21
+ *
22
+ * Every session backend that needs sync-over-async uses this — RESP
23
+ * (Redis/Valkey), memcached's text protocol, and MongoDB — so the blocking
24
+ * handshake exists once rather than once per backend.
25
+ */
26
+ import { Worker } from "node:worker_threads";
27
+
28
+ /** Reply status, written by the worker into control[IDX_STATUS]. */
29
+ export const STATUS_OK = 0;
30
+ /** A healthy server answered with an error (RESP `-ERR`, a Mongo command error). */
31
+ export const STATUS_ERROR = 1;
32
+ /** A genuine miss — no such key/document. NOT a failure. */
33
+ export const STATUS_NIL = 2;
34
+ /** The reply did not fit the shared buffer. */
35
+ export const STATUS_TOO_LARGE = 3;
36
+ /** Connection/socket/driver failure, as opposed to an answer from a healthy server. */
37
+ export const STATUS_TRANSPORT = 4;
38
+
39
+ export const IDX_SEQ = 0; // bumped by the worker on every reply; what Atomics.wait watches
40
+ export const IDX_LENGTH = 1; // reply byte length
41
+ export const IDX_STATUS = 2; // one of STATUS_*
42
+ export const IDX_READY = 3; // set to 1 by the worker as soon as its loop is running
43
+
44
+ /**
45
+ * Reply payload ceiling. A session document beyond this is pathological, and a
46
+ * fixed buffer keeps the fast path allocation-free. A worker reports
47
+ * STATUS_TOO_LARGE rather than truncating — a silently truncated session would
48
+ * deserialise into garbage.
49
+ */
50
+ export const DATA_BYTES = 8 * 1024 * 1024;
51
+
52
+ /** How long a caller blocks before giving up on a reply. */
53
+ export const REPLY_TIMEOUT_MS = 5000;
54
+
55
+ /**
56
+ * How long the FIRST caller waits for a brand-new worker to come up.
57
+ *
58
+ * Boot and the command round-trip must not share one budget. Cold start is
59
+ * normally ~27ms, but on a loaded machine (the session suite spawns a batch of
60
+ * blocking Mongo children immediately beforehand) it can stretch — and when it
61
+ * ate into the 5s command budget the very first Valkey write failed with
62
+ * "timed out after 5000ms", which read exactly like the flake this transport
63
+ * was built to remove. Boot gets its own generous budget so the per-command
64
+ * timeout can stay tight and mean what it says.
65
+ */
66
+ export const BOOT_TIMEOUT_MS = 15000;
67
+
68
+ /**
69
+ * The worker-side helper, injected into every worker body. Kept here so the
70
+ * reply protocol is written once: a worker only has to call
71
+ * `__reply(status, payload)` and never touches Atomics itself.
72
+ */
73
+ export const WORKER_REPLY_HELPER = `
74
+ const __control = new Int32Array(workerData.controlBuffer);
75
+ const __data = new Uint8Array(workerData.dataBuffer);
76
+ const __encoder = new TextEncoder();
77
+
78
+ // Announce readiness the moment this thread is executing. The parent blocks on
79
+ // this before its first command, so a slow boot can never be mistaken for a
80
+ // slow command. This is signalled by the worker's OWN bootstrap and needs no
81
+ // message from the parent, so it is safe even while the parent is blocked.
82
+ Atomics.store(__control, ${IDX_READY}, 1);
83
+ Atomics.notify(__control, ${IDX_READY});
84
+
85
+ function __reply(status, payload) {
86
+ let length = 0;
87
+ if (payload !== undefined && payload !== null && status !== ${STATUS_NIL}) {
88
+ const bytes = __encoder.encode(String(payload));
89
+ if (bytes.length > __data.length) {
90
+ Atomics.store(__control, ${IDX_STATUS}, ${STATUS_TOO_LARGE});
91
+ Atomics.store(__control, ${IDX_LENGTH}, 0);
92
+ Atomics.add(__control, ${IDX_SEQ}, 1);
93
+ Atomics.notify(__control, ${IDX_SEQ});
94
+ return;
95
+ }
96
+ __data.set(bytes, 0);
97
+ length = bytes.length;
98
+ }
99
+ Atomics.store(__control, ${IDX_STATUS}, status);
100
+ Atomics.store(__control, ${IDX_LENGTH}, length);
101
+ Atomics.add(__control, ${IDX_SEQ}, 1);
102
+ Atomics.notify(__control, ${IDX_SEQ});
103
+ }
104
+ `;
105
+
106
+ export interface BridgeReply {
107
+ status: number;
108
+ payload: string;
109
+ }
110
+
111
+ export interface Bridge {
112
+ /** Send a message to the worker and BLOCK until it replies. */
113
+ call(message: unknown, label: string): BridgeReply;
114
+ worker: Worker;
115
+ }
116
+
117
+ const bridges = new Map<string, Bridge>();
118
+
119
+ /**
120
+ * Get (or create) the bridge for a key. One worker per key, created once and
121
+ * unref'd so it can never hold the process open.
122
+ *
123
+ * @param key Identity of the connection target — same key, same worker
124
+ * @param workerSource The worker body; WORKER_REPLY_HELPER is prepended for it
125
+ * @param workerData Passed to the worker verbatim (plus the shared buffers)
126
+ */
127
+ export function getBridge(key: string, workerSource: string, workerData: Record<string, unknown>): Bridge {
128
+ const existing = bridges.get(key);
129
+ if (existing) return existing;
130
+
131
+ const controlBuffer = new SharedArrayBuffer(4 * Int32Array.BYTES_PER_ELEMENT);
132
+ const dataBuffer = new SharedArrayBuffer(DATA_BYTES);
133
+ const control = new Int32Array(controlBuffer);
134
+ const data = new Uint8Array(dataBuffer);
135
+
136
+ const worker = new Worker(
137
+ `const { parentPort, workerData } = require("node:worker_threads");\n${WORKER_REPLY_HELPER}\n${workerSource}`,
138
+ { eval: true, workerData: { ...workerData, controlBuffer, dataBuffer } },
139
+ );
140
+ // A session channel must never keep a process alive.
141
+ worker.unref();
142
+
143
+ // Block until the worker is up. A cold channel pays this once.
144
+ if (Atomics.load(control, IDX_READY) === 0) {
145
+ if (Atomics.wait(control, IDX_READY, 0, BOOT_TIMEOUT_MS) === "timed-out") {
146
+ void worker.terminate();
147
+ bridges.delete(key);
148
+ throw new Error(`session worker failed to start within ${BOOT_TIMEOUT_MS}ms`);
149
+ }
150
+ }
151
+
152
+ const decoder = new TextDecoder();
153
+ const bridge: Bridge = {
154
+ worker,
155
+ call(message: unknown, label: string): BridgeReply {
156
+ // Watch a monotonic counter rather than a flag: a counter cannot race with
157
+ // a reset, so a late reply from a previous (timed-out) call can never be
158
+ // mistaken for this one's.
159
+ const before = Atomics.load(control, IDX_SEQ);
160
+ worker.postMessage(message);
161
+
162
+ if (Atomics.wait(control, IDX_SEQ, before, REPLY_TIMEOUT_MS) === "timed-out") {
163
+ throw new Error(`${label} command failed: timed out after ${REPLY_TIMEOUT_MS}ms`);
164
+ }
165
+
166
+ const status = Atomics.load(control, IDX_STATUS);
167
+ const length = Atomics.load(control, IDX_LENGTH);
168
+
169
+ if (status === STATUS_TOO_LARGE) {
170
+ throw new Error(`${label} command failed: reply exceeds the ${DATA_BYTES} byte buffer`);
171
+ }
172
+ return { status, payload: decoder.decode(data.subarray(0, length)) };
173
+ },
174
+ };
175
+
176
+ bridges.set(key, bridge);
177
+ return bridge;
178
+ }
179
+
180
+ /**
181
+ * Terminate every worker. Tests and short-lived scripts call this so a spawned
182
+ * worker never outlives the work that created it — "reap what you spawn". Normal
183
+ * apps do not need it: the workers are unref'd.
184
+ */
185
+ export function closeBridges(): void {
186
+ for (const { worker } of bridges.values()) {
187
+ void worker.terminate();
188
+ }
189
+ bridges.clear();
190
+ }