tina4-nodejs 3.13.37 → 3.13.39
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 +65 -20
- package/README.md +6 -6
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +56 -8
- package/packages/core/src/server.ts +138 -23
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -12,7 +12,117 @@
|
|
|
12
12
|
* checkPassword("secret123", hash); // true
|
|
13
13
|
*/
|
|
14
14
|
import { createHmac, createSign, createVerify, pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
15
17
|
import type { Middleware, Tina4Request, Tina4Response } from "./types.js";
|
|
18
|
+
import { isTruthy } from "./dotenv.js";
|
|
19
|
+
|
|
20
|
+
// ── Blank-secret warning + dev-secret bootstrap ────────────────────
|
|
21
|
+
//
|
|
22
|
+
// Mirrors the Python master (tina4_python/auth/__init__.py):
|
|
23
|
+
// • The default signing secret stays BLANK — never a guessable built-in.
|
|
24
|
+
// • In DEV (not CI, not production) a blank secret is auto-generated once and
|
|
25
|
+
// persisted to a gitignored .env.local, so a local dev never has to be told
|
|
26
|
+
// what to set. INFO, not a warning.
|
|
27
|
+
// • In CI / production a blank secret keeps the loud, ACTIONABLE warning.
|
|
28
|
+
|
|
29
|
+
/** Actionable blank-secret warning — emitted from both the bootstrap (CI/prod) and the lazy resolvers. */
|
|
30
|
+
const BLANK_SECRET_WARNING =
|
|
31
|
+
"Auth: TINA4_SECRET is not set — JWT signing is insecure. Set TINA4_SECRET to a random " +
|
|
32
|
+
"value (e.g. `openssl rand -hex 32`) in your environment or .env before serving traffic.";
|
|
33
|
+
|
|
34
|
+
/** True when running under CI — the de-facto `CI` env var (set by every major CI). */
|
|
35
|
+
function _isCi(): boolean {
|
|
36
|
+
return isTruthy(process.env.CI);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True in development — the framework debug flag is truthy (e.g. TINA4_DEBUG=true). */
|
|
40
|
+
function _isDev(): boolean {
|
|
41
|
+
return isTruthy(process.env.TINA4_DEBUG);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when TINA4_ENV is explicitly "production". */
|
|
45
|
+
function _isProduction(): boolean {
|
|
46
|
+
return (process.env.TINA4_ENV ?? "development") === "production";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** INFO log via Tina4 Log when available, else stderr. (Local import avoids a load-time cycle.) */
|
|
50
|
+
async function _logInfo(message: string): Promise<void> {
|
|
51
|
+
try {
|
|
52
|
+
const { Log } = await import("./logger.js");
|
|
53
|
+
Log.info(message);
|
|
54
|
+
} catch {
|
|
55
|
+
process.stderr.write(message + "\n");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** WARNING log via Tina4 Log when available, else stderr. */
|
|
60
|
+
async function _logWarning(message: string): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
const { Log } = await import("./logger.js");
|
|
63
|
+
Log.warning(message);
|
|
64
|
+
} catch {
|
|
65
|
+
process.stderr.write(message + "\n");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Emit the shared, actionable blank-secret warning (used by CI/prod bootstrap + lazy resolvers). */
|
|
70
|
+
function _warnBlankSecret(): void {
|
|
71
|
+
void _logWarning(BLANK_SECRET_WARNING);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ensure a usable TINA4_SECRET exists. Run ONCE at server boot, after env load
|
|
76
|
+
* and before auth is used. Mirrors Python's `ensure_dev_secret()`.
|
|
77
|
+
*
|
|
78
|
+
* Order:
|
|
79
|
+
* 1. TINA4_SECRET already set → no-op (return null).
|
|
80
|
+
* 2. NOT dev, OR CI, OR production → emit the actionable warning, return null.
|
|
81
|
+
* NEVER generates or persists a secret in CI / production / non-dev.
|
|
82
|
+
* 3. Otherwise (dev, not CI, not prod, blank secret) → generate a 32-byte hex
|
|
83
|
+
* secret, set it in process.env for THIS run immediately, then try to append
|
|
84
|
+
* it to <cwd>/.env.local (create if missing; never touch .env). On a write
|
|
85
|
+
* failure keep the in-memory secret and warn — boot must never crash.
|
|
86
|
+
*
|
|
87
|
+
* @param cwd - Directory to write .env.local into. Tests pass a temp dir; production passes nothing.
|
|
88
|
+
* @returns The newly-generated secret, or null when nothing was generated.
|
|
89
|
+
*/
|
|
90
|
+
export function ensureDevSecret(cwd?: string): string | null {
|
|
91
|
+
if (process.env.TINA4_SECRET) return null; // already configured
|
|
92
|
+
|
|
93
|
+
// Only the dev-and-not-CI-and-not-production path may generate / persist.
|
|
94
|
+
if (!_isDev() || _isCi() || _isProduction()) {
|
|
95
|
+
_warnBlankSecret();
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 32 bytes hex = 64 hex chars (parity with Python's secrets.token_hex(32)).
|
|
100
|
+
const newSecret = randomBytes(32).toString("hex");
|
|
101
|
+
// Set immediately so it's available for this run even if the write fails.
|
|
102
|
+
process.env.TINA4_SECRET = newSecret;
|
|
103
|
+
|
|
104
|
+
const baseDir = cwd ?? process.cwd();
|
|
105
|
+
const envLocalPath = join(baseDir, ".env.local");
|
|
106
|
+
try {
|
|
107
|
+
// If the file exists and its content doesn't end in a newline, prepend one
|
|
108
|
+
// so the new key lands on its own line.
|
|
109
|
+
let prefix = "";
|
|
110
|
+
if (existsSync(envLocalPath)) {
|
|
111
|
+
const existing = readFileSync(envLocalPath, "utf-8");
|
|
112
|
+
if (existing.length > 0 && !existing.endsWith("\n")) prefix = "\n";
|
|
113
|
+
}
|
|
114
|
+
appendFileSync(envLocalPath, `${prefix}TINA4_SECRET=${newSecret}\n`);
|
|
115
|
+
void _logInfo("Auth: generated a development secret, saved to .env.local (gitignored)");
|
|
116
|
+
} catch {
|
|
117
|
+
// Keep the in-memory secret for this run; warn but never crash boot.
|
|
118
|
+
void _logWarning(
|
|
119
|
+
"Auth: generated a development secret but could not write .env.local — " +
|
|
120
|
+
"using it in-memory for this run only.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return newSecret;
|
|
125
|
+
}
|
|
16
126
|
|
|
17
127
|
// ── Base64url helpers (RFC 7515) ──────────────────────────────────
|
|
18
128
|
|
|
@@ -58,7 +168,7 @@ export function getToken(
|
|
|
58
168
|
}
|
|
59
169
|
|
|
60
170
|
if (!resolvedSecret) {
|
|
61
|
-
|
|
171
|
+
_warnBlankSecret();
|
|
62
172
|
}
|
|
63
173
|
const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
|
|
64
174
|
|
|
@@ -95,7 +205,7 @@ export function getToken(
|
|
|
95
205
|
export function validToken(token: string, secret?: string, algorithm?: string): Record<string, unknown> | null {
|
|
96
206
|
const resolvedSecret = secret ?? process.env.TINA4_SECRET ?? "";
|
|
97
207
|
if (!resolvedSecret) {
|
|
98
|
-
|
|
208
|
+
_warnBlankSecret();
|
|
99
209
|
}
|
|
100
210
|
const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
|
|
101
211
|
try {
|
|
@@ -247,7 +247,7 @@ class RespClient {
|
|
|
247
247
|
private username: string | null;
|
|
248
248
|
private password: string | null;
|
|
249
249
|
private sock: net.Socket | null = null;
|
|
250
|
-
private buffer = Buffer.alloc(0);
|
|
250
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
251
251
|
private waiters: Array<{ resolve: (r: RespReply) => void; reject: (e: Error) => void }> = [];
|
|
252
252
|
private connecting: Promise<void> | null = null;
|
|
253
253
|
private connected = false;
|
|
@@ -662,7 +662,7 @@ class MemcachedClient {
|
|
|
662
662
|
private host: string;
|
|
663
663
|
private port: number;
|
|
664
664
|
private sock: net.Socket | null = null;
|
|
665
|
-
private buffer = Buffer.alloc(0);
|
|
665
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
666
666
|
private pending: { terminator: string; resolve: (s: string) => void } | null = null;
|
|
667
667
|
private connecting: Promise<void> | null = null;
|
|
668
668
|
private connected = false;
|
|
@@ -19,7 +19,7 @@ import { DevMailbox } from "./devMailbox.js";
|
|
|
19
19
|
import { isTruthy } from "./dotenv.js";
|
|
20
20
|
import { quickMetrics, fullAnalysis, fileDetail } from "./metrics.js";
|
|
21
21
|
import { registerFeedbackRoutes } from "./feedback.js";
|
|
22
|
-
import { getDefaultDevServer } from "./mcp.js";
|
|
22
|
+
import { getDefaultDevServer, mcpEnabled } from "./mcp.js";
|
|
23
23
|
|
|
24
24
|
const cpuCount = osCpus().length;
|
|
25
25
|
|
|
@@ -561,18 +561,6 @@ export class DevAdmin {
|
|
|
561
561
|
{ method: "POST", pattern: "/__dev/api/deps/install", handler: handleDepsInstall },
|
|
562
562
|
// Git status
|
|
563
563
|
{ method: "GET", pattern: "/__dev/api/git/status", handler: handleGitStatus },
|
|
564
|
-
// MCP tool introspection over the built-in MCP server (browser dev-admin REST shim)
|
|
565
|
-
{ method: "GET", pattern: "/__dev/api/mcp/tools", handler: handleMcpTools },
|
|
566
|
-
{ method: "POST", pattern: "/__dev/api/mcp/call", handler: handleMcpCall },
|
|
567
|
-
// MCP JSON-RPC + SSE endpoints that REAL MCP clients (Claude Code/Desktop)
|
|
568
|
-
// speak. POST /__dev/mcp[/message] -> JSON-RPC handleMessage; GET
|
|
569
|
-
// /__dev/mcp/sse -> SSE handshake announcing the message endpoint. Mounted
|
|
570
|
-
// through the same dispatch as the REST shim above and gated by the same
|
|
571
|
-
// /__dev public-route rule. Mirrors the Python v3 fix (POST /__dev/mcp +
|
|
572
|
-
// /__dev/mcp/message, GET /__dev/mcp/sse).
|
|
573
|
-
{ method: "POST", pattern: "/__dev/mcp", handler: handleMcpMessage },
|
|
574
|
-
{ method: "POST", pattern: "/__dev/mcp/message", handler: handleMcpMessage },
|
|
575
|
-
{ method: "GET", pattern: "/__dev/mcp/sse", handler: handleMcpSse },
|
|
576
564
|
// Scaffolding
|
|
577
565
|
{ method: "GET", pattern: "/__dev/api/scaffold", handler: handleScaffoldList },
|
|
578
566
|
{ method: "POST", pattern: "/__dev/api/scaffold/run", handler: handleScaffoldRun },
|
|
@@ -610,12 +598,41 @@ export class DevAdmin {
|
|
|
610
598
|
});
|
|
611
599
|
}
|
|
612
600
|
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
601
|
+
// MCP exposure is gated SEPARATELY from the rest of the dev dashboard.
|
|
602
|
+
// The MCP dev tools expose powerful operations (DB query, file read/WRITE,
|
|
603
|
+
// route listing), so they must NOT auto-expose on a non-localhost
|
|
604
|
+
// TINA4_DEBUG=true deployment. mcpEnabled() honours an explicit TINA4_MCP on
|
|
605
|
+
// any host, else requires TINA4_DEBUG AND (localhost OR TINA4_MCP_REMOTE) —
|
|
606
|
+
// full parity with Python master tina4_python.mcp.is_enabled(). When the
|
|
607
|
+
// gate is closed, neither the REST shim, the JSON-RPC/SSE endpoints, nor the
|
|
608
|
+
// default dev MCP server (with its dev tools) are registered.
|
|
609
|
+
if (mcpEnabled()) {
|
|
610
|
+
const mcpRoutes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [
|
|
611
|
+
// MCP tool introspection over the built-in MCP server (browser dev-admin REST shim)
|
|
612
|
+
{ method: "GET", pattern: "/__dev/api/mcp/tools", handler: handleMcpTools },
|
|
613
|
+
{ method: "POST", pattern: "/__dev/api/mcp/call", handler: handleMcpCall },
|
|
614
|
+
// MCP JSON-RPC + SSE endpoints that REAL MCP clients (Claude Code/Desktop)
|
|
615
|
+
// speak. POST /__dev/mcp[/message] -> JSON-RPC handleMessage; GET
|
|
616
|
+
// /__dev/mcp/sse -> SSE handshake announcing the message endpoint. Mirrors
|
|
617
|
+
// the Python v3 fix (POST /__dev/mcp + /__dev/mcp/message, GET /__dev/mcp/sse).
|
|
618
|
+
{ method: "POST", pattern: "/__dev/mcp", handler: handleMcpMessage },
|
|
619
|
+
{ method: "POST", pattern: "/__dev/mcp/message", handler: handleMcpMessage },
|
|
620
|
+
{ method: "GET", pattern: "/__dev/mcp/sse", handler: handleMcpSse },
|
|
621
|
+
];
|
|
622
|
+
for (const route of mcpRoutes) {
|
|
623
|
+
router.addRoute({
|
|
624
|
+
method: route.method,
|
|
625
|
+
pattern: route.pattern,
|
|
626
|
+
handler: route.handler,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Ensure the default /__dev/mcp MCP server exists with its dev tools
|
|
631
|
+
// registered. This is the single shared instance behind both the REST shim
|
|
632
|
+
// and the JSON-RPC + SSE endpoints registered above, so tools/list and the
|
|
633
|
+
// REST shim return tools immediately, before any first call.
|
|
634
|
+
getDefaultDevServer();
|
|
635
|
+
}
|
|
619
636
|
}
|
|
620
637
|
|
|
621
638
|
/**
|
|
@@ -1041,8 +1058,16 @@ const handleTables: RouteHandler = async (_req, res) => {
|
|
|
1041
1058
|
|
|
1042
1059
|
const handleSeed: RouteHandler = async (req, res) => {
|
|
1043
1060
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1044
|
-
const
|
|
1045
|
-
const
|
|
1061
|
+
const body = (req as any).body ?? {};
|
|
1062
|
+
const table = url.searchParams.get("table") ?? body?.table ?? "";
|
|
1063
|
+
const count = parseInt(String(url.searchParams.get("count") ?? body?.count ?? "10"), 10) || 10;
|
|
1064
|
+
// P4b — accept seed/clear/strict; drop the previous hard-coded behaviour.
|
|
1065
|
+
const seedRaw = url.searchParams.get("seed") ?? body?.seed;
|
|
1066
|
+
const seed = seedRaw !== undefined && seedRaw !== null && String(seedRaw) !== ""
|
|
1067
|
+
? (Number.isNaN(parseInt(String(seedRaw), 10)) ? undefined : parseInt(String(seedRaw), 10))
|
|
1068
|
+
: undefined;
|
|
1069
|
+
const clear = String(url.searchParams.get("clear") ?? body?.clear ?? "") === "true" || body?.clear === true;
|
|
1070
|
+
const strict = String(url.searchParams.get("strict") ?? body?.strict ?? "") === "true" || body?.strict === true;
|
|
1046
1071
|
if (!table) {
|
|
1047
1072
|
res.json({ error: "Missing table parameter" });
|
|
1048
1073
|
return;
|
|
@@ -1051,43 +1076,36 @@ const handleSeed: RouteHandler = async (req, res) => {
|
|
|
1051
1076
|
const orm = await import("@tina4/orm");
|
|
1052
1077
|
const db = orm.getAdapter();
|
|
1053
1078
|
const { seedTable } = orm;
|
|
1054
|
-
|
|
1079
|
+
// A shared FakeData seeds the RNG so a `seed` makes the run reproducible.
|
|
1080
|
+
const fake = new orm.FakeData(seed);
|
|
1055
1081
|
const columns = db.columns(table);
|
|
1056
1082
|
if (!columns.length) {
|
|
1057
1083
|
res.json({ error: `Table '${table}' not found or has no columns` });
|
|
1058
1084
|
return;
|
|
1059
1085
|
}
|
|
1060
|
-
// Build a field map based on column info
|
|
1086
|
+
// Build a field map based on column info (skip auto-increment/id PKs).
|
|
1061
1087
|
const fieldMap: Record<string, () => unknown> = {};
|
|
1062
1088
|
for (const col of columns) {
|
|
1063
1089
|
const name = col.name.toLowerCase();
|
|
1064
1090
|
const type = col.type.toLowerCase();
|
|
1065
|
-
if (name === "id") continue; // skip primary key
|
|
1091
|
+
if (name === "id" || (col as any).primaryKey === true) continue; // skip primary key
|
|
1066
1092
|
if (name.includes("email")) { fieldMap[col.name] = () => fake.email(); }
|
|
1067
1093
|
else if (name.includes("name")) { fieldMap[col.name] = () => fake.name(); }
|
|
1068
1094
|
else if (name.includes("phone")) { fieldMap[col.name] = () => fake.phone(); }
|
|
1069
1095
|
else if (name.includes("address") || name.includes("city") || name.includes("country")) { fieldMap[col.name] = () => fake.address(); }
|
|
1070
1096
|
else if (name.includes("url") || name.includes("website")) { fieldMap[col.name] = () => fake.url(); }
|
|
1071
1097
|
else if (type.includes("int")) { fieldMap[col.name] = () => fake.integer(1, 1000); }
|
|
1072
|
-
else if (type.includes("real") || type.includes("float") || type.includes("double") || type.includes("numeric") || type.includes("decimal")) { fieldMap[col.name] = () => fake.
|
|
1098
|
+
else if (type.includes("real") || type.includes("float") || type.includes("double") || type.includes("numeric") || type.includes("decimal")) { fieldMap[col.name] = () => fake.numeric(0, 1000, 2); }
|
|
1073
1099
|
else if (type.includes("bool")) { fieldMap[col.name] = () => fake.boolean(); }
|
|
1074
1100
|
else if (type.includes("date") || type.includes("time")) { fieldMap[col.name] = () => fake.date(); }
|
|
1075
1101
|
else { fieldMap[col.name] = () => fake.sentence(3); }
|
|
1076
1102
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
try {
|
|
1084
|
-
db.insert(table, row);
|
|
1085
|
-
inserted++;
|
|
1086
|
-
} catch { /* skip failed rows */ }
|
|
1087
|
-
}
|
|
1088
|
-
res.json({ inserted, table });
|
|
1089
|
-
} catch {
|
|
1090
|
-
res.json({ error: "Database not connected" });
|
|
1103
|
+
// P1 — delegate to the shared seedTable so each row is wrapped (no
|
|
1104
|
+
// unhandled failure can crash the endpoint) and we get a summary back.
|
|
1105
|
+
const summary = await seedTable(db, table, count, fieldMap, undefined, { clear, seed, strict });
|
|
1106
|
+
res.json({ seeded: summary.seeded, failed: summary.failed, errors: summary.errors, table });
|
|
1107
|
+
} catch (e) {
|
|
1108
|
+
res.json({ error: (e as Error)?.message ?? "Database not connected" });
|
|
1091
1109
|
}
|
|
1092
1110
|
};
|
|
1093
1111
|
|
|
@@ -1495,20 +1513,24 @@ const handleConnectionsTest: RouteHandler = async (req, res) => {
|
|
|
1495
1513
|
} catch { tableCount = 0; }
|
|
1496
1514
|
try {
|
|
1497
1515
|
const urlLower = url.toLowerCase();
|
|
1516
|
+
// NOTE: db.execute() is async; these calls are intentionally left
|
|
1517
|
+
// un-awaited to preserve the exact existing runtime behaviour during
|
|
1518
|
+
// this type-only cleanup. `row` is therefore a Promise and the `as any`
|
|
1519
|
+
// access below evaluates to the fallback string. See report open question.
|
|
1498
1520
|
if (urlLower.includes("sqlite")) {
|
|
1499
|
-
const row = db.execute("SELECT sqlite_version() as v")
|
|
1521
|
+
const row = db.execute("SELECT sqlite_version() as v");
|
|
1500
1522
|
version = `SQLite ${(row as any)?.[0]?.v ?? ""}`;
|
|
1501
1523
|
} else if (urlLower.includes("postgres")) {
|
|
1502
|
-
const row = db.execute("SELECT version() as v")
|
|
1524
|
+
const row = db.execute("SELECT version() as v");
|
|
1503
1525
|
version = ((row as any)?.[0]?.v ?? "PostgreSQL").toString().split(",")[0];
|
|
1504
1526
|
} else if (urlLower.includes("mysql")) {
|
|
1505
|
-
const row = db.execute("SELECT version() as v")
|
|
1527
|
+
const row = db.execute("SELECT version() as v");
|
|
1506
1528
|
version = `MySQL ${(row as any)?.[0]?.v ?? ""}`;
|
|
1507
1529
|
} else if (urlLower.includes("mssql")) {
|
|
1508
|
-
const row = db.execute("SELECT @@VERSION as v")
|
|
1530
|
+
const row = db.execute("SELECT @@VERSION as v");
|
|
1509
1531
|
version = ((row as any)?.[0]?.v ?? "MSSQL").toString().split("\n")[0];
|
|
1510
1532
|
} else if (urlLower.includes("firebird")) {
|
|
1511
|
-
const row = db.execute("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database")
|
|
1533
|
+
const row = db.execute("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database");
|
|
1512
1534
|
version = `Firebird ${(row as any)?.[0]?.v ?? ""}`;
|
|
1513
1535
|
}
|
|
1514
1536
|
} catch { /* keep version as Connected */ }
|
|
@@ -296,6 +296,10 @@ export function createMessenger(): Messenger | DevMailbox {
|
|
|
296
296
|
const debug = process.env.TINA4_DEBUG;
|
|
297
297
|
const smtpHost = process.env.TINA4_MAIL_HOST;
|
|
298
298
|
|
|
299
|
+
// Production = NOT debug mode AND NODE_ENV is "production".
|
|
300
|
+
// Derived here (was previously referenced undefined → ReferenceError).
|
|
301
|
+
const isProd = !isTruthy(debug) && process.env.NODE_ENV === "production";
|
|
302
|
+
|
|
299
303
|
// Force dev mode when TINA4_DEBUG is truthy
|
|
300
304
|
if (isTruthy(debug)) {
|
|
301
305
|
return new DevMailbox();
|
|
@@ -74,17 +74,26 @@ function parseEnvContent(content: string): Record<string, string> {
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Load environment variables from a .env file into process.env.
|
|
77
|
-
*
|
|
77
|
+
*
|
|
78
|
+
* By default does NOT override existing process.env values — it is first-wins:
|
|
79
|
+
* a key is only set if it is not already present. This is how real env vars
|
|
80
|
+
* always win. To get the precedence real-env > `.env.local` > `.env`, load
|
|
81
|
+
* `.env.local` FIRST then `.env`, both with override=false (the default): the
|
|
82
|
+
* real env (already present) wins over both, `.env.local` fills local-only keys,
|
|
83
|
+
* and `.env` fills the rest. Do NOT load `.env.local` with override=true — that
|
|
84
|
+
* would let a stray gitignored `.env.local` clobber an explicitly set real env
|
|
85
|
+
* var (e.g. a production TINA4_SECRET).
|
|
78
86
|
*
|
|
79
87
|
* Resolution order for the env file path:
|
|
80
88
|
* 1. Explicit `path` argument
|
|
81
89
|
* 2. `TINA4_ENV_FILE` env var (if set and non-empty)
|
|
82
90
|
* 3. `.env` in the current working directory
|
|
83
91
|
*
|
|
84
|
-
* @param path
|
|
92
|
+
* @param path - Path to the .env file. Optional override.
|
|
93
|
+
* @param override - When true, overwrite keys already present in process.env.
|
|
85
94
|
* @returns The parsed key-value pairs, or an empty object if the file doesn't exist.
|
|
86
95
|
*/
|
|
87
|
-
export function loadEnv(path?: string): Record<string, string> {
|
|
96
|
+
export function loadEnv(path?: string, override = false): Record<string, string> {
|
|
88
97
|
const fromEnv = (process.env.TINA4_ENV_FILE ?? "").trim();
|
|
89
98
|
const target = path ?? (fromEnv.length > 0 ? fromEnv : ".env");
|
|
90
99
|
const envPath = resolve(target);
|
|
@@ -97,7 +106,7 @@ export function loadEnv(path?: string): Record<string, string> {
|
|
|
97
106
|
const parsed = parseEnvContent(content);
|
|
98
107
|
|
|
99
108
|
for (const [key, value] of Object.entries(parsed)) {
|
|
100
|
-
if (process.env[key] === undefined) {
|
|
109
|
+
if (override || process.env[key] === undefined) {
|
|
101
110
|
process.env[key] = value;
|
|
102
111
|
_loadedKeys.push(key);
|
|
103
112
|
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* Events.once("app.ready", () => console.log("App started!"));
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { Log } from "./logger.js";
|
|
15
|
+
|
|
14
16
|
interface ListenerEntry {
|
|
15
17
|
priority: number;
|
|
16
18
|
callback: (...args: unknown[]) => void;
|
|
@@ -19,6 +21,50 @@ interface ListenerEntry {
|
|
|
19
21
|
|
|
20
22
|
const _listeners: Map<string, ListenerEntry[]> = new Map();
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Log a listener error — NEVER silent. Mirrors Python's _log_listener_error:
|
|
26
|
+
* routes through the Tina4 Log (warning) with BOTH the event name and the
|
|
27
|
+
* error type + message. The log call is itself wrapped so a broken logger
|
|
28
|
+
* can't break the event bus — on any logger failure it falls back to
|
|
29
|
+
* console.error so the error is still surfaced.
|
|
30
|
+
*/
|
|
31
|
+
function logListenerError(event: string, error: unknown): void {
|
|
32
|
+
const err = error as { name?: string; message?: string };
|
|
33
|
+
const type = err?.name ?? (error as object)?.constructor?.name ?? "Error";
|
|
34
|
+
const message = err?.message ?? String(error);
|
|
35
|
+
try {
|
|
36
|
+
Log.warning(`Event listener for '${event}' raised ${type}: ${message}`);
|
|
37
|
+
} catch {
|
|
38
|
+
try {
|
|
39
|
+
console.error(`Event listener for '${event}' raised ${type}: ${message}`);
|
|
40
|
+
} catch {
|
|
41
|
+
/* a broken console can't break the bus either */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mirror Python's keyword-only `strict` param within JS's positional-args
|
|
48
|
+
* model. If the FIRST emit argument is a plain options object carrying a
|
|
49
|
+
* boolean `strict`, it is consumed as the option and the remaining args are
|
|
50
|
+
* the event payload; otherwise every arg is treated as payload (so the
|
|
51
|
+
* common `emit("evt", a, b)` call is unchanged). Listeners never see the
|
|
52
|
+
* options object.
|
|
53
|
+
*/
|
|
54
|
+
function parseEmitArgs(rest: unknown[]): { strict: boolean; args: unknown[] } {
|
|
55
|
+
const first = rest[0];
|
|
56
|
+
if (
|
|
57
|
+
first !== null &&
|
|
58
|
+
typeof first === "object" &&
|
|
59
|
+
!Array.isArray(first) &&
|
|
60
|
+
Object.prototype.hasOwnProperty.call(first, "strict") &&
|
|
61
|
+
typeof (first as { strict?: unknown }).strict === "boolean"
|
|
62
|
+
) {
|
|
63
|
+
return { strict: (first as { strict: boolean }).strict, args: rest.slice(1) };
|
|
64
|
+
}
|
|
65
|
+
return { strict: false, args: rest };
|
|
66
|
+
}
|
|
67
|
+
|
|
22
68
|
function getEntries(event: string): ListenerEntry[] {
|
|
23
69
|
let entries = _listeners.get(event);
|
|
24
70
|
if (!entries) {
|
|
@@ -68,8 +114,24 @@ export class Events {
|
|
|
68
114
|
|
|
69
115
|
/**
|
|
70
116
|
* Fire an event synchronously. Returns array of listener results.
|
|
117
|
+
*
|
|
118
|
+
* Listener isolation (E1): each listener call is wrapped — a listener
|
|
119
|
+
* that THROWS does NOT abort the rest of emit(). The error is LOGGED
|
|
120
|
+
* (never silent) and the failed listener contributes a `null` slot, so
|
|
121
|
+
* N listeners always yield N results in priority order; surviving
|
|
122
|
+
* listeners run regardless of an earlier throw.
|
|
123
|
+
*
|
|
124
|
+
* Pass `{ strict: true }` to RE-RAISE on the first listener error
|
|
125
|
+
* instead of isolating it (later listeners then do NOT run).
|
|
126
|
+
*
|
|
127
|
+
* once() cleanup stays correct under isolation: the one-shot listener is
|
|
128
|
+
* spliced out BEFORE its callback runs, so a throw never leaves it
|
|
129
|
+
* registered.
|
|
71
130
|
*/
|
|
72
|
-
static emit(event: string, ...args: unknown[]): unknown[]
|
|
131
|
+
static emit(event: string, ...args: unknown[]): unknown[];
|
|
132
|
+
static emit(event: string, options: { strict?: boolean }, ...args: unknown[]): unknown[];
|
|
133
|
+
static emit(event: string, ...rest: unknown[]): unknown[] {
|
|
134
|
+
const { strict, args } = parseEmitArgs(rest);
|
|
73
135
|
const entries = _listeners.get(event);
|
|
74
136
|
if (!entries) return [];
|
|
75
137
|
|
|
@@ -81,7 +143,13 @@ export class Events {
|
|
|
81
143
|
const idx = entries.indexOf(entry);
|
|
82
144
|
if (idx !== -1) entries.splice(idx, 1);
|
|
83
145
|
}
|
|
84
|
-
|
|
146
|
+
try {
|
|
147
|
+
results.push(entry.callback(...args));
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (strict) throw error;
|
|
150
|
+
logListenerError(event, error);
|
|
151
|
+
results.push(null);
|
|
152
|
+
}
|
|
85
153
|
}
|
|
86
154
|
|
|
87
155
|
return results;
|
|
@@ -90,8 +158,16 @@ export class Events {
|
|
|
90
158
|
/**
|
|
91
159
|
* Emit an event and await all async listeners.
|
|
92
160
|
* Returns array of resolved results from each listener.
|
|
161
|
+
*
|
|
162
|
+
* Listener isolation (E1): identical to emit() — each awaited listener
|
|
163
|
+
* is isolated; a rejection/throw is LOGGED and contributes a `null`
|
|
164
|
+
* slot without aborting the others. `{ strict: true }` re-raises on the
|
|
165
|
+
* first error.
|
|
93
166
|
*/
|
|
94
|
-
static async emitAsync(event: string, ...args: unknown[]): Promise<unknown[]
|
|
167
|
+
static async emitAsync(event: string, ...args: unknown[]): Promise<unknown[]>;
|
|
168
|
+
static async emitAsync(event: string, options: { strict?: boolean }, ...args: unknown[]): Promise<unknown[]>;
|
|
169
|
+
static async emitAsync(event: string, ...rest: unknown[]): Promise<unknown[]> {
|
|
170
|
+
const { strict, args } = parseEmitArgs(rest);
|
|
95
171
|
const entries = _listeners.get(event);
|
|
96
172
|
if (!entries) return [];
|
|
97
173
|
|
|
@@ -103,7 +179,13 @@ export class Events {
|
|
|
103
179
|
const idx = entries.indexOf(entry);
|
|
104
180
|
if (idx !== -1) entries.splice(idx, 1);
|
|
105
181
|
}
|
|
106
|
-
|
|
182
|
+
try {
|
|
183
|
+
results.push(await entry.callback(...args));
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (strict) throw error;
|
|
186
|
+
logListenerError(event, error);
|
|
187
|
+
results.push(null);
|
|
188
|
+
}
|
|
107
189
|
}
|
|
108
190
|
|
|
109
191
|
return results;
|