tina4-nodejs 3.13.43 → 3.13.45
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/queueBackends/kafkaBackend.ts +303 -167
- 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
|
@@ -242,6 +242,8 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
242
242
|
let buffer = Buffer.alloc(0);
|
|
243
243
|
let step = "handshake";
|
|
244
244
|
let deliveryTag = null;
|
|
245
|
+
let expectedBody = 0;
|
|
246
|
+
let receivedBody = 0;
|
|
245
247
|
|
|
246
248
|
sock.on("data", (chunk) => {
|
|
247
249
|
buffer = Buffer.concat([buffer, chunk]);
|
|
@@ -264,11 +266,29 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
264
266
|
const methodId = payload.readUInt16BE(2);
|
|
265
267
|
handleMethod(classId, methodId, payload.subarray(4), channel);
|
|
266
268
|
} else if (frameType === 2) { // HEADER frame
|
|
267
|
-
// Content header —
|
|
269
|
+
// Content header — capture the declared body size so we know when the
|
|
270
|
+
// body is complete (a body may arrive in several frames).
|
|
271
|
+
if (operation === "get") {
|
|
272
|
+
// body-size is a 64-bit field at offset 4 of the header payload;
|
|
273
|
+
// the low 32 bits are enough for our JSON payloads.
|
|
274
|
+
expectedBody = payload.readUInt32BE(8);
|
|
275
|
+
receivedBody = 0;
|
|
276
|
+
}
|
|
268
277
|
} else if (frameType === 3) { // BODY frame
|
|
269
278
|
// Content body
|
|
270
279
|
const body = payload.toString("utf-8");
|
|
271
280
|
process.stdout.write(body);
|
|
281
|
+
if (operation === "get") {
|
|
282
|
+
receivedBody += payload.length;
|
|
283
|
+
// Once the whole body has arrived, cleanly close the connection so
|
|
284
|
+
// the child exits 0 with the body on stdout. Without this the get
|
|
285
|
+
// handler fell through to the 10s watchdog (exit 1), so pop()
|
|
286
|
+
// always saw a failed child and returned null even though the
|
|
287
|
+
// message body had been written to stdout.
|
|
288
|
+
if (receivedBody >= expectedBody) {
|
|
289
|
+
closeConnection();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
272
292
|
}
|
|
273
293
|
}
|
|
274
294
|
}
|
|
@@ -310,11 +330,29 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
310
330
|
sendMethod(0, 10, 11, payload.subarray(0, offset));
|
|
311
331
|
}
|
|
312
332
|
else if (classId === 10 && methodId === 30) {
|
|
313
|
-
// Connection.Tune → send Connection.Tune-Ok + Connection.Open
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
333
|
+
// Connection.Tune → send Connection.Tune-Ok + Connection.Open.
|
|
334
|
+
// AMQP 0-9-1 requires TuneOk values to NOT exceed the server's proposal.
|
|
335
|
+
// The broker's Tune args are channel-max:short, frame-max:long,
|
|
336
|
+
// heartbeat:short. Hardcoding channel-max=0 means "no limit", which
|
|
337
|
+
// RabbitMQ treats as exceeding its proposed channel-max (e.g. 2047) and
|
|
338
|
+
// it aborts the connection right after Open — so negotiate instead.
|
|
339
|
+
const desiredFrameMax = 131072;
|
|
340
|
+
const desiredHeartbeat = 60;
|
|
341
|
+
const serverChannelMax = args.length >= 2 ? args.readUInt16BE(0) : 0;
|
|
342
|
+
const serverFrameMax = args.length >= 6 ? args.readUInt32BE(2) : 0;
|
|
343
|
+
const serverHeartbeat = args.length >= 8 ? args.readUInt16BE(6) : 0;
|
|
344
|
+
|
|
345
|
+
// channel-max: echo the server's value (its cap); 0 = unlimited.
|
|
346
|
+
const channelMax = serverChannelMax;
|
|
347
|
+
// frame-max: min(desired, server), treating 0 as unlimited on either side.
|
|
348
|
+
const frameMax = serverFrameMax === 0 ? desiredFrameMax : Math.min(desiredFrameMax, serverFrameMax);
|
|
349
|
+
// heartbeat: our choice, clamped to the server's if it proposed a non-zero one.
|
|
350
|
+
const heartbeat = serverHeartbeat === 0 ? desiredHeartbeat : Math.min(desiredHeartbeat, serverHeartbeat);
|
|
351
|
+
|
|
352
|
+
const tuneOk = Buffer.alloc(8);
|
|
353
|
+
tuneOk.writeUInt16BE(channelMax, 0); // channel-max (negotiated)
|
|
354
|
+
tuneOk.writeUInt32BE(frameMax, 2); // frame-max (negotiated)
|
|
355
|
+
tuneOk.writeUInt16BE(heartbeat, 6); // heartbeat (negotiated)
|
|
318
356
|
sendMethod(0, 10, 31, tuneOk);
|
|
319
357
|
|
|
320
358
|
// Connection.Open
|
|
@@ -334,8 +372,13 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
334
372
|
}
|
|
335
373
|
else if (classId === 20 && methodId === 11) {
|
|
336
374
|
// Channel.Open-Ok → declare queue
|
|
375
|
+
// Payload: reserved(2) + queue short-string(1 + len) + flags(1) +
|
|
376
|
+
// arguments empty-table(4) = 8 + len bytes. (Was 7 + len, which
|
|
377
|
+
// overflowed the 4-byte empty-table write at offset 4 + len and threw
|
|
378
|
+
// ERR_OUT_OF_RANGE — swallowed by the catch, so publish/get/size all
|
|
379
|
+
// silently failed even once the handshake succeeded.)
|
|
337
380
|
const qBuf = Buffer.from(queueName, "utf-8");
|
|
338
|
-
const declPayload = Buffer.alloc(
|
|
381
|
+
const declPayload = Buffer.alloc(8 + qBuf.length);
|
|
339
382
|
declPayload.writeUInt16BE(0, 0); // reserved
|
|
340
383
|
declPayload.writeUInt8(qBuf.length, 2);
|
|
341
384
|
qBuf.copy(declPayload, 3);
|
|
@@ -357,25 +400,21 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
357
400
|
|
|
358
401
|
sendMethod(1, 60, 40, pubPayload);
|
|
359
402
|
|
|
360
|
-
// Content header
|
|
403
|
+
// Content header frame. AMQP 0-9-1 content header body is:
|
|
404
|
+
// class-id(2) + weight(2) + body-size(8) + property-flags(2), then
|
|
405
|
+
// one entry per set property flag. With property-flags = 0 (no
|
|
406
|
+
// properties) the header is EXACTLY 14 bytes. The previous code
|
|
407
|
+
// allocated 14 + 1 + ct.length + 1 = 32 bytes but only wrote the
|
|
408
|
+
// first 14, leaving 18 trailing zero bytes that the broker parsed as
|
|
409
|
+
// bogus property data — it replied INTERNAL_ERROR (541) and closed
|
|
410
|
+
// the connection, so no message was ever stored. Allocate exactly 14.
|
|
361
411
|
const bodyBuf = Buffer.from(data, "utf-8");
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// body
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
header.writeUInt16BE(0x6000, 12); // property flags: delivery-mode + content-type
|
|
369
|
-
// content-type
|
|
370
|
-
const ct = Buffer.from("application/json");
|
|
371
|
-
header.writeUInt8(ct.length, 14);
|
|
372
|
-
|
|
373
|
-
const fullHeader = Buffer.alloc(14 + 1 + ct.length + 1);
|
|
374
|
-
fullHeader.writeUInt16BE(60, 0);
|
|
375
|
-
fullHeader.writeUInt16BE(0, 2);
|
|
376
|
-
fullHeader.writeUInt32BE(0, 4);
|
|
377
|
-
fullHeader.writeUInt32BE(bodyBuf.length, 8);
|
|
378
|
-
fullHeader.writeUInt16BE(0x0000, 12); // no properties for simplicity
|
|
412
|
+
const fullHeader = Buffer.alloc(14);
|
|
413
|
+
fullHeader.writeUInt16BE(60, 0); // class = basic
|
|
414
|
+
fullHeader.writeUInt16BE(0, 2); // weight
|
|
415
|
+
fullHeader.writeUInt32BE(0, 4); // body-size high 32 bits
|
|
416
|
+
fullHeader.writeUInt32BE(bodyBuf.length, 8); // body-size low 32 bits
|
|
417
|
+
fullHeader.writeUInt16BE(0x0000, 12); // property flags: none set
|
|
379
418
|
|
|
380
419
|
// Send header frame
|
|
381
420
|
const hFrame = Buffer.alloc(7 + fullHeader.length + 1);
|
|
@@ -440,9 +479,21 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
440
479
|
closeConnection();
|
|
441
480
|
}
|
|
442
481
|
else if (classId === 10 && methodId === 50) {
|
|
443
|
-
// Connection.Close
|
|
482
|
+
// Connection.Close (server-initiated, e.g. a channel/protocol error)
|
|
483
|
+
// → send Connection.Close-Ok and exit non-zero so the caller sees the
|
|
484
|
+
// failure rather than a half-completed operation.
|
|
444
485
|
sendMethod(0, 10, 51, Buffer.alloc(0));
|
|
445
486
|
sock.destroy();
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
else if (classId === 10 && methodId === 51) {
|
|
490
|
+
// Connection.Close-Ok — the broker has acknowledged our Close, which
|
|
491
|
+
// means it has fully processed everything we sent (incl. a Basic.Publish
|
|
492
|
+
// and its content frames). Only now is it safe to drop the socket. The
|
|
493
|
+
// previous code destroyed the socket on a blind 200ms timer right after
|
|
494
|
+
// writing the body frame, racing the broker and dropping the message.
|
|
495
|
+
sock.destroy();
|
|
496
|
+
process.exit(0);
|
|
446
497
|
}
|
|
447
498
|
}
|
|
448
499
|
|
|
@@ -482,14 +533,26 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
482
533
|
}
|
|
483
534
|
|
|
484
535
|
function closeConnection() {
|
|
485
|
-
// Send Connection.Close
|
|
486
|
-
|
|
536
|
+
// Send Connection.Close — payload is reply-code(2) + reply-text
|
|
537
|
+
// short-string(1 + 0) + class-id(2) + method-id(2) = 7 bytes. (Was
|
|
538
|
+
// Buffer.alloc(6), so the method-id write at offset 5 overflowed and
|
|
539
|
+
// threw ERR_OUT_OF_RANGE right after "__PUBLISHED__" was written —
|
|
540
|
+
// swallowed by the parent catch, so push() reported "publish failed"
|
|
541
|
+
// even though the message had already reached the broker.)
|
|
542
|
+
const closePayload = Buffer.alloc(7);
|
|
487
543
|
closePayload.writeUInt16BE(200, 0); // reply code
|
|
488
|
-
closePayload.writeUInt8(0, 2); // reply text (empty)
|
|
544
|
+
closePayload.writeUInt8(0, 2); // reply text (empty short-string)
|
|
489
545
|
closePayload.writeUInt16BE(0, 3); // class
|
|
490
546
|
closePayload.writeUInt16BE(0, 5); // method
|
|
491
547
|
sendMethod(0, 10, 50, closePayload);
|
|
492
|
-
|
|
548
|
+
// Do NOT destroy the socket here — wait for the broker's
|
|
549
|
+
// Connection.Close-Ok (10/51), which confirms it has fully processed
|
|
550
|
+
// everything we sent (the Basic.Publish + content frames in particular).
|
|
551
|
+
// The 10/51 handler exits 0. This safety net only fires if the broker
|
|
552
|
+
// never replies, and still exits 0 because stdout already carries the
|
|
553
|
+
// operation's result (the message was flushed to the socket before
|
|
554
|
+
// Close was sent).
|
|
555
|
+
setTimeout(() => { sock.destroy(); process.exit(0); }, 3000).unref();
|
|
493
556
|
}
|
|
494
557
|
|
|
495
558
|
sock.on("error", (err) => {
|
|
@@ -497,7 +560,10 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
497
560
|
process.exit(1);
|
|
498
561
|
});
|
|
499
562
|
|
|
500
|
-
|
|
563
|
+
// Watchdog: only fires if an operation never completes (handshake hangs).
|
|
564
|
+
// unref() so a completed operation's pending timer can't keep the event
|
|
565
|
+
// loop alive and delay/override the clean exit above.
|
|
566
|
+
setTimeout(() => { sock.destroy(); process.exit(1); }, 10000).unref();
|
|
501
567
|
`;
|
|
502
568
|
|
|
503
569
|
try {
|
|
@@ -937,8 +937,13 @@ ${reset}
|
|
|
937
937
|
console.log(` \x1b[35m${definition.tableName}\x1b[0m (${Object.keys(definition.fields).length} fields)`);
|
|
938
938
|
}
|
|
939
939
|
|
|
940
|
-
// Generate auto-CRUD routes for
|
|
941
|
-
|
|
940
|
+
// Generate auto-CRUD routes ONLY for models that explicitly opted in via
|
|
941
|
+
// `static autoCrud = true` (the documented opt-in gate; default false). The
|
|
942
|
+
// server previously generated the 5 CRUD endpoints for every discovered model
|
|
943
|
+
// regardless of the flag, contradicting the documented contract. (Python's
|
|
944
|
+
// AutoCrud is opt-in too — via an explicit AutoCrud.register/discover call.)
|
|
945
|
+
const crudModels = orm.crudEligibleModels(models);
|
|
946
|
+
const crudRoutes = crudModels.length > 0 ? orm.generateCrudRoutes(crudModels) : [];
|
|
942
947
|
for (const route of crudRoutes) {
|
|
943
948
|
// Only add if no file-based route already handles this
|
|
944
949
|
const existing = router.match(route.method, route.pattern.replace(/\{(\w+)\}/g, "test").replace(/\[(\w+)\]/g, "test"));
|
|
@@ -947,9 +952,11 @@ ${reset}
|
|
|
947
952
|
}
|
|
948
953
|
}
|
|
949
954
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
955
|
+
if (crudRoutes.length > 0) {
|
|
956
|
+
console.log(`\n Auto-CRUD endpoints:`);
|
|
957
|
+
for (const route of crudRoutes) {
|
|
958
|
+
console.log(` \x1b[33m${route.method.padEnd(7)}\x1b[0m ${route.pattern}`);
|
|
959
|
+
}
|
|
953
960
|
}
|
|
954
961
|
}
|
|
955
962
|
} catch (err) {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
import { randomBytes } from "node:crypto";
|
|
28
28
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
29
29
|
import { join } from "node:path";
|
|
30
|
-
import { execFileSync } from "node:child_process";
|
|
31
30
|
import { Log } from "./logger.js";
|
|
32
31
|
import { isTruthy } from "./dotenv.js";
|
|
32
|
+
import { respCommandSync } from "./sessionHandlers/respClient.js";
|
|
33
33
|
import { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
|
|
34
34
|
import { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
|
|
35
35
|
import { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
|
|
@@ -196,103 +196,19 @@ export class RedisSessionHandler implements SessionHandler {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
|
-
* Execute a Redis command synchronously
|
|
199
|
+
* Execute a Redis command synchronously against the live server.
|
|
200
200
|
*
|
|
201
|
-
*
|
|
202
|
-
* transport/connection FAILURE (server unreachable,
|
|
203
|
-
* THROWS so the Session boundary can distinguish "not found"
|
|
204
|
-
* "backend failed" (log-loud + degrade). Backend-failure
|
|
201
|
+
* Delegates to the shared {@link respCommandSync} transport: a genuine key miss
|
|
202
|
+
* yields `""`, and a transport/connection FAILURE (server unreachable, rejected
|
|
203
|
+
* AUTH, timeout) THROWS so the Session boundary can distinguish "not found"
|
|
204
|
+
* (silent) from "backend failed" (log-loud + degrade). Backend-failure parity.
|
|
205
205
|
*/
|
|
206
206
|
private execSync(args: string[]): string {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const db = ${this.db};
|
|
213
|
-
const args = ${JSON.stringify(args)};
|
|
214
|
-
|
|
215
|
-
function buildCommand(a) {
|
|
216
|
-
let cmd = "*" + a.length + "\\r\\n";
|
|
217
|
-
for (const s of a) cmd += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
218
|
-
return cmd;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function parseResp(buf) {
|
|
222
|
-
const str = buf.toString("utf-8");
|
|
223
|
-
if (str.startsWith("+")) return str.slice(1).split("\\r\\n")[0];
|
|
224
|
-
if (str.startsWith("-")) return "ERR:" + str.slice(1).split("\\r\\n")[0];
|
|
225
|
-
if (str.startsWith(":")) return str.slice(1).split("\\r\\n")[0];
|
|
226
|
-
if (str.startsWith("$-1")) return null;
|
|
227
|
-
if (str.startsWith("$")) {
|
|
228
|
-
const nl = str.indexOf("\\r\\n");
|
|
229
|
-
const len = parseInt(str.slice(1, nl), 10);
|
|
230
|
-
const start = nl + 2;
|
|
231
|
-
return str.slice(start, start + len);
|
|
232
|
-
}
|
|
233
|
-
return str;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const sock = net.createConnection({ host, port }, () => {
|
|
237
|
-
let commands = "";
|
|
238
|
-
if (password) commands += buildCommand(["AUTH", password]);
|
|
239
|
-
if (db !== 0) commands += buildCommand(["SELECT", String(db)]);
|
|
240
|
-
commands += buildCommand(args);
|
|
241
|
-
sock.write(commands);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
let buffer = Buffer.alloc(0);
|
|
245
|
-
sock.on("data", (chunk) => {
|
|
246
|
-
buffer = Buffer.concat([buffer, chunk]);
|
|
247
|
-
});
|
|
248
|
-
sock.on("end", () => {
|
|
249
|
-
// Parse last response (skip AUTH/SELECT responses)
|
|
250
|
-
const lines = buffer.toString("utf-8").split("\\r\\n");
|
|
251
|
-
let responses = [];
|
|
252
|
-
let i = 0;
|
|
253
|
-
while (i < lines.length) {
|
|
254
|
-
const line = lines[i];
|
|
255
|
-
if (!line) { i++; continue; }
|
|
256
|
-
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(":")) {
|
|
257
|
-
responses.push(line);
|
|
258
|
-
i++;
|
|
259
|
-
} else if (line.startsWith("$")) {
|
|
260
|
-
const len = parseInt(line.slice(1), 10);
|
|
261
|
-
if (len === -1) { responses.push(null); i++; }
|
|
262
|
-
else { responses.push(lines[i+1] || ""); i += 2; }
|
|
263
|
-
} else { i++; }
|
|
264
|
-
}
|
|
265
|
-
// The last response is our actual command result
|
|
266
|
-
const result = responses[responses.length - 1];
|
|
267
|
-
if (result === null) process.stdout.write("__NULL__");
|
|
268
|
-
else if (typeof result === "string" && result.startsWith("-")) process.stdout.write("__ERR__" + result);
|
|
269
|
-
else process.stdout.write(String(result ?? "__NULL__"));
|
|
270
|
-
});
|
|
271
|
-
sock.on("error", (err) => {
|
|
272
|
-
process.stderr.write(err.message);
|
|
273
|
-
process.exit(1);
|
|
274
|
-
});
|
|
275
|
-
setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
|
|
276
|
-
`;
|
|
277
|
-
|
|
278
|
-
let result: string;
|
|
279
|
-
try {
|
|
280
|
-
result = execFileSync(process.execPath, ["-e", script], {
|
|
281
|
-
encoding: "utf-8",
|
|
282
|
-
timeout: 5000,
|
|
283
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
284
|
-
});
|
|
285
|
-
} catch (err) {
|
|
286
|
-
// Non-zero exit = the child hit a socket error / AUTH failure / timeout.
|
|
287
|
-
// That is a transport FAILURE, not a key miss — surface it so the
|
|
288
|
-
// Session boundary logs + degrades (or re-throws under strict mode).
|
|
289
|
-
throw new Error(`Redis command failed: ${(err as Error).message}`);
|
|
290
|
-
}
|
|
291
|
-
if (result === "__NULL__") return ""; // genuine key miss
|
|
292
|
-
if (result.startsWith("__ERR__")) {
|
|
293
|
-
throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
|
|
294
|
-
}
|
|
295
|
-
return result;
|
|
207
|
+
return respCommandSync(
|
|
208
|
+
{ host: this.host, port: this.port, password: this.password, db: this.db },
|
|
209
|
+
args,
|
|
210
|
+
"Redis",
|
|
211
|
+
);
|
|
296
212
|
}
|
|
297
213
|
|
|
298
214
|
private key(sessionId: string): string {
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 synchronous MongoDB transport — used by the MongoDB session handler.
|
|
3
|
+
*
|
|
4
|
+
* The session-handler interface is synchronous, but every Mongo client is async,
|
|
5
|
+
* so a command runs in a short-lived `node -e` child (execFileSync) that blocks
|
|
6
|
+
* the caller until it exits. Two transports, mirroring the Python master's
|
|
7
|
+
* pymongo-first design:
|
|
8
|
+
*
|
|
9
|
+
* 1. The official `mongodb` driver when it resolves (robust, the normal path —
|
|
10
|
+
* @tina4/orm depends on it, so it is present in any app that uses the ORM).
|
|
11
|
+
* 2. A raw MongoDB wire-protocol (OP_MSG) fallback over node:net with a minimal
|
|
12
|
+
* BSON codec — zero dependencies.
|
|
13
|
+
*
|
|
14
|
+
* The bugs this replaces (raw path): the old encoder put `$db` FIRST in the command
|
|
15
|
+
* document, so the server read `$db` as the command name and answered
|
|
16
|
+
* CommandNotFound; doubles were written big-endian; arrays/booleans were not encoded
|
|
17
|
+
* at all (so `updates: [{... upsert: true}]` was malformed); and the response was
|
|
18
|
+
* "parsed" by regex-matching `"data":{...}` against binary BSON, which can never
|
|
19
|
+
* match. Here the command name comes first and `$db` last (matching the Python
|
|
20
|
+
* master), the BSON codec is little-endian and handles every type the commands use,
|
|
21
|
+
* and the OP_MSG response is decoded properly (cursor.firstBatch for find, `ok` for
|
|
22
|
+
* writes).
|
|
23
|
+
*/
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
export interface MongoTarget {
|
|
27
|
+
host: string;
|
|
28
|
+
port: number;
|
|
29
|
+
database: string;
|
|
30
|
+
collection: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Command args: `filter` (always), plus `data`/`last_accessed` for an update. */
|
|
34
|
+
export interface MongoCommandArgs {
|
|
35
|
+
filter: Record<string, unknown>;
|
|
36
|
+
data?: unknown;
|
|
37
|
+
last_accessed?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run a session Mongo command synchronously and return the raw child stdout.
|
|
42
|
+
*
|
|
43
|
+
* - command "find" -> the matched document as a JSON string, or "__EMPTY__".
|
|
44
|
+
* - command "update" -> "__OK__" (upsert of `{_id, data, last_accessed}`).
|
|
45
|
+
* - command "delete" -> "__OK__".
|
|
46
|
+
*
|
|
47
|
+
* THROWS `<label> command failed: ...` on a transport failure (server unreachable,
|
|
48
|
+
* timeout) OR a Mongo command error (`ok != 1`), so the Session boundary can
|
|
49
|
+
* log-loud + degrade (or re-throw under strict mode).
|
|
50
|
+
*/
|
|
51
|
+
export function mongoCommandSync(
|
|
52
|
+
target: MongoTarget,
|
|
53
|
+
command: "find" | "update" | "delete",
|
|
54
|
+
args: MongoCommandArgs,
|
|
55
|
+
label = "MongoDB",
|
|
56
|
+
): string {
|
|
57
|
+
const script = `
|
|
58
|
+
const net = require("node:net");
|
|
59
|
+
const host = ${JSON.stringify(target.host)};
|
|
60
|
+
const port = ${target.port};
|
|
61
|
+
const database = ${JSON.stringify(target.database)};
|
|
62
|
+
const collection = ${JSON.stringify(target.collection)};
|
|
63
|
+
const command = ${JSON.stringify(command)};
|
|
64
|
+
const args = ${JSON.stringify(args)};
|
|
65
|
+
|
|
66
|
+
// ── driver path (preferred — matches the Python master's pymongo-first) ──
|
|
67
|
+
let hasDriver = false;
|
|
68
|
+
try { require.resolve("mongodb"); hasDriver = true; } catch (e) {}
|
|
69
|
+
|
|
70
|
+
if (hasDriver) {
|
|
71
|
+
(async () => {
|
|
72
|
+
let client;
|
|
73
|
+
try {
|
|
74
|
+
const { MongoClient } = require("mongodb");
|
|
75
|
+
client = new MongoClient("mongodb://" + host + ":" + port, { serverSelectionTimeoutMS: 4000 });
|
|
76
|
+
await client.connect();
|
|
77
|
+
const coll = client.db(database).collection(collection);
|
|
78
|
+
let out;
|
|
79
|
+
if (command === "find") {
|
|
80
|
+
const doc = await coll.findOne(args.filter);
|
|
81
|
+
out = doc ? JSON.stringify(doc) : "__EMPTY__";
|
|
82
|
+
} else if (command === "update") {
|
|
83
|
+
await coll.updateOne(args.filter, { $set: { data: args.data, last_accessed: args.last_accessed } }, { upsert: true });
|
|
84
|
+
out = "__OK__";
|
|
85
|
+
} else {
|
|
86
|
+
await coll.deleteOne(args.filter);
|
|
87
|
+
out = "__OK__";
|
|
88
|
+
}
|
|
89
|
+
await client.close();
|
|
90
|
+
process.stdout.write(out, () => process.exit(0));
|
|
91
|
+
} catch (err) {
|
|
92
|
+
try { if (client) await client.close(); } catch (e) {}
|
|
93
|
+
process.stderr.write(String((err && err.message) || err));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
} else {
|
|
98
|
+
|
|
99
|
+
// ── raw OP_MSG fallback (zero-dep, mirrors the Python master codec) ──
|
|
100
|
+
function encElem(key, value) {
|
|
101
|
+
const ck = Buffer.concat([Buffer.from(key, "utf-8"), Buffer.from([0])]);
|
|
102
|
+
if (value === null || value === undefined) return Buffer.concat([Buffer.from([0x0a]), ck]);
|
|
103
|
+
if (typeof value === "boolean") return Buffer.concat([Buffer.from([0x08]), ck, Buffer.from([value ? 1 : 0])]);
|
|
104
|
+
if (typeof value === "number") {
|
|
105
|
+
if (Number.isInteger(value) && value >= -2147483648 && value <= 2147483647) {
|
|
106
|
+
const b = Buffer.alloc(4); b.writeInt32LE(value, 0);
|
|
107
|
+
return Buffer.concat([Buffer.from([0x10]), ck, b]);
|
|
108
|
+
}
|
|
109
|
+
if (Number.isInteger(value)) {
|
|
110
|
+
const b = Buffer.alloc(8); b.writeBigInt64LE(BigInt(value), 0);
|
|
111
|
+
return Buffer.concat([Buffer.from([0x12]), ck, b]);
|
|
112
|
+
}
|
|
113
|
+
const b = Buffer.alloc(8); b.writeDoubleLE(value, 0);
|
|
114
|
+
return Buffer.concat([Buffer.from([0x01]), ck, b]);
|
|
115
|
+
}
|
|
116
|
+
if (typeof value === "string") {
|
|
117
|
+
const s = Buffer.from(value, "utf-8");
|
|
118
|
+
const len = Buffer.alloc(4); len.writeInt32LE(s.length + 1, 0);
|
|
119
|
+
return Buffer.concat([Buffer.from([0x02]), ck, len, s, Buffer.from([0])]);
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(value)) {
|
|
122
|
+
const indexed = {};
|
|
123
|
+
value.forEach((v, i) => { indexed[String(i)] = v; });
|
|
124
|
+
return Buffer.concat([Buffer.from([0x04]), ck, encDoc(indexed)]);
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === "object") return Buffer.concat([Buffer.from([0x03]), ck, encDoc(value)]);
|
|
127
|
+
const s = Buffer.from(String(value), "utf-8");
|
|
128
|
+
const len = Buffer.alloc(4); len.writeInt32LE(s.length + 1, 0);
|
|
129
|
+
return Buffer.concat([Buffer.from([0x02]), ck, len, s, Buffer.from([0])]);
|
|
130
|
+
}
|
|
131
|
+
function encDoc(obj) {
|
|
132
|
+
let body = Buffer.alloc(0);
|
|
133
|
+
for (const k of Object.keys(obj)) body = Buffer.concat([body, encElem(k, obj[k])]);
|
|
134
|
+
body = Buffer.concat([body, Buffer.from([0])]);
|
|
135
|
+
const out = Buffer.alloc(4 + body.length);
|
|
136
|
+
out.writeInt32LE(out.length, 0);
|
|
137
|
+
body.copy(out, 4);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
function decDoc(buf, pos) {
|
|
141
|
+
const docLen = buf.readInt32LE(pos.i); pos.i += 4;
|
|
142
|
+
const end = pos.i + docLen - 5;
|
|
143
|
+
const doc = {};
|
|
144
|
+
while (pos.i < end) {
|
|
145
|
+
const type = buf[pos.i]; pos.i += 1;
|
|
146
|
+
const keyEnd = buf.indexOf(0, pos.i);
|
|
147
|
+
const key = buf.toString("utf-8", pos.i, keyEnd);
|
|
148
|
+
pos.i = keyEnd + 1;
|
|
149
|
+
doc[key] = decVal(buf, pos, type, end);
|
|
150
|
+
}
|
|
151
|
+
pos.i += 1;
|
|
152
|
+
return doc;
|
|
153
|
+
}
|
|
154
|
+
function decVal(buf, pos, type, end) {
|
|
155
|
+
if (type === 0x01) { const v = buf.readDoubleLE(pos.i); pos.i += 8; return v; }
|
|
156
|
+
if (type === 0x02) { const len = buf.readInt32LE(pos.i); pos.i += 4; const v = buf.toString("utf-8", pos.i, pos.i + len - 1); pos.i += len; return v; }
|
|
157
|
+
if (type === 0x03) return decDoc(buf, pos);
|
|
158
|
+
if (type === 0x04) { const d = decDoc(buf, pos); return Object.keys(d).map((k) => d[k]); }
|
|
159
|
+
if (type === 0x05) { const len = buf.readInt32LE(pos.i); pos.i += 4; pos.i += 1; const v = buf.subarray(pos.i, pos.i + len); pos.i += len; return v; }
|
|
160
|
+
if (type === 0x07) { const v = buf.toString("hex", pos.i, pos.i + 12); pos.i += 12; return v; }
|
|
161
|
+
if (type === 0x08) { const v = buf[pos.i] !== 0; pos.i += 1; return v; }
|
|
162
|
+
if (type === 0x09) { const v = Number(buf.readBigInt64LE(pos.i)); pos.i += 8; return v; }
|
|
163
|
+
if (type === 0x0a) return null;
|
|
164
|
+
if (type === 0x10) { const v = buf.readInt32LE(pos.i); pos.i += 4; return v; }
|
|
165
|
+
if (type === 0x11) { const v = Number(buf.readBigUInt64LE(pos.i)); pos.i += 8; return v; }
|
|
166
|
+
if (type === 0x12) { const v = Number(buf.readBigInt64LE(pos.i)); pos.i += 8; return v; }
|
|
167
|
+
// Unknown type: cannot size it — stop at the document boundary to avoid a loop.
|
|
168
|
+
pos.i = end;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let cmdDoc;
|
|
173
|
+
if (command === "find") {
|
|
174
|
+
cmdDoc = { find: collection, filter: args.filter, limit: 1, "$db": database };
|
|
175
|
+
} else if (command === "update") {
|
|
176
|
+
const u = { _id: args.filter._id, data: args.data, last_accessed: args.last_accessed };
|
|
177
|
+
cmdDoc = { update: collection, updates: [{ q: args.filter, u: u, upsert: true }], "$db": database };
|
|
178
|
+
} else {
|
|
179
|
+
cmdDoc = { delete: collection, deletes: [{ q: args.filter, limit: 1 }], "$db": database };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const bodyDoc = encDoc(cmdDoc);
|
|
183
|
+
const section = Buffer.concat([Buffer.from([0]), bodyDoc]); // section kind 0 = body
|
|
184
|
+
const flags = Buffer.alloc(4); // flagBits = 0
|
|
185
|
+
const payload = Buffer.concat([flags, section]);
|
|
186
|
+
const header = Buffer.alloc(16);
|
|
187
|
+
header.writeInt32LE(16 + payload.length, 0); // messageLength
|
|
188
|
+
header.writeInt32LE(1, 4); // requestID
|
|
189
|
+
header.writeInt32LE(0, 8); // responseTo
|
|
190
|
+
header.writeInt32LE(2013, 12); // opCode = OP_MSG
|
|
191
|
+
const message = Buffer.concat([header, payload]);
|
|
192
|
+
|
|
193
|
+
const sock = net.createConnection({ host, port });
|
|
194
|
+
sock.setNoDelay(true);
|
|
195
|
+
let buffer = Buffer.alloc(0);
|
|
196
|
+
let done = false;
|
|
197
|
+
const timer = setTimeout(() => { if (!done) { done = true; try { sock.destroy(); } catch (e) {} process.stderr.write("timeout"); process.exitCode = 1; } }, 5000);
|
|
198
|
+
function emit(s) {
|
|
199
|
+
if (done) return; done = true; clearTimeout(timer);
|
|
200
|
+
process.stdout.write(s, () => { try { sock.destroy(); } catch (e) {} });
|
|
201
|
+
}
|
|
202
|
+
function fail(msg) {
|
|
203
|
+
if (done) return; done = true; clearTimeout(timer);
|
|
204
|
+
try { sock.destroy(); } catch (e) {}
|
|
205
|
+
process.stderr.write(msg || ""); process.exitCode = 1;
|
|
206
|
+
}
|
|
207
|
+
sock.on("connect", () => sock.write(message));
|
|
208
|
+
sock.on("data", (chunk) => {
|
|
209
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
210
|
+
if (buffer.length < 4) return;
|
|
211
|
+
const msgLen = buffer.readInt32LE(0);
|
|
212
|
+
if (buffer.length < msgLen) return;
|
|
213
|
+
let doc;
|
|
214
|
+
try { doc = decDoc(buffer, { i: 21 }); } catch (e) { fail("decode error: " + e.message); return; }
|
|
215
|
+
const ok = doc && (doc.ok === 1 || doc.ok === 1.0);
|
|
216
|
+
if (!ok) { fail("command error: " + ((doc && doc.errmsg) || JSON.stringify(doc))); return; }
|
|
217
|
+
if (command === "find") {
|
|
218
|
+
const batch = (doc.cursor && doc.cursor.firstBatch) || [];
|
|
219
|
+
emit(batch.length ? JSON.stringify(batch[0]) : "__EMPTY__");
|
|
220
|
+
} else {
|
|
221
|
+
emit("__OK__");
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
sock.on("error", (err) => fail(err.message));
|
|
225
|
+
sock.on("close", () => { if (!done) fail("connection closed before reply"); });
|
|
226
|
+
}
|
|
227
|
+
`;
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
return execFileSync(process.execPath, ["-e", script], {
|
|
231
|
+
encoding: "utf-8",
|
|
232
|
+
timeout: 8000,
|
|
233
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
234
|
+
});
|
|
235
|
+
} catch (err) {
|
|
236
|
+
throw new Error(`${label} command failed: ${(err as Error).message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|