@dtdyq/restbase 2.0.0 → 2.1.1

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/README.md CHANGED
@@ -24,7 +24,7 @@ bun install -g @dtdyq/restbase
24
24
  安装后在任意目录使用 `restbase` 命令。所有配置通过 `.env` 文件读取:
25
25
 
26
26
  ```bash
27
- # 生成 .env 配置模板
27
+ # 交互式配置 .env(已存在则用当前值作为默认)
28
28
  restbase env
29
29
 
30
30
  # 前台启动(读取当前目录 .env)
@@ -36,11 +36,11 @@ restbase start
36
36
  # 查看运行中的实例(含健康状态)
37
37
  restbase status
38
38
 
39
- # 查看某个实例的实时日志
40
- restbase log <pid>
39
+ # 查看某个实例的实时日志(支持 PID 或 SVR_NAME)
40
+ restbase log <pid|name>
41
41
 
42
- # 停止
43
- restbase stop <pid> # 停止指定实例
42
+ # 停止(支持 PID、SVR_NAME 或 all)
43
+ restbase stop <pid|name> # 停止指定实例
44
44
  restbase stop all # 停止所有实例
45
45
  ```
46
46
 
@@ -60,9 +60,9 @@ bun run src/server.ts
60
60
  ### 验证
61
61
 
62
62
  ```bash
63
- # 健康检查(返回实例状态 + 资源占用)
63
+ # 健康检查(返回实例状态 + 资源占用 + cwd + logFile)
64
64
  curl http://localhost:3333/api/health
65
- # → { "code": "OK", "data": { "status": "healthy", "port": 3333, "pid": 12345, "uptime": 60, "memory": {...}, "cpu": {...} } }
65
+ # → { "code": "OK", "data": { "status": "healthy", "port": 3333, "pid": 12345, "cwd": "/path/to/dir", "uptime": 60, "memory": {...}, "cpu": {...} } }
66
66
 
67
67
  # Basic Auth 查询
68
68
  curl -u admin:admin http://localhost:3333/api/data/products
@@ -82,6 +82,10 @@ import RestBase, { gt } from "@dtdyq/restbase-client";
82
82
  const rb = new RestBase(); // 同源部署不传参
83
83
  await rb.auth.login("admin", "admin");
84
84
  const data = await rb.table("products").query().where(gt("price", 100)).data();
85
+
86
+ // 多节点负载均衡(所有节点连同一 DB)
87
+ const rb2 = new RestBase(["http://node1:3333", "http://node2:3333"]);
88
+ await rb2.auth.login("admin", "admin"); // 一次登录,token 共享
85
89
  ```
86
90
 
87
91
  详见 [client/README.md](client/README.md)。
@@ -103,6 +107,7 @@ bun run build # 编译为当前平台独立二进制
103
107
  | `SVR_PORT` | 服务端口 | `3333` |
104
108
  | `SVR_STATIC` | 静态文件目录(前端托管) | 空 |
105
109
  | `SVR_API_LIMIT` | API 限流(每秒每接口请求数) | `100` |
110
+ | `SVR_CORS_ORIGIN` | CORS 允许的来源(逗号分隔,`*`=全部) | `*` |
106
111
  | `DB_URL` | 数据库连接串 | `sqlite://:memory:` |
107
112
  | `DB_AUTH_TABLE` | 用户表名 | `users` |
108
113
  | `DB_AUTH_FIELD` | owner 字段名 | `owner` |
@@ -139,28 +144,18 @@ restbase <command> [arguments]
139
144
  Commands:
140
145
  run 前台启动服务(读取当前目录 .env)
141
146
  start 后台启动(daemon 模式)
142
- stop <pid|all> 停止后台实例
143
- status 查看运行中的实例(含健康检查)
144
- log <pid> 实时查看实例日志
145
- env 在当前目录生成 .env 配置模板
147
+ stop <pid|name|all> 停止后台实例(支持 PID、SVR_NAME 或 all)
148
+ status 查看运行中的实例(含健康检查)
149
+ log <pid|name> 实时查看实例日志(支持 PID 或 SVR_NAME)
150
+ env 交互式 .env 配置(创建或重新配置)
146
151
  version 显示版本号
147
152
  help 显示帮助
