tina4-nodejs 3.13.92 → 3.13.95

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.
Files changed (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +33062 -29908
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/devMailbox.ts +20 -44
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +7 -6
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +81 -13
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +6 -9
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
@@ -1,27 +1,38 @@
1
1
  /**
2
- * Tina4 synchronous MongoDB transport — used by the MongoDB session handler.
2
+ * Tina4 synchronous MongoDB transport — ONE persistent client per target.
3
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:
4
+ * The session-handler interface is synchronous but every Mongo client is async,
5
+ * so this rides the shared sync-over-async bridge (syncBridge): a Worker thread
6
+ * keeps its own event loop and holds the connection, the caller blocks in
7
+ * Atomics.wait until the worker answers through a SharedArrayBuffer.
8
8
  *
9
- * 1. The official `mongodb` driver when it resolves (robust, the normal path
9
+ * WHY IT CHANGED. Each command used to run in a short-lived `node -e` child, so
10
+ * every session read or write paid a process spawn AND a `require("mongodb")`
11
+ * AND a fresh connection handshake. Measured on this machine that was p50 230ms
12
+ * per session write+read — by far the worst of the four backends (Valkey on the
13
+ * same bridge is p50 10.5ms), and the same class of defect that made the Valkey
14
+ * session tests flaky. The client is now created once and reused.
15
+ *
16
+ * Two transports are kept, mirroring the Python master's pymongo-first design:
17
+ *
18
+ * 1. The official `mongodb` driver when it resolves (the normal path —
10
19
  * @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.
20
+ * 2. A raw MongoDB wire-protocol (OP_MSG) fallback over node:net with a
21
+ * minimal BSON codec — zero dependencies.
13
22
  *
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
+ * The raw path's hard-won details are preserved exactly: the command name comes
24
+ * FIRST and `$db` LAST (putting `$db` first made the server read it as the
25
+ * command name and answer CommandNotFound), the BSON codec is little-endian and
26
+ * encodes arrays/booleans/doubles properly, and the OP_MSG response is decoded
27
+ * rather than regex-matched against binary.
23
28
  */
24
- import { execFileSync } from "node:child_process";
29
+ import {
30
+ getBridge,
31
+ STATUS_ERROR,
32
+ STATUS_NIL,
33
+ STATUS_OK,
34
+ STATUS_TRANSPORT,
35
+ } from "./syncBridge.js";
25
36
 
26
37
  export interface MongoTarget {
27
38
  host: string;
@@ -30,23 +41,271 @@ export interface MongoTarget {
30
41
  collection: string;
31
42
  }
32
43
 
33
- /** Command args: `filter` (always), plus `data`/`last_accessed` for an update. */
44
+ /**
45
+ * Command args: `filter` (always), plus `data`/`expires_at`/`last_accessed` for
46
+ * an update.
47
+ *
48
+ * `expires_at` is an ABSOLUTE epoch-seconds deadline computed at write time. It
49
+ * is what makes the session actually expire: this handler previously stored no
50
+ * expiry at all, created no TTL index, and never consulted anything on read, so
51
+ * MongoDB sessions lived forever.
52
+ */
34
53
  export interface MongoCommandArgs {
35
54
  filter: Record<string, unknown>;
36
55
  data?: unknown;
56
+ expires_at?: number;
37
57
  last_accessed?: number;
38
58
  }
39
59
 
40
60
  /**
41
- * Run a session Mongo command synchronously and return the raw child stdout.
61
+ * Server-selection budget for the driver. Kept BELOW the bridge's reply timeout
62
+ * so an unreachable server is reported as a Mongo connection failure with a real
63
+ * message, rather than surfacing as the caller's generic reply timeout.
64
+ */
65
+ const SERVER_SELECTION_MS = 3000;
66
+
67
+ const MONGO_WORKER = `
68
+ const net = require("node:net");
69
+ const target = workerData.target;
70
+ const host = target.host;
71
+ const port = target.port;
72
+ const database = target.database;
73
+ const collection = target.collection;
74
+
75
+ // TINA4_MONGO_FORCE_RAW selects the zero-dep wire-protocol path even where the
76
+ // driver IS installed. Without it the raw fallback can never be exercised on a
77
+ // machine that has mongodb (which is every machine using @tina4/orm), so the
78
+ // zero-dependency path would ship untested. This selects a REAL code path
79
+ // against a REAL server - it is not a stub.
80
+ let hasDriver = false;
81
+ if (!workerData.forceRaw) {
82
+ try { require.resolve("mongodb"); hasDriver = true; } catch (e) {}
83
+ }
84
+
85
+ // ── driver path (preferred — matches the Python master's pymongo-first) ──
86
+ // The client is created ONCE and reused. Creating and closing one per command
87
+ // was the whole cost: a driver load plus a handshake on every session read.
88
+ let client = null;
89
+ let coll = null;
90
+
91
+ async function driverCall(command, args) {
92
+ try {
93
+ if (!coll) {
94
+ const { MongoClient } = require("mongodb");
95
+ client = new MongoClient("mongodb://" + host + ":" + port, { serverSelectionTimeoutMS: ${SERVER_SELECTION_MS} });
96
+ await client.connect();
97
+ coll = client.db(database).collection(collection);
98
+ }
99
+ if (command === "find") {
100
+ const doc = await coll.findOne(args.filter);
101
+ // A genuine miss is NOT a failure - it is "no session yet".
102
+ if (!doc) { __reply(${STATUS_NIL}, null); return; }
103
+ __reply(${STATUS_OK}, JSON.stringify(doc));
104
+ return;
105
+ }
106
+ if (command === "update") {
107
+ await coll.updateOne(
108
+ args.filter,
109
+ { $set: { data: args.data, expires_at: args.expires_at, last_accessed: args.last_accessed } },
110
+ { upsert: true },
111
+ );
112
+ } else {
113
+ await coll.deleteOne(args.filter);
114
+ }
115
+ __reply(${STATUS_OK}, "__OK__");
116
+ } catch (err) {
117
+ // Drop the client so the NEXT command reconnects - a transient blip must
118
+ // not poison the channel forever.
119
+ try { if (client) await client.close(); } catch (e) {}
120
+ client = null;
121
+ coll = null;
122
+ __reply(${STATUS_TRANSPORT}, String((err && err.message) || err));
123
+ }
124
+ }
125
+
126
+ // ── raw OP_MSG fallback (zero-dep, mirrors the Python master codec) ──
127
+ function encElem(key, value) {
128
+ const ck = Buffer.concat([Buffer.from(key, "utf-8"), Buffer.from([0])]);
129
+ if (value === null || value === undefined) return Buffer.concat([Buffer.from([0x0a]), ck]);
130
+ if (typeof value === "boolean") return Buffer.concat([Buffer.from([0x08]), ck, Buffer.from([value ? 1 : 0])]);
131
+ if (typeof value === "number") {
132
+ if (Number.isInteger(value) && value >= -2147483648 && value <= 2147483647) {
133
+ const b = Buffer.alloc(4); b.writeInt32LE(value, 0);
134
+ return Buffer.concat([Buffer.from([0x10]), ck, b]);
135
+ }
136
+ if (Number.isInteger(value)) {
137
+ const b = Buffer.alloc(8); b.writeBigInt64LE(BigInt(value), 0);
138
+ return Buffer.concat([Buffer.from([0x12]), ck, b]);
139
+ }
140
+ const b = Buffer.alloc(8); b.writeDoubleLE(value, 0);
141
+ return Buffer.concat([Buffer.from([0x01]), ck, b]);
142
+ }
143
+ if (typeof value === "string") {
144
+ const s = Buffer.from(value, "utf-8");
145
+ const len = Buffer.alloc(4); len.writeInt32LE(s.length + 1, 0);
146
+ return Buffer.concat([Buffer.from([0x02]), ck, len, s, Buffer.from([0])]);
147
+ }
148
+ if (Array.isArray(value)) {
149
+ const indexed = {};
150
+ value.forEach((v, i) => { indexed[String(i)] = v; });
151
+ return Buffer.concat([Buffer.from([0x04]), ck, encDoc(indexed)]);
152
+ }
153
+ if (typeof value === "object") return Buffer.concat([Buffer.from([0x03]), ck, encDoc(value)]);
154
+ const s = Buffer.from(String(value), "utf-8");
155
+ const len = Buffer.alloc(4); len.writeInt32LE(s.length + 1, 0);
156
+ return Buffer.concat([Buffer.from([0x02]), ck, len, s, Buffer.from([0])]);
157
+ }
158
+ function encDoc(obj) {
159
+ let body = Buffer.alloc(0);
160
+ for (const k of Object.keys(obj)) body = Buffer.concat([body, encElem(k, obj[k])]);
161
+ body = Buffer.concat([body, Buffer.from([0])]);
162
+ const out = Buffer.alloc(4 + body.length);
163
+ out.writeInt32LE(out.length, 0);
164
+ body.copy(out, 4);
165
+ return out;
166
+ }
167
+ function decDoc(buf, pos) {
168
+ const docLen = buf.readInt32LE(pos.i); pos.i += 4;
169
+ const end = pos.i + docLen - 5;
170
+ const doc = {};
171
+ while (pos.i < end) {
172
+ const type = buf[pos.i]; pos.i += 1;
173
+ const keyEnd = buf.indexOf(0, pos.i);
174
+ const key = buf.toString("utf-8", pos.i, keyEnd);
175
+ pos.i = keyEnd + 1;
176
+ doc[key] = decVal(buf, pos, type, end);
177
+ }
178
+ pos.i += 1;
179
+ return doc;
180
+ }
181
+ function decVal(buf, pos, type, end) {
182
+ if (type === 0x01) { const v = buf.readDoubleLE(pos.i); pos.i += 8; return v; }
183
+ 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; }
184
+ if (type === 0x03) return decDoc(buf, pos);
185
+ if (type === 0x04) { const d = decDoc(buf, pos); return Object.keys(d).map((k) => d[k]); }
186
+ 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; }
187
+ if (type === 0x07) { const v = buf.toString("hex", pos.i, pos.i + 12); pos.i += 12; return v; }
188
+ if (type === 0x08) { const v = buf[pos.i] !== 0; pos.i += 1; return v; }
189
+ if (type === 0x09) { const v = Number(buf.readBigInt64LE(pos.i)); pos.i += 8; return v; }
190
+ if (type === 0x0a) return null;
191
+ if (type === 0x10) { const v = buf.readInt32LE(pos.i); pos.i += 4; return v; }
192
+ if (type === 0x11) { const v = Number(buf.readBigUInt64LE(pos.i)); pos.i += 8; return v; }
193
+ if (type === 0x12) { const v = Number(buf.readBigInt64LE(pos.i)); pos.i += 8; return v; }
194
+ // Unknown type: cannot size it — stop at the document boundary to avoid a loop.
195
+ pos.i = end;
196
+ return null;
197
+ }
198
+
199
+ let sock = null;
200
+ let buffer = Buffer.alloc(0);
201
+ let pending = null; // { command }
202
+ let requestId = 0;
203
+
204
+ function settle(status, payload) {
205
+ if (!pending) return;
206
+ pending = null;
207
+ __reply(status, payload);
208
+ }
209
+
210
+ function drop(err) {
211
+ try { if (sock) sock.destroy(); } catch (e) {}
212
+ sock = null;
213
+ buffer = Buffer.alloc(0);
214
+ settle(${STATUS_TRANSPORT}, err || "connection lost");
215
+ }
216
+
217
+ function onData(chunk) {
218
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
219
+ if (!pending) return;
220
+ if (buffer.length < 4) return;
221
+ const msgLen = buffer.readInt32LE(0);
222
+ if (buffer.length < msgLen) return; // OP_MSG split across TCP chunks
223
+
224
+ const frame = buffer.subarray(0, msgLen);
225
+ buffer = buffer.subarray(msgLen);
226
+
227
+ let doc;
228
+ // 16 header + 4 flagBits + 1 section kind = 21
229
+ try { doc = decDoc(frame, { i: 21 }); } catch (e) { settle(${STATUS_TRANSPORT}, "decode error: " + e.message); return; }
230
+
231
+ const ok = doc && (doc.ok === 1 || doc.ok === 1.0);
232
+ if (!ok) { settle(${STATUS_ERROR}, "command error: " + ((doc && doc.errmsg) || JSON.stringify(doc))); return; }
233
+
234
+ if (pending.command === "find") {
235
+ const batch = (doc.cursor && doc.cursor.firstBatch) || [];
236
+ if (!batch.length) { settle(${STATUS_NIL}, null); return; }
237
+ settle(${STATUS_OK}, JSON.stringify(batch[0]));
238
+ return;
239
+ }
240
+ settle(${STATUS_OK}, "__OK__");
241
+ }
242
+
243
+ function buildMessage(command, args) {
244
+ let cmdDoc;
245
+ // The command name FIRST and $db LAST. Reversed, the server reads $db as the
246
+ // command name and answers CommandNotFound.
247
+ if (command === "find") {
248
+ cmdDoc = { find: collection, filter: args.filter, limit: 1, "$db": database };
249
+ } else if (command === "update") {
250
+ const u = { _id: args.filter._id, data: args.data, expires_at: args.expires_at, last_accessed: args.last_accessed };
251
+ cmdDoc = { update: collection, updates: [{ q: args.filter, u: u, upsert: true }], "$db": database };
252
+ } else {
253
+ cmdDoc = { delete: collection, deletes: [{ q: args.filter, limit: 1 }], "$db": database };
254
+ }
255
+
256
+ const bodyDoc = encDoc(cmdDoc);
257
+ const section = Buffer.concat([Buffer.from([0]), bodyDoc]); // section kind 0 = body
258
+ const flags = Buffer.alloc(4); // flagBits = 0
259
+ const payload = Buffer.concat([flags, section]);
260
+ const header = Buffer.alloc(16);
261
+ requestId += 1;
262
+ header.writeInt32LE(16 + payload.length, 0); // messageLength
263
+ header.writeInt32LE(requestId, 4); // requestID
264
+ header.writeInt32LE(0, 8); // responseTo
265
+ header.writeInt32LE(2013, 12); // opCode = OP_MSG
266
+ return Buffer.concat([header, payload]);
267
+ }
268
+
269
+ function connect(then) {
270
+ const s = net.createConnection({ host: host, port: port });
271
+ s.setNoDelay(true);
272
+ const connectTimer = setTimeout(() => {
273
+ if (sock !== s) { try { s.destroy(); } catch (e) {} drop("connect timed out"); }
274
+ }, ${SERVER_SELECTION_MS});
275
+ s.on("data", onData);
276
+ s.on("error", (e) => { clearTimeout(connectTimer); drop(e.message); });
277
+ s.on("close", () => { clearTimeout(connectTimer); if (sock === s) drop("connection closed"); });
278
+ s.on("connect", () => { clearTimeout(connectTimer); sock = s; then(); });
279
+ }
280
+
281
+ function rawCall(command, args) {
282
+ // Set pending BEFORE connecting: a connect error otherwise arrives with
283
+ // nothing in flight and the caller blocks for its whole reply timeout.
284
+ pending = { command: command };
285
+ const write = () => {
286
+ try { sock.write(buildMessage(command, args)); } catch (e) { drop(e.message); }
287
+ };
288
+ if (sock) write(); else { try { connect(write); } catch (e) { drop(e.message); } }
289
+ }
290
+
291
+ parentPort.on("message", (msg) => {
292
+ if (hasDriver) void driverCall(msg.command, msg.args);
293
+ else rawCall(msg.command, msg.args);
294
+ });
295
+ `;
296
+
297
+ /**
298
+ * Run a session Mongo command synchronously.
42
299
  *
43
300
  * - command "find" -> the matched document as a JSON string, or "__EMPTY__".
44
301
  * - command "update" -> "__OK__" (upsert of `{_id, data, last_accessed}`).
45
302
  * - command "delete" -> "__OK__".
46
303
  *
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).
304
+ * THROWS `<label> command failed: ...` on a transport failure (server
305
+ * unreachable, timeout) OR a Mongo command error (`ok != 1`), so the Session
306
+ * boundary can log-loud + degrade (or re-throw under strict mode). A genuine
307
+ * miss is NOT a failure and comes back as "__EMPTY__" — collapsing the two is
308
+ * how a dead backend silently logs every user out.
50
309
  */
51
310
  export function mongoCommandSync(
52
311
  target: MongoTarget,
@@ -54,185 +313,17 @@ export function mongoCommandSync(
54
313
  args: MongoCommandArgs,
55
314
  label = "MongoDB",
56
315
  ): 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 {
316
+ const key = `mongo:${target.host}:${target.port}:${target.database}:${target.collection}`;
317
+ const forceRaw = process.env.TINA4_MONGO_FORCE_RAW ? 1 : 0;
318
+ const { status, payload } = getBridge(
319
+ `${key}:${forceRaw ? "raw" : "drv"}`,
320
+ MONGO_WORKER,
321
+ { target, forceRaw },
322
+ ).call({ command, args }, label);
98
323
 
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}`);
324
+ if (status === STATUS_NIL) return "__EMPTY__"; // genuine miss, not an error
325
+ if (status === STATUS_TRANSPORT || status === STATUS_ERROR) {
326
+ throw new Error(`${label} command failed: ${payload}`);
237
327
  }
328
+ return payload;
238
329
  }
@@ -10,7 +10,7 @@
10
10
  * TINA4_SESSION_MONGO_URI (overrides host/port if set)
11
11
  * TINA4_SESSION_MONGO_USERNAME (optional)
12
12
  * TINA4_SESSION_MONGO_PASSWORD (optional)
13
- * TINA4_SESSION_MONGO_DB (default: "tina4_sessions")
13
+ * TINA4_SESSION_MONGO_DB (default: "tina4")
14
14
  * TINA4_SESSION_MONGO_COLLECTION (default: "sessions")
15
15
  */
16
16
  import type { SessionHandler } from "../session.js";
@@ -57,8 +57,18 @@ export class MongoSessionHandler implements SessionHandler {
57
57
  private password: string;
58
58
  private database: string;
59
59
  private collection: string;
60
+ private hostExplicit: boolean;
61
+ private portExplicit: boolean;
62
+ private uriExplicit: boolean;
60
63
 
61
64
  constructor(config?: MongoSessionConfig) {
65
+ // WHICH VALUES THE CALLER GAVE US EXPLICITLY, recorded because a URI from the
66
+ // ENVIRONMENT must never override an argument the caller passed by hand. See
67
+ // target() for the precedence and the defect this fixes.
68
+ this.hostExplicit = config?.host !== undefined;
69
+ this.portExplicit = config?.port !== undefined;
70
+ this.uriExplicit = config?.uri !== undefined;
71
+
62
72
  this.host = config?.host
63
73
  ?? process.env.TINA4_SESSION_MONGO_HOST
64
74
  ?? "127.0.0.1";
@@ -77,23 +87,67 @@ export class MongoSessionHandler implements SessionHandler {
77
87
  this.password = config?.password
78
88
  ?? process.env.TINA4_SESSION_MONGO_PASSWORD
79
89
  ?? "";
90
+ // "tina4" is the default in tina4-python, tina4-php and tina4-ruby. Node was
91
+ // the outlier at "tina4_sessions", so the SAME .env put Node's sessions in a
92
+ // different database from the other three - identical configuration, different
93
+ // observable outcome (the ADR-0024 failure mode). Breaking on purpose: see the
94
+ // migration note in the commit. No fallback read is offered; a session store is
95
+ // ephemeral by definition, so the impact self-heals within one TTL.
80
96
  this.database = config?.database
81
97
  ?? process.env.TINA4_SESSION_MONGO_DB
82
- ?? "tina4_sessions";
98
+ ?? "tina4";
83
99
  this.collection = config?.collection
84
100
  ?? process.env.TINA4_SESSION_MONGO_COLLECTION
85
101
  ?? "sessions";
86
102
  }
87
103
 
88
- /** Resolve the effective host/port (honours a configured mongodb:// URI). */
104
+ /**
105
+ * Resolve the effective host/port (honours a configured mongodb:// URI).
106
+ *
107
+ * PRECEDENCE, and it runs the way every other resolver in Tina4 runs -
108
+ * EXPLICIT CONFIGURATION BEATS THE ENVIRONMENT:
109
+ *
110
+ * 1. an explicitly passed `uri` - the caller named a complete address
111
+ * 2. an explicitly passed host/port - per field, and an ENV uri may not touch them
112
+ * 3. TINA4_SESSION_MONGO_URI / _URL - the ambient address
113
+ * 4. TINA4_SESSION_MONGO_HOST/_PORT - ambient parts
114
+ * 5. 127.0.0.1:27017
115
+ *
116
+ * THE DEFECT THIS FIXES, measured 2026-08-05 on the lab host: the URI won
117
+ * UNCONDITIONALLY, including over an argument the caller had just passed by
118
+ * hand. With TINA4_SESSION_MONGO_URI=mongodb://127.0.0.1:27017/tina4_node
119
+ * exported - an entirely ordinary deployment setting -
120
+ *
121
+ * new MongoSessionHandler({ host: "127.0.0.1", port: 59999 })
122
+ *
123
+ * resolved to 127.0.0.1:27017. The handler dialled a DIFFERENT SERVER from the
124
+ * one it was told to use, said nothing, and a read against it came back null:
125
+ * indistinguishable from a genuine miss. So an app that points a handler at one
126
+ * Mongo while the environment names another writes its sessions to the wrong
127
+ * server, and the backend-failure policy cannot fire because nothing failed.
128
+ *
129
+ * It also made two suites report a framework contract as broken - the
130
+ * unreachable-server-must-throw cases in sessionHandlers and
131
+ * sessionMongoRawProtocol never reached the dead port at all, so they measured
132
+ * a live server and got a miss. Those cases were RIGHT; this was the bug they
133
+ * were catching.
134
+ *
135
+ * Same class as the TINA4_QUEUE_URL precedence inversion fixed in PHP's
136
+ * Queue::resolveMongoConfig earlier the same day: environment quietly beating
137
+ * an explicit argument.
138
+ */
89
139
  private target(): MongoTarget {
90
140
  let host = this.host;
91
141
  let port = this.port;
92
- if (this.uri) {
142
+ // An ENV-supplied uri may fill in only what the caller did NOT pin. An
143
+ // explicitly passed uri is the caller's own choice and still wins outright.
144
+ const uriMayOverrideHost = this.uriExplicit || !this.hostExplicit;
145
+ const uriMayOverridePort = this.uriExplicit || !this.portExplicit;
146
+ if (this.uri && (uriMayOverrideHost || uriMayOverridePort)) {
93
147
  const match = this.uri.match(/mongodb:\/\/(?:[^@/]+@)?([^/:]+):?(\d+)?/);
94
148
  if (match) {
95
- host = match[1];
96
- port = match[2] ? parseInt(match[2], 10) : 27017;
149
+ if (uriMayOverrideHost) host = match[1];
150
+ if (uriMayOverridePort) port = match[2] ? parseInt(match[2], 10) : 27017;
97
151
  }
98
152
  }
99
153
  return { host, port, database: this.database, collection: this.collection };
@@ -103,7 +157,21 @@ export class MongoSessionHandler implements SessionHandler {
103
157
  const result = mongoCommandSync(this.target(), "find", { filter: { _id: sessionId } });
104
158
  if (!result || result === "__EMPTY__") return null; // genuine miss
105
159
  try {
106
- const doc = JSON.parse(result) as { data?: unknown };
160
+ const doc = JSON.parse(result) as { data?: unknown; expires_at?: number };
161
+
162
+ // Expiry is an ABSOLUTE deadline stamped at write time, and an absent or
163
+ // zero stamp means "never expires" - so it is guarded OUT of the
164
+ // comparison, never fed INTO it. Before this, read() consulted NOTHING:
165
+ // no stamp was stored, no TTL index was created, and write() took the ttl
166
+ // as `_ttl` and discarded it, so a mongodb-backed session never expired at
167
+ // all. Measured: write with ttl=1, sleep 3s, read still returned the data
168
+ // while file/redis/valkey/memcached/database all returned null.
169
+ const expiresAt = Number(doc?.expires_at ?? 0);
170
+ if (expiresAt > 0 && Date.now() / 1000 > expiresAt) {
171
+ this.destroy(sessionId);
172
+ return null;
173
+ }
174
+
107
175
  const data = doc?.data;
108
176
  // Stored as a nested document (parity with the Python master); read returns it.
109
177
  return data && typeof data === "object" ? (data as SessionData) : null;
@@ -112,10 +180,22 @@ export class MongoSessionHandler implements SessionHandler {
112
180
  }
113
181
  }
114
182
 
115
- write(sessionId: string, data: SessionData, _ttl: number): void {
183
+ /**
184
+ * Write session data.
185
+ *
186
+ * The ttl is consumed HERE, at write time, and baked into an absolute deadline,
187
+ * so nothing at read time needs to know what the ttl was. The parameter used to
188
+ * be named `_ttl` and thrown away.
189
+ *
190
+ * @param sessionId - the session id
191
+ * @param data - the payload to store
192
+ * @param ttl - lifetime in seconds; 0 or less means never expires
193
+ */
194
+ write(sessionId: string, data: SessionData, ttl: number = 0): void {
116
195
  mongoCommandSync(this.target(), "update", {
117
196
  filter: { _id: sessionId },
118
197
  data,
198
+ expires_at: ttl > 0 ? Math.floor(Date.now() / 1000) + ttl : 0,
119
199
  last_accessed: Date.now() / 1000,
120
200
  });
121
201
  }