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
@@ -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
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Tina4 synchronous socket transport — ONE persistent connection per target.
3
+ *
4
+ * Serves the RESP backends (Redis, Valkey) and memcached's text protocol. The
5
+ * sync-over-async plumbing lives in syncBridge; this file is only the socket
6
+ * worker and the two protocol entry points.
7
+ *
8
+ * See syncBridge for WHY: each command used to run in a short-lived `node -e`
9
+ * child, paying a process spawn AND a fresh TCP connection every time (spawn p50
10
+ * 41ms / p99 487ms, connect tail 0.5-0.9s), and that tail tripped the child's
11
+ * deadline under load — the cause of the Valkey session flake.
12
+ *
13
+ * Reconnection is the worker's business: a dropped socket is re-established on
14
+ * the next command rather than surfacing as a caller error.
15
+ */
16
+ import {
17
+ getBridge,
18
+ closeBridges,
19
+ DATA_BYTES,
20
+ STATUS_ERROR,
21
+ STATUS_NIL,
22
+ STATUS_OK,
23
+ STATUS_TRANSPORT,
24
+ } from "./syncBridge.js";
25
+
26
+ export interface SyncSocketTarget {
27
+ host: string;
28
+ port: number;
29
+ /** AUTH password (empty/undefined = no AUTH sent). */
30
+ password?: string;
31
+ /** SELECT db index (0/undefined = no SELECT sent). */
32
+ db?: number;
33
+ }
34
+
35
+ /**
36
+ * How long the worker waits for a TCP connection. Deliberately shorter than the
37
+ * bridge's reply timeout so an unreachable host is reported as a connect failure
38
+ * with a useful message, rather than as a generic reply timeout — before this
39
+ * existed, learning a server was down took the full 5s deadline.
40
+ */
41
+ const CONNECT_TIMEOUT_MS = 2000;
42
+
43
+ const SOCKET_WORKER = `
44
+ const net = require("node:net");
45
+ const target = workerData.target;
46
+
47
+ let sock = null;
48
+ let buffer = Buffer.alloc(0);
49
+ let pending = null;
50
+
51
+ function encodeCommand(args) {
52
+ let out = "*" + args.length + "\\r\\n";
53
+ for (const s of args) out += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
54
+ return out;
55
+ }
56
+
57
+ // Parse one RESP value at offset. Returns { value, next } or null when more
58
+ // bytes are needed, so a bulk string split across TCP chunks is reassembled
59
+ // rather than truncated.
60
+ function parse(buf, off) {
61
+ if (off >= buf.length) return null;
62
+ const type = buf[off];
63
+ const crlf = buf.indexOf("\\r\\n", off + 1, "utf-8");
64
+ if (crlf === -1) return null;
65
+ const line = buf.toString("utf-8", off + 1, crlf);
66
+ const after = crlf + 2;
67
+ if (type === 0x2b) return { value: line, next: after }; // '+'
68
+ if (type === 0x3a) return { value: line, next: after }; // ':'
69
+ if (type === 0x2d) return { value: { __err: line }, next: after }; // '-'
70
+ if (type === 0x24) { // '$'
71
+ const len = parseInt(line, 10);
72
+ if (len === -1) return { value: null, next: after };
73
+ if (after + len + 2 > buf.length) return null;
74
+ return { value: buf.toString("utf-8", after, after + len), next: after + len + 2 };
75
+ }
76
+ if (type === 0x2a) { // '*'
77
+ const count = parseInt(line, 10);
78
+ if (count === -1) return { value: null, next: after };
79
+ const arr = [];
80
+ let pos = after;
81
+ for (let i = 0; i < count; i++) {
82
+ const el = parse(buf, pos);
83
+ if (!el) return null;
84
+ arr.push(el.value);
85
+ pos = el.next;
86
+ }
87
+ return { value: arr, next: pos };
88
+ }
89
+ return { value: line, next: after };
90
+ }
91
+
92
+ function settle(status, payload) {
93
+ if (!pending) return;
94
+ pending = null;
95
+ __reply(status, payload);
96
+ }
97
+
98
+ function drop(err) {
99
+ // The socket is gone. Fail the in-flight command, then let the NEXT command
100
+ // reconnect - a transient blip must not poison the channel forever.
101
+ try { if (sock) sock.destroy(); } catch (e) {}
102
+ sock = null;
103
+ buffer = Buffer.alloc(0);
104
+ settle(${STATUS_TRANSPORT}, err || "connection lost");
105
+ }
106
+
107
+ function onRespData(chunk) {
108
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
109
+ while (pending) {
110
+ const p = parse(buffer, 0);
111
+ if (!p) break;
112
+ buffer = buffer.subarray(p.next);
113
+ pending.replies.push(p.value);
114
+ if (pending.replies.length < pending.expected) continue;
115
+
116
+ // A rejected AUTH/SELECT is surfaced ahead of the command's own reply.
117
+ for (let i = 0; i < pending.expected - 1; i++) {
118
+ const r = pending.replies[i];
119
+ if (r && typeof r === "object" && r.__err !== undefined) { settle(${STATUS_ERROR}, r.__err); return; }
120
+ }
121
+ const result = pending.replies[pending.expected - 1];
122
+ if (result && typeof result === "object" && result.__err !== undefined) settle(${STATUS_ERROR}, result.__err);
123
+ else if (result === null || result === undefined) settle(${STATUS_NIL}, null);
124
+ else settle(${STATUS_OK}, result);
125
+ return;
126
+ }
127
+ }
128
+
129
+ function onTextData(chunk) {
130
+ // Text protocols (memcached) carry no length prefix: the reply is complete
131
+ // when one of the caller's terminators appears.
132
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
133
+ if (!pending) return;
134
+ const text = buffer.toString("utf-8");
135
+ for (const t of pending.terminators) {
136
+ if (text.includes(t)) { buffer = Buffer.alloc(0); settle(${STATUS_OK}, text); return; }
137
+ }
138
+ }
139
+
140
+ function connect(then) {
141
+ const s = net.createConnection({ host: target.host, port: target.port });
142
+ s.setNoDelay(true);
143
+ // An unroutable host neither connects nor errors promptly, so bound it here:
144
+ // otherwise the caller sits out its whole reply deadline for a host that was
145
+ // never going to answer.
146
+ const connectTimer = setTimeout(() => {
147
+ if (sock !== s) { try { s.destroy(); } catch (e) {} drop("connect timed out"); }
148
+ }, ${CONNECT_TIMEOUT_MS});
149
+ s.on("data", (c) => (pending && pending.text ? onTextData(c) : onRespData(c)));
150
+ s.on("error", (e) => { clearTimeout(connectTimer); drop(e.message); });
151
+ s.on("close", () => { clearTimeout(connectTimer); if (sock === s) drop("connection closed"); });
152
+ s.on("connect", () => { clearTimeout(connectTimer); sock = s; then(); });
153
+ }
154
+
155
+ parentPort.on("message", (msg) => {
156
+ // Set pending BEFORE connecting. A connect error (ECONNREFUSED) otherwise
157
+ // arrives with nothing in flight, and the caller blocks for its full reply
158
+ // timeout just to learn the server is down.
159
+ const fresh = !sock;
160
+
161
+ if (msg.mode === "text") {
162
+ pending = { text: true, terminators: msg.terminators };
163
+ const writeText = () => {
164
+ try { sock.write(msg.payload, "utf-8"); } catch (e) { drop(e.message); }
165
+ };
166
+ if (sock) writeText(); else { try { connect(writeText); } catch (e) { drop(e.message); } }
167
+ return;
168
+ }
169
+
170
+ // The handshake rides with the FIRST command on a fresh connection only;
171
+ // afterwards the connection is already authenticated and selected.
172
+ const handshake = fresh ? ((target.password ? 1 : 0) + (target.db ? 1 : 0)) : 0;
173
+ pending = { expected: 1 + handshake, replies: [] };
174
+
175
+ const write = () => {
176
+ let payload = "";
177
+ if (fresh) {
178
+ if (target.password) payload += encodeCommand(["AUTH", target.password]);
179
+ if (target.db) payload += encodeCommand(["SELECT", String(target.db)]);
180
+ }
181
+ payload += encodeCommand(msg.args);
182
+ try { sock.write(payload); } catch (e) { drop(e.message); }
183
+ };
184
+
185
+ if (sock) write(); else { try { connect(write); } catch (e) { drop(e.message); } }
186
+ });
187
+ `;
188
+
189
+ function socketBridge(target: SyncSocketTarget) {
190
+ const key = `sock:${target.host}:${target.port}:${target.db ?? 0}:${target.password ? "auth" : ""}`;
191
+ return getBridge(key, SOCKET_WORKER, { target });
192
+ }
193
+
194
+ /**
195
+ * Run a single RESP command synchronously and return the reply.
196
+ *
197
+ * - A genuine nil / key-miss returns `""` (callers treat "" as "no session yet").
198
+ * - A transport FAILURE (unreachable, timeout, connection closed) THROWS
199
+ * `<label> command failed: ...`.
200
+ * - A RESP error reply — including a rejected AUTH/SELECT — THROWS
201
+ * `<label> error: ...`.
202
+ *
203
+ * The miss/failure split is the whole contract: collapsing them is how a dead
204
+ * backend silently logs every user out instead of surfacing an outage. A dead
205
+ * socket and a healthy server saying "-ERR" are different failures and read
206
+ * differently in a log, so they keep different wording.
207
+ */
208
+ export function syncCommand(target: SyncSocketTarget, args: string[], label = "Redis"): string {
209
+ const { status, payload } = socketBridge(target).call({ args }, label);
210
+
211
+ if (status === STATUS_NIL) return ""; // genuine key miss
212
+ if (status === STATUS_TRANSPORT) throw new Error(`${label} command failed: ${payload}`);
213
+ if (status === STATUS_ERROR) throw new Error(`${label} error: ${payload}`);
214
+ return payload;
215
+ }
216
+
217
+ /**
218
+ * Run a raw text-protocol command (memcached) over the same persistent channel.
219
+ *
220
+ * The reply is whatever arrived once one of `terminators` appears — a text
221
+ * protocol carries no length prefix, so the caller names its own end markers.
222
+ * Failure semantics match {@link syncCommand}.
223
+ */
224
+ export function syncTextCommand(
225
+ target: SyncSocketTarget,
226
+ payload: string,
227
+ terminators: string[],
228
+ label = "Memcached",
229
+ ): string {
230
+ const reply = socketBridge(target).call({ mode: "text", payload, terminators }, label);
231
+ if (reply.status === STATUS_TRANSPORT) throw new Error(`${label} command failed: ${reply.payload}`);
232
+ return reply.payload;
233
+ }
234
+
235
+ /** Re-exported so callers do not need to know the bridge exists. */
236
+ export { closeBridges as closeSyncSockets, DATA_BYTES };
@@ -19,7 +19,7 @@ import { Socket } from "node:net";
19
19
  import { createRequest } from "./request.js";
