@dtdyq/restbase 1.0.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/README.md +105 -0
- package/auth.ts +164 -0
- package/bin/restbase.ts +2 -0
- package/client/package.json +22 -0
- package/client/restbase-client.ts +576 -0
- package/crud.ts +329 -0
- package/db.ts +197 -0
- package/logger.ts +94 -0
- package/package.json +50 -0
- package/query.ts +580 -0
- package/server.ts +201 -0
- package/types.ts +186 -0
package/crud.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crud.ts — 通用 CRUD 路由 + Body 模式查询/删除
|
|
3
|
+
*
|
|
4
|
+
* ── 数据操作(/api/data,URL 参数模式,适合 CLI 调试) ──
|
|
5
|
+
* GET /api/data/:table/:id — 按主键查单条
|
|
6
|
+
* POST /api/data/:table — 创建(object 或 array),已存在则报错
|
|
7
|
+
* PUT /api/data/:table — 不存在创建、存在增量覆盖(upsert)
|
|
8
|
+
* DELETE /api/data/:table/:id — 按主键删除单条
|
|
9
|
+
* DELETE /api/data/:table — 按条件批量删除(WHERE 语法同 GET)
|
|
10
|
+
* GET /api/data/:table — 复杂条件列表查询(URL query 参数)
|
|
11
|
+
*
|
|
12
|
+
* ── 前端接口(POST JSON Body,适合前端调用) ──
|
|
13
|
+
* POST /api/query/:table — Body 传入 select/where/order/group/分页
|
|
14
|
+
* POST /api/delete/:table — Body 传入 where 条件
|
|
15
|
+
*
|
|
16
|
+
* 含 owner 字段的表自动按当前用户过滤
|
|
17
|
+
* auth 表不允许通过此接口操作(使用 /api/auth/* )
|
|
18
|
+
*/
|
|
19
|
+
import type {Hono} from "hono";
|
|
20
|
+
import {zValidator} from "@hono/zod-validator";
|
|
21
|
+
import {
|
|
22
|
+
type AppEnv, AppError, cfg, ok, ownerCond, paged, q,
|
|
23
|
+
zodHook, bodyQuerySchema, bodyDeleteSchema, bodyDataSchema,
|
|
24
|
+
type BodyQuery, type BodyWhereInput,
|
|
25
|
+
} from "./types.ts";
|
|
26
|
+
import {getTable, isAuthTable, run, type TblMeta} from "./db.ts";
|
|
27
|
+
import {buildBodyDeleteSQL, buildBodyListSQL, buildDeleteSQL, buildListSQL} from "./query.ts";
|
|
28
|
+
|
|
29
|
+
/* ═══════════ 注册路由 ═══════════ */
|
|
30
|
+
|
|
31
|
+
export function registerCrudRoutes(app: Hono<AppEnv>) {
|
|
32
|
+
/* ══════════════════════════════════════════════════════════
|
|
33
|
+
前端接口(POST JSON Body)
|
|
34
|
+
══════════════════════════════════════════════════════════ */
|
|
35
|
+
|
|
36
|
+
/* ── POST /api/query/:table — Body 查询 ── */
|
|
37
|
+
app.post("/api/query/:table", zValidator("json", bodyQuerySchema, zodHook as any), async (c) => {
|
|
38
|
+
const tbl = requireTable(c.req.param("table"));
|
|
39
|
+
const userId = c.get("userId");
|
|
40
|
+
const body = c.req.valid("json") as BodyQuery;
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
sql, countSql, values, countValues, pageNo, pageSize,
|
|
44
|
+
} = buildBodyListSQL(tbl, body, userId);
|
|
45
|
+
|
|
46
|
+
const rows = (await run(sql, values)) as any[];
|
|
47
|
+
const data = stripOwner(rows, tbl);
|
|
48
|
+
|
|
49
|
+
if (pageNo !== undefined && pageSize !== undefined) {
|
|
50
|
+
const [countRow] = (await run(countSql, countValues)) as any[];
|
|
51
|
+
return c.json(paged(data, pageNo, pageSize, Number(countRow?.total ?? 0)));
|
|
52
|
+
}
|
|
53
|
+
return c.json(ok(data));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/* ── POST /api/delete/:table — Body 条件删除 ── */
|
|
57
|
+
app.post("/api/delete/:table", zValidator("json", bodyDeleteSchema, zodHook as any), async (c) => {
|
|
58
|
+
const tbl = requireTable(c.req.param("table"));
|
|
59
|
+
const userId = c.get("userId");
|
|
60
|
+
const body = c.req.valid("json")
|
|
61
|
+
|
|
62
|
+
const {sql, values} = buildBodyDeleteSQL(tbl, body, userId);
|
|
63
|
+
const ids = await collectDeletedIds(tbl, sql, values);
|
|
64
|
+
await run(sql, values);
|
|
65
|
+
return c.json(ok({deleted: ids}));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/* ══════════════════════════════════════════════════════════
|
|
69
|
+
数据操作(URL 参数模式,适合 CLI 调试)
|
|
70
|
+
══════════════════════════════════════════════════════════ */
|
|
71
|
+
|
|
72
|
+
/* ── GET /api/data/:table/:id — 查单条 ── */
|
|
73
|
+
app.get("/api/data/:table/:id", async (c) => {
|
|
74
|
+
const {table: tName, id} = c.req.param();
|
|
75
|
+
const tbl = requireTable(tName);
|
|
76
|
+
if (!tbl.pk)
|
|
77
|
+
throw new AppError("TABLE_ERROR", `Table "${tName}" has no primary key`);
|
|
78
|
+
|
|
79
|
+
const pkCol = tbl.colMap.get(tbl.pk)!;
|
|
80
|
+
const params: unknown[] = [pkCol.isNumeric ? Number(id) : id];
|
|
81
|
+
let sql = `SELECT *
|
|
82
|
+
FROM ${q(tName)}
|
|
83
|
+
WHERE ${q(tbl.pk)} = $1`;
|
|
84
|
+
|
|
85
|
+
if (tbl.hasOwner) {
|
|
86
|
+
sql += ` AND ${ownerCond(2)}`;
|
|
87
|
+
params.push(c.get("userId"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rows = await run(sql, params);
|
|
91
|
+
if (rows.length === 0) return c.json(ok(null));
|
|
92
|
+
|
|
93
|
+
const row = {...(rows[0] as any)};
|
|
94
|
+
if (tbl.hasOwner) delete row[cfg.ownerField];
|
|
95
|
+
return c.json(ok(row));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/* ── POST /api/data/:table — 创建(存在则报错) ── */
|
|
99
|
+
app.post("/api/data/:table", zValidator("json", bodyDataSchema, zodHook as any), async (c) => {
|
|
100
|
+
const tbl = requireTable(c.req.param("table"));
|
|
101
|
+
const body = c.req.valid("json");
|
|
102
|
+
const items: Record<string, unknown>[] = Array.isArray(body) ? body : [body];
|
|
103
|
+
const userId = c.get("userId");
|
|
104
|
+
const created: unknown[] = [];
|
|
105
|
+
|
|
106
|
+
for (const item of items) {
|
|
107
|
+
if (tbl.pk && item[tbl.pk] !== undefined) {
|
|
108
|
+
const exist = await run(
|
|
109
|
+
`SELECT 1
|
|
110
|
+
FROM ${q(tbl.name)}
|
|
111
|
+
WHERE ${q(tbl.pk)} = $1`,
|
|
112
|
+
[item[tbl.pk]],
|
|
113
|
+
);
|
|
114
|
+
if (exist.length > 0)
|
|
115
|
+
throw new AppError(
|
|
116
|
+
"CONFLICT",
|
|
117
|
+
`Record ${tbl.pk}=${item[tbl.pk]} already exists`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
created.push(await insertRow(tbl, item, userId));
|
|
121
|
+
}
|
|
122
|
+
return c.json(ok({created}));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/* ── PUT /api/data/:table — 不存在创建,存在增量覆盖 ── */
|
|
126
|
+
app.put("/api/data/:table", zValidator("json", bodyDataSchema, zodHook as any), async (c) => {
|
|
127
|
+
const tbl = requireTable(c.req.param("table"));
|
|
128
|
+
const body = c.req.valid("json");
|
|
129
|
+
const items: Record<string, unknown>[] = Array.isArray(body) ? body : [body];
|
|
130
|
+
const userId = c.get("userId");
|
|
131
|
+
const created: unknown[] = [];
|
|
132
|
+
const updated: unknown[] = [];
|
|
133
|
+
|
|
134
|
+
for (const item of items) {
|
|
135
|
+
if (tbl.pk && item[tbl.pk] !== undefined) {
|
|
136
|
+
if (await existsRow(tbl, item[tbl.pk], userId)) {
|
|
137
|
+
await updateRow(tbl, item, userId);
|
|
138
|
+
updated.push(item[tbl.pk]);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
created.push(await insertRow(tbl, item, userId));
|
|
143
|
+
}
|
|
144
|
+
return c.json(ok({created, updated}));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/* ── DELETE /api/data/:table/:id — 按主键删除单条 ── */
|
|
148
|
+
app.delete("/api/data/:table/:id", async (c) => {
|
|
149
|
+
const {table: tName, id} = c.req.param();
|
|
150
|
+
const tbl = requireTable(tName);
|
|
151
|
+
if (!tbl.pk)
|
|
152
|
+
throw new AppError("TABLE_ERROR", `Table "${tName}" has no primary key`);
|
|
153
|
+
|
|
154
|
+
const pkCol = tbl.colMap.get(tbl.pk)!;
|
|
155
|
+
const pkVal = pkCol.isNumeric ? Number(id) : id;
|
|
156
|
+
const params: unknown[] = [pkVal];
|
|
157
|
+
let sql = `DELETE
|
|
158
|
+
FROM ${q(tName)}
|
|
159
|
+
WHERE ${q(tbl.pk)} = $1`;
|
|
160
|
+
if (tbl.hasOwner) {
|
|
161
|
+
sql += ` AND ${ownerCond(2)}`;
|
|
162
|
+
params.push(c.get("userId"));
|
|
163
|
+
}
|
|
164
|
+
const result = await run(sql, params);
|
|
165
|
+
const deleted = (result as any).count > 0 ? [pkVal] : [];
|
|
166
|
+
return c.json(ok({deleted}));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/* ── DELETE /api/data/:table — 按条件批量删除(URL query) ── */
|
|
170
|
+
app.delete("/api/data/:table", async (c) => {
|
|
171
|
+
const tbl = requireTable(c.req.param("table"));
|
|
172
|
+
const userId = c.get("userId");
|
|
173
|
+
|
|
174
|
+
const params: Record<string, string> = {};
|
|
175
|
+
const url = new URL(c.req.url);
|
|
176
|
+
for (const [k, v] of url.searchParams.entries()) params[k] = v;
|
|
177
|
+
|
|
178
|
+
const {sql, values} = buildDeleteSQL(tbl, params, userId);
|
|
179
|
+
const ids = await collectDeletedIds(tbl, sql, values);
|
|
180
|
+
await run(sql, values);
|
|
181
|
+
return c.json(ok({deleted: ids}));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/* ── GET /api/data/:table — 复杂条件列表查询(URL query) ── */
|
|
185
|
+
app.get("/api/data/:table", async (c) => {
|
|
186
|
+
const tbl = requireTable(c.req.param("table"));
|
|
187
|
+
const userId = c.get("userId");
|
|
188
|
+
|
|
189
|
+
const params: Record<string, string> = {};
|
|
190
|
+
const url = new URL(c.req.url);
|
|
191
|
+
for (const [k, v] of url.searchParams.entries()) params[k] = v;
|
|
192
|
+
|
|
193
|
+
const {
|
|
194
|
+
sql, countSql, values, countValues, pageNo, pageSize,
|
|
195
|
+
} = buildListSQL(tbl, params, userId);
|
|
196
|
+
|
|
197
|
+
const rows = (await run(sql, values)) as any[];
|
|
198
|
+
const data = stripOwner(rows, tbl);
|
|
199
|
+
|
|
200
|
+
if (pageNo !== undefined && pageSize !== undefined) {
|
|
201
|
+
const [countRow] = (await run(countSql, countValues)) as any[];
|
|
202
|
+
return c.json(paged(data, pageNo, pageSize, Number(countRow?.total ?? 0)));
|
|
203
|
+
}
|
|
204
|
+
return c.json(ok(data));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ═══════════ 内部工具 ═══════════ */
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 在执行 DELETE 之前,先用相同 WHERE 条件 SELECT 出即将被删的主键列表。
|
|
212
|
+
* 将 DELETE SQL 改写为 SELECT pk FROM ... WHERE ...
|
|
213
|
+
*/
|
|
214
|
+
async function collectDeletedIds(
|
|
215
|
+
tbl: TblMeta, deleteSql: string, values: unknown[],
|
|
216
|
+
): Promise<unknown[]> {
|
|
217
|
+
if (!tbl.pk) return [];
|
|
218
|
+
/* 把 "DELETE ... FROM ..." 替换为 "SELECT pk FROM ..."(SQL 模板可能含换行) */
|
|
219
|
+
const selectSql = deleteSql.replace(
|
|
220
|
+
/^DELETE\s+FROM/i,
|
|
221
|
+
`SELECT ${q(tbl.pk)} FROM`,
|
|
222
|
+
);
|
|
223
|
+
const rows = await run(selectSql, values);
|
|
224
|
+
return rows.map((r: any) => r[tbl.pk!]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 去掉 owner 字段 */
|
|
228
|
+
function stripOwner(rows: any[], tbl: TblMeta): any[] {
|
|
229
|
+
if (!tbl.hasOwner) return rows;
|
|
230
|
+
return rows.map((r) => {
|
|
231
|
+
const row = {...r};
|
|
232
|
+
delete row[cfg.ownerField];
|
|
233
|
+
return row;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 校验表名:禁止直接操作 auth 表、表必须存在 */
|
|
238
|
+
function requireTable(name: string): TblMeta {
|
|
239
|
+
if (isAuthTable(name))
|
|
240
|
+
throw new AppError("FORBIDDEN", "Use /api/auth/* for auth table");
|
|
241
|
+
const tbl = getTable(name);
|
|
242
|
+
if (!tbl) throw new AppError("NOT_FOUND", `Table "${name}" not found`);
|
|
243
|
+
return tbl;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 判断行是否存在(带 owner 过滤) */
|
|
247
|
+
async function existsRow(
|
|
248
|
+
tbl: TblMeta, pkVal: unknown, userId: number,
|
|
249
|
+
): Promise<boolean> {
|
|
250
|
+
const params: unknown[] = [pkVal];
|
|
251
|
+
let sql = `SELECT 1
|
|
252
|
+
FROM ${q(tbl.name)}
|
|
253
|
+
WHERE ${q(tbl.pk!)} = $1`;
|
|
254
|
+
if (tbl.hasOwner) {
|
|
255
|
+
sql += ` AND ${ownerCond(2)}`;
|
|
256
|
+
params.push(userId);
|
|
257
|
+
}
|
|
258
|
+
return (await run(sql, params)).length > 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 插入一行,返回主键值 */
|
|
262
|
+
async function insertRow(
|
|
263
|
+
tbl: TblMeta, item: Record<string, unknown>, userId: number,
|
|
264
|
+
): Promise<unknown> {
|
|
265
|
+
const cols: string[] = [];
|
|
266
|
+
const vals: unknown[] = [];
|
|
267
|
+
|
|
268
|
+
for (const [k, v] of Object.entries(item)) {
|
|
269
|
+
/* 禁止用户自行指定 owner(防止伪造身份) */
|
|
270
|
+
if (k === cfg.ownerField && tbl.hasOwner) continue;
|
|
271
|
+
if (tbl.colMap.has(k)) {
|
|
272
|
+
cols.push(k);
|
|
273
|
+
vals.push(v);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* 自动填充 owner(始终使用当前用户 ID) */
|
|
278
|
+
if (tbl.hasOwner) {
|
|
279
|
+
cols.push(cfg.ownerField);
|
|
280
|
+
vals.push(userId);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const ph = cols.map((_, i) => `$${i + 1}`).join(", ");
|
|
284
|
+
await run(
|
|
285
|
+
`INSERT INTO ${q(tbl.name)} (${cols.map(q).join(", ")})
|
|
286
|
+
VALUES (${ph})`,
|
|
287
|
+
vals,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
/* 获取插入的主键 */
|
|
291
|
+
if (tbl.pk && item[tbl.pk] !== undefined) return item[tbl.pk];
|
|
292
|
+
if (tbl.pk) {
|
|
293
|
+
const [row] = cfg.isSqlite
|
|
294
|
+
? await run(`SELECT last_insert_rowid() AS id`)
|
|
295
|
+
: await run(`SELECT LAST_INSERT_ID() AS id`);
|
|
296
|
+
return (row as any).id;
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 增量更新一行(仅更新传入的字段) */
|
|
302
|
+
async function updateRow(
|
|
303
|
+
tbl: TblMeta, item: Record<string, unknown>, userId: number,
|
|
304
|
+
) {
|
|
305
|
+
const sets: string[] = [];
|
|
306
|
+
const vals: unknown[] = [];
|
|
307
|
+
let n = 1;
|
|
308
|
+
|
|
309
|
+
for (const [k, v] of Object.entries(item)) {
|
|
310
|
+
/* 跳过主键和 owner 字段(owner 不可被用户修改) */
|
|
311
|
+
if (k === tbl.pk) continue;
|
|
312
|
+
if (k === cfg.ownerField && tbl.hasOwner) continue;
|
|
313
|
+
if (tbl.colMap.has(k)) {
|
|
314
|
+
sets.push(`${q(k)} = $${n++}`);
|
|
315
|
+
vals.push(v);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (sets.length === 0) return;
|
|
319
|
+
|
|
320
|
+
let sql = `UPDATE ${q(tbl.name)}
|
|
321
|
+
SET ${sets.join(", ")}
|
|
322
|
+
WHERE ${q(tbl.pk!)} = $${n++}`;
|
|
323
|
+
vals.push(item[tbl.pk!]);
|
|
324
|
+
if (tbl.hasOwner) {
|
|
325
|
+
sql += ` AND ${ownerCond(n++)}`;
|
|
326
|
+
vals.push(userId);
|
|
327
|
+
}
|
|
328
|
+
await run(sql, vals);
|
|
329
|
+
}
|
package/db.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db.ts — 数据库连接、表结构分析、初始化校验
|
|
3
|
+
*
|
|
4
|
+
* 启动时:
|
|
5
|
+
* 1. 确保 auth 表存在(不存在则创建最小结构)
|
|
6
|
+
* 2. 获取全部表的列名、类型、主键、是否有 owner 字段
|
|
7
|
+
* 3. 校验 auth 表必须包含 id / username / password
|
|
8
|
+
*/
|
|
9
|
+
import {cfg, q, reqStore} from "./types.ts";
|
|
10
|
+
import {log} from "./logger.ts";
|
|
11
|
+
|
|
12
|
+
/* ═══════════ 元数据类型 ═══════════ */
|
|
13
|
+
|
|
14
|
+
export interface ColMeta {
|
|
15
|
+
name: string;
|
|
16
|
+
type: string; // 原始类型(小写)
|
|
17
|
+
isNumeric: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TblMeta {
|
|
21
|
+
name: string;
|
|
22
|
+
columns: ColMeta[];
|
|
23
|
+
colMap: Map<string, ColMeta>; // name → ColMeta(快速查找)
|
|
24
|
+
pk: string | null; // 主键字段名
|
|
25
|
+
hasOwner: boolean; // 是否含 owner 字段
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* ═══════════ 全局表缓存 ═══════════ */
|
|
29
|
+
|
|
30
|
+
const tables = new Map<string, TblMeta>();
|
|
31
|
+
|
|
32
|
+
export const getTable = (name: string) => tables.get(name);
|
|
33
|
+
export const allTables = () => tables;
|
|
34
|
+
export const isAuthTable = (name: string) => name === cfg.authTable;
|
|
35
|
+
|
|
36
|
+
/* ═══════════ 数据库实例 ═══════════ */
|
|
37
|
+
|
|
38
|
+
export const db = new Bun.SQL(cfg.db);
|
|
39
|
+
|
|
40
|
+
/** 执行 SQL(DEBUG 模式自动打印完整 SQL + 参数 + requestId) */
|
|
41
|
+
export async function run(sql: string, values?: unknown[]): Promise<any[]> {
|
|
42
|
+
if (cfg.logLevel === "DEBUG") {
|
|
43
|
+
const requestId = reqStore.getStore()?.requestId;
|
|
44
|
+
log.debug({requestId, sql, params: values}, "SQL");
|
|
45
|
+
}
|
|
46
|
+
/* MySQL 的 unsafe() 不支持 $1/$2 占位符,需转换为 ?
|
|
47
|
+
跳过 SQL 字符串字面量内部的内容,避免误替换 */
|
|
48
|
+
const finalSql = cfg.isSqlite ? sql : sql.replace(
|
|
49
|
+
/'[^']*'|(\$\d+)/g,
|
|
50
|
+
(m, p1) => p1 ? "?" : m,
|
|
51
|
+
);
|
|
52
|
+
return (await db.unsafe(finalSql, values)) as any[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ═══════════ 初始化入口 ═══════════ */
|
|
56
|
+
|
|
57
|
+
export async function initDb() {
|
|
58
|
+
await ensureAuthTable();
|
|
59
|
+
/* 加载用户指定的初始化 SQL */
|
|
60
|
+
if (cfg.initSql) await loadInitSql();
|
|
61
|
+
const names = await listTables();
|
|
62
|
+
for (const n of names) tables.set(n, await introspect(n));
|
|
63
|
+
validateAuth();
|
|
64
|
+
log.info({tables: [...tables.keys()]}, `DB loaded ${tables.size} table(s)`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 序列化单张表元数据 */
|
|
68
|
+
function serializeMeta(meta: TblMeta) {
|
|
69
|
+
return {
|
|
70
|
+
name: meta.name,
|
|
71
|
+
pk: meta.pk,
|
|
72
|
+
hasOwner: meta.hasOwner,
|
|
73
|
+
columns: meta.columns.map((c) => ({
|
|
74
|
+
name: c.name, type: c.type, isNumeric: c.isNumeric,
|
|
75
|
+
})),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 获取所有非 auth 表的元数据(供 /api/meta/tables 使用) */
|
|
80
|
+
export function getTablesMeta() {
|
|
81
|
+
const result: ReturnType<typeof serializeMeta>[] = [];
|
|
82
|
+
for (const [name, meta] of tables) {
|
|
83
|
+
if (name === cfg.authTable) continue;
|
|
84
|
+
result.push(serializeMeta(meta));
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 获取指定表的元数据(供 /api/meta/tables/:name 使用) */
|
|
90
|
+
export function getTableMetaByName(name: string) {
|
|
91
|
+
if (name === cfg.authTable) return null;
|
|
92
|
+
const meta = tables.get(name);
|
|
93
|
+
return meta ? serializeMeta(meta) : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 运行时重新同步数据库表结构(供 /api/meta/sync 使用) */
|
|
97
|
+
export async function syncTablesMeta() {
|
|
98
|
+
tables.clear();
|
|
99
|
+
const names = await listTables();
|
|
100
|
+
for (const n of names) tables.set(n, await introspect(n));
|
|
101
|
+
validateAuth();
|
|
102
|
+
log.info({tables: [...tables.keys()]}, `Synced ${tables.size} table(s)`);
|
|
103
|
+
return getTablesMeta();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 读取并执行 DB_INIT_SQL 指定的 SQL 文件 */
|
|
107
|
+
async function loadInitSql() {
|
|
108
|
+
const file = Bun.file(cfg.initSql);
|
|
109
|
+
if (!(await file.exists())) {
|
|
110
|
+
log.warn({path: cfg.initSql}, "DB_INIT_SQL file not found, skipping");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const content = await file.text();
|
|
114
|
+
/* 移除单行注释后按分号分割 */
|
|
115
|
+
const cleaned = content.replace(/--.*$/gm, "");
|
|
116
|
+
const stmts = cleaned.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
117
|
+
for (const stmt of stmts) {
|
|
118
|
+
await db.unsafe(stmt);
|
|
119
|
+
}
|
|
120
|
+
log.info({path: cfg.initSql, statements: stmts.length}, "Executed init SQL");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ═══════════ 确保 auth 表存在 ═══════════ */
|
|
124
|
+
|
|
125
|
+
async function ensureAuthTable() {
|
|
126
|
+
const tbl = q(cfg.authTable);
|
|
127
|
+
if (cfg.isSqlite) {
|
|
128
|
+
await db.unsafe(`CREATE TABLE IF NOT EXISTS ${tbl} (
|
|
129
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
130
|
+
username TEXT NOT NULL UNIQUE,
|
|
131
|
+
password TEXT NOT NULL
|
|
132
|
+
)`);
|
|
133
|
+
} else {
|
|
134
|
+
await db.unsafe(`CREATE TABLE IF NOT EXISTS ${tbl} (
|
|
135
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
136
|
+
username VARCHAR(255) NOT NULL UNIQUE,
|
|
137
|
+
password VARCHAR(255) NOT NULL
|
|
138
|
+
)`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ═══════════ 获取所有表名 ═══════════ */
|
|
143
|
+
|
|
144
|
+
async function listTables(): Promise<string[]> {
|
|
145
|
+
const rows = cfg.isSqlite
|
|
146
|
+
? await db.unsafe(
|
|
147
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
|
|
148
|
+
)
|
|
149
|
+
: await db.unsafe(
|
|
150
|
+
`SELECT TABLE_NAME AS name FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE()`,
|
|
151
|
+
);
|
|
152
|
+
return (rows as any[]).map((r) => r.name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ═══════════ 分析单张表结构 ═══════════ */
|
|
156
|
+
|
|
157
|
+
async function introspect(table: string): Promise<TblMeta> {
|
|
158
|
+
const columns: ColMeta[] = [];
|
|
159
|
+
let pk: string | null = null;
|
|
160
|
+
|
|
161
|
+
if (cfg.isSqlite) {
|
|
162
|
+
for (const r of (await db.unsafe(`PRAGMA table_info(${q(table)})`)) as any[]) {
|
|
163
|
+
const type = (r.type || "text").toLowerCase();
|
|
164
|
+
columns.push({name: r.name, type, isNumeric: NUM_RE.test(type)});
|
|
165
|
+
if (r.pk === 1) pk = r.name;
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
const rows = await db.unsafe(
|
|
169
|
+
`SELECT COLUMN_NAME, DATA_TYPE, COLUMN_KEY
|
|
170
|
+
FROM information_schema.columns
|
|
171
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${table}'
|
|
172
|
+
ORDER BY ORDINAL_POSITION`,
|
|
173
|
+
);
|
|
174
|
+
for (const r of rows as any[]) {
|
|
175
|
+
const type = (r.DATA_TYPE || "varchar").toLowerCase();
|
|
176
|
+
columns.push({name: r.COLUMN_NAME, type, isNumeric: NUM_RE.test(type)});
|
|
177
|
+
if (r.COLUMN_KEY === "PRI") pk = r.COLUMN_NAME;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const colMap = new Map(columns.map((c) => [c.name, c]));
|
|
182
|
+
return {name: table, columns, colMap, pk, hasOwner: colMap.has(cfg.ownerField)};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 数值型类型正则 */
|
|
186
|
+
const NUM_RE = /int|real|float|double|decimal|numeric/;
|
|
187
|
+
|
|
188
|
+
/* ═══════════ 校验 auth 表 ═══════════ */
|
|
189
|
+
|
|
190
|
+
function validateAuth() {
|
|
191
|
+
const m = tables.get(cfg.authTable);
|
|
192
|
+
if (!m) throw new Error(`Auth table "${cfg.authTable}" not found`);
|
|
193
|
+
for (const f of ["id", "username", "password"]) {
|
|
194
|
+
if (!m.colMap.has(f))
|
|
195
|
+
throw new Error(`Auth table "${cfg.authTable}" missing required column: "${f}"`);
|
|
196
|
+
}
|
|
197
|
+
}
|
package/logger.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* logger.ts — 基于 pino 的统一日志
|
|
3
|
+
*
|
|
4
|
+
* 支持:
|
|
5
|
+
* - 控制台输出(LOG_CONSOLE,默认 true,纯文本格式)
|
|
6
|
+
* - 文件输出(LOG_FILE,NDJSON 格式,pino-roll 按天+按大小滚动)
|
|
7
|
+
* - 日志保留天数(LOG_RETAIN_DAYS,默认 7)
|
|
8
|
+
* - 日志等级(LOG_LEVEL)
|
|
9
|
+
*
|
|
10
|
+
* 导出 log 实例,全局使用 log.info / log.debug / log.error / log.fatal
|
|
11
|
+
*/
|
|
12
|
+
import pino from "pino";
|
|
13
|
+
import {cfg} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/* ═══════════ pino level 映射 ═══════════ */
|
|
16
|
+
|
|
17
|
+
const LEVEL_MAP: Record<string, string> = {
|
|
18
|
+
ERROR: "error",
|
|
19
|
+
INFO: "info",
|
|
20
|
+
DEBUG: "debug",
|
|
21
|
+
};
|
|
22
|
+
const pinoLevel = LEVEL_MAP[cfg.logLevel] ?? "info";
|
|
23
|
+
|
|
24
|
+
/* ═══════════ 控制台纯文本流 ═══════════ */
|
|
25
|
+
|
|
26
|
+
function createTextStream(): pino.DestinationStream {
|
|
27
|
+
return {
|
|
28
|
+
write(chunk: string) {
|
|
29
|
+
try {
|
|
30
|
+
const obj = JSON.parse(chunk);
|
|
31
|
+
const ts = obj.time || new Date().toISOString();
|
|
32
|
+
const level = (obj.level || "INFO").padEnd(5);
|
|
33
|
+
const msg = obj.msg || "";
|
|
34
|
+
const {level: _, time: _t, pid: _p, hostname: _h, msg: _m, ...rest} = obj;
|
|
35
|
+
const extra = Object.keys(rest).length > 0
|
|
36
|
+
? " " + Object.entries(rest).map(([k, v]) =>
|
|
37
|
+
`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`
|
|
38
|
+
).join(" ")
|
|
39
|
+
: "";
|
|
40
|
+
process.stdout.write(`${ts} ${level} ${msg}${extra}\n`);
|
|
41
|
+
} catch {
|
|
42
|
+
process.stdout.write(chunk);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
} as pino.DestinationStream;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ═══════════ 构建 multistream ═══════════ */
|
|
49
|
+
|
|
50
|
+
const streams: pino.StreamEntry[] = [];
|
|
51
|
+
|
|
52
|
+
/* 控制台(纯文本) */
|
|
53
|
+
if (cfg.logConsole) {
|
|
54
|
+
streams.push({
|
|
55
|
+
level: pinoLevel as pino.Level,
|
|
56
|
+
stream: createTextStream(),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* 文件(pino-roll 滚动写入 NDJSON) */
|
|
61
|
+
if (cfg.logFile) {
|
|
62
|
+
const {default: buildRollStream} = await import("pino-roll");
|
|
63
|
+
const rollStream = await buildRollStream({
|
|
64
|
+
file: cfg.logFile,
|
|
65
|
+
frequency: "daily",
|
|
66
|
+
size: "20m",
|
|
67
|
+
dateFormat: "yyyy-MM-dd",
|
|
68
|
+
limit: {count: cfg.logRetainDays},
|
|
69
|
+
mkdir: true,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
streams.push({
|
|
73
|
+
level: pinoLevel as pino.Level,
|
|
74
|
+
stream: rollStream,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ═══════════ 创建 logger ═══════════ */
|
|
79
|
+
|
|
80
|
+
export const log: pino.Logger =
|
|
81
|
+
streams.length > 0
|
|
82
|
+
? pino(
|
|
83
|
+
{
|
|
84
|
+
level: pinoLevel,
|
|
85
|
+
formatters: {
|
|
86
|
+
level(label) {
|
|
87
|
+
return {level: label.toUpperCase()};
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
|
91
|
+
},
|
|
92
|
+
pino.multistream(streams),
|
|
93
|
+
)
|
|
94
|
+
: pino({level: pinoLevel, enabled: false});
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dtdyq/restbase",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-code REST API server for SQLite / MySQL with built-in auth, tenant isolation, and TypeScript client",
|
|
5
|
+
"keywords": ["rest", "api", "crud", "sqlite", "mysql", "hono", "bun", "zero-code"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"restbase": "./bin/restbase.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"server.ts",
|
|
13
|
+
"types.ts",
|
|
14
|
+
"db.ts",
|
|
15
|
+
"auth.ts",
|
|
16
|
+
"crud.ts",
|
|
17
|
+
"meta.ts",
|
|
18
|
+
"query.ts",
|
|
19
|
+
"logger.ts",
|
|
20
|
+
"client/",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"bun": ">=1.2.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"dev": "bun run --watch server.ts",
|
|
28
|
+
"start": "bun run server.ts",
|
|
29
|
+
"test": "bun test rest.test.ts",
|
|
30
|
+
"build": "bun build --compile --minify --sourcemap ./server.ts --outfile restbase",
|
|
31
|
+
"build:linux": "bun build --compile --minify --target=bun-linux-x64 ./server.ts --outfile restbase-linux-x64",
|
|
32
|
+
"build:linux-arm": "bun build --compile --minify --target=bun-linux-arm64 ./server.ts --outfile restbase-linux-arm64",
|
|
33
|
+
"build:mac": "bun build --compile --minify --target=bun-darwin-x64 ./server.ts --outfile restbase-darwin-x64",
|
|
34
|
+
"build:mac-arm": "bun build --compile --minify --target=bun-darwin-arm64 ./server.ts --outfile restbase-darwin-arm64",
|
|
35
|
+
"build:windows": "bun build --compile --minify --target=bun-windows-x64 ./server.ts --outfile restbase-windows-x64.exe"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@hono/zod-validator": "^0.4.0",
|
|
39
|
+
"hono": "^4.6.0",
|
|
40
|
+
"pino": "^10.3.1",
|
|
41
|
+
"pino-roll": "^4.0.0",
|
|
42
|
+
"zod": "^3.23.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/bun": "latest"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"typescript": "^5"
|
|
49
|
+
}
|
|
50
|
+
}
|