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,16 +1,50 @@
1
1
  /**
2
- * Tina4 Database Session Handler — SQLite via Node's built-in node:sqlite,
3
- * zero extra dependencies.
2
+ * Tina4 Database Session Handler — sessions in a `tina4_session` table on ANY
3
+ * engine the ORM Database layer supports: sqlite, postgres, mysql, mssql,
4
+ * firebird.
4
5
  *
5
- * Uses the same `node:sqlite` (DatabaseSync) the ORM's SQLite adapter uses
6
- * no third-party driver, nothing to install.
7
- * Stores sessions in a `tina4_session` table with JSON data and expiry.
6
+ * WHAT THIS USED TO BE, and why it changed. This handler was SQLite-only by
7
+ * construction: resolveDbPath() THREW on any non-sqlite TINA4_DATABASE_URL. So
8
+ * an app developed on SQLite and deployed on PostgreSQL did not start at all -
9
+ * the founding scenario of ADR-0024, landing in the one subsystem that decides
10
+ * whether anybody is logged in. A backend that advertises support for an engine
11
+ * has to work on that engine.
12
+ *
13
+ * The refusal was defended on the grounds that the session API is SYNCHRONOUS
14
+ * and a PostgreSQL driver is async, so multi-engine "would need a synchronous
15
+ * Postgres bridge". That premise was wrong: the bridge already existed
16
+ * (syncBridge.ts) with four consumers - RESP for Redis/Valkey, memcached and
17
+ * MongoDB. sqlClient.ts makes this the fifth.
18
+ *
19
+ * SQLITE STAYS ON `node:sqlite`, DIRECTLY. It is already synchronous, so
20
+ * putting it on the bridge would add a thread hop and a JSON round-trip to the
21
+ * one engine that needs neither.
22
+ *
23
+ * WHAT DID NOT CHANGE: an unsupported engine still REFUSES, loudly, by name. It
24
+ * must never fall back to a local SQLite file - that hands every horizontally
25
+ * scaled instance its own private session store, so a user is logged out on
26
+ * every request that lands elsewhere. An outage that looks exactly like success
27
+ * is worse than a refusal, which is why the refusal is kept even though the
28
+ * supported set is now five engines instead of one.
8
29
  *
9
30
  * Configure via environment variables:
10
- * TINA4_DATABASE_URL (default: "sqlite:///data/tina4_sessions.db")
31
+ * TINA4_DATABASE_URL (default: a local SQLite file, "data/tina4_sessions.db")
32
+ * TINA4_DATABASE_USERNAME (used when the URL carries no credentials)
33
+ * TINA4_DATABASE_PASSWORD (same)
11
34
  */
12
35
  import { DatabaseSync } from "node:sqlite";
13
36
  import type { SessionHandler } from "../session.js";
37
+ // The ORM's connection-string parser, NOT a second copy of it. The invariant is
38
+ // "the session backend works on every engine the Database layer supports", so
39
+ // it has to agree with that layer about what a connection string MEANS -
40
+ // aliases (postgresql/pgsql/sqlserver), default ports, credential fallbacks and
41
+ // the explicitly-blank-password rule included. A private parser here would be
42
+ // free to drift from the corpus fixture that keeps all four frameworks honest.
43
+ // databaseUrl.ts imports nothing but node:util, so this adds one pure module to
44
+ // the graph and no package cycle.
45
+ import { DatabaseUrl } from "../../../orm/src/databaseUrl.js";
46
+ import { SQL_SESSION_ENGINES, sqlCommandSync } from "./sqlClient.js";
47
+ import type { BridgedEngine, SqlTarget } from "./sqlClient.js";
14
48
 