20
20
  import { createResponse } from "./response.js";
21
21
  import { defaultRouter, Router, runRouteMiddlewares } from "./router.js";
22
- import { MiddlewareRunner } from "./middleware.js";
22
+ import { MiddlewareRunner, isMiddlewareClass } from "./middleware.js";
23
23
  import { enforceRouteAuth } from "./authGate.js";
24
24
 
25
25
  export class TestResponse {
@@ -220,10 +220,21 @@ export class TestClient {
220
220
  }
221
221
  }
222
222
 
223
- // Per-route middleware, same as the live dispatcher.
223
+ // Per-route middleware: functions, string specs, and CLASSES, through the
224
+ // same runner the live dispatcher uses. A route class's afterX hooks join
225
+ // the after pass below, exactly as in server.ts.
226
+ //
227
+ // ORDER DRIFT, stated rather than hidden: the live dispatcher runs the auth
228
+ // gate BEFORE the route's own middleware (ADR-0012 — middleware on a
229
+ // secured route must never process a request that is about to be rejected);
230
+ // here it still runs before the gate. Aligning the two is a behaviour
231
+ // change to the test surface and belongs in its own change.
232
+ const routeMiddlewareClasses = (match.middlewares ?? []).filter(isMiddlewareClass);
224
233
  if (match.middlewares && match.middlewares.length > 0) {
225
234
  const proceed = await runRouteMiddlewares(match.middlewares, req, res);
226
235
  if (!proceed || res.raw.writableEnded) {
236
+ await MiddlewareRunner.runAfter([...globalMiddleware, ...routeMiddlewareClasses], req, res);
237
+ if (!res.raw.writableEnded) res.raw.end();
227
238
  return this._collect(rawRes, chunks, socket);
228
239
  }
229
240
  }
@@ -243,9 +254,11 @@ export class TestClient {
243
254
  // Execute handler (only if auth passed)
244
255
  if (!rejected) {
245
256
  await match.handler(req, res);
246
- // Global afterX hooks (logging / post-processing), mirroring the live tail.
247
- if (globalMiddleware.length > 0) {
248
- await MiddlewareRunner.runAfter(globalMiddleware, req, res);
257
+ // Global + route-class afterX hooks (logging / post-processing),
258
+ // mirroring the live tail.
259
+ const afterMiddleware = [...globalMiddleware, ...routeMiddlewareClasses];
260
+ if (afterMiddleware.length > 0) {
261
+ await MiddlewareRunner.runAfter(afterMiddleware, req, res);
249
262
  }
250
263
  }
251
264