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.
@@ -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
+ }
@@ -13,8 +13,8 @@
13
13
  * TINA4_SESSION_MONGO_DB (default: "tina4_sessions")
14
14
  * TINA4_SESSION_MONGO_COLLECTION (default: "sessions")
15
15
  */
16
- import { execFileSync } from "node:child_process";
17
16
  import type { SessionHandler } from "../session.js";
17
+ import { mongoCommandSync, type MongoTarget } from "./mongoClient.js";
18
18
 
19
19
  interface SessionData {
20
20
  _created: number;
@@ -85,221 +85,42 @@ export class MongoSessionHandler implements SessionHandler {
85
85
  ?? "sessions";
86
86
  }
87
87
 
88
- /**
89
- * Execute a MongoDB command synchronously via a child process.
90
- *
91
- * Uses the MongoDB wire protocol (OP_MSG) to communicate with the server.
92
- * For simplicity, we use the `runCommand` approach with JSON serialization
93
- * of BSON documents using a minimal BSON encoder.
94
- *
95
- * Returns the command response string (or `__EMPTY__` for an absent doc).
96
- * A transport/connection FAILURE (server unreachable, socket error, timeout)
97
- * THROWS so the Session boundary can distinguish "not found" (silent) from
98
- * "backend failed" (log-loud + degrade). Backend-failure policy parity.
99
- */
100
- private execSync(command: string, args: string): string {
101
- const script = `
102
- const net = require("node:net");
103
- const host = ${JSON.stringify(this.uri ? this.parseUri().host : this.host)};
104
- const port = ${this.uri ? this.parseUri().port : this.port};
105
- const database = ${JSON.stringify(this.database)};
106
- const collection = ${JSON.stringify(this.collection)};
107
- const command = ${JSON.stringify(command)};
108
- const args = ${JSON.stringify(args)};
109
-
110
- // Minimal BSON encoder/decoder for session operations
111
- function encodeBsonDocument(obj) {
112
- const parts = [];
113
- for (const [key, value] of Object.entries(obj)) {
114
- if (typeof value === "string") {
115
- const keyBuf = Buffer.from(key + "\\0", "utf-8");
116
- const valBuf = Buffer.from(value, "utf-8");
117
- const entry = Buffer.alloc(1 + keyBuf.length + 4 + valBuf.length + 1);
118
- entry.writeUInt8(2, 0); // string type
119
- keyBuf.copy(entry, 1);
120
- entry.writeInt32LE(valBuf.length + 1, 1 + keyBuf.length);
121
- valBuf.copy(entry, 1 + keyBuf.length + 4);
122
- entry.writeUInt8(0, entry.length - 1);
123
- parts.push(entry);
124
- } else if (typeof value === "number") {
125
- const keyBuf = Buffer.from(key + "\\0", "utf-8");
126
- if (Number.isInteger(value)) {
127
- const entry = Buffer.alloc(1 + keyBuf.length + 4);
128
- entry.writeUInt8(16, 0); // int32 type
129
- keyBuf.copy(entry, 1);
130
- entry.writeInt32LE(value, 1 + keyBuf.length);
131
- parts.push(entry);
132
- } else {
133
- const entry = Buffer.alloc(1 + keyBuf.length + 8);
134
- entry.writeUInt8(1, 0); // double type
135
- keyBuf.copy(entry, 1);
136
- entry.writeDoubleBE(value, 1 + keyBuf.length);
137
- parts.push(entry);
138
- }
139
- } else if (typeof value === "object" && value !== null) {
140
- const keyBuf = Buffer.from(key + "\\0", "utf-8");
141
- const subDoc = encodeBsonDocument(value);
142
- const entry = Buffer.alloc(1 + keyBuf.length + subDoc.length);
143
- entry.writeUInt8(3, 0); // document type
144
- keyBuf.copy(entry, 1);
145
- subDoc.copy(entry, 1 + keyBuf.length);
146
- parts.push(entry);
147
- }
148
- }
149
- const body = Buffer.concat(parts);
150
- const doc = Buffer.alloc(4 + body.length + 1);
151
- doc.writeInt32LE(doc.length, 0);
152
- body.copy(doc, 4);
153
- doc.writeUInt8(0, doc.length - 1);
154
- return doc;
155
- }
156
-
157
- // Use MongoDB OP_MSG (opcode 2013) with runCommand
158
- function buildOpMsg(dbName, cmd) {
159
- const cmdDoc = Object.assign({ "$db": dbName }, cmd);
160
- const body = encodeBsonDocument(cmdDoc);
161
-
162
- // OP_MSG: flagBits(4) + kind(1) + body
163
- const section = Buffer.alloc(1 + body.length);
164
- section.writeUInt8(0, 0); // kind 0 = body
165
- body.copy(section, 1);
166
-
167
- const msgHeader = Buffer.alloc(16 + 4 + section.length);
168
- msgHeader.writeInt32LE(msgHeader.length, 0); // messageLength
169
- msgHeader.writeInt32LE(1, 4); // requestID
170
- msgHeader.writeInt32LE(0, 8); // responseTo
171
- msgHeader.writeInt32LE(2013, 12); // opCode = OP_MSG
172
- msgHeader.writeUInt32LE(0, 16); // flagBits
173
- section.copy(msgHeader, 20);
174
-
175
- return msgHeader;
176
- }
177
-
178
- // For this simplified implementation, we use a TCP connection
179
- // and send/receive raw OP_MSG frames
180
- const sock = net.createConnection({ host, port }, () => {
181
- let cmd;
182
- const parsedArgs = JSON.parse(args);
183
-
184
- if (command === "find") {
185
- cmd = {
186
- find: collection,
187
- filter: parsedArgs.filter,
188
- limit: 1
189
- };
190
- } else if (command === "update") {
191
- cmd = {
192
- update: collection,
193
- updates: [{ q: parsedArgs.filter, u: { "$set": parsedArgs.data }, upsert: true }]
194
- };
195
- } else if (command === "delete") {
196
- cmd = {
197
- delete: collection,
198
- deletes: [{ q: parsedArgs.filter, limit: 1 }]
199
- };
200
- }
201
-
202
- const msg = buildOpMsg(database, cmd);
203
- sock.write(msg);
204
- });
205
-
206
- let buffer = Buffer.alloc(0);
207
- sock.on("data", (chunk) => {
208
- buffer = Buffer.concat([buffer, chunk]);
209
- if (buffer.length >= 4) {
210
- const msgLen = buffer.readInt32LE(0);
211
- if (buffer.length >= msgLen) {
212
- // Parse response — extract body section
213
- // Header is 16 bytes + 4 bytes flagBits + 1 byte section kind + BSON doc
214
- if (buffer.length > 21) {
215
- const bsonStart = 21;
216
- const bsonLen = buffer.readInt32LE(bsonStart);
217
- const bsonDoc = buffer.subarray(bsonStart, bsonStart + bsonLen);
218
-
219
- // Simple BSON parser — just look for the session data string
220
- const rawStr = bsonDoc.toString("utf-8", 4);
221
-
222
- if (command === "find") {
223
- // Look for cursor.firstBatch documents
224
- // For simplicity, extract JSON-like data from response
225
- process.stdout.write(rawStr);
226
- } else {
227
- process.stdout.write("__OK__");
228
- }
229
- } else {
230
- process.stdout.write("__EMPTY__");
231
- }
232
- sock.destroy();
233
- }
234
- }
235
- });
236
-
237
- sock.on("error", (err) => {
238
- process.stderr.write(err.message);
239
- process.exit(1);
240
- });
241
-
242
- setTimeout(() => { sock.destroy(); process.exit(1); }, 5000);
243
- `;
244
-
245
- try {
246
- return execFileSync(process.execPath, ["-e", script], {
247
- encoding: "utf-8",
248
- timeout: 8000,
249
- stdio: ["pipe", "pipe", "pipe"],
250
- });
251
- } catch (err) {
252
- // Non-zero exit = the child hit a socket error / timeout. That is a
253
- // transport FAILURE, not an absent document — surface it so the Session
254
- // boundary logs + degrades (or re-throws under strict mode).
255
- throw new Error(`MongoDB command failed: ${(err as Error).message}`);
256
- }
257
- }
258
-
259
- private parseUri(): { host: string; port: number } {
260
- if (!this.uri) return { host: this.host, port: this.port };
261
- try {
262
- // mongodb://host:port/db
263
- const match = this.uri.match(/mongodb:\/\/([^/:]+):?(\d+)?/);
88
+ /** Resolve the effective host/port (honours a configured mongodb:// URI). */
89
+ private target(): MongoTarget {
90
+ let host = this.host;
91
+ let port = this.port;
92
+ if (this.uri) {
93
+ const match = this.uri.match(/mongodb:\/\/(?:[^@/]+@)?([^/:]+):?(\d+)?/);
264
94
  if (match) {
265
- return {
266
- host: match[1],
267
- port: match[2] ? parseInt(match[2], 10) : 27017,
268
- };
95
+ host = match[1];
96
+ port = match[2] ? parseInt(match[2], 10) : 27017;
269
97
  }
270
- } catch { /* ignore */ }
271
- return { host: this.host, port: this.port };
98
+ }
99
+ return { host, port, database: this.database, collection: this.collection };
272
100
  }
273
101
 
274
102
  read(sessionId: string): SessionData | null {
275
- const result = this.execSync("find", JSON.stringify({
276
- filter: { _id: sessionId },
277
- }));
278
-
279
- if (!result || result === "__EMPTY__") return null;
280
-
281
- // Try to extract session data from the BSON response
103
+ const result = mongoCommandSync(this.target(), "find", { filter: { _id: sessionId } });
104
+ if (!result || result === "__EMPTY__") return null; // genuine miss
282
105
  try {
283
- // Look for the JSON data pattern in the response
284
- const dataMatch = result.match(/"data"\s*:\s*(\{[^}]+\})/);
285
- if (dataMatch) {
286
- return JSON.parse(dataMatch[1]) as SessionData;
287
- }
288
- } catch { /* ignore */ }
289
-
290
- return null;
106
+ const doc = JSON.parse(result) as { data?: unknown };
107
+ const data = doc?.data;
108
+ // Stored as a nested document (parity with the Python master); read returns it.
109
+ return data && typeof data === "object" ? (data as SessionData) : null;
110
+ } catch {
111
+ return null;
112
+ }
291
113
  }
292
114
 
293
115
  write(sessionId: string, data: SessionData, _ttl: number): void {
294
- this.execSync("update", JSON.stringify({
116
+ mongoCommandSync(this.target(), "update", {
295
117
  filter: { _id: sessionId },
296
- data: { _id: sessionId, data: JSON.stringify(data), updatedAt: new Date().toISOString() },
297
- }));
118
+ data,
119
+ last_accessed: Date.now() / 1000,
120
+ });
298
121
  }
299
122
 
300
123
  destroy(sessionId: string): void {
301
- this.execSync("delete", JSON.stringify({
302
- filter: { _id: sessionId },
303
- }));
124
+ mongoCommandSync(this.target(), "delete", { filter: { _id: sessionId } });
304
125
  }
305
126
  }