tina4-nodejs 3.13.42 → 3.13.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -1
- package/package.json +1 -1
- package/packages/core/src/graphql.ts +23 -14
- package/packages/core/src/mcp.ts +21 -2
- package/packages/core/src/queue.ts +101 -9
- package/packages/core/src/queueBackends/kafkaBackend.ts +303 -167
- package/packages/core/src/queueBackends/mongoBackend.ts +143 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +97 -31
- package/packages/core/src/server.ts +12 -5
- package/packages/core/src/session.ts +11 -95
- package/packages/core/src/sessionHandlers/mongoClient.ts +238 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +25 -204
- package/packages/core/src/sessionHandlers/redisHandler.ts +69 -114
- package/packages/core/src/sessionHandlers/respClient.ts +171 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +11 -95
- package/packages/orm/src/adapters/firebird.ts +20 -2
- package/packages/orm/src/adapters/mssql.ts +24 -2
- package/packages/orm/src/adapters/mysql.ts +20 -2
- package/packages/orm/src/adapters/postgres.ts +40 -12
- package/packages/orm/src/adapters/sqlite.ts +16 -2
- package/packages/orm/src/autoCrud.ts +13 -0
- package/packages/orm/src/baseModel.ts +3 -1
- package/packages/orm/src/cachedDatabase.ts +1 -1
- package/packages/orm/src/database.ts +42 -11
- package/packages/orm/src/index.ts +1 -1
- package/packages/orm/src/migration.ts +124 -45
- package/packages/orm/src/types.ts +5 -3
|
@@ -17,7 +17,21 @@
|
|
|
17
17
|
* TINA4_SESSION_REDIS_DB (default: 0)
|
|
18
18
|
*/
|
|
19
19
|
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { createRequire } from "node:module";
|
|
20
21
|
import type { SessionHandler } from "../session.js";
|
|
22
|
+
import { respCommandSync } from "./respClient.js";
|
|
23
|
+
|
|
24
|
+
// Resolve packages relative to this module so the optional `redis` driver is
|
|
25
|
+
// detected exactly as a consumer would resolve it (createRequire works in ESM).
|
|
26
|
+
const moduleRequire = createRequire(import.meta.url);
|
|
27
|
+
function redisDriverAvailable(): boolean {
|
|
28
|
+
try {
|
|
29
|
+
moduleRequire.resolve("redis");
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
21
35
|
|
|
22
36
|
interface SessionData {
|
|
23
37
|
_created: number;
|
|
@@ -83,125 +97,72 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
83
97
|
: 0);
|
|
84
98
|
}
|
|
85
99
|
|
|
100
|
+
/** Resolve a host/port for the raw-RESP path (parses TINA4_SESSION_REDIS_URL if set). */
|
|
101
|
+
private resolveHostPort(): { host: string; port: number } {
|
|
102
|
+
if (this.url) {
|
|
103
|
+
try {
|
|
104
|
+
const u = new URL(this.url);
|
|
105
|
+
return { host: u.hostname || "127.0.0.1", port: parseInt(u.port, 10) || 6379 };
|
|
106
|
+
} catch {
|
|
107
|
+
return { host: this.host, port: this.port };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { host: this.host, port: this.port };
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
/**
|
|
87
|
-
* Execute a Redis command synchronously
|
|
114
|
+
* Execute a Redis command synchronously.
|
|
88
115
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
116
|
+
* Prefers the official `redis` driver when it is installed (the class's reason
|
|
117
|
+
* to exist); otherwise speaks raw RESP via the shared {@link respCommandSync}
|
|
118
|
+
* transport — same correct path as the Valkey handler, no `redis` dependency.
|
|
91
119
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* "backend failed" (log-loud + degrade). Backend-failure policy parity.
|
|
120
|
+
* A genuine key miss yields `""`; a transport/connection FAILURE (server
|
|
121
|
+
* unreachable, rejected AUTH, timeout) THROWS so the Session boundary can
|
|
122
|
+
* distinguish "not found" (silent) from "backend failed" (log-loud + degrade).
|
|
96
123
|
*/
|
|
97
124
|
private execSync(args: string[]): string {
|
|
125
|
+
if (redisDriverAvailable()) {
|
|
126
|
+
return this.execViaNpm(args);
|
|
127
|
+
}
|
|
128
|
+
const { host, port } = this.resolveHostPort();
|
|
129
|
+
return respCommandSync({ host, port, password: this.password, db: this.db }, args, "Redis");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Drive the command through the `redis` npm client in a short-lived child. */
|
|
133
|
+
private execViaNpm(args: string[]): string {
|
|
98
134
|
const script = `
|
|
99
|
-
const
|
|
100
|
-
const
|
|
135
|
+
const useUrl = ${JSON.stringify(!!this.url)};
|
|
136
|
+
const url = ${JSON.stringify(this.url)};
|
|
137
|
+
const host = ${JSON.stringify(this.host)};
|
|
101
138
|
const port = ${this.port};
|
|
102
139
|
const password = ${JSON.stringify(this.password)};
|
|
103
140
|
const db = ${this.db};
|
|
104
|
-
const useUrl = ${JSON.stringify(!!this.url)};
|
|
105
|
-
const url = ${JSON.stringify(this.url)};
|
|
106
141
|
const args = ${JSON.stringify(args)};
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
let result;
|
|
128
|
-
if (cmd === "GET") {
|
|
129
|
-
result = await client.get(args[1]);
|
|
130
|
-
} else if (cmd === "SET") {
|
|
131
|
-
result = await client.set(args[1], args[2]);
|
|
132
|
-
} else if (cmd === "SETEX") {
|
|
133
|
-
result = await client.setEx(args[1], parseInt(args[2], 10), args[3]);
|
|
134
|
-
} else if (cmd === "DEL") {
|
|
135
|
-
result = await client.del(args[1]);
|
|
136
|
-
}
|
|
137
|
-
await client.quit();
|
|
138
|
-
|
|
139
|
-
if (result === null || result === undefined) {
|
|
140
|
-
process.stdout.write("__NULL__");
|
|
141
|
-
} else {
|
|
142
|
-
process.stdout.write(String(result));
|
|
143
|
-
}
|
|
144
|
-
} catch (err) {
|
|
145
|
-
process.stderr.write(err.message);
|
|
146
|
-
process.exit(1);
|
|
147
|
-
}
|
|
148
|
-
})();
|
|
149
|
-
} else {
|
|
150
|
-
// Fallback: raw TCP RESP protocol (no redis package needed)
|
|
151
|
-
const actualHost = useUrl ? (() => {
|
|
152
|
-
try { const u = new URL(url); return u.hostname || "127.0.0.1"; } catch { return "127.0.0.1"; }
|
|
153
|
-
})() : host;
|
|
154
|
-
const actualPort = useUrl ? (() => {
|
|
155
|
-
try { const u = new URL(url); return parseInt(u.port, 10) || 6379; } catch { return 6379; }
|
|
156
|
-
})() : port;
|
|
157
|
-
|
|
158
|
-
function buildCommand(a) {
|
|
159
|
-
let cmd = "*" + a.length + "\\r\\n";
|
|
160
|
-
for (const s of a) cmd += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
161
|
-
return cmd;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const sock = net.createConnection({ host: actualHost, port: actualPort }, () => {
|
|
165
|
-
let commands = "";
|
|
166
|
-
if (password) commands += buildCommand(["AUTH", password]);
|
|
167
|
-
if (db !== 0) commands += buildCommand(["SELECT", String(db)]);
|
|
168
|
-
commands += buildCommand(args);
|
|
169
|
-
sock.write(commands);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
let buffer = Buffer.alloc(0);
|
|
173
|
-
sock.on("data", (chunk) => {
|
|
174
|
-
buffer = Buffer.concat([buffer, chunk]);
|
|
175
|
-
});
|
|
176
|
-
sock.on("end", () => {
|
|
177
|
-
const lines = buffer.toString("utf-8").split("\\r\\n");
|
|
178
|
-
let responses = [];
|
|
179
|
-
let i = 0;
|
|
180
|
-
while (i < lines.length) {
|
|
181
|
-
const line = lines[i];
|
|
182
|
-
if (!line) { i++; continue; }
|
|
183
|
-
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(":")) {
|
|
184
|
-
responses.push(line);
|
|
185
|
-
i++;
|
|
186
|
-
} else if (line.startsWith("$")) {
|
|
187
|
-
const len = parseInt(line.slice(1), 10);
|
|
188
|
-
if (len === -1) { responses.push(null); i++; }
|
|
189
|
-
else { responses.push(lines[i+1] || ""); i += 2; }
|
|
190
|
-
} else { i++; }
|
|
191
|
-
}
|
|
192
|
-
const result = responses[responses.length - 1];
|
|
193
|
-
if (result === null) process.stdout.write("__NULL__");
|
|
194
|
-
else if (typeof result === "string" && result.startsWith("-")) process.stdout.write("__ERR__" + result);
|
|
195
|
-
else process.stdout.write(String(result ?? "__NULL__"));
|
|
196
|
-
});
|
|
197
|
-
sock.on("error", (err) => {
|
|
198
|
-
process.stderr.write(err.message);
|
|
142
|
+
(async () => {
|
|
143
|
+
try {
|
|
144
|
+
const redis = require("redis");
|
|
145
|
+
const clientOpts = useUrl
|
|
146
|
+
? { url }
|
|
147
|
+
: { socket: { host, port }, password: password || undefined, database: db };
|
|
148
|
+
const client = redis.createClient(clientOpts);
|
|
149
|
+
client.on("error", () => {});
|
|
150
|
+
await client.connect();
|
|
151
|
+
const cmd = args[0].toUpperCase();
|
|
152
|
+
let result;
|
|
153
|
+
if (cmd === "GET") result = await client.get(args[1]);
|
|
154
|
+
else if (cmd === "SET") result = await client.set(args[1], args[2]);
|
|
155
|
+
else if (cmd === "SETEX") result = await client.setEx(args[1], parseInt(args[2], 10), args[3]);
|
|
156
|
+
else if (cmd === "DEL") result = await client.del(args[1]);
|
|
157
|
+
await client.quit();
|
|
158
|
+
const out = (result === null || result === undefined) ? "__NULL__" : String(result);
|
|
159
|
+
process.stdout.write(out, () => process.exit(0));
|
|
160
|
+
} catch (err) {
|
|
161
|
+
process.stderr.write(String((err && err.message) || err));
|
|
199
162
|
process.exit(1);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
}
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
203
165
|
`;
|
|
204
|
-
|
|
205
166
|
let result: string;
|
|
206
167
|
try {
|
|
207
168
|
result = execFileSync(process.execPath, ["-e", script], {
|
|
@@ -210,15 +171,9 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
210
171
|
stdio: ["pipe", "pipe", "pipe"],
|
|
211
172
|
});
|
|
212
173
|
} catch (err) {
|
|
213
|
-
// Non-zero exit = the child hit a socket error / AUTH failure / timeout.
|
|
214
|
-
// That is a transport FAILURE, not a key miss — surface it so the
|
|
215
|
-
// Session boundary logs + degrades (or re-throws under strict mode).
|
|
216
174
|
throw new Error(`Redis command failed: ${(err as Error).message}`);
|
|
217
175
|
}
|
|
218
|
-
if (result === "__NULL__") return "";
|
|
219
|
-
if (result.startsWith("__ERR__")) {
|
|
220
|
-
throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
|
|
221
|
-
}
|
|
176
|
+
if (result === "__NULL__") return ""; // genuine key miss
|
|
222
177
|
return result;
|
|
223
178
|
}
|
|
224
179
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 synchronous RESP transport — shared by every Redis/Valkey session handler.
|
|
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.
|
|
8
|
+
*
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
export interface RespTarget {
|
|
21
|
+
host: string;
|
|
22
|
+
port: number;
|
|
23
|
+
/** AUTH password (empty/undefined = no AUTH sent). */
|
|
24
|
+
password?: string;
|
|
25
|
+
/** SELECT db index (0/undefined = no SELECT sent). */
|
|
26
|
+
db?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run a single RESP command synchronously against host:port and return the reply.
|
|
31
|
+
*
|
|
32
|
+
* - A genuine nil / key-miss returns `""` (callers treat "" as "no session yet").
|
|
33
|
+
* - A transport FAILURE (server unreachable, timeout, connection closed before a
|
|
34
|
+
* reply) THROWS `<label> command failed: ...`.
|
|
35
|
+
* - A RESP error reply — including a rejected AUTH/SELECT handshake — THROWS
|
|
36
|
+
* `<label> error: ...`. A rejected handshake is a transport failure, not a
|
|
37
|
+
* result, so it is surfaced ahead of the command reply.
|
|
38
|
+
*
|
|
39
|
+
* Session values are JSON strings (they start with `{`), so they never collide
|
|
40
|
+
* with the `__NULL__` / `__ERR__` sentinels the child uses on stdout.
|
|
41
|
+
*/
|
|
42
|
+
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;
|
|
171
|
+
}
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* TINA4_SESSION_VALKEY_PREFIX (default: "tina4:session:")
|
|
12
12
|
* TINA4_SESSION_VALKEY_DB (default: 0)
|
|
13
13
|
*/
|
|
14
|
-
import { execFileSync } from "node:child_process";
|
|
15
14
|
import type { SessionHandler } from "../session.js";
|
|
15
|
+
import { respCommandSync } from "./respClient.js";
|
|
16
16
|
|
|
17
17
|
interface SessionData {
|
|
18
18
|
_created: number;
|
|
@@ -75,103 +75,19 @@ export class ValkeySessionHandler implements SessionHandler {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* Execute a RESP command synchronously
|
|
78
|
+
* Execute a RESP command synchronously against the live Valkey server.
|
|
79
79
|
*
|
|
80
|
-
*
|
|
81
|
-
* transport/connection FAILURE (server unreachable,
|
|
82
|
-
* THROWS so the Session boundary can distinguish "not found"
|
|
83
|
-
* "backend failed" (log-loud + degrade). Backend-failure
|
|
80
|
+
* Delegates to the shared {@link respCommandSync} transport: a genuine key miss
|
|
81
|
+
* yields `""`, and a transport/connection FAILURE (server unreachable, rejected
|
|
82
|
+
* AUTH, timeout) THROWS so the Session boundary can distinguish "not found"
|
|
83
|
+
* (silent) from "backend failed" (log-loud + degrade). Backend-failure parity.
|
|
84
84
|
*/
|
|
85
85
|
private execSync(args: string[]): string {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const db = ${this.db};
|
|
92
|
-
const args = ${JSON.stringify(args)};
|
|
93
|
-
|
|
94
|
-
function buildCommand(a) {
|
|
95
|
-
let cmd = "*" + a.length + "\\r\\n";
|
|
96
|
-
for (const s of a) cmd += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
97
|
-
return cmd;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function parseResp(buf) {
|
|
101
|
-
const str = buf.toString("utf-8");
|
|
102
|
-
if (str.startsWith("+")) return str.slice(1).split("\\r\\n")[0];
|
|
103
|
-
if (str.startsWith("-")) return "ERR:" + str.slice(1).split("\\r\\n")[0];
|
|
104
|
-
if (str.startsWith(":")) return str.slice(1).split("\\r\\n")[0];
|
|
105
|
-
if (str.startsWith("$-1")) return null;
|
|
106
|
-
if (str.startsWith("$")) {
|
|
107
|
-
const nl = str.indexOf("\\r\\n");
|
|
108
|
-
const len = parseInt(str.slice(1, nl), 10);
|
|
109
|
-
const start = nl + 2;
|
|
110
|
-
return str.slice(start, start + len);
|
|
111
|
-
}
|
|
112
|
-
return str;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const sock = net.createConnection({ host, port }, () => {
|
|
116
|
-
let commands = "";
|
|
117
|
-
if (password) commands += buildCommand(["AUTH", password]);
|
|
118
|
-
if (db !== 0) commands += buildCommand(["SELECT", String(db)]);
|
|
119
|
-
commands += buildCommand(args);
|
|
120
|
-
sock.write(commands);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
let buffer = Buffer.alloc(0);
|
|
124
|
-
sock.on("data", (chunk) => {
|
|
125
|
-
buffer = Buffer.concat([buffer, chunk]);
|
|
126
|
-
});
|
|
127
|
-
sock.on("end", () => {
|
|
128
|
-
// Parse last response (skip AUTH/SELECT responses)
|
|
129
|
-
const lines = buffer.toString("utf-8").split("\\r\\n");
|
|
130
|
-
let responses = [];
|
|
131
|
-
let i = 0;
|
|
132
|
-
while (i < lines.length) {
|
|
133
|
-
const line = lines[i];
|
|
134
|
-
if (!line) { i++; continue; }
|
|
135
|
-
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(":")) {
|
|
136
|
-
responses.push(line);
|
|
137
|
-
i++;
|
|
138
|
-
} else if (line.startsWith("$")) {
|
|
139
|
-
const len = parseInt(line.slice(1), 10);
|
|
140
|
-
if (len === -1) { responses.push(null); i++; }
|
|
141
|
-
else { responses.push(lines[i+1] || ""); i += 2; }
|
|
142
|
-
} else { i++; }
|
|
143
|
-
}
|
|
144
|
-
// The last response is our actual command result
|
|
145
|
-
const result = responses[responses.length - 1];
|
|
146
|
-
if (result === null) process.stdout.write("__NULL__");
|
|
147
|
-
else if (typeof result === "string" && result.startsWith("-")) process.stdout.write("__ERR__" + result);
|
|
148
|
-
else process.stdout.write(String(result ?? "__NULL__"));
|
|
149
|
-
});
|
|
150
|
-
sock.on("error", (err) => {
|
|
151
|
-
process.stderr.write(err.message);
|
|
152
|
-
process.exit(1);
|
|
153
|
-
});
|
|
154
|
-
setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
|
|
155
|
-
`;
|
|
156
|
-
|
|
157
|
-
let result: string;
|
|
158
|
-
try {
|
|
159
|
-
result = execFileSync(process.execPath, ["-e", script], {
|
|
160
|
-
encoding: "utf-8",
|
|
161
|
-
timeout: 5000,
|
|
162
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
163
|
-
});
|
|
164
|
-
} catch (err) {
|
|
165
|
-
// Non-zero exit = the child hit a socket error / AUTH failure / timeout.
|
|
166
|
-
// That is a transport FAILURE, not a key miss — surface it so the
|
|
167
|
-
// Session boundary logs + degrades (or re-throws under strict mode).
|
|
168
|
-
throw new Error(`Valkey command failed: ${(err as Error).message}`);
|
|
169
|
-
}
|
|
170
|
-
if (result === "__NULL__") return ""; // genuine key miss
|
|
171
|
-
if (result.startsWith("__ERR__")) {
|
|
172
|
-
throw new Error(`Valkey error: ${result.slice("__ERR__".length)}`);
|
|
173
|
-
}
|
|
174
|
-
return result;
|
|
86
|
+
return respCommandSync(
|
|
87
|
+
{ host: this.host, port: this.port, password: this.password, db: this.db },
|
|
88
|
+
args,
|
|
89
|
+
"Valkey",
|
|
90
|
+
);
|
|
175
91
|
}
|
|
176
92
|
|
|
177
93
|
private key(sessionId: string): string {
|
|
@@ -278,12 +278,30 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
278
278
|
return rows[0] ?? null;
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
insert(table: string, data: Record<string, unknown>): DatabaseResult {
|
|
281
|
+
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
|
|
282
282
|
throw new Error("Use insertAsync() for Firebird.");
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
async insertAsync(table: string, data: Record<string, unknown>): Promise<DatabaseResult> {
|
|
285
|
+
async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
|
|
286
286
|
this.ensureConnected();
|
|
287
|
+
// A list of dicts is a batch insert — one parameterised INSERT run per row via
|
|
288
|
+
// executeManyAsync (ONE connection). Firebird has no generic last_insert_id, so
|
|
289
|
+
// the batch reports affectedRows == row count and no lastInsertId (same as the
|
|
290
|
+
// single-row path). See PostgresAdapter for the array-crash rationale.
|
|
291
|
+
if (Array.isArray(data)) {
|
|
292
|
+
if (data.length === 0) return { success: true, rowsAffected: 0 };
|
|
293
|
+
const keys = Object.keys(data[0]);
|
|
294
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
295
|
+
const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
|
|
296
|
+
const paramsList = data.map((row) => keys.map((k) => row[k]));
|
|
297
|
+
try {
|
|
298
|
+
const result = await this.executeManyAsync(sql, paramsList);
|
|
299
|
+
return { success: true, rowsAffected: result.totalAffected, lastInsertId: result.lastInsertId };
|
|
300
|
+
} catch (e) {
|
|
301
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
const keys = Object.keys(data);
|
|
288
306
|
const placeholders = keys.map(() => "?").join(", ");
|
|
289
307
|
const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
|
|
@@ -236,12 +236,34 @@ export class MssqlAdapter implements DatabaseAdapter {
|
|
|
236
236
|
return rows[0] ?? null;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
insert(table: string, data: Record<string, unknown>): DatabaseResult {
|
|
239
|
+
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
|
|
240
240
|
throw new Error("Use insertAsync() for MSSQL.");
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
async insertAsync(table: string, data: Record<string, unknown>): Promise<DatabaseResult> {
|
|
243
|
+
async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
|
|
244
244
|
this.ensureConnected();
|
|
245
|
+
// A list of dicts is a batch insert — one parameterised INSERT run per row via
|
|
246
|
+
// executeManyAsync (ONE connection). The single-row path appends SELECT
|
|
247
|
+
// SCOPE_IDENTITY() to surface the id; the batch path omits it (no per-row id is
|
|
248
|
+
// tracked for a batch — affectedRows == row count is what callers rely on).
|
|
249
|
+
// See PostgresAdapter for the array-crash rationale this branch fixes.
|
|
250
|
+
if (Array.isArray(data)) {
|
|
251
|
+
if (data.length === 0) return { success: true, rowsAffected: 0 };
|
|
252
|
+
const keys = Object.keys(data[0]);
|
|
253
|
+
// `?` placeholders — executeManyAsync -> executeAsync runs convertPlaceholders,
|
|
254
|
+
// which rewrites them to @p0, @p1, ... for tedious.
|
|
255
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
256
|
+
const sql = `INSERT INTO [${table}] ([${keys.join("], [")}]) VALUES (${placeholders})`;
|
|
257
|
+
const paramsList = data.map((row) => keys.map((k) => row[k]));
|
|
258
|
+
try {
|
|
259
|
+
const result = await this.executeManyAsync(sql, paramsList);
|
|
260
|
+
if (result.lastInsertId !== undefined) this._lastInsertId = result.lastInsertId;
|
|
261
|
+
return { success: true, rowsAffected: result.totalAffected, lastInsertId: result.lastInsertId };
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
245
267
|
const keys = Object.keys(data);
|
|
246
268
|
const placeholders = keys.map((_, i) => `@p${i}`).join(", ");
|
|
247
269
|
const sql = `INSERT INTO [${table}] ([${keys.join("], [")}]) VALUES (${placeholders}); SELECT SCOPE_IDENTITY() AS id`;
|
|
@@ -163,12 +163,30 @@ export class MysqlAdapter implements DatabaseAdapter {
|
|
|
163
163
|
return rows[0] ?? null;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
insert(table: string, data: Record<string, unknown>): DatabaseResult {
|
|
166
|
+
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
|
|
167
167
|
throw new Error("Use insertAsync() for MySQL.");
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
async insertAsync(table: string, data: Record<string, unknown>): Promise<DatabaseResult> {
|
|
170
|
+
async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
|
|
171
171
|
this.ensureConnected();
|
|
172
|
+
// A list of dicts is a batch insert — one parameterised INSERT run per row via
|
|
173
|
+
// executeManyAsync (ONE connection). See PostgresAdapter for the rationale;
|
|
174
|
+
// without this branch a list crashed/mis-built SQL via Object.keys() on the array.
|
|
175
|
+
if (Array.isArray(data)) {
|
|
176
|
+
if (data.length === 0) return { success: true, rowsAffected: 0 };
|
|
177
|
+
const keys = Object.keys(data[0]);
|
|
178
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
179
|
+
const sql = `INSERT INTO \`${table}\` (\`${keys.join("`, `")}\`) VALUES (${placeholders})`;
|
|
180
|
+
const paramsList = data.map((row) => keys.map((k) => row[k]));
|
|
181
|
+
try {
|
|
182
|
+
const result = await this.executeManyAsync(sql, paramsList);
|
|
183
|
+
if (result.lastInsertId !== undefined) this._lastInsertId = result.lastInsertId;
|
|
184
|
+
return { success: true, rowsAffected: result.totalAffected, lastInsertId: result.lastInsertId };
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
172
190
|
const keys = Object.keys(data);
|
|
173
191
|
const placeholders = keys.map(() => "?").join(", ");
|
|
174
192
|
const sql = `INSERT INTO \`${table}\` (\`${keys.join("`, `")}\`) VALUES (${placeholders})`;
|