148
153
  ```
149
154
 
150
155
  所有配置通过 `.env` 文件管理,可在 `.env` 中设置 `SVR_NAME` 为实例命名。
151
156
 
152
- 实例元数据和默认日志存放在 `~/.restbase/`:
153
-
154
- ```
155
- ~/.restbase/
156
- ├── instances/ # 实例元数据(name/port/pid/logPath)
157
- │ ├── 12345.json
158
- │ └── 23456.json
159
- └── logs/ # daemon 默认日志目录
160
- ├── 3333.log
161
- ├── 3333.out
162
- └── 8080.log
163
- ```
157
+ 实例管理完全无状态:通过 `ps` 自动发现运行中的实例,通过 `/api/health` 获取实例详情。
158
+ daemon 模式默认日志存放在 `~/.restbase/logs/`。
164
159
 
165
160
  ## 文件结构
166
161
 
package/bin/restbase.ts CHANGED
@@ -1,91 +1,74 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * bin/restbase.ts — CLI entry point
3
+ * bin/restbase.ts — CLI entry point (stateless management)
4
4
  *
5
5
  * Commands:
6
6
  * restbase run Start server in foreground (reads .env)
7
7
  * restbase start Start server in background (daemon)
8
- * restbase stop <pid|all> Stop instance(s)
9
- * restbase status Show running instances (table + health check)
10
- * restbase log <pid> Tail log of a background instance
8
+ * restbase stop <pid|name|all> Stop instance(s) by PID, name or all
9
+ * restbase status Show running instances (table + health check)
10
+ * restbase log <pid|name> Tail log of a background instance
11
11
  * restbase env Generate .env template in current directory
12
12
  * restbase version Show version
13
13
  * restbase help Show help
14
14
  *
15
15
  * All configuration is read from .env in the current working directory.
16
- * Instance metadata is stored in ~/.restbase/ for global access.
16
+ *
17
+ * Instance discovery is stateless:
18
+ * - `restbase start` spawns the child with a `--port=N` marker arg
19
+ * - Management commands use `ps` to find processes matching `restbase.*--port=`
20
+ * - Detailed info (name, logFile, cwd, uptime…) is fetched via /api/health
17
21
  */
18
22
 
19
- import {spawn} from "child_process";
23
+ import {spawn, execSync} from "child_process";
20
24
  import {
25
+ closeSync,
21
26
  existsSync,
22
27
  mkdirSync,
23
- readFileSync,
24
- unlinkSync,
25
- readdirSync,
26
28
  openSync,
27
29
  writeFileSync,
28
30
  } from "fs";
29
31
  import {resolve, join} from "path";
32
+ import {readFileSync} from "fs";
30
33
  import {homedir} from "os";
34
+ import * as p from "@clack/prompts";
31
35
 
32
36
  /* ═══════════ Global paths ═══════════ */
33
37
 
34
38
  const RESTBASE_HOME = resolve(homedir(), ".restbase");
35
- const INSTANCES_DIR = join(RESTBASE_HOME, "instances");
36
39
  const LOGS_DIR = join(RESTBASE_HOME, "logs");
37
40
 
38
- function ensureDirs() {
39
- mkdirSync(INSTANCES_DIR, {recursive: true});
41
+ function ensureLogDir() {
40
42
  mkdirSync(LOGS_DIR, {recursive: true});
41
43
  }
42
44
 
43
- /* ═══════════ Instance metadata ═══════════ */
45
+ /* ═══════════ Process discovery via ps ═══════════ */
44
46
 
45
- interface InstanceMeta {
47
+ interface PsInstance {
46
48
  pid: number;
47
- name: string;
48
49
  port: number;
49
- logPath: string;
50
- cwd: string;
51
- startedAt: string;
52
- }
53
-
54
- function instanceFile(pid: number) {
55
- return join(INSTANCES_DIR, `${pid}.json`);
56
- }
57
-
58
- function saveInstance(meta: InstanceMeta) {
59
- writeFileSync(instanceFile(meta.pid), JSON.stringify(meta, null, 2));
60
50
  }
61
51
 
62
- function loadInstance(pid: number): InstanceMeta | null {
63
- const f = instanceFile(pid);
64
- if (!existsSync(f)) return null;
52
+ /**
53
+ * Scan running processes for `restbase.*--port=N` and extract PID + port.
54
+ * Works on macOS and Linux.
55
+ */
56
+ function discoverInstances(): PsInstance[] {
65
57
  try {
66
- return JSON.parse(readFileSync(f, "utf-8")) as InstanceMeta;
58
+ const out = execSync("ps -eo pid,args", {encoding: "utf-8"});
59
+ const results: PsInstance[] = [];
60
+ for (const line of out.split("\n")) {
61
+ const match = line.match(/^\s*(\d+)\s+.*restbase.*--port=(\d+)/);
62
+ if (match) {
63
+ results.push({pid: Number(match[1]), port: Number(match[2])});
64
+ }
65
+ }
66
+ return results;
67
67
  } catch {
68
- return null;
68
+ return [];
69
69
  }
70
70
  }
71
71
 
72
- function loadAllInstances(): InstanceMeta[] {
73
- ensureDirs();
74
- const files = readdirSync(INSTANCES_DIR).filter((f) => f.endsWith(".json"));
75
- const result: InstanceMeta[] = [];
76
- for (const f of files) {
77
- try {
78
- result.push(JSON.parse(readFileSync(join(INSTANCES_DIR, f), "utf-8")));
79
- } catch { /* corrupted, skip */ }
80
- }
81
- return result;
82
- }
83
-
84
- function removeInstance(pid: number) {
85
- const f = instanceFile(pid);
86
- if (existsSync(f)) unlinkSync(f);
87
- }
88
-
89
72
  /* ═══════════ Process helpers ═══════════ */
90
73
 
91
74
  function isAlive(pid: number): boolean {
@@ -102,6 +85,7 @@ interface HealthInfo {
102
85
  name?: string;
103
86
  port?: number;
104
87
  pid?: number;
88
+ cwd?: string;
105
89
  logFile?: string;
106
90
  startedAt?: string;
107
91
  uptime?: number;
@@ -141,31 +125,25 @@ async function cmdRun() {
141
125
 
142
126
  /** start — daemon mode */
143
127
  async function cmdStart() {
144
- ensureDirs();
128
+ ensureLogDir();
145
129
 
146
130
  const port = Number(process.env.SVR_PORT) || 3333;
147
- const name = process.env.SVR_NAME || "";
148
131
 
149
132
  // Reject if this port is already running
150
- for (const inst of loadAllInstances()) {
151
- if (inst.port === port && isAlive(inst.pid)) {
133
+ for (const inst of discoverInstances()) {
134
+ if (inst.port === port) {
152
135
  console.log(`RestBase already running on port ${port} (PID: ${inst.pid})`);
153
136
  return;
154
137
  }
155
138
  }
156
139
 
157
- // Determine log path
158
- const logPath = process.env.LOG_FILE
159
- ? resolve(process.cwd(), process.env.LOG_FILE)
160
- : join(LOGS_DIR, `${port}.log`);
161
-
162
- // Build child env: silence console, ensure LOG_FILE
140
+ // Build child env: silence console, ensure LOG_FILE for daemon
163
141
  const env: Record<string, string> = {
164
142
  ...(process.env as Record<string, string>),
165
143
  LOG_CONSOLE: "false",
166
144
  };
167
145
  if (!process.env.LOG_FILE) {
168
- env.LOG_FILE = logPath;
146
+ env.LOG_FILE = join(LOGS_DIR, `${port}.log`);
169
147
  }
170
148
 
171
149
  // stdout/stderr → separate crash log (safety net for non-pino output)
@@ -174,7 +152,7 @@ async function cmdStart() {
174
152
  const err = openSync(stdoutPath, "a");
175
153
 
176
154
  const self = getSelfCommand();
177
- const child = spawn(self[0]!, [...self.slice(1), "run"], {
155
+ const child = spawn(self[0]!, [...self.slice(1), "run", `--port=${port}`], {
178
156
  detached: true,
179
157
  stdio: ["ignore", out, err],
180
158
  env,
@@ -182,52 +160,65 @@ async function cmdStart() {
182
160
  });
183
161
  child.unref();
184
162
 
185
- // Save instance metadata
186
- saveInstance({
187
- pid: child.pid!,
188
- name,
189
- port,
190
- logPath,
191
- cwd: process.cwd(),
192
- startedAt: new Date().toISOString(),
193
- });
163
+ /* 父进程不再需要这些 fd,关闭防止泄漏 */
164
+ closeSync(out);
165
+ closeSync(err);
194
166
 
195
167
  console.log(`RestBase started (PID: ${child.pid}, port: ${port})`);
196
168
  }
197
169
 
198
- /** stop — by PID or "all" */
199
- function cmdStop(target: string) {
200
- if (target === "all") {
201
- const instances = loadAllInstances();
202
- if (instances.length === 0) {
203
- console.log("No RestBase instances found");
204
- return;
205
- }
206
- for (const inst of instances) stopOne(inst.pid);
207
- return;
170
+ /**
171
+ * Resolve a target identifier to matching instances.
172
+ * Supports: "all", numeric PID, or SVR_NAME (fetched from health endpoint).
173
+ */
174
+ async function resolveInstances(target: string): Promise<{ pid: number; port: number; name?: string }[]> {
175
+ const all = discoverInstances();
176
+ if (all.length === 0) return [];
177
+ if (target === "all") return all;
178
+
179
+ // Try numeric PID first
180
+ const num = parseInt(target, 10);
181
+ if (!isNaN(num)) {
182
+ const byPid = all.find((i) => i.pid === num);
183
+ if (byPid) return [byPid];
184
+ // Maybe it's a port number?
185
+ const byPort = all.find((i) => i.port === num);
186
+ if (byPort) return [byPort];
187
+ return [];
208
188
  }
209
189
 
210
- const pid = parseInt(target, 10);
211
- if (isNaN(pid)) {
212
- console.error(`Invalid PID: ${target}`);
213
- process.exit(1);
190
+ // Otherwise treat as SVR_NAME — need to fetch health for each to match
191
+ const matched: { pid: number; port: number; name?: string }[] = [];
192
+ for (const inst of all) {
193
+ const health = await fetchHealth(inst.port);
194
+ if (health?.name === target) {
195
+ matched.push({...inst, name: health.name});
196
+ }
214
197
  }
215
- stopOne(pid);
198
+ return matched;
216
199
  }
217
200
 
218
- function stopOne(pid: number) {
219
- const meta = loadInstance(pid);
220
- if (!meta) {
221
- console.log(`No instance found with PID ${pid}`);
201
+ /** stop by PID, name, or "all" */
202
+ async function cmdStop(target: string) {
203
+ const instances = await resolveInstances(target);
204
+ if (instances.length === 0) {
205
+ console.log(target === "all" ? "No RestBase instances found" : `No instance found matching "${target}"`);
222
206
  return;
223
207
  }
208
+ for (const inst of instances) stopOne(inst.pid, inst.port, inst.name);
209
+ }
210
+
211
+ function stopOne(pid: number, port?: number, name?: string) {
212
+ const label = [name, port ? `port ${port}` : ""].filter(Boolean).join(", ");
224
213
  try {
225
- if (isAlive(pid)) process.kill(pid, "SIGTERM");
226
- removeInstance(pid);
227
- console.log(`Stopped PID ${pid} (port ${meta.port})`);
214
+ if (isAlive(pid)) {
215
+ process.kill(pid, "SIGTERM");
216
+ console.log(`Stopped PID ${pid}${label ? ` (${label})` : ""}`);
217
+ } else {
218
+ console.log(`Process ${pid} is not running`);
219
+ }
228
220
  } catch {
229
- removeInstance(pid);
230
- console.log(`Process ${pid} already gone, cleaned up`);
221
+ console.log(`Failed to stop PID ${pid}`);
231
222
  }
232
223
  }
233
224
 
@@ -271,7 +262,7 @@ function fmtTime(iso: string): string {
271
262
 
272
263
  /** status — table with health check */
273
264
  async function cmdStatus() {
274
- const instances = loadAllInstances();
265
+ const instances = discoverInstances();
275
266
  if (instances.length === 0) {
276
267
  console.log("No RestBase instances found");
277
268
  return;
@@ -294,24 +285,18 @@ async function cmdStatus() {
294
285
  console.log("─".repeat(nW + pW + sW + ptW + stW + uW + mW + cW + 30));
295
286
 
296
287
  for (const inst of instances) {
297
- // Dead process → clean up silently
298
- if (!isAlive(inst.pid)) {
299
- removeInstance(inst.pid);
300
- continue;
301
- }
302
-
303
288
  // Fetch live info from health endpoint
304
289
  const health = await fetchHealth(inst.port);
305
290
 
306
- const name = health?.name || inst.name || "-";
291
+ const name = health?.name || "-";
307
292
  const state = health?.status ?? "unreachable";
308
- const started = health?.startedAt ? fmtTime(health.startedAt) : fmtTime(inst.startedAt);
293
+ const started = health?.startedAt ? fmtTime(health.startedAt) : "-";
309
294
  const uptime = health?.uptime !== undefined ? fmtUptime(health.uptime) : "-";
310
295
  const mem = health?.memory ? fmtBytes(health.memory.rss) : "-";
311
296
  const cpu = (health?.cpu && health?.uptime)
312
297
  ? fmtCpu(health.cpu.user, health.cpu.system, health.uptime)
313
298
  : "-";
314
- const logPath = health?.logFile || inst.logPath;
299
+ const logPath = health?.logFile || "-";
315
300
 
316
301
  console.log(
317
302
  name.padEnd(nW) +
@@ -327,55 +312,36 @@ async function cmdStatus() {
327
312
  }
328
313
  }
329
314
 
330
- /**
331
- * Find the latest log file matching the base path.
332
- * pino-roll creates files like: app.2026-02-11.1.log (date + sequence inserted before extension)
333
- * So for base "log/app.log", we look for "app*.log" in "log/".
334
- */
335
- function findLatestLog(basePath: string): string | null {
336
- // If exact file exists, use it
337
- if (existsSync(basePath)) return basePath;
338
-
339
- const dir = resolve(basePath, "..");
340
- if (!existsSync(dir)) return null;
341
-
342
- const base = basePath.split("/").pop()!; // "app.log"
343
- const dot = base.lastIndexOf(".");
344
- const stem = dot > 0 ? base.slice(0, dot) : base; // "app"
345
- const ext = dot > 0 ? base.slice(dot) : ""; // ".log"
346
-
347
- // Find all matching files, sort by mtime descending
348
- const files = readdirSync(dir)
349
- .filter((f) => f.startsWith(stem) && f.endsWith(ext) && f !== base)
350
- .map((f) => ({name: f, mtime: Bun.file(join(dir, f)).lastModified}))
351
- .sort((a, b) => b.mtime - a.mtime);
352
-
353
- return files.length > 0 ? join(dir, files[0]!.name) : null;
354
- }
355
-
356
- /** log — tail -f the log file of an instance */
315
+ /** log — tail -f the log file of an instance (by PID or name) */
357
316
  async function cmdLog(target: string) {
358
- const pid = parseInt(target, 10);
359
- if (isNaN(pid)) {
360
- console.error(`Invalid PID: ${target}`);
317
+ const instances = await resolveInstances(target);
318
+ if (instances.length === 0) {
319
+ console.error(`No running RestBase instance found matching "${target}"`);
320
+ process.exit(1);
321
+ }
322
+ if (instances.length > 1) {
323
+ console.error(`Multiple instances match "${target}". Use PID to be specific:`);
324
+ for (const i of instances) console.error(` PID ${i.pid} (port ${i.port})`);
361
325
  process.exit(1);
362
326
  }
363
327
 
364
- const meta = loadInstance(pid);
365
- if (!meta) {
366
- console.error(`No instance found with PID ${pid}`);
328
+ const inst = instances[0]!;
329
+
330
+ // Fetch log file path from health endpoint (points to current.log symlink)
331
+ const health = await fetchHealth(inst.port);
332
+ if (!health?.logFile) {
333
+ console.error(`Cannot determine log file for PID ${inst.pid} (health endpoint unreachable or logFile not configured)`);
367
334
  process.exit(1);
368
335
  }
369
336
 
370
- const logFile = findLatestLog(meta.logPath);
371
- if (!logFile) {
372
- console.error(`Log file not found for base path: ${meta.logPath}`);
337
+ if (!existsSync(health.logFile)) {
338
+ console.error(`Log file not found: ${health.logFile}`);
373
339
  process.exit(1);
374
340
  }
375
341
 
376
- console.log(`Tailing ${logFile} (Ctrl+C to stop)\n`);
342
+ console.log(`Tailing ${health.logFile} (Ctrl+C to stop)\n`);
377
343
 
378
- const tail = spawn("tail", ["-f", "-n", "100", logFile], {
344
+ const tail = spawn("tail", ["-f", "-n", "100", health.logFile], {
379
345
  stdio: "inherit",
380
346
  });
381
347
 
@@ -387,54 +353,202 @@ async function cmdLog(target: string) {
387
353
  await new Promise<void>((resolve) => tail.on("close", () => resolve()));
388
354
  }
389
355
 
390
- /** env generate .env template */
391
- function cmdEnv() {
392
- const envPath = resolve(process.cwd(), ".env");
393
- if (existsSync(envPath)) {
394
- console.log(`.env already exists at ${envPath}`);
395
- console.log("Remove it first if you want to regenerate.");
396
- return;
356
+ /* ═══════════ Interactive env configuration ═══════════ */
357
+
358
+ /** Parse an existing .env file into a key→value map */
359
+ function parseEnvFile(path: string): Record<string, string> {
360
+ const map: Record<string, string> = {};
361
+ if (!existsSync(path)) return map;
362
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
363
+ const trimmed = line.trim();
364
+ if (!trimmed || trimmed.startsWith("#")) continue;
365
+ const eq = trimmed.indexOf("=");
366
+ if (eq < 0) continue;
367
+ const key = trimmed.slice(0, eq).trim();
368
+ let val = trimmed.slice(eq + 1).trim();
369
+ // strip surrounding quotes
370
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
371
+ val = val.slice(1, -1);
372
+ }
373
+ map[key] = val;
397
374
  }
375
+ return map;
376
+ }
398
377
 
399
- const content = `# ═══════════════════════════════════════════════════════════
400
- # RestBase Configuration
401
- # ═══════════════════════════════════════════════════════════
402
- # Uncomment and modify the values you need.
403
- # All variables have sensible defaults — you can start with
404
- # an empty .env and only override what you need.
405
- # ═══════════════════════════════════════════════════════════
378
+ /** Interactive env prompt user for each config parameter */
379
+ async function cmdEnv() {
380
+ const nodeEnv = process.env.NODE_ENV;
381
+ const envFile = nodeEnv ? `.env.${nodeEnv}` : ".env";
382
+ const envPath = resolve(process.cwd(), envFile);
383
+ const existing = parseEnvFile(envPath);
384
+ const isUpdate = Object.keys(existing).length > 0;
385
+
386
+ p.intro(isUpdate ? `Reconfigure ${envFile} (current values as defaults)` : `Initialize ${envFile} configuration`);
387
+
388
+ /* helper: get default — existing value > built-in default */
389
+ const d = (key: string, fallback: string) => existing[key] ?? fallback;
390
+
391
+ /* helper: wrap clack text — returns string (empty if user skipped) */
392
+ const ask = async (opts: { message: string; key: string; fallback: string; placeholder?: string }) => {
393
+ const result = await p.text({
394
+ message: opts.message,
395
+ initialValue: d(opts.key, opts.fallback),
396
+ placeholder: opts.placeholder,
397
+ });
398
+ if (p.isCancel(result)) { p.cancel("Cancelled"); process.exit(0); }
399
+ return (result as string).trim();
400
+ };
406
401
 
407
- # ── Server ────────────────────────────────────────────────
402
+ /* helper: select */
403
+ const choose = async (opts: { message: string; key: string; fallback: string; options: { value: string; label: string }[] }) => {
404
+ const result = await p.select({
405
+ message: opts.message,
406
+ initialValue: d(opts.key, opts.fallback),
407
+ options: opts.options,
408
+ });
409
+ if (p.isCancel(result)) { p.cancel("Cancelled"); process.exit(0); }
410
+ return result as string;
411
+ };
408
412
 
409
- # SVR_NAME= # Instance name (shown in 'restbase status')
410
- # SVR_PORT=3333 # Server port
411
- # SVR_STATIC= # Static file directory for SPA hosting
412
- # SVR_API_LIMIT=100 # Rate limit: max requests per second per API
413
+ // ── Server ──
414
+ p.log.step("Server");
413
415
 
414
- # ── Database ──────────────────────────────────────────────
416
+ const SVR_NAME = await ask({
417
+ message: "Instance name (shown in 'restbase status')",
418
+ key: "SVR_NAME", fallback: "", placeholder: "optional",
419
+ });
420
+ const SVR_PORT = await ask({
421
+ message: "Server port",
422
+ key: "SVR_PORT", fallback: "3333",
423
+ });
424
+ const SVR_STATIC = await ask({
425
+ message: "Static file directory for SPA hosting",
426
+ key: "SVR_STATIC", fallback: "", placeholder: "optional, e.g. dist",
427
+ });
428
+ const SVR_API_LIMIT = await ask({
429
+ message: "Rate limit (max requests per second per API, 0 = off)",
430
+ key: "SVR_API_LIMIT", fallback: "100",
431
+ });
432
+ const SVR_CORS_ORIGIN = await ask({
433
+ message: "CORS allowed origins (comma-separated, * = all)",
434
+ key: "SVR_CORS_ORIGIN", fallback: "*", placeholder: "e.g. https://example.com",
435
+ });
415
436
 
416
- # DB_URL=sqlite://:memory: # sqlite://<path> or mysql://user:pass@host/db
417
- # DB_AUTH_TABLE=users # Auth table name
418
- # DB_AUTH_FIELD=owner # Owner field name for tenant isolation
419
- # DB_AUTH_FIELD_NULL_OPEN=false # Treat owner=NULL rows as public data
420
- # DB_INIT_SQL= # SQL file to run on startup
437
+ // ── Database ──
438
+ p.log.step("Database");
421
439
 
422
- # ── Auth ──────────────────────────────────────────────────
440
+ const DB_URL = await ask({
441
+ message: "Database URL (sqlite://<path> or mysql://user:pass@host/db)",
442
+ key: "DB_URL", fallback: "sqlite://:memory:",
443
+ });
444
+ const DB_AUTH_TABLE = await ask({
445
+ message: "Auth table name",
446
+ key: "DB_AUTH_TABLE", fallback: "users",
447
+ });
448
+ const DB_AUTH_FIELD = await ask({
449
+ message: "Owner field name (tenant isolation)",
450
+ key: "DB_AUTH_FIELD", fallback: "owner",
451
+ });
452
+ const DB_AUTH_FIELD_NULL_OPEN = await choose({
453
+ message: "Treat owner=NULL rows as public data?",
454
+ key: "DB_AUTH_FIELD_NULL_OPEN", fallback: "false",
455
+ options: [
456
+ {value: "false", label: "false — NULL rows are hidden"},
457
+ {value: "true", label: "true — NULL rows are visible to everyone"},
458
+ ],
459
+ });
460
+ const DB_INIT_SQL = await ask({
461
+ message: "SQL file to run on startup",
462
+ key: "DB_INIT_SQL", fallback: "", placeholder: "optional, e.g. init.sql",
463
+ });
423
464
 
424
- # AUTH_JWT_SECRET=restbase # JWT secret — CHANGE THIS IN PRODUCTION!
425
- # AUTH_JWT_EXP=43200 # JWT expiry in seconds (default: 12 hours)
426
- # AUTH_BASIC_OPEN=true # Enable Basic Auth
465
+ // ── Auth ──
466
+ p.log.step("Auth");
427
467
 
428
- # ── Logging ───────────────────────────────────────────────
468
+ const AUTH_JWT_SECRET = await ask({
469
+ message: "JWT secret (⚠ change in production!)",
470
+ key: "AUTH_JWT_SECRET", fallback: "restbase",
471
+ });
472
+ const AUTH_JWT_EXP = await ask({
473
+ message: "JWT expiry in seconds (default: 12 hours)",
474
+ key: "AUTH_JWT_EXP", fallback: "43200",
475
+ });
476
+ const AUTH_BASIC_OPEN = await choose({
477
+ message: "Enable Basic Auth?",
478
+ key: "AUTH_BASIC_OPEN", fallback: "true",
479
+ options: [
480
+ {value: "true", label: "true — Basic Auth enabled"},
481
+ {value: "false", label: "false — JWT only"},
482
+ ],
483
+ });
429
484
 
430
- # LOG_LEVEL=INFO # ERROR / INFO / DEBUG
431
- # LOG_CONSOLE=true # Console output (auto-disabled in daemon mode)
432
- # LOG_FILE= # Log file path (auto-configured in daemon mode)
433
- # LOG_RETAIN_DAYS=7 # Log file retention days
434
- `;
485
+ // ── Logging ──
486
+ p.log.step("Logging");
487
+
488
+ const LOG_LEVEL = await choose({
489
+ message: "Log level",
490
+ key: "LOG_LEVEL", fallback: "INFO",
491
+ options: [
492
+ {value: "ERROR", label: "ERROR — errors only"},
493
+ {value: "INFO", label: "INFO — requests + errors"},
494
+ {value: "DEBUG", label: "DEBUG — verbose (headers, body, SQL)"},
495
+ ],
496
+ });
497
+ const LOG_CONSOLE = await choose({
498
+ message: "Console output? (auto-disabled in daemon mode)",
499
+ key: "LOG_CONSOLE", fallback: "true",
500
+ options: [
501
+ {value: "true", label: "true — print to console"},
502
+ {value: "false", label: "false — silent"},
503
+ ],
504
+ });
505
+ const LOG_FILE = await ask({
506
+ message: "Log file path (auto-configured in daemon mode)",
507
+ key: "LOG_FILE", fallback: "", placeholder: "optional, e.g. log/app.log",
508
+ });
509
+ const LOG_RETAIN_DAYS = await ask({
510
+ message: "Log file retention days",
511
+ key: "LOG_RETAIN_DAYS", fallback: "7",
512
+ });
435
513
 
436
- writeFileSync(envPath, content);
437
- console.log(`Created ${envPath}`);
514
+ // ── Build .env content ──
515
+ const line = (key: string, val: string, comment: string) =>
516
+ val ? `${key}=${val}${comment ? ` # ${comment}` : ""}` : `# ${key}=${comment ? ` # ${comment}` : ""}`;
517
+
518
+ const lines = [
519
+ "# ═══════════════════════════════════════════════════════════",
520
+ "# RestBase Configuration",
521
+ "# ═══════════════════════════════════════════════════════════",
522
+ "",
523
+ "# ── Server ────────────────────────────────────────────────",
524
+ line("SVR_NAME", SVR_NAME, "Instance name"),
525
+ line("SVR_PORT", SVR_PORT, "Server port"),
526
+ line("SVR_STATIC", SVR_STATIC, "Static file directory"),
527
+ line("SVR_API_LIMIT", SVR_API_LIMIT, "Rate limit per API"),
528
+ line("SVR_CORS_ORIGIN", SVR_CORS_ORIGIN, "CORS origins (* = all)"),
529
+ "",
530
+ "# ── Database ──────────────────────────────────────────────",
531
+ line("DB_URL", DB_URL, ""),
532
+ line("DB_AUTH_TABLE", DB_AUTH_TABLE, "Auth table name"),
533
+ line("DB_AUTH_FIELD", DB_AUTH_FIELD, "Owner field"),
534
+ line("DB_AUTH_FIELD_NULL_OPEN", DB_AUTH_FIELD_NULL_OPEN, ""),
535
+ line("DB_INIT_SQL", DB_INIT_SQL, "SQL file on startup"),
536
+ "",
537
+ "# ── Auth ──────────────────────────────────────────────────",
538
+ line("AUTH_JWT_SECRET", AUTH_JWT_SECRET, "⚠ change in production"),
539
+ line("AUTH_JWT_EXP", AUTH_JWT_EXP, "seconds"),
540
+ line("AUTH_BASIC_OPEN", AUTH_BASIC_OPEN, ""),
541
+ "",
542
+ "# ── Logging ───────────────────────────────────────────────",
543
+ line("LOG_LEVEL", LOG_LEVEL, "ERROR / INFO / DEBUG"),
544
+ line("LOG_CONSOLE", LOG_CONSOLE, ""),
545
+ line("LOG_FILE", LOG_FILE, "auto-configured in daemon mode"),
546
+ line("LOG_RETAIN_DAYS", LOG_RETAIN_DAYS, "days"),
547
+ "",
548
+ ];
549
+
550
+ writeFileSync(envPath, lines.join("\n"));
551
+ p.outro(`Saved to ${envPath}`);
438
552
  }
