tina4-nodejs 3.2.1 → 3.4.0
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 +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +13 -1
- package/packages/cli/src/commands/migrate.ts +19 -5
- package/packages/cli/src/commands/migrateCreate.ts +29 -28
- package/packages/cli/src/commands/migrateRollback.ts +59 -0
- package/packages/cli/src/commands/migrateStatus.ts +62 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +1 -1
- package/packages/core/src/auth.ts +44 -10
- package/packages/core/src/devAdmin.ts +14 -16
- package/packages/core/src/index.ts +9 -2
- package/packages/core/src/queue.ts +127 -25
- package/packages/core/src/queueBackends/mongoBackend.ts +223 -0
- package/packages/core/src/request.ts +3 -3
- package/packages/core/src/router.ts +90 -51
- package/packages/core/src/server.ts +47 -3
- package/packages/core/src/session.ts +17 -1
- package/packages/core/src/sessionHandlers/databaseHandler.ts +134 -0
- package/packages/core/src/sessionHandlers/redisHandler.ts +230 -0
- package/packages/core/src/types.ts +12 -6
- package/packages/core/src/websocket.ts +11 -2
- package/packages/core/src/websocketConnection.ts +4 -2
- package/packages/frond/src/engine.ts +66 -1
- package/packages/orm/src/autoCrud.ts +17 -12
- package/packages/orm/src/baseModel.ts +99 -21
- package/packages/orm/src/database.ts +197 -69
- package/packages/orm/src/databaseResult.ts +207 -0
- package/packages/orm/src/index.ts +6 -3
- package/packages/orm/src/migration.ts +296 -71
- package/packages/orm/src/model.ts +1 -0
- package/packages/orm/src/types.ts +1 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Redis Session Handler — Redis via `redis` npm package (optional dependency).
|
|
3
|
+
*
|
|
4
|
+
* Provides a session handler backed by the official `redis` npm package,
|
|
5
|
+
* complementing the built-in raw-TCP RedisSessionHandler in session.ts.
|
|
6
|
+
*
|
|
7
|
+
* This handler uses synchronous child-process execution (same pattern as
|
|
8
|
+
* valkeyHandler.ts) so it fits the synchronous SessionHandler interface
|
|
9
|
+
* without requiring async refactoring.
|
|
10
|
+
*
|
|
11
|
+
* Configure via environment variables:
|
|
12
|
+
* TINA4_SESSION_REDIS_HOST (default: "127.0.0.1")
|
|
13
|
+
* TINA4_SESSION_REDIS_PORT (default: 6379)
|
|
14
|
+
* TINA4_SESSION_REDIS_URL (optional — full redis:// URL, overrides host/port)
|
|
15
|
+
* TINA4_SESSION_REDIS_PASSWORD (optional)
|
|
16
|
+
* TINA4_SESSION_REDIS_PREFIX (default: "tina4:session:")
|
|
17
|
+
* TINA4_SESSION_REDIS_DB (default: 0)
|
|
18
|
+
*/
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import type { SessionHandler } from "../session.js";
|
|
21
|
+
|
|
22
|
+
interface SessionData {
|
|
23
|
+
_created: number;
|
|
24
|
+
_accessed: number;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RedisNpmSessionConfig {
|
|
29
|
+
host?: string;
|
|
30
|
+
port?: number;
|
|
31
|
+
url?: string;
|
|
32
|
+
password?: string;
|
|
33
|
+
prefix?: string;
|
|
34
|
+
db?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Redis session handler using the `redis` npm package.
|
|
39
|
+
*
|
|
40
|
+
* Falls back to raw TCP (RESP protocol) if the `redis` package is not
|
|
41
|
+
* installed, matching the approach used by the Valkey handler.
|
|
42
|
+
*
|
|
43
|
+
* Stores session data as JSON strings with Redis TTL for automatic expiry.
|
|
44
|
+
*/
|
|
45
|
+
export class RedisNpmSessionHandler implements SessionHandler {
|
|
46
|
+
private host: string;
|
|
47
|
+
private port: number;
|
|
48
|
+
private url: string;
|
|
49
|
+
private password: string;
|
|
50
|
+
private prefix: string;
|
|
51
|
+
private db: number;
|
|
52
|
+
|
|
53
|
+
constructor(config?: RedisNpmSessionConfig) {
|
|
54
|
+
this.url = config?.url
|
|
55
|
+
?? process.env.TINA4_SESSION_REDIS_URL
|
|
56
|
+
?? "";
|
|
57
|
+
this.host = config?.host
|
|
58
|
+
?? process.env.TINA4_SESSION_REDIS_HOST
|
|
59
|
+
?? "127.0.0.1";
|
|
60
|
+
this.port = config?.port
|
|
61
|
+
?? (process.env.TINA4_SESSION_REDIS_PORT
|
|
62
|
+
? parseInt(process.env.TINA4_SESSION_REDIS_PORT, 10)
|
|
63
|
+
: 6379);
|
|
64
|
+
this.password = config?.password
|
|
65
|
+
?? process.env.TINA4_SESSION_REDIS_PASSWORD
|
|
66
|
+
?? "";
|
|
67
|
+
this.prefix = config?.prefix
|
|
68
|
+
?? process.env.TINA4_SESSION_REDIS_PREFIX
|
|
69
|
+
?? "tina4:session:";
|
|
70
|
+
this.db = config?.db
|
|
71
|
+
?? (process.env.TINA4_SESSION_REDIS_DB
|
|
72
|
+
? parseInt(process.env.TINA4_SESSION_REDIS_DB, 10)
|
|
73
|
+
: 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Execute a Redis command synchronously via a short-lived child process.
|
|
78
|
+
*
|
|
79
|
+
* Attempts to use the `redis` npm package first. If unavailable, falls
|
|
80
|
+
* back to raw TCP (RESP protocol) — same as valkeyHandler.ts.
|
|
81
|
+
*/
|
|
82
|
+
private execSync(args: string[]): string {
|
|
83
|
+
const script = `
|
|
84
|
+
const net = require("node:net");
|
|
85
|
+
const host = ${JSON.stringify(this.url || this.host)};
|
|
86
|
+
const port = ${this.port};
|
|
87
|
+
const password = ${JSON.stringify(this.password)};
|
|
88
|
+
const db = ${this.db};
|
|
89
|
+
const useUrl = ${JSON.stringify(!!this.url)};
|
|
90
|
+
const url = ${JSON.stringify(this.url)};
|
|
91
|
+
const args = ${JSON.stringify(args)};
|
|
92
|
+
|
|
93
|
+
// Try the redis npm package first
|
|
94
|
+
let redisAvailable = false;
|
|
95
|
+
try {
|
|
96
|
+
require.resolve("redis");
|
|
97
|
+
redisAvailable = true;
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
if (redisAvailable) {
|
|
101
|
+
const redis = require("redis");
|
|
102
|
+
(async () => {
|
|
103
|
+
try {
|
|
104
|
+
const clientOpts = useUrl
|
|
105
|
+
? { url }
|
|
106
|
+
: { socket: { host, port }, password: password || undefined, database: db };
|
|
107
|
+
const client = redis.createClient(clientOpts);
|
|
108
|
+
client.on("error", () => {});
|
|
109
|
+
await client.connect();
|
|
110
|
+
|
|
111
|
+
const cmd = args[0].toUpperCase();
|
|
112
|
+
let result;
|
|
113
|
+
if (cmd === "GET") {
|
|
114
|
+
result = await client.get(args[1]);
|
|
115
|
+
} else if (cmd === "SET") {
|
|
116
|
+
result = await client.set(args[1], args[2]);
|
|
117
|
+
} else if (cmd === "SETEX") {
|
|
118
|
+
result = await client.setEx(args[1], parseInt(args[2], 10), args[3]);
|
|
119
|
+
} else if (cmd === "DEL") {
|
|
120
|
+
result = await client.del(args[1]);
|
|
121
|
+
}
|
|
122
|
+
await client.quit();
|
|
123
|
+
|
|
124
|
+
if (result === null || result === undefined) {
|
|
125
|
+
process.stdout.write("__NULL__");
|
|
126
|
+
} else {
|
|
127
|
+
process.stdout.write(String(result));
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
process.stderr.write(err.message);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
} else {
|
|
135
|
+
// Fallback: raw TCP RESP protocol (no redis package needed)
|
|
136
|
+
const actualHost = useUrl ? (() => {
|
|
137
|
+
try { const u = new URL(url); return u.hostname || "127.0.0.1"; } catch { return "127.0.0.1"; }
|
|
138
|
+
})() : host;
|
|
139
|
+
const actualPort = useUrl ? (() => {
|
|
140
|
+
try { const u = new URL(url); return parseInt(u.port, 10) || 6379; } catch { return 6379; }
|
|
141
|
+
})() : port;
|
|
142
|
+
|
|
143
|
+
function buildCommand(a) {
|
|
144
|
+
let cmd = "*" + a.length + "\\r\\n";
|
|
145
|
+
for (const s of a) cmd += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
146
|
+
return cmd;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sock = net.createConnection({ host: actualHost, port: actualPort }, () => {
|
|
150
|
+
let commands = "";
|
|
151
|
+
if (password) commands += buildCommand(["AUTH", password]);
|
|
152
|
+
if (db !== 0) commands += buildCommand(["SELECT", String(db)]);
|
|
153
|
+
commands += buildCommand(args);
|
|
154
|
+
sock.write(commands);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let buffer = Buffer.alloc(0);
|
|
158
|
+
sock.on("data", (chunk) => {
|
|
159
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
160
|
+
});
|
|
161
|
+
sock.on("end", () => {
|
|
162
|
+
const lines = buffer.toString("utf-8").split("\\r\\n");
|
|
163
|
+
let responses = [];
|
|
164
|
+
let i = 0;
|
|
165
|
+
while (i < lines.length) {
|
|
166
|
+
const line = lines[i];
|
|
167
|
+
if (!line) { i++; continue; }
|
|
168
|
+
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(":")) {
|
|
169
|
+
responses.push(line);
|
|
170
|
+
i++;
|
|
171
|
+
} else if (line.startsWith("$")) {
|
|
172
|
+
const len = parseInt(line.slice(1), 10);
|
|
173
|
+
if (len === -1) { responses.push(null); i++; }
|
|
174
|
+
else { responses.push(lines[i+1] || ""); i += 2; }
|
|
175
|
+
} else { i++; }
|
|
176
|
+
}
|
|
177
|
+
const result = responses[responses.length - 1];
|
|
178
|
+
if (result === null) process.stdout.write("__NULL__");
|
|
179
|
+
else if (typeof result === "string" && result.startsWith("-")) process.stdout.write("__ERR__" + result);
|
|
180
|
+
else process.stdout.write(String(result ?? "__NULL__"));
|
|
181
|
+
});
|
|
182
|
+
sock.on("error", (err) => {
|
|
183
|
+
process.stderr.write(err.message);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
});
|
|
186
|
+
setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const result = execFileSync(process.execPath, ["-e", script], {
|
|
192
|
+
encoding: "utf-8",
|
|
193
|
+
timeout: 5000,
|
|
194
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
195
|
+
});
|
|
196
|
+
if (result === "__NULL__") return "";
|
|
197
|
+
if (result.startsWith("__ERR__")) return "";
|
|
198
|
+
return result;
|
|
199
|
+
} catch {
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private key(sessionId: string): string {
|
|
205
|
+
return `${this.prefix}${sessionId}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
read(sessionId: string): SessionData | null {
|
|
209
|
+
const raw = this.execSync(["GET", this.key(sessionId)]);
|
|
210
|
+
if (!raw) return null;
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(raw) as SessionData;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
write(sessionId: string, data: SessionData, ttl: number): void {
|
|
219
|
+
const json = JSON.stringify(data);
|
|
220
|
+
if (ttl > 0) {
|
|
221
|
+
this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
|
|
222
|
+
} else {
|
|
223
|
+
this.execSync(["SET", this.key(sessionId), json]);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
destroy(sessionId: string): void {
|
|
228
|
+
this.execSync(["DEL", this.key(sessionId)]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -3,8 +3,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
3
3
|
export interface UploadedFile {
|
|
4
4
|
fieldName: string;
|
|
5
5
|
filename: string;
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
type: string;
|
|
7
|
+
content: Buffer;
|
|
8
8
|
size: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
@@ -69,6 +69,10 @@ export interface RouteDefinition {
|
|
|
69
69
|
middlewares?: Middleware[];
|
|
70
70
|
/** Template file to render when handler returns a plain object */
|
|
71
71
|
template?: string;
|
|
72
|
+
/** Whether this route requires bearer-token authentication */
|
|
73
|
+
secure?: boolean;
|
|
74
|
+
/** Whether this route's response should be cached */
|
|
75
|
+
cached?: boolean;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
export interface RouteMeta {
|
|
@@ -103,12 +107,14 @@ export type Middleware = (
|
|
|
103
107
|
|
|
104
108
|
/**
|
|
105
109
|
* Handler for WebSocket routes.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
110
|
+
* connection — object with send/broadcast/close methods and route params.
|
|
111
|
+
* event — one of "open", "message", or "close".
|
|
112
|
+
* data — the incoming text message (only present for "message" events).
|
|
108
113
|
*/
|
|
109
114
|
export type WebSocketRouteHandler = (
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
connection: import("./websocketConnection.js").WebSocketConnection,
|
|
116
|
+
event: "open" | "message" | "close",
|
|
117
|
+
data: string,
|
|
112
118
|
) => void | Promise<void>;
|
|
113
119
|
|
|
114
120
|
export interface WebSocketRouteDefinition {
|
|
@@ -52,6 +52,8 @@ export interface WebSocketClient {
|
|
|
52
52
|
ip: string;
|
|
53
53
|
connectedAt: number;
|
|
54
54
|
closed: boolean;
|
|
55
|
+
/** The URL path this client connected on (e.g. "/chat", "/notifications"). */
|
|
56
|
+
path: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
type EventHandler = (...args: unknown[]) => void;
|
|
@@ -185,14 +187,20 @@ export class WebSocketServer {
|
|
|
185
187
|
|
|
186
188
|
/**
|
|
187
189
|
* Broadcast a message to all connected clients.
|
|
190
|
+
*
|
|
191
|
+
* When `path` is provided, only clients connected on that specific path
|
|
192
|
+
* receive the message (matching PHP's WebSocket::broadcast behaviour).
|
|
193
|
+
* When `path` is omitted/undefined, all clients receive the message
|
|
194
|
+
* (backward compatible).
|
|
188
195
|
*/
|
|
189
|
-
broadcast(message: string, excludeIds?: string[]): void {
|
|
196
|
+
broadcast(message: string, excludeIds?: string[], path?: string): void {
|
|
190
197
|
const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
|
|
191
198
|
const exclude = new Set(excludeIds ?? []);
|
|
192
199
|
|
|
193
200
|
for (const [id, client] of this.clients) {
|
|
194
201
|
if (exclude.has(id)) continue;
|
|
195
202
|
if (client.closed) continue;
|
|
203
|
+
if (path !== undefined && client.path !== path) continue;
|
|
196
204
|
try {
|
|
197
205
|
client.socket.write(frame);
|
|
198
206
|
} catch {
|
|
@@ -310,7 +318,7 @@ export class WebSocketServer {
|
|
|
310
318
|
|
|
311
319
|
socket.write(response);
|
|
312
320
|
|
|
313
|
-
// Create client
|
|
321
|
+
// Create client — track the URL path for path-scoped broadcast
|
|
314
322
|
const clientId = randomUUID().slice(0, 8);
|
|
315
323
|
const client: WebSocketClient = {
|
|
316
324
|
id: clientId,
|
|
@@ -318,6 +326,7 @@ export class WebSocketServer {
|
|
|
318
326
|
ip: (socket.remoteAddress ?? "unknown"),
|
|
319
327
|
connectedAt: Date.now(),
|
|
320
328
|
closed: false,
|
|
329
|
+
path: req.url ?? "/",
|
|
321
330
|
};
|
|
322
331
|
|
|
323
332
|
this.clients.set(clientId, client);
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
export interface WebSocketConnection {
|
|
6
6
|
/** Unique connection identifier */
|
|
7
7
|
id: string;
|
|
8
|
-
/** Send a message to this connection */
|
|
8
|
+
/** Send a message to this connection only */
|
|
9
9
|
send(message: string): void;
|
|
10
|
-
/** Broadcast a message to all
|
|
10
|
+
/** Broadcast a message to all connections on the same path (path-scoped) */
|
|
11
11
|
broadcast(message: string): void;
|
|
12
12
|
/** Close this connection */
|
|
13
13
|
close(): void;
|
|
14
14
|
/** The WebSocket route path this connection is on */
|
|
15
15
|
path: string;
|
|
16
|
+
/** Route parameters extracted from `{param}` segments in the path */
|
|
17
|
+
params: Record<string, string>;
|
|
16
18
|
}
|
|
@@ -757,8 +757,16 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
757
757
|
slug: (v) => String(v).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
|
758
758
|
md5: (v) => createHash("md5").update(String(v)).digest("hex"),
|
|
759
759
|
sha256: (v) => createHash("sha256").update(String(v)).digest("hex"),
|
|
760
|
-
base64_encode: (v) => Buffer.from(String(v)).toString("base64"),
|
|
760
|
+
base64_encode: (v) => Buffer.isBuffer(v) ? v.toString("base64") : Buffer.from(String(v)).toString("base64"),
|
|
761
761
|
base64_decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
|
|
762
|
+
data_uri: (v) => {
|
|
763
|
+
if (v && typeof v === "object" && "content" in v) {
|
|
764
|
+
const ct = (v as any).type ?? "application/octet-stream";
|
|
765
|
+
const raw = Buffer.isBuffer((v as any).content) ? (v as any).content : Buffer.from(String((v as any).content));
|
|
766
|
+
return `data:${ct};base64,${raw.toString("base64")}`;
|
|
767
|
+
}
|
|
768
|
+
return String(v);
|
|
769
|
+
},
|
|
762
770
|
url_encode: (v) => encodeURIComponent(String(v)),
|
|
763
771
|
format: (v, ...args) => {
|
|
764
772
|
let s = String(v);
|
|
@@ -1155,6 +1163,63 @@ export class Frond {
|
|
|
1155
1163
|
}
|
|
1156
1164
|
|
|
1157
1165
|
private evalVar(expr: string, context: Record<string, unknown>): unknown {
|
|
1166
|
+
// Check for top-level ternary BEFORE splitting filters so that
|
|
1167
|
+
// expressions like ``products|length != 1 ? "s" : ""`` work correctly.
|
|
1168
|
+
const ternaryIdx = findTernary(expr);
|
|
1169
|
+
if (ternaryIdx !== -1) {
|
|
1170
|
+
const condPart = expr.slice(0, ternaryIdx).trim();
|
|
1171
|
+
const rest = expr.slice(ternaryIdx + 1);
|
|
1172
|
+
const colonIdx = findColon(rest);
|
|
1173
|
+
if (colonIdx !== -1) {
|
|
1174
|
+
const truePart = rest.slice(0, colonIdx).trim();
|
|
1175
|
+
const falsePart = rest.slice(colonIdx + 1).trim();
|
|
1176
|
+
const cond = this.evalVarRaw(condPart, context);
|
|
1177
|
+
return cond ? this.evalVar(truePart, context) : this.evalVar(falsePart, context);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return this.evalVarInner(expr, context);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private evalVarRaw(expr: string, context: Record<string, unknown>): unknown {
|
|
1185
|
+
const [varName, filters] = parseFilterChain(expr);
|
|
1186
|
+
let value = evalExpr(varName, context);
|
|
1187
|
+
for (const [fname, args] of filters) {
|
|
1188
|
+
if (fname === "raw" || fname === "safe") continue;
|
|
1189
|
+
const fn = this.filters[fname];
|
|
1190
|
+
if (fn) {
|
|
1191
|
+
value = fn(value, ...args);
|
|
1192
|
+
} else {
|
|
1193
|
+
// The filter name may include a trailing comparison operator,
|
|
1194
|
+
// e.g. "length != 1". Extract the real filter name and the
|
|
1195
|
+
// comparison suffix, apply the filter, then evaluate the comparison.
|
|
1196
|
+
const m = fname.match(/^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/);
|
|
1197
|
+
if (m) {
|
|
1198
|
+
const realFilter = m[1];
|
|
1199
|
+
const op = m[2];
|
|
1200
|
+
const rightExpr = m[3].trim();
|
|
1201
|
+
const fn2 = this.filters[realFilter];
|
|
1202
|
+
if (fn2) {
|
|
1203
|
+
value = fn2(value, ...args);
|
|
1204
|
+
}
|
|
1205
|
+
const right = evalExpr(rightExpr, context);
|
|
1206
|
+
switch (op) {
|
|
1207
|
+
case "!=": value = value !== right; break;
|
|
1208
|
+
case "==": value = value === right; break;
|
|
1209
|
+
case ">=": value = (value as number) >= (right as number); break;
|
|
1210
|
+
case "<=": value = (value as number) <= (right as number); break;
|
|
1211
|
+
case ">": value = (value as number) > (right as number); break;
|
|
1212
|
+
case "<": value = (value as number) < (right as number); break;
|
|
1213
|
+
}
|
|
1214
|
+
} else {
|
|
1215
|
+
value = evalExpr(fname, context);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return value;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
private evalVarInner(expr: string, context: Record<string, unknown>): unknown {
|
|
1158
1223
|
const [varName, filters] = parseFilterChain(expr);
|
|
1159
1224
|
|
|
1160
1225
|
// Sandbox: check variable access
|
|
@@ -8,11 +8,16 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
8
8
|
const routes: RouteDefinition[] = [];
|
|
9
9
|
|
|
10
10
|
for (const { definition } of models) {
|
|
11
|
-
const { tableName, fields, softDelete, tableFilter } = definition;
|
|
11
|
+
const { tableName, fields, softDelete, tableFilter, fieldMapping } = definition;
|
|
12
12
|
const basePath = `/api/${tableName}`;
|
|
13
|
+
const mapping = fieldMapping ?? {};
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
+
// Helper to get DB column name for a JS property name
|
|
16
|
+
const getDbCol = (prop: string): string => mapping[prop] ?? prop;
|
|
17
|
+
|
|
18
|
+
// Find primary key field (JS property name) and its DB column name
|
|
15
19
|
const pkField = Object.entries(fields).find(([, def]) => def.primaryKey)?.[0] ?? "id";
|
|
20
|
+
const pkColumn = getDbCol(pkField);
|
|
16
21
|
|
|
17
22
|
// Build extra WHERE conditions for soft delete and table filter
|
|
18
23
|
const extraConditions: string[] = [];
|
|
@@ -65,7 +70,7 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
65
70
|
},
|
|
66
71
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
67
72
|
const adapter = getAdapter();
|
|
68
|
-
const conditions = [`"${
|
|
73
|
+
const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
|
|
69
74
|
const items = adapter.query(
|
|
70
75
|
`SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
|
|
71
76
|
[req.params.id],
|
|
@@ -112,7 +117,7 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
112
117
|
insertFields.push(["is_deleted", 0]);
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
const columns = insertFields.map(([k]) => `"${k}"`).join(", ");
|
|
120
|
+
const columns = insertFields.map(([k]) => `"${getDbCol(k)}"`).join(", ");
|
|
116
121
|
const placeholders = insertFields.map(() => "?").join(", ");
|
|
117
122
|
const values = insertFields.map(([, v]) => v);
|
|
118
123
|
|
|
@@ -123,7 +128,7 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
123
128
|
|
|
124
129
|
const id = result.lastInsertRowid;
|
|
125
130
|
const created = adapter.query(
|
|
126
|
-
`SELECT * FROM "${tableName}" WHERE "${
|
|
131
|
+
`SELECT * FROM "${tableName}" WHERE "${pkColumn}" = ?`,
|
|
127
132
|
[id],
|
|
128
133
|
);
|
|
129
134
|
|
|
@@ -155,7 +160,7 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
155
160
|
const adapter = getAdapter();
|
|
156
161
|
|
|
157
162
|
// Check exists (respect soft delete)
|
|
158
|
-
const conditions = [`"${
|
|
163
|
+
const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
|
|
159
164
|
const existing = adapter.query(
|
|
160
165
|
`SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
|
|
161
166
|
[req.params.id],
|
|
@@ -172,16 +177,16 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
172
177
|
return;
|
|
173
178
|
}
|
|
174
179
|
|
|
175
|
-
const setClause = updateFields.map(([k]) => `"${k}" = ?`).join(", ");
|
|
180
|
+
const setClause = updateFields.map(([k]) => `"${getDbCol(k)}" = ?`).join(", ");
|
|
176
181
|
const values = [...updateFields.map(([, v]) => v), req.params.id];
|
|
177
182
|
|
|
178
183
|
adapter.execute(
|
|
179
|
-
`UPDATE "${tableName}" SET ${setClause} WHERE "${
|
|
184
|
+
`UPDATE "${tableName}" SET ${setClause} WHERE "${pkColumn}" = ?`,
|
|
180
185
|
values,
|
|
181
186
|
);
|
|
182
187
|
|
|
183
188
|
const updated = adapter.query(
|
|
184
|
-
`SELECT * FROM "${tableName}" WHERE "${
|
|
189
|
+
`SELECT * FROM "${tableName}" WHERE "${pkColumn}" = ?`,
|
|
185
190
|
[req.params.id],
|
|
186
191
|
);
|
|
187
192
|
|
|
@@ -200,7 +205,7 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
200
205
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
201
206
|
const adapter = getAdapter();
|
|
202
207
|
|
|
203
|
-
const conditions = [`"${
|
|
208
|
+
const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
|
|
204
209
|
const existing = adapter.query(
|
|
205
210
|
`SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
|
|
206
211
|
[req.params.id],
|
|
@@ -212,13 +217,13 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
212
217
|
|
|
213
218
|
if (softDelete) {
|
|
214
219
|
adapter.execute(
|
|
215
|
-
`UPDATE "${tableName}" SET is_deleted = 1 WHERE "${
|
|
220
|
+
`UPDATE "${tableName}" SET is_deleted = 1 WHERE "${pkColumn}" = ?`,
|
|
216
221
|
[req.params.id],
|
|
217
222
|
);
|
|
218
223
|
res.json({ message: "Deleted (soft)", data: existing[0] });
|
|
219
224
|
} else {
|
|
220
225
|
adapter.execute(
|
|
221
|
-
`DELETE FROM "${tableName}" WHERE "${
|
|
226
|
+
`DELETE FROM "${tableName}" WHERE "${pkColumn}" = ?`,
|
|
222
227
|
[req.params.id],
|
|
223
228
|
);
|
|
224
229
|
res.json({ message: "Deleted", data: existing[0] });
|