15
49
  interface SessionData {
16
50
  _created: number;
@@ -19,7 +53,7 @@ interface SessionData {
19
53
  }
20
54
 
21
55
  export interface DatabaseSessionConfig {
22
- /** SQLite database file path (default: extracted from TINA4_DATABASE_URL or "data/tina4_sessions.db") */
56
+ /** SQLite database file path. Explicit config wins over TINA4_DATABASE_URL. */
23
57
  dbPath?: string;
24
58
  // Unified SessionConfig fields are tolerated (and ignored) so the central
25
59
  // Session can forward its config object without a structural mismatch.
@@ -34,32 +68,267 @@ export interface DatabaseSessionConfig {
34
68
  }
35
69
 
36
70
  /**
37
- * Database session handler using node:sqlite (synchronous SQLite).
71
+ * CREATE TABLE per engine. The only genuinely per-engine SQL in this file -
72
+ * every other statement is written once with `?` placeholders and rewritten for
73
+ * the driver by sqlClient.
74
+ *
75
+ * The COLUMNS are identical everywhere (session_id, data, expires_at) because
76
+ * the table is a cross-framework contract: a tina4_session table written by
77
+ * tina4-python must be readable by tina4-nodejs. Only the type spellings and
78
+ * the "create it only if absent" idiom differ.
79
+ */
80
+ const CREATE_TABLE: Record<string, string> = {
81
+ // Unchanged from the SQLite-only original, deliberately: an existing
82
+ // deployment's table must keep working untouched.
83
+ sqlite: `
84
+ CREATE TABLE IF NOT EXISTS tina4_session (
85
+ session_id TEXT PRIMARY KEY,
86
+ data TEXT NOT NULL,
87
+ expires_at REAL NOT NULL
88
+ )
89
+ `,
90
+ postgres: `
91
+ CREATE TABLE IF NOT EXISTS tina4_session (
92
+ session_id VARCHAR(255) PRIMARY KEY,
93
+ data TEXT NOT NULL,
94
+ expires_at DOUBLE PRECISION NOT NULL
95
+ )
96
+ `,
97
+ mysql: `
98
+ CREATE TABLE IF NOT EXISTS tina4_session (
99
+ session_id VARCHAR(255) PRIMARY KEY,
100
+ data TEXT NOT NULL,
101
+ expires_at DOUBLE NOT NULL
102
+ )
103
+ `,
104
+ // T-SQL has no CREATE TABLE IF NOT EXISTS; the catalog check is the idiom.
105
+ mssql: `
106
+ IF OBJECT_ID(N'tina4_session', N'U') IS NULL
107
+ CREATE TABLE tina4_session (
108
+ session_id NVARCHAR(255) NOT NULL PRIMARY KEY,
109
+ data NVARCHAR(MAX) NOT NULL,
110
+ expires_at FLOAT NOT NULL
111
+ )
112
+ `,
113
+ // Firebird has neither IF NOT EXISTS nor a TEXT type, so the catalog check
114
+ // goes in an EXECUTE BLOCK and the payload is a VARCHAR.
115
+ //
116
+ // VERIFIED 2026-08-04 against a live Firebird 5.0.4 (the lab's
117
+ // tina4-lab-firebird container). This comment previously said UNVERIFIED and
118
+ // claimed there was no server on the lab; there is, and this SQL was run on
119
+ // it. What was measured, at the isql prompt:
120
+ //
121
+ // CREATE TABLE IF NOT EXISTS ... -> SQLSTATE 42000, -104,
122
+ // "Token unknown - line 1, column 17 -NOT"
123
+ // a column typed TEXT -> -607, "Specified domain or source
124
+ // column TEXT does not exist"
125
+ // DOUBLE PRECISION -> accepted
126
+ // this EXECUTE BLOCK -> created the table; confirmed out of band
127
+ // in RDB$RELATIONS
128
+ // this EXECUTE BLOCK, run AGAIN
129
+ // with the table present -> clean, no error: it is IDEMPOTENT
130
+ //
131
+ // So both halves of the first line above are now measurement rather than
132
+ // inference: Firebird really has neither IF NOT EXISTS nor a TEXT type.
133
+ //
134
+ // The idempotence is NOT a race guard. It is check-then-act inside one block,
135
+ // so two connections can still both find the table absent and both create it -
136
+ // measured directly: a bare CREATE TABLE with the table present gives
137
+ // SQLSTATE 42S01 "Table TINA4_SESSION already exists". The caller's
138
+ // create-then-recheck rescue is what closes that window, on every engine.
139
+ //
140
+ // A VARCHAR rather than BLOB SUB_TYPE TEXT on purpose: node-firebird hands a
141
+ // blob back as a reader function rather than a string, which the read path
142
+ // here would not understand. The cost is a session payload ceiling of 8191
143
+ // characters on this engine alone. Still unverified: the node-firebird DRIVER
144
+ // path end to end - this measurement was taken at the SQL level via isql.
145
+ firebird: `
146
+ EXECUTE BLOCK AS BEGIN
147
+ IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = 'TINA4_SESSION')) THEN
148
+ EXECUTE STATEMENT 'CREATE TABLE TINA4_SESSION (SESSION_ID VARCHAR(255) NOT NULL PRIMARY KEY, DATA VARCHAR(8191) NOT NULL, EXPIRES_AT DOUBLE PRECISION NOT NULL)';
149
+ END
150
+ `,
151
+ };
152
+
153
+ /**
154
+ * Read a column out of a result row, case-insensitively.
155
+ *
156
+ * PostgreSQL folds unquoted identifiers to lower case and Firebird folds them
157
+ * to UPPER, so the same SELECT legitimately comes back as `expires_at` on four
158
+ * engines and `EXPIRES_AT` on one. Three lines here beats quoting every
159
+ * identifier in every statement.
160
+ */
161
+ function column(row: Record<string, unknown>, name: string): unknown {
162
+ if (name in row) return row[name];
163
+ return row[name.toUpperCase()];
164
+ }
165
+
166
+ /**
167
+ * Database session handler.
38
168
  *
39
169
  * Stores session data as JSON in a `tina4_session` table.
40
170
  * Expiry is checked on read; expired rows are cleaned up lazily.
41
171
  */
42
172
  export class DatabaseSessionHandler implements SessionHandler {
43
- private db: any;
173
+ private sqliteHandle: any = null;
174
+ /** Set for SQLite. Null when this handler talks to a networked engine. */
175
+ private dbPath: string | null = null;
176
+ /** Set for a networked engine. Null for SQLite. */
177
+ private target: SqlTarget | null = null;
44
178
  private initialized = false;
45
179
 
180
+ /**
181
+ * NO I/O IN A CONSTRUCTOR (ADR-0021).
182
+ *
183
+ * This used to run `new DatabaseSync(dbPath)` and a `PRAGMA journal_mode =
184
+ * WAL` right here. Both are real work against real storage: opening the
185
+ * database CREATES the file, and switching to WAL creates its `-wal` and
186
+ * `-shm` siblings. Measured from a clean temp cwd, merely constructing this
187
+ * handler left three files on disk before a single session was ever read or
188
+ * written.
189
+ *
190
+ * A constructor sits OUTSIDE the log-loud-and-degrade policy, so nothing it
191
+ * does can be logged, degraded, or re-raised by TINA4_SESSION_STRICT - the one
192
+ * place the policy cannot protect is the first thing that runs.
193
+ *
194
+ * Everything below is pure string work. Resolving the target parses a URL;
195
+ * refusing an unsupported engine is a CONFIGURATION error that must still be
196
+ * loud at construction. The database - file or socket - is opened on first
197
+ * use. Going multi-engine is the change most likely to reintroduce
198
+ * constructor-time I/O, which is why test/sessionHandlerConstruction.test.ts
199
+ * measures a real filesystem and a real listening socket rather than trusting
200
+ * this comment.
201
+ */
46
202
  constructor(config?: DatabaseSessionConfig) {
47
- const dbPath = config?.dbPath ?? this.resolveDbPath();
203
+ if (config?.dbPath) {
204
+ // An explicit path is an explicit choice of SQLite, and it wins over the
205
+ // environment exactly as it always has.
206
+ this.dbPath = config.dbPath;
207
+ return;
208
+ }
209
+ this.resolveTarget();
210
+ }
211
+
212
+ /** Open the SQLite database on FIRST USE, not at construction. */
213
+ private get sqlite(): any {
214
+ if (this.sqliteHandle === null) {
215
+ this.sqliteHandle = new DatabaseSync(this.dbPath as string);
216
+ this.sqliteHandle.exec("PRAGMA journal_mode = WAL");
217
+ }
218
+ return this.sqliteHandle;
219
+ }
48
220
 
49
- this.db = new DatabaseSync(dbPath);
50
- this.db.exec("PRAGMA journal_mode = WAL");
221
+ private get engine(): string {
222
+ return this.target === null ? "sqlite" : this.target.engine;
51
223
  }
52
224
 
53
225
  /**
54
- * Resolve the database file path from TINA4_DATABASE_URL or use the default.
226
+ * Decide, from TINA4_DATABASE_URL, which engine this handler talks to.
227
+ *
228
+ * A NON-SQLITE URL NOW WORKS. It used to throw, because the handler drove
229
+ * `node:sqlite` directly and had no way to reach anything else; the async
230
+ * drivers now ride the sync bridge, so the reason for the refusal is gone.
231
+ *
232
+ * AN UNSUPPORTED ENGINE STILL REFUSES, and that half is not negotiable. The
233
+ * original defect was worse than a refusal: an unrecognised URL fell through
234
+ * to the literal default `"data/tina4_sessions.db"`, so
235
+ * `TINA4_DATABASE_URL=postgres://...` with `TINA4_SESSION_BACKEND=database`
236
+ * round-tripped happily while writing SQLite files into the process working
237
+ * directory. Measured from a clean temp cwd: round-trip true, and `data/`
238
+ * contained `tina4_sessions.db`, `-shm` and `-wal`. Every horizontally-scaled
239
+ * instance therefore had its own private session store and a user was logged
240
+ * out on every request that landed elsewhere.
241
+ *
242
+ * This is the same rule `resolveBackend()` applies one layer up, where an
243
+ * unknown backend name raises rather than falling through to disk.
244
+ *
245
+ * @throws Error naming the offending scheme and the engines this backend
246
+ * speaks. The URL itself is NEVER in the message - it may carry a
247
+ * password.
55
248
  */
56
- private resolveDbPath(): string {
249
+ private resolveTarget(): void {
57
250
  const url = process.env.TINA4_DATABASE_URL;
58
- if (url && url.startsWith("sqlite://")) {
251
+ if (!url) {
252
+ this.dbPath = "data/tina4_sessions.db";
253
+ return;
254
+ }
255
+
256
+ if (url.startsWith("sqlite:")) {
59
257
  // sqlite:///path/to/db or sqlite://./relative/path
60
- return url.replace(/^sqlite:\/\//, "");
258
+ //
259
+ // KNOWN DIVERGENCE, deliberately left alone. DatabaseUrl reads the
260
+ // three-slash form as RELATIVE (`sqlite:///data/app.db` -> `data/app.db`)
261
+ // per the documented cross-framework contract, while this strips the
262
+ // prefix and yields `/data/app.db` - an absolute path at the filesystem
263
+ // root. They disagree, and the ORM's reading is the correct one. Changing
264
+ // it here would silently relocate the session store of every deployment
265
+ // using that form, which is precisely the class of failure this invariant
266
+ // is about, so it is reported rather than smuggled into a multi-engine
267
+ // change.
268
+ this.dbPath = url.replace(/^sqlite:(\/\/)?/, "");
269
+ return;
270
+ }
271
+
272
+ let parsed: DatabaseUrl;
273
+ try {
274
+ parsed = new DatabaseUrl(
275
+ url,
276
+ process.env.TINA4_DATABASE_USERNAME,
277
+ process.env.TINA4_DATABASE_PASSWORD,
278
+ );
279
+ } catch {
280
+ // DatabaseUrl refuses a scheme it does not know at all. Its own message
281
+ // lists engines this backend cannot use (mongodb, odbc), so the refusal
282
+ // is restated in terms of what the SESSION backend actually speaks.
283
+ throw this.unsupportedEngine(schemeOf(url));
284
+ }
285
+
286
+ if (!(SQL_SESSION_ENGINES as readonly string[]).includes(parsed.engine)) {
287
+ // A real engine the Database layer supports, but not a SQL one - mongodb
288
+ // and odbc land here.
289
+ throw this.unsupportedEngine(parsed.engine);
290
+ }
291
+
292
+ this.target = {
293
+ engine: parsed.engine as BridgedEngine,
294
+ host: parsed.host ?? "127.0.0.1",
295
+ port: parsed.port ?? 0,
296
+ database: parsed.database,
297
+ username: parsed.username,
298
+ password: parsed.password,
299
+ };
300
+ }
301
+
302
+ private unsupportedEngine(scheme: string): Error {
303
+ return new Error(
304
+ `The "database" session backend cannot use a "${scheme}" URL. It speaks the SQL `
305
+ + `engines the Database layer supports: ${SQL_SESSION_ENGINES.join(", ")}. Point `
306
+ + `TINA4_DATABASE_URL at one of those, or pass an explicit dbPath in the session `
307
+ + `config, or choose a session backend that speaks ${scheme} (redis, valkey, `
308
+ + `mongodb, memcached). It will NOT fall back to a local SQLite file: that gives `
309
+ + `every instance its own private session store and logs users out at random.`,
310
+ );
311
+ }
312
+
313
+ // ── one statement, five engines ───────────────────────────────────
314
+ // The SQL is written ONCE with `?` placeholders - the same statement text as
315
+ // the Python master - and sqlClient rewrites the placeholders per driver.
316
+
317
+ /** Run a statement that returns rows. */
318
+ private query(sql: string, params: unknown[]): Record<string, unknown>[] {
319
+ if (this.target === null) {
320
+ return this.sqlite.prepare(sql).all(...params) as Record<string, unknown>[];
321
+ }
322
+ return sqlCommandSync(this.target, sql, params);
323
+ }
324
+
325
+ /** Run a statement that returns nothing. */
326
+ private exec(sql: string, params: unknown[]): void {
327
+ if (this.target === null) {
328
+ this.sqlite.prepare(sql).run(...params);
329
+ return;
61
330
  }
62
- return "data/tina4_sessions.db";
331
+ sqlCommandSync(this.target, sql, params);
63
332
  }
64
333
 
65
334
  /**
@@ -67,35 +336,40 @@ export class DatabaseSessionHandler implements SessionHandler {
67
336
  */
68
337
  private ensureTable(): void {
69
338
  if (this.initialized) return;
70
- this.db.exec(`
71
- CREATE TABLE IF NOT EXISTS tina4_session (
72
- session_id TEXT PRIMARY KEY,
73
- data TEXT NOT NULL,
74
- expires_at REAL NOT NULL
75
- )
76
- `);
339
+ const ddl = CREATE_TABLE[this.engine];
340
+ if (this.target === null) this.sqlite.exec(ddl);
341
+ else sqlCommandSync(this.target, ddl, []);
77
342
  this.initialized = true;
78
343
  }
79
344
 
80
345
  read(sessionId: string): SessionData | null {
81
346
  this.ensureTable();
82
347
 
83
- const row = this.db
84
- .prepare("SELECT data, expires_at FROM tina4_session WHERE session_id = ?")
85
- .get(sessionId) as { data: string; expires_at: number } | undefined;
86
-
348
+ const rows = this.query(
349
+ "SELECT data, expires_at FROM tina4_session WHERE session_id = ?",
350
+ [sessionId],
351
+ );
352
+ const row = rows[0];
87
353
  if (!row) return null;
88
354
 
89
- // Check expiry
355
+ // Check expiry.
356
+ //
357
+ // An ABSENT or ZERO deadline means "never expires" and is guarded OUT of the
358
+ // comparison. Without the `> 0` test, a row carrying no expiry (0) satisfies
359
+ // `0 < now` against every clock and is DESTROYED on read — the same shape
360
+ // that made tina4-php's file backend delete records. gc() below has always
361
+ // had this guard (`WHERE expires_at > 0 AND expires_at < ?`); this read path
362
+ // did not, so the two disagreed about what a zero meant.
90
363
  const now = Date.now() / 1000;
91
- if (row.expires_at < now) {
364
+ const expiresAt = Number(column(row, "expires_at") ?? 0);
365
+ if (expiresAt > 0 && expiresAt < now) {
92
366
  // Expired — clean up and return null
93
367
  this.destroy(sessionId);
94
368
  return null;
95
369
  }
96
370
 
97
371
  try {
98
- return JSON.parse(row.data) as SessionData;
372
+ return JSON.parse(String(column(row, "data"))) as SessionData;
99
373
  } catch {
100
374
  return null;
101
375
  }
@@ -105,35 +379,51 @@ export class DatabaseSessionHandler implements SessionHandler {
105
379
  this.ensureTable();
106
380
 
107
381
  const json = JSON.stringify(data);
108
- const expiresAt = (Date.now() / 1000) + (ttl > 0 ? ttl : 3600);
382
+ // A ttl of 0 (or less) means NEVER EXPIRES and is stored as the 0 that read()
383
+ // and gc() both guard out. It used to silently substitute 3600, so asking for
384
+ // a non-expiring session quietly got a one-hour one.
385
+ const expiresAt = ttl > 0 ? (Date.now() / 1000) + ttl : 0;
109
386
 
110
- const existing = this.db
111
- .prepare("SELECT 1 FROM tina4_session WHERE session_id = ?")
112
- .get(sessionId);
387
+ // SELECT-then-UPDATE-or-INSERT, matching the Python master. Deliberately NOT
388
+ // an upsert: ON CONFLICT / ON DUPLICATE KEY / MERGE are spelled differently
389
+ // on all five engines, and this shape needs no dialect at all.
390
+ const existing = this.query(
391
+ "SELECT session_id FROM tina4_session WHERE session_id = ?",
392
+ [sessionId],
393
+ );
113
394
 
114
- if (existing) {
115
- this.db
116
- .prepare("UPDATE tina4_session SET data = ?, expires_at = ? WHERE session_id = ?")
117
- .run(json, expiresAt, sessionId);
395
+ if (existing.length > 0) {
396
+ this.exec(
397
+ "UPDATE tina4_session SET data = ?, expires_at = ? WHERE session_id = ?",
398
+ [json, expiresAt, sessionId],
399
+ );
118
400
  } else {
119
- this.db
120
- .prepare("INSERT INTO tina4_session (session_id, data, expires_at) VALUES (?, ?, ?)")
121
- .run(sessionId, json, expiresAt);
401
+ this.exec(
402
+ "INSERT INTO tina4_session (session_id, data, expires_at) VALUES (?, ?, ?)",
403
+ [sessionId, json, expiresAt],
404
+ );
122
405
  }
123
406
  }
124
407
 
125
408
  destroy(sessionId: string): void {
126
409
  this.ensureTable();
127
- this.db
128
- .prepare("DELETE FROM tina4_session WHERE session_id = ?")
129
- .run(sessionId);
410
+ this.exec("DELETE FROM tina4_session WHERE session_id = ?", [sessionId]);
130
411
  }
131
412
 
132
413
  gc(_maxLifetime: number): void {
133
414
  this.ensureTable();
134
415
  const now = Date.now() / 1000;
135
- this.db
136
- .prepare("DELETE FROM tina4_session WHERE expires_at > 0 AND expires_at < ?")
137
- .run(now);
416
+ this.exec("DELETE FROM tina4_session WHERE expires_at > 0 AND expires_at < ?", [now]);
138
417
  }
139
418
  }
419
+
420
+ /**
421
+ * The scheme of a connection URL, for an error message.
422
+ *
423
+ * Only the scheme, never the URL: a connection string may carry a password, and
424
+ * an exception message ends up in the boot log, the crash report and CI output.
425
+ */
426
+ function schemeOf(url: string): string {
427
+ const match = url.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
428
+ return match ? match[1].toLowerCase() : "unknown";
429
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Tina4 Memcached Session Handler — zero-dependency text protocol over TCP.
3
+ *
4
+ * Memcached was already one of the seven CACHE backends in all four frameworks
5
+ * but was NOT a session backend in any of them, even though it is the classic
6
+ * PHP session store. This closes that gap.
7
+ *
8
+ * The SessionHandler interface is synchronous and node:net is async-only, so
9
+ * commands go through the shared persistent-connection transport (syncSocket) —
10
+ * one long-lived socket behind a worker thread, the same transport the
11
+ * Redis/Valkey handlers use. Memcached keeps the connection open after a reply
12
+ * and its text protocol carries no length prefix, so the reply is complete when
13
+ * one of the caller's terminators appears.
14
+ *
15
+ * BACKEND-FAILURE POLICY. A genuine key miss returns `null` silently (no session
16
+ * yet is normal). A TRANSPORT failure — server unreachable, connection dropped
17
+ * mid-reply, a protocol error — THROWS, so the Session layer can log-loud and
18
+ * degrade. Collapsing the two is how a dead cache silently logs every user out.
19
+ *
20
+ * Memcached has no persistence and no replication: a restart drops every
21
+ * session. That is a deliberate trade (it is a cache), and it is why
22
+ * file/database remain the defaults.
23
+ *
24
+ * Configure via environment variables:
25
+ * TINA4_SESSION_MEMCACHED_HOST (default: "127.0.0.1")
26
+ * TINA4_SESSION_MEMCACHED_PORT (default: 11211)
27
+ * TINA4_SESSION_MEMCACHED_PREFIX (default: "tina4:session:")
28
+ * TINA4_SESSION_TTL (default: 3600)
29
+ */
30
+ import { createHash } from "node:crypto";
31
+ import type { SessionHandler } from "../session.js";
32
+ import { syncTextCommand } from "./syncSocket.js";
33
+
34
+ interface SessionData {
35
+ _created: number;
36
+ _accessed: number;
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ export interface MemcachedSessionConfig {
41
+ host?: string;
42
+ port?: number;
43
+ prefix?: string;
44
+ ttl?: number;
45
+ // Unified SessionConfig fields are tolerated (and ignored) so the central
46
+ // Session can forward its config object without a structural mismatch.
47
+ backend?: string;
48
+ path?: string;
49
+ }
50
+
51
+ /**
52
+ * Memcached rejects a key over 250 bytes or containing a space/control
53
+ * character. A key that could break either rule is HASHED rather than
54
+ * truncated — truncating would let two different sessions collide on one key,
55
+ * handing one user another user's session.
56
+ */
57
+ const MAX_KEY_BYTES = 250;
58
+
59
+ /**
60
+ * memcached's exptime field changes meaning at 30 days: at or below this it is
61
+ * RELATIVE seconds, above it the server reads an ABSOLUTE UNIX TIMESTAMP.
62
+ * See MemcachedSessionHandler.expTime for why we convert instead of clamping.
63
+ */
64
+ const MAX_RELATIVE_EXPTIME = 2592000;
65
+
66
+ export class MemcachedSessionHandler implements SessionHandler {
67
+ private host: string;
68
+ private port: number;
69
+ private prefix: string;
70
+ private ttl: number;
71
+
72
+ constructor(config?: MemcachedSessionConfig) {
73
+ this.host = config?.host ?? process.env.TINA4_SESSION_MEMCACHED_HOST ?? "127.0.0.1";
74
+ this.port =
75
+ config?.port ??
76
+ (process.env.TINA4_SESSION_MEMCACHED_PORT
77
+ ? parseInt(process.env.TINA4_SESSION_MEMCACHED_PORT, 10)
78
+ : 11211);
79
+ this.prefix = config?.prefix ?? process.env.TINA4_SESSION_MEMCACHED_PREFIX ?? "tina4:session:";
80
+ this.ttl =
81
+ config?.ttl ??
82
+ (process.env.TINA4_SESSION_TTL ? parseInt(process.env.TINA4_SESSION_TTL, 10) : 3600);
83
+ }
84
+
85
+ private key(sessionId: string): string {
86
+ const candidate = `${this.prefix}${sessionId}`;
87
+ if (Buffer.byteLength(candidate) > MAX_KEY_BYTES || /[\x00-\x20\x7f]/.test(candidate)) {
88
+ return `${this.prefix}${createHash("sha256").update(sessionId).digest("hex")}`;
89
+ }
90
+ return candidate;
91
+ }
92
+
93
+ /**
94
+ * Run one memcached command synchronously and return the raw reply.
95
+ *
96
+ * Delegates to the shared persistent-connection transport (syncSocket) rather
97
+ * than spawning a child per command: that cost a process spawn plus a fresh
98
+ * TCP connection every time (p50 41ms, p99 487ms) and its tail tripped the
99
+ * deadline under load — the same defect that made the Valkey session tests
100
+ * flaky, which this handler inherited on the day it was written.
101
+ *
102
+ * @throws Error on any transport failure — never swallowed to an empty
103
+ * result, because for a session an outage must be distinguishable
104
+ * from "no session yet".
105
+ */
106
+ private command(payload: string, terminators: string[]): string {
107
+ return syncTextCommand(
108
+ { host: this.host, port: this.port },
109
+ payload,
110
+ terminators,
111
+ "Memcached",
112
+ );
113
+ }
114
+
115
+ read(sessionId: string): SessionData | null {
116
+ const resp = this.command(`get ${this.key(sessionId)}\r\n`, ["END\r\n"]);
117
+ if (!resp.startsWith("VALUE")) return null; // genuine miss — NOT an error
118
+
119
+ const split = resp.indexOf("\r\n");
120
+ if (split === -1) return null;
121
+ const header = resp.slice(0, split).split(" ");
122
+ const bytes = parseInt(header[3] ?? "0", 10);
123
+ const body = Buffer.from(resp.slice(split + 2), "utf-8").subarray(0, bytes).toString("utf-8");
124
+ try {
125
+ return JSON.parse(body) as SessionData;
126
+ } catch {
127
+ // A corrupt value is treated as no session rather than crashing the
128
+ // request; the next write replaces it.
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Convert a ttl in SECONDS to memcached's dual-meaning exptime field.
135
+ *
136
+ * memcached documents exptime as RELATIVE seconds up to 2592000 (30 days),
137
+ * and as an ABSOLUTE UNIX TIMESTAMP for anything larger. Sending a raw ttl of
138
+ * 2592001 therefore does not mean "30 days and one second" - it means
139
+ * 1970-01-31, which is already past, so the item expires the instant it is
140
+ * stored. memcached still replies STORED, so the write looks successful and
141
+ * the very next read is a miss: a silent logout on every request.
142
+ *
143
+ * Measured against real memcached 1.6.45: ttl=2592000 survives, ttl=2592001
144
+ * vanishes instantly.
145
+ *
146
+ * We CONVERT rather than CLAMP. Clamping a 60-day session down to 30 days
147
+ * would silently shorten a lifetime the operator explicitly asked to be
148
+ * longer, which is the same class of lie in the other direction.
149
+ */
150
+ private expTime(ttl: number): number {
151
+ return ttl > MAX_RELATIVE_EXPTIME ? Math.floor(Date.now() / 1000) + ttl : ttl;
152
+ }
153
+
154
+ write(sessionId: string, data: SessionData, ttl: number): void {
155
+ const effectiveTtl = this.expTime(ttl > 0 ? ttl : this.ttl);
156
+ const json = JSON.stringify(data);
157
+ const bytes = Buffer.byteLength(json);
158
+ const cmd = `set ${this.key(sessionId)} 0 ${effectiveTtl} ${bytes}\r\n`;
159
+ const resp = this.command(`${cmd}${json}\r\n`, [
160
+ "STORED\r\n",
161
+ "ERROR\r\n",
162
+ "SERVER_ERROR",
163
+ "CLIENT_ERROR",
164
+ ]);
165
+ if (!resp.startsWith("STORED")) {
166
+ throw new Error(`Memcached did not store the session: ${resp.slice(0, 80)}`);
167
+ }
168
+ }
169
+
170
+ destroy(sessionId: string): void {
171
+ // A session that was already gone is not an error.
172
+ this.command(`delete ${this.key(sessionId)}\r\n`, [
173
+ "DELETED\r\n",
174
+ "NOT_FOUND\r\n",
175
+ "ERROR\r\n",
176
+ ]);
177
+ }
178
+
179
+ /** No-op — memcached expires its own keys via the TTL set on write. */
180
+ gc(_maxLifetime: number): void {}
181
+ }