439
553
 
440
554
  /** version */
@@ -454,10 +568,10 @@ Usage:
454
568
  Commands:
455
569
  run Start server in foreground (reads .env)
456
570
  start Start server in background (daemon mode)
457
- stop <pid|all> Stop a background instance by PID, or stop all
458
- status Show all running background instances
459
- log <pid> Tail the log of a background instance
460
- env Generate a .env template in current directory
571
+ stop <pid|name|all> Stop instance(s) by PID, SVR_NAME, or all
572
+ status Show all running background instances
573
+ log <pid|name> Tail the log of an instance by PID or SVR_NAME
574
+ env Interactive .env configuration (create or reconfigure)
461
575
  version Show version
462
576
  help Show this help
463
577
 
@@ -465,16 +579,20 @@ Examples:
465
579
  restbase run Start in foreground
466
580
  restbase start Start in background (daemon)
467
581
  restbase status List running instances with health status
468
- restbase log 12345 Tail log for instance with PID 12345
469
- restbase stop 12345 Stop instance with PID 12345
582
+ restbase log 12345 Tail log by PID
583
+ restbase log my-api Tail log by SVR_NAME
584
+ restbase stop 12345 Stop instance by PID
585
+ restbase stop my-api Stop instance by SVR_NAME
470
586
  restbase stop all Stop all background instances
