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.
@@ -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 via a short-lived child process.
114
+ * Execute a Redis command synchronously.
88
115
  *
89
- * Attempts to use the `redis` npm package first. If unavailable, falls
90
- * back to raw TCP (RESP protocol) same as valkeyHandler.ts.
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
- * Returns the command result string. A genuine key miss yields `""`. A
93
- * transport/connection FAILURE (server unreachable, AUTH error, timeout)
94
- * THROWS so the Session boundary can distinguish "not found" (silent) from
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 net = require("node:net");
100
- const host = ${JSON.stringify(this.url || this.host)};
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
- // Try the redis npm package first
109
- let redisAvailable = false;
110
- try {
111
- require.resolve("redis");
112
- redisAvailable = true;
113
- } catch {}
114
-
115
- if (redisAvailable) {
116
- const redis = require("redis");
117
- (async () => {
118
- try {
119
- const clientOpts = useUrl
120
- ? { url }
121
- : { socket: { host, port }, password: password || undefined, database: db };
122
- const client = redis.createClient(clientOpts);
123
- client.on("error", () => {});
124
- await client.connect();
125
-
126
- const cmd = args[0].toUpperCase();
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
- setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
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 ""; // genuine key miss
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 via a short-lived TCP connection.
78
+ * Execute a RESP command synchronously against the live Valkey server.
79
79
  *
80
- * Returns the command result string. A genuine key miss yields `""`. A
81
- * transport/connection FAILURE (server unreachable, AUTH error, timeout)
82
- * THROWS so the Session boundary can distinguish "not found" (silent) from
83
- * "backend failed" (log-loud + degrade). Backend-failure policy parity.
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
- const script = `
87
- const net = require("node:net");
88
- const host = ${JSON.stringify(this.host)};
89
- const port = ${this.port};
90
- const password = ${JSON.stringify(this.password)};
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})`;