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.
- package/CLAUDE.md +170 -28
- package/README.md +2 -2
- package/package.json +13 -9
- package/packages/cli/dist/bin.js +33126 -30055
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +33062 -29908
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +7 -6
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +81 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -143
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +26554 -23400
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +64 -25
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +338 -198
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +177 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +163 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +87 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +92 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +200 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +41 -0
- package/types/core/src/middleware.d.ts +330 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +268 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +366 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +146 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +341 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +242 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +402 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +183 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +77 -0
- package/types/orm/src/adapters/mysql.d.ts +67 -0
- package/types/orm/src/adapters/odbc.d.ts +94 -0
- package/types/orm/src/adapters/postgres.d.ts +86 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +68 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +427 -0
- package/types/orm/src/cachedDatabase.d.ts +190 -0
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +655 -0
- package/types/orm/src/databaseResult.d.ts +109 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +241 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +43 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +193 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +258 -0
- package/types/orm/src/types.d.ts +148 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
|
@@ -1,16 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tina4 Database Session Handler —
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
this.
|
|
221
|
+
private get engine(): string {
|
|
222
|
+
return this.target === null ? "sqlite" : this.target.engine;
|
|
51
223
|
}
|
|
52
224
|
|
|
53
225
|
/**
|
|
54
|
-
*
|
|
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
|
|
249
|
+
private resolveTarget(): void {
|
|
57
250
|
const url = process.env.TINA4_DATABASE_URL;
|
|
58
|
-
if (url
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|