471
- restbase env Create a documented .env template
587
+ restbase env Interactive .env setup (reconfigure if exists)
472
588
 
473
589
  Configuration:
474
590
  All settings are read from .env in the current working directory.
475
- Run 'restbase env' to generate a documented template with all options.
591
+ Run 'restbase env' for interactive configuration (create or update .env).
476
592
 
477
- Instance data is stored in ~/.restbase/ (PID files, logs, metadata).
593
+ Instance discovery:
594
+ Background instances are discovered via 'ps' — no PID files needed.
595
+ Instance details (name, log path, uptime…) are fetched from /api/health.
478
596
  `);
479
597
  }
480
598
 
@@ -495,10 +613,10 @@ switch (command) {
495
613
 
496
614
  case "stop":
497
615
  if (!arg1) {
498
- console.error("Usage: restbase stop <pid|all>");
616
+ console.error("Usage: restbase stop <pid|name|all>");
499
617
  process.exit(1);
500
618
  }
501
- cmdStop(arg1);
619
+ await cmdStop(arg1);
502
620
  process.exit(0);
503
621
  break;
504
622
 
@@ -509,14 +627,14 @@ switch (command) {
509
627
 
510
628
  case "log":
511
629
  if (!arg1) {
512
- console.error("Usage: restbase log <pid>");
630
+ console.error("Usage: restbase log <pid|name>");
513
631
  process.exit(1);
514
632
  }
515
633
  await cmdLog(arg1);
516
634
  break;
517
635
 
518
636
  case "env":
519
- cmdEnv();
637
+ await cmdEnv();
520
638
  process.exit(0);
521
639
  break;
522
640
 
package/client/README.md CHANGED
@@ -51,7 +51,7 @@ import RestBase, {
51
51
  ```ts
