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.
@@ -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 — skip for basic.get
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
- const tuneOk = Buffer.alloc(12);
315
- tuneOk.writeUInt16BE(0, 0); // channel-max
316
- tuneOk.writeUInt32BE(131072, 2); // frame-max
317
- tuneOk.writeUInt16BE(60, 6); // heartbeat
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(7 + qBuf.length);
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 header = Buffer.alloc(18);
363
- header.writeUInt16BE(60, 0); // class = basic
364
- header.writeUInt16BE(0, 2); // weight
365
- // body size (64-bit, we only use lower 32)
366
- header.writeUInt32BE(0, 4);
367
- header.writeUInt32BE(bodyBuf.length, 8);
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 send Connection.Close-Ok
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
- const closePayload = Buffer.alloc(6);
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
- setTimeout(() => sock.destroy(), 500);
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
- setTimeout(() => { sock.destroy(); process.exit(1); }, 10000);
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 all discovered models
941
- const crudRoutes = orm.generateCrudRoutes(models);
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
- console.log(`\n Auto-CRUD endpoints:`);
951
- for (const route of crudRoutes) {
952
- console.log(` \x1b[33m${route.method.padEnd(7)}\x1b[0m ${route.pattern}`);
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 via a short-lived TCP connection.
199
+ * Execute a Redis command synchronously against the live server.
200
200
  *
201
- * Returns the command result string. A genuine key miss yields `""`. A
202
- * transport/connection FAILURE (server unreachable, AUTH error, timeout)
203
- * THROWS so the Session boundary can distinguish "not found" (silent) from
204
- * "backend failed" (log-loud + degrade). Backend-failure policy parity.
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
- const script = `
208
- const net = require("node:net");
209
- const host = ${JSON.stringify(this.host)};
210
- const port = ${this.port};
211
- const password = ${JSON.stringify(this.password)};
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
+ }