52
52
  import RestBase, { eq, gt, or, agg, sel } from "@dtdyq/restbase-client";
53
53
 
54
- // 同源部署(Vite proxy / 静态托管)— 不需要传 endpoint
54
+ // 同源部署 不需要传 endpoint
55
55
  const rb = new RestBase();
56
56
 
57
57
  // 或指定后端地址
@@ -67,6 +67,16 @@ const list = await products.query()
67
67
  .orderDesc("price")
68
68
  .page(1, 20)
69
69
  .data();
70
+
71
+ // ── 多节点负载均衡 ──
72
+ // 所有节点连同一个数据库,请求随机分发
73
+ const rb2 = new RestBase([
74
+ "http://node1:3333",
75
+ "http://node2:3333",
76
+ "http://node3:3333",
77
+ ]);
78
+ await rb2.auth.login("admin", "admin"); // 登录一次,token 共享
79
+ const list2 = await rb2.table("products").query().data(); // 随机打到某个节点
70
80
  ```
71
81
 
72
82
  ---
@@ -74,20 +84,32 @@ const list = await products.query()
74
84
  ## RestBase — 主入口
75
85
 
76
86
  ```ts
77
- const rb = new RestBase(); // 同源
78
- const rb = new RestBase("http://localhost:3333"); // 跨域
87
+ // 同源部署(Vite proxy / 静态托管)
88
+ const rb = new RestBase();
89
+
90
+ // 单个 endpoint
91
+ const rb = new RestBase("http://localhost:3333");
92
+
93
+ // 多个 endpoint — 负载均衡(每次请求随机选一个节点)
94
+ const rb = new RestBase([
95
+ "http://localhost:3333",
96
+ "http://localhost:8080",
97
+ "http://localhost:9090",
98
+ ]);
79
99
  ```
80
100
 
81
- | 方法 | 返回类型 | 说明 |
82
- |:----------------------|:------------------------------------------|:--------------------------|
83
- | `rb.auth` | `AuthClient` | 鉴权客户端 |
84
- | `rb.table<T>(name)` | `TableClient<T>` | 获取表操作客户端(可指定泛型) |
85
- | `rb.health()` | `Promise<ApiResponse>` | 健康检查 |
86
- | `rb.tables()` | `Promise<ApiResponse<TableMeta[]>>` | 获取所有表元数据(不含 users) |
87
- | `rb.tableMeta(name)` | `Promise<ApiResponse<TableMeta \| null>>` | 获取单表元数据(不存在返回 null |
88
- | `rb.syncMeta()` | `Promise<ApiResponse<TableMeta[]>>` | 运行时同步 DB 表结构 |
89
- | `rb.setHeader(k, v)` | `this` | 设置自定义请求头 |
90
- | `rb.setRequestId(id)` | `this` | 设置请求追踪 ID(`X-Request-Id`) |
101
+ > endpoint 模式要求所有服务端实例连接同一个数据库。客户端共享同一套 auth 状态(token / Basic Auth),每次请求随机分发到不同节点,分散单节点压力。
102
+
103
+ | 方法 | 返回类型 | 说明 |
104
+ |:----------------------|:------------------------------------------|:------------------------------------|
105
+ | `rb.auth` | `AuthClient` | 鉴权客户端 |
106
+ | `rb.table<T>(name)` | `TableClient<T>` | 获取表操作客户端 |
107
+ | `rb.health()` | `Promise<ApiResponse>` | 健康检查(返回 name/port/pid/uptime/mem/cpu |
108
+ | `rb.tables()` | `Promise<ApiResponse<TableMeta[]>>` | 获取所有表元数据(不含 users) |
109
+ | `rb.tableMeta(name)` | `Promise<ApiResponse<TableMeta \| null>>` | 获取单表元数据 |
110
+ | `rb.syncMeta()` | `Promise<ApiResponse<TableMeta[]>>` | 运行时同步 DB 表结构 |
111
+ | `rb.setHeader(k, v)` | `this` | 设置自定义请求头 |
112
+ | `rb.setRequestId(id)` | `this` | 设置请求追踪 ID(`X-Request-Id`) |
91
113
 
92
114
  **TableMeta 结构:**
93
115
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtdyq/restbase-client",
3
- "version": "1.0.1",
3
+ "version": "2.1.1",
4
4
  "description": "Type-safe, zero-dependency client for RestBase API — works in Browser / Node / Bun / Deno",
5
5
  "keywords": ["restbase", "rest", "api", "client", "typescript", "query-builder"],
6
6
  "license": "MIT",
@@ -451,14 +451,20 @@ export class AuthClient {
451
451
  ══════════════════════════════════════════════════════════════ */
452
452
 
453
453
  class HttpClient {
454
- private _baseUrl: string;
454
+ private _urls: string[];
455
455
  private _token: string | null = null;
456
456
  private _basicAuth: string | null = null;
457
457
  private _requestId?: string;
458
458
  private _headers: Record<string, string> = {};
459
459
 
460
- constructor(baseUrl: string) {
461
- this._baseUrl = baseUrl.replace(/\/+$/, "");
460
+ constructor(urls: string | string[]) {
461
+ const list = Array.isArray(urls) ? urls : [urls];
462
+ this._urls = list.map((u) => u.replace(/\/+$/, ""));
463
+ }
464
+
465
+ /** Pick a random base URL (load balancing) */
466
+ private _pickUrl(): string {
467
+ return this._urls[Math.floor(Math.random() * this._urls.length)]!;
462
468
  }
463
469
 
464
470
  setToken(token: string | null): void {
@@ -471,7 +477,11 @@ class HttpClient {
471
477
  }
472
478
 
473
479
  setBasicAuth(username: string, password: string): void {
474
- this._basicAuth = btoa(`${username}:${password}`);
480
+ /* Unicode-safe base64: TextEncoder 处理非 ASCII 字符 */
481
+ const bytes = new TextEncoder().encode(`${username}:${password}`);
482
+ let binary = "";
483
+ for (const b of bytes) binary += String.fromCharCode(b);
484
+ this._basicAuth = btoa(binary);
475
485
  this._token = null;
476
486
  }
477
487
 
@@ -511,7 +521,7 @@ class HttpClient {
511
521
  }
512
522
 
513
523
  private async _fetch<T>(method: string, path: string, params?: Record<string, string>, body?: unknown): Promise<ApiResponse<T>> {
514
- let url = `${this._baseUrl}${path}`;
524
+ let url = `${this._pickUrl()}${path}`;
515
525
  if (params && Object.keys(params).length > 0) {
516
526
  url += `?${new URLSearchParams(params).toString()}`;
517
527
  }
@@ -526,12 +536,35 @@ class HttpClient {
526
536
  RestBase — 主入口
527
537
  ══════════════════════════════════════════════════════════════ */
528
538
 
539
+ /**
540
+ * RestBase — 前端客户端主入口
541
+ *
542
+ * 支持三种构造方式:
543
+ *
544
+ * ```ts
545
+ * // 1. 同源部署(无参数)
546
+ * const rb = new RestBase();
547
+ *
548
+ * // 2. 单个 endpoint
549
+ * const rb = new RestBase("http://localhost:3333");
550
+ *
551
+ * // 3. 多个 endpoint(负载均衡,每次请求随机选一个)
552
+ * const rb = new RestBase([
553
+ * "http://localhost:3333",
554
+ * "http://localhost:8080",
555
+ * "http://localhost:9090",
556
+ * ]);
557
+ * ```
558
+ *
559
+ * 多 endpoint 模式下,所有服务端实例应连接同一个数据库。
560
+ * 客户端共享同一套 auth 状态,每次请求随机分发到不同节点。
561
+ */
529
562
  export class RestBase {
530
563
  readonly auth: AuthClient;
531
564
  private _http: HttpClient;
532
565
 
533
- constructor(endpoint: string = "") {
534
- this._http = new HttpClient(endpoint);
566
+ constructor(endpoint?: string | string[]) {
567
+ this._http = new HttpClient(endpoint ?? "");
535
568
  this.auth = new AuthClient(this._http);
536
569
  }
537
570
 
@@ -541,8 +574,8 @@ export class RestBase {
541
574
  }
542
575
 
543
576
  /** 健康检查 */
544
- async health(): Promise<ApiResponse<{ status: string }>> {
545
- return this._http.get<{ status: string }>("/api/health");
577
+ async health(): Promise<ApiResponse> {
578
+ return this._http.get("/api/health");
546
579
  }
547
580
 
548
581
  /** 获取所有表元数据(不含 users 表) */
@@ -560,13 +593,13 @@ export class RestBase {
560
593
  return this._http.get<TableMeta[]>("/api/meta/sync");
561
594
  }
562
595
 
563
- /** 设置自定义请求头 */
596
+ /** 设置自定义请求头(当前 endpoint) */
564
597
  setHeader(key: string, value: string): this {
565
598
  this._http.setHeader(key, value);
566
599
  return this;
567
600
  }
568
601
 
569
- /** 设置请求追踪 ID */
602
+ /** 设置请求追踪 ID(当前 endpoint) */
570
603
  setRequestId(id: string): this {
571
604
  this._http.setRequestId(id);
572
605
  return this;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtdyq/restbase",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Zero-code REST API server for SQLite / MySQL — CLI with daemon mode, health monitoring, built-in auth & tenant isolation",
5
5
  "keywords": ["rest", "api", "crud", "sqlite", "mysql", "hono", "bun", "zero-code", "cli", "daemon"],
6
6
  "license": "MIT",
@@ -18,10 +18,12 @@
18
18
  },
19
19
  "scripts": {
20
20
  "dev": "bun run --watch src/server.ts",
21
+ "start": "bun run src/server.ts",
21
22
  "test": "bun test src/rest.test.ts",
22
23
  "build": "bun build --compile --minify --sourcemap ./bin/restbase.ts --outfile restbase"
23
24
  },
24
25
  "dependencies": {
26
+ "@clack/prompts": "^1.0.0",
25
27
  "@hono/zod-validator": "^0.4.0",
26
28
  "hono": "^4.6.0",
27
29
  "pino": "^10.3.1",
package/src/auth.ts CHANGED
@@ -51,7 +51,7 @@ export const authMiddleware = async (
51
51
  const password = decoded.slice(sep + 1);
52
52
  const user = await findUser(username);
53
53
  if (user && user.password === password) {
54
- c.set("userId", user.id as number);
54
+ c.set("userId", user.id);
55
55
  c.set("username", username);
56
56
  return next();
57
57
  }
@@ -66,14 +66,20 @@ export const authMiddleware = async (
66
66
 
67
67
  /* ═══════════ 内部工具 ═══════════ */
68
68
 
69
- async function findUser(username: string) {
69
+ interface AuthUser {
70
+ id: number;
71
+ username: string;
72
+ password: string;
73
+ }
74
+
75
+ async function findUser(username: string): Promise<AuthUser | null> {
70
76
  const rows = await run(
71
77
  `SELECT id, username, password
72
78
  FROM ${q(cfg.authTable)}
73
79
  WHERE username = $1`,
74
80
  [username],
75
81
  );
76
- return rows.length > 0 ? (rows[0] as any) : null;
82
+ return rows.length > 0 ? (rows[0] as AuthUser) : null;
77
83
  }
78
84
 
79
85
  async function issueToken(uid: number, username: string): Promise<string> {
@@ -114,6 +120,7 @@ export function registerAuthRoutes(app: Hono<AppEnv>) {
114
120
  [username, password],
115
121
  );
116
122
  const user = await findUser(username);
123
+ if (!user) throw new AppError("AUTH_ERROR", "Registration failed");
117
124
  return c.json(ok(await issueToken(user.id, username)));
118
125
  },
119
126
  );
@@ -128,9 +135,7 @@ export function registerAuthRoutes(app: Hono<AppEnv>) {
128
135
  [userId],
129
136
  );
130
137
  if (rows.length === 0) throw new AppError("AUTH_ERROR", "User not found");
131
- const data = {...(rows[0] as any)};
132
- delete data.id;
133
- delete data.password;
138
+ const {id: _, password: _pw, ...data} = rows[0] as Record<string, unknown>;
134
139
  return c.json(ok(data));
135
140
  });
136
141
 
package/src/crud.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  zodHook, bodyQuerySchema, bodyDeleteSchema, bodyDataSchema,
24
24
  type BodyQuery, type BodyWhereInput,
25
25
  } from "./types.ts";
26
- import {getTable, isAuthTable, run, type TblMeta} from "./db.ts";
26
+ import {db, getTable, isAuthTable, run, type TblMeta} from "./db.ts";
27
27
  import {buildBodyDeleteSQL, buildBodyListSQL, buildDeleteSQL, buildListSQL} from "./query.ts";
28
28
 
29
29
  /* ═══════════ 注册路由 ═══════════ */
@@ -60,8 +60,7 @@ export function registerCrudRoutes(app: Hono<AppEnv>) {
60
60
  const body = c.req.valid("json")
61
61
 
62
62
  const {sql, values} = buildBodyDeleteSQL(tbl, body, userId);
63
- const ids = await collectDeletedIds(tbl, sql, values);
64
- await run(sql, values);
63
+ const ids = await transactionalDelete(tbl, sql, values);
65
64
  return c.json(ok({deleted: ids}));
66
65
  });
67
66
 
@@ -176,8 +175,7 @@ export function registerCrudRoutes(app: Hono<AppEnv>) {
176
175
  for (const [k, v] of url.searchParams.entries()) params[k] = v;
177
176
 
178
177
  const {sql, values} = buildDeleteSQL(tbl, params, userId);
179
- const ids = await collectDeletedIds(tbl, sql, values);
180
- await run(sql, values);
178
+ const ids = await transactionalDelete(tbl, sql, values);
181
179
  return c.json(ok({deleted: ids}));
182
180
  });
183
181
 
@@ -208,20 +206,31 @@ export function registerCrudRoutes(app: Hono<AppEnv>) {
208
206
  /* ═══════════ 内部工具 ═══════════ */
209
207
 
210
208
  /**
211
- * 在执行 DELETE 之前,先用相同 WHERE 条件 SELECT 出即将被删的主键列表。
209
+ * 在事务中先 SELECT 待删除的主键列表,再执行 DELETE,保证一致性。
212
210
  * 将 DELETE SQL 改写为 SELECT pk FROM ... WHERE ...
213
211
  */
214
- async function collectDeletedIds(
212
+ async function transactionalDelete(
215
213
  tbl: TblMeta, deleteSql: string, values: unknown[],
216
214
  ): Promise<unknown[]> {
217
- if (!tbl.pk) return [];
218
- /* "DELETE ... FROM ..." 替换为 "SELECT pk FROM ..."(SQL 模板可能含换行) */
215
+ if (!tbl.pk) {
216
+ await run(deleteSql, values);
217
+ return [];
218
+ }
219
219
  const selectSql = deleteSql.replace(
220
220
  /^DELETE\s+FROM/i,
221
221
  `SELECT ${q(tbl.pk)} FROM`,
222
222
  );
223
- const rows = await run(selectSql, values);
224
- return rows.map((r: any) => r[tbl.pk!]);
223
+ await db.unsafe("BEGIN");
224
+ try {
225
+ const rows = await run(selectSql, values);
226
+ const ids = rows.map((r: any) => r[tbl.pk!]);
227
+ await run(deleteSql, values);
228
+ await db.unsafe("COMMIT");
229
+ return ids;
230
+ } catch (err) {
231
+ await db.unsafe("ROLLBACK");
232
+ throw err;
233
+ }
225
234
  }
226
235
 
227
236
  /** 去掉 owner 字段 */
package/src/logger.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * - 日志保留天数(LOG_RETAIN_DAYS,默认 7)
8
8
  * - 日志等级(LOG_LEVEL)
9
9
  *
10
+ * pino-roll 启用 symlink,当前日志始终通过 current.log 软链接访问。
11
+ *
10
12
  * 导出 log 实例,全局使用 log.info / log.debug / log.error / log.fatal
11
13
  */
12
14
  import pino from "pino";
@@ -57,7 +59,7 @@ if (cfg.logConsole) {
57
59
  });
58
60
  }
59
61
 
60
- /* 文件(pino-roll 滚动写入 NDJSON */
62
+ /* 文件(pino-roll 滚动写入 NDJSON,symlink 指向当前文件) */
61
63
  if (cfg.logFile) {
62
64
  const {default: buildRollStream} = await import("pino-roll");
63
65
  const rollStream = await buildRollStream({
@@ -67,6 +69,7 @@ if (cfg.logFile) {
67
69
  dateFormat: "yyyy-MM-dd",
68
70
  limit: {count: cfg.logRetainDays},
69
71
  mkdir: true,
72
+ symlink: true,
70
73
  });
71
74
 
72
75
  streams.push({
package/src/query.ts CHANGED
@@ -238,10 +238,13 @@ function buildIn(
238
238
 
239
239
  /* BETWEEN: 12...20 */
240
240
  if (inner.includes("...")) {
241
- const [lo, hi] = inner.split("...");
241
+ const parts = inner.split("...");
242
+ const lo = parts[0];
243
+ const hi = parts[parts.length - 1];
244
+ if (!lo || !hi) throw new AppError("QUERY_ERROR", "BETWEEN requires two values: lo...hi");
242
245
  return {
243
246
  sql: `${f} ${not}BETWEEN $${ctx.n++} AND $${ctx.n++}`,
244
- values: [typed(lo!, col), typed(hi!, col)],
247
+ values: [typed(lo, col), typed(hi, col)],
245
248
  };
246
249
  }
247
250
 
package/src/server.ts CHANGED
@@ -21,6 +21,7 @@ import {Hono} from "hono";
21
21
  import {requestId} from "hono/request-id";
22
22
  import {cors} from "hono/cors";
23
23
  import {serveStatic} from "hono/bun";
24
+ import {resolve, dirname} from "path";
24
25
  import {type ApiRes, type AppEnv, AppError, cfg, ok, reqStore} from "./types.ts";
25
26
  import {log} from "./logger.ts";
26
27
  import {getTableMetaByName, getTablesMeta, initDb, syncTablesMeta} from "./db.ts";
@@ -50,7 +51,7 @@ const app = new Hono<AppEnv>();
50
51
  /* ═══════════ 3. CORS 中间件 ═══════════ */
51
52
 
52
53
  app.use("*", cors({
53
- origin: "*", // 允许所有来源(生产环境可改为具体域名)
54
+ origin: cfg.corsOrigin === "*" ? "*" : cfg.corsOrigin.split(",").map((s) => s.trim()),
54
55
  allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
55
56
  allowHeaders: ["Content-Type", "Authorization", "X-Request-Id"],
56
57
  exposeHeaders: ["X-Request-Id"],
@@ -68,8 +69,26 @@ if (cfg.apiLimit > 0) {
68
69
  /** API 路径 → { tokens: 剩余令牌, lastRefill: 上次填充时间戳 } */
69
70
  const buckets = new Map<string, { tokens: number; lastRefill: number }>();
70
71
 
72
+ /**
73
+ * 归一化路径:只保留 /api/ 后前两段,截断更深的路径参数。
74
+ * 例如 /api/data/products/123 → /api/data/products
75
+ * 通用规则,新增接口无需修改。
76
+ */
77
+ function normalizePath(path: string): string {
78
+ const segs = path.split("/"); // ["", "api", "data", "products", "123"]
79
+ return segs.length > 4 ? segs.slice(0, 4).join("/") : path;
80
+ }
81
+
82
+ /** 定期清理超过 60 秒未使用的 bucket,防止内存泄漏 */
83
+ setInterval(() => {
84
+ const cutoff = Date.now() - 60_000;
85
+ for (const [key, bucket] of buckets) {
86
+ if (bucket.lastRefill < cutoff) buckets.delete(key);
87
+ }
88
+ }, 60_000).unref();
89
+
71
90
  app.use("/api/*", async (c, next) => {
72
- const key = `${c.req.method} ${c.req.path}`;
91
+ const key = `${c.req.method} ${normalizePath(c.req.path)}`;
73
92
  const now = Date.now();
74
93
  let bucket = buckets.get(key);
75
94
 
@@ -173,7 +192,8 @@ app.get("/api/health", (c) => {
173
192
  name: cfg.name || undefined,
174
193
  port: cfg.port,
175
194
  pid: process.pid,
176
- logFile: cfg.logFile || undefined,
195
+ cwd: process.cwd(),
196
+ logFile: cfg.logFile ? resolve(dirname(cfg.logFile), "current.log") : undefined,
177
197
  startedAt,
178
198
  uptime: uptimeSec,
179
199
  memory: {
package/src/types.ts CHANGED
@@ -18,7 +18,9 @@ export const cfg = {
18
18
  /** 静态文件目录(相对路径,为空则不托管) */
19
19
  staticDir: Bun.env.SVR_STATIC ?? "",
20
20
  /** API 限流:每秒每个 API 接口允许的最大请求数(0 = 不限流) */
21
- apiLimit: Number(Bun.env.SVR_API_LIMIT) || 100,
21
+ apiLimit: Bun.env.SVR_API_LIMIT !== undefined ? Number(Bun.env.SVR_API_LIMIT) : 100,
22
+ /** CORS 允许的来源(默认 *,生产环境建议改为具体域名,逗号分隔多个) */
23
+ corsOrigin: Bun.env.SVR_CORS_ORIGIN ?? "*",
22
24
  /** 数据库连接字符串 */
23
25
  db: _db,
24
26
  /** 是否 SQLite(非 mysql:// 则视为 SQLite) */