diff --git a/.gitignore b/.gitignore index a6ba442..3a5ce9b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ __snapshots__/ .claude/ *.swp *.swo +data/ # OS generated files .DS_Store diff --git a/doc/better-sqlite3.md b/doc/better-sqlite3.md new file mode 100644 index 0000000..7bdae12 --- /dev/null +++ b/doc/better-sqlite3.md @@ -0,0 +1,476 @@ +下面是一份结合目前(约 2026 年)社区共识的 **better-sqlite3 使用指南 + 最佳实践实战版**,你可以直接拿来做项目的参考模板。 + +--- + +# better-sqlite3 使用指南(含最佳实践) + +## 一、定位与总体建议 + +### 1.1 为什么选 better-sqlite3 + +在 Node.js 中操作 SQLite,目前主流选型是: + +- 新项目首选 **better-sqlite3** +- Node.js 24+ 内置的 `node:sqlite` 适合:轻量脚本、小工具、对依赖敏感的场景 +- 旧项目上的 `node-sqlite3` 已被弃用,不建议新项目继续使用 + +**better-sqlite3 的优点:** + +- **同步 API**:代码简单可读,事务逻辑不被 Promise/回调搅乱 +- **性能优秀**:普遍被认为是 Node 生态里最快的 SQLite 绑定之一 +- **完整事务支持**:内置事务封装,写多步业务逻辑非常顺手 +- **PRAGMA / 备份等高级能力完备** + +安装: + +```bash +npm install better-sqlite3 +``` + +--- + +## 二、连接管理:一个进程一个连接 + +### 2.1 最佳实践:进程级单例 + +SQLite 本质是操作一个文件;和 MySQL/Postgres 不同,连接很轻量,不需要连接池。 + +**推荐模式:** + +- 应用启动时创建 **一个全局 Database 实例** +- 全程复用,不要在每个请求里频繁 open/close +- 在进程退出前再统一 close(很多服务甚至不显式 close) + +```js +// db/index.js +import Database from 'better-sqlite3'; + +let db; + +export function getDB() { + if (!db) { + db = new Database('app.sqlite', { + readonly: false, + fileMustExist: false, + timeout: 5000, // 等待锁超时时间 + // verbose: console.log // 调试时可打开 + }); + + // 基础 PRAGMA,见后文性能调优部分 + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + db.pragma('cache_size = -64000'); // ~64MB + db.pragma('temp_store = MEMORY'); + } + return db; +} +``` + +**反例(不要这样写):** + +```js +// ❌ 每次请求新建 & 关闭连接 +function handler(req, res) { + const db = new Database('app.sqlite'); + const row = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); + db.close(); + res.json(row); +} +``` + +问题: + +- 无谓的 open/close 开销 +- 预编译语句无法复用 +- 容易出现并发锁和资源浪费 + +### 2.2 多线程(Worker Threads)场景 + +当读操作很多、单线程成为瓶颈时: + +- 使用 **Worker Threads** +- **每个 worker 线程自己 new 一个 Database 实例** +- 不在多个线程之间共享同一个 `Database` 对象 + +示意: + +```js +// workers/readWorker.js +import { workerData, parentPort } from 'node:worker_threads'; +import Database from 'better-sqlite3'; + +const db = new Database('app.sqlite', { + readonly: true, + fileMustExist: true, + timeout: 3000 +}); + +db.pragma('cache_size = -32000'); +db.pragma('temp_store = MEMORY'); +db.pragma('query_only = ON'); // 防止误写 + +const stmt = db.prepare(workerData.sql); +const result = stmt.all(...(workerData.params || [])); +parentPort.postMessage(result); +``` + +--- + +## 三、查询与预编译语句(Prepared Statements) + +### 3.1 必须坚持使用预编译 + +**目标:同时保证性能和安全(防 SQL 注入)**。 + +```js +const db = getDB(); + +// 预编译语句在模块级缓存 +const getUserById = db.prepare('SELECT * FROM users WHERE id = ?'); +const listUsersByEmail = db.prepare('SELECT * FROM users WHERE email = ?'); + +export function findUserById(id) { + return getUserById.get(id); // 单行 +} + +export function findUsersByEmail(email) { + return listUsersByEmail.all(email); // 多行 +} +``` + +**防注入反例:** + +```js +// ❌ 直接拼字符串,存在注入风险 +const sql = `SELECT * FROM users WHERE email = '${email}'`; +db.prepare(sql).all(); + +// ✅ 使用参数绑定 +db.prepare('SELECT * FROM users WHERE email = ?').all(email); +``` + +### 3.2 get / all / run / exec 的使用场景 + +| 方法 | 用途 | 返回值典型用法 | +|--------|-----------------------|--------------------------------------| +| `get` | 查询**一条**记录 | `const row = stmt.get(id)` | +| `all` | 查询**多条**记录 | `const rows = stmt.all(status)` | +| `run` | INSERT/UPDATE/DELETE | `const info = stmt.run(...params)` | +| `exec` | 执行多条 SQL/DDL 语句 | `db.exec('CREATE TABLE ...; ...')` | + +示例: + +```js +const db = getDB(); + +const findActiveUsers = db.prepare('SELECT * FROM users WHERE active = 1'); +const deactivateUser = db.prepare('UPDATE users SET active = 0 WHERE id = ?'); + +// 多行 +const users = findActiveUsers.all(); + +// 更新 +const info = deactivateUser.run(123); +console.log(info.changes); // 受影响行数 +console.log(info.lastInsertRowid); // 如是INSERT语句则有意义 +``` + +### 3.3 善用 RETURNING 减少一次查询 + +如果 SQLite 版本支持 `RETURNING`: + +```js +const createOrder = db.prepare(` + INSERT INTO orders (user_id, amount) + VALUES (?, ?) + RETURNING id, user_id, amount, created_at +`); + +const order = createOrder.get(userId, amount); +// 这里已经拿到刚插入的订单信息,无需再额外 SELECT +``` + +--- + +## 四、事务管理:写操作的必备武器 + +任何「多步写入」逻辑(比如:下单、转账、批量导入)都应该放在事务里。 + +### 4.1 使用 better-sqlite3 的事务封装 + +```js +const db = getDB(); + +const placeOrder = db.transaction((userId, items) => { + const insertOrder = db.prepare(` + INSERT INTO orders (user_id, status) + VALUES (?, 'pending') + `); + const insertOrderItem = db.prepare(` + INSERT INTO order_items (order_id, product_id, qty, price) + VALUES (?, ?, ?, ?) + `); + const updateStock = db.prepare(` + UPDATE products + SET stock = stock - ? + WHERE id = ? + `); + + const { lastInsertRowid: orderId } = insertOrder.run(userId); + + for (const item of items) { + insertOrderItem.run(orderId, item.productId, item.qty, item.price); + updateStock.run(item.qty, item.productId); + } + + return orderId; +}); + +// 使用 +try { + const orderId = placeOrder(userId, items); + // 成功自动 COMMIT +} catch (err) { + // 抛出异常时自动 ROLLBACK + console.error('placeOrder 失败', err); +} +``` + +**注意事项:** + +- 事务函数体 **必须是同步代码**,不要在里面 `await`/Promise/`setTimeout` +- 所有需要原子性的写操作都要包进同一次 `db.transaction()` +- 对于批量插入/更新,**一个大事务远快于 N 个小事务** + +--- + +## 五、性能调优:常用 PRAGMA 配置 + +### 5.1 典型 Web 服务推荐配置 + +在应用启动时统一设置一次: + +```js +const db = getDB(); + +// 提升并发 & 写性能:建议生产使用 +db.pragma('journal_mode = WAL'); // 写前日志 + +// 写安全性 & 性能折中 +db.pragma('synchronous = NORMAL'); // 不要贸然用 OFF + +// 内存缓存(负数表示 KiB) +db.pragma('cache_size = -64000'); // ≈64MB + +// 临时对象走内存 +db.pragma('temp_store = MEMORY'); + +// 利用 mmap 加速 IO(根据机器内存调整) +db.pragma('mmap_size = 268435456'); // 256MB +``` + +**不推荐在有写入的生产环境用 `synchronous = OFF`**,否则断电/崩溃时有丢数据风险。 + +### 5.2 读密集型 Worker 优化 + +针对只读 worker: + +```js +const db = new Database('app.sqlite', { readonly: true, fileMustExist: true }); + +db.pragma('cache_size = -32000'); // ~32MB +db.pragma('temp_store = MEMORY'); +db.pragma('mmap_size = 1073741824'); // ~1GB +db.pragma('query_only = ON'); // 强制只读 +``` + +### 5.3 查询计划与优化 + +数据库结构或索引大改之后,可以执行一次: + +```js +db.exec('ANALYZE;'); +db.pragma('optimize'); +``` + +有需要时,用 `EXPLAIN QUERY PLAN` 分析慢查询(开发/排查时用)。 + +--- + +## 六、安全实践:防注入、防误删、防泄露 + +### 6.1 防 SQL 注入 + +核心原则: + +- 所有用户输入**必须**通过「参数绑定」进入 SQL +- 不拼接字符串、不直接插入变量 + +```js +// ✅ 推荐使用形式 +const stmt = db.prepare('SELECT * FROM users WHERE email = ?'); +const user = stmt.get(email); +``` + +### 6.2 参数校验 + +在进入 SQL 前尽量校验类型 / 长度 / 格式: + +```js +function assertId(id) { + if (!Number.isInteger(id) || id <= 0) { + throw new Error('非法ID'); + } +} +``` + +### 6.3 SQLite 文件存放与权限 + +- **不要**把 `.sqlite` 文件放在可被 Web 服务器直接访问的静态目录下 +- 赋予最小权限:如仅运行用户可读写 + +```bash +chmod 600 app.sqlite +chown node_user:node_group app.sqlite +``` + +--- + +## 七、备份与恢复 + +### 7.1 使用 better-sqlite3 的备份 API(在线备份) + +```js +const db = getDB(); + +// 备份到另一个文件(热备,无需停服务) +await db.backup('backup.sqlite'); +``` + +你可以把这段逻辑放进定时任务里,例如每天低峰执行一次。 + +### 7.2 冷备份(停机拷贝) + +如果可以短暂停机: + +1. 停止 Node 进程 +2. 直接复制数据库文件 + +```bash +cp app.sqlite app.sqlite.bak +``` + +在 WAL 模式下,确保 `.sqlite` 和相关的 `-wal`/`-shm` 文件也一并妥善处理(备份 API 已经帮你做了这件事,一般更推荐)。 + +--- + +## 八、迁移与工程化 + +### 8.1 简单迁移系统示例 + +建议至少维护一个简易迁移机制,例如: + +- 建一张 `migrations` 表,记录已执行的版本 +- 在应用启动时按顺序执行 `migrations/*.sql` + +伪代码: + +```js +// db/migrate.js +import { getDB } from './index.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +export function runMigrations() { + const db = getDB(); + db.exec(`CREATE TABLE IF NOT EXISTS migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + run_at TEXT NOT NULL + )`); + + const applied = new Set( + db.prepare('SELECT name FROM migrations').all().map(r => r.name) + ); + + const dir = path.join(process.cwd(), 'src/db/migrations'); + const files = fs.readdirSync(dir).sort(); // 如:001_init.sql, 002_add_user.sql + + const runMigration = db.transaction((name, sql) => { + db.exec(sql); + db.prepare('INSERT INTO migrations (name, run_at) VALUES (?, datetime(\'now\'))') + .run(name); + }); + + for (const file of files) { + if (applied.has(file)) continue; + const sql = fs.readFileSync(path.join(dir, file), 'utf8'); + runMigration(file, sql); + console.log('已执行迁移:', file); + } +} +``` + +### 8.2 典型项目结构建议 + +```txt +src/ + db/ + index.js # 初始化 & 导出 db 实例 + PRAGMA + migrate.js # 迁移逻辑 + migrations/ # *.sql 文件 + models/ + user.js # 用户相关 SQL & 事务 + order.js + workers/ + readWorker.js # 高并发只读 worker + services/ + userService.js # 业务逻辑。只调用 models,不直接写 SQL + app.js # 应用入口,启动HTTP服务 & 调用 runMigrations() +``` + +--- + +## 九、与 Node.js 内置 `node:sqlite` 的关系 + +- Node.js 24 LTS 起,内置了 `node:sqlite` 模块,方便做无依赖的小工具 +- 但在实际生产服务里: + - **性能、特性、生态** 综合考虑,better-sqlite3 依然是首选 + - 两者 API 思路类似:同步操作、小而美,更方便从一种迁移到另一种 + +可以简记为: + +> **重业务 / 生产服务:better-sqlite3** +> **简单脚本 / 工具:node:sqlite 也可以考虑** + +--- + +## 十、一页小抄(速查版) + +1. **连接管理**: + - 进程内只创建一个 `Database` 实例,全局复用 + - Worker Threads 中,一线程一实例,不共享连接 + +2. **查询写法**: + - 所有 SQL 使用 **prepared statement + 参数绑定** + - 单行用 `get`,多行用 `all`,写操作用 `run` + +3. **事务**: + - 多步写操作必须放在 `db.transaction()` 中 + - 事务函数里面不要写任何异步代码 + +4. **PRAGMA 调优**: + - `journal_mode = WAL` + - `synchronous = NORMAL` + - 合理设置 `cache_size` / `temp_store = MEMORY` / `mmap_size` + +5. **安全**: + - 禁止拼接 SQL 字符串 + - 校验输入 + - 数据库文件不要暴露在静态目录,限制文件权限 + +6. **扩展读性能**: + - 使用 Worker Threads + - 每个 worker 使用只读连接 + `query_only = ON` + +如果你后续有更具体的场景(例如 Electron 桌面应用、嵌入式设备、日志收集服务等),可以再描述一下,我可以基于这些最佳实践帮你落到更贴近场景的代码模板与配置。 \ No newline at end of file diff --git a/package.json b/package.json index 3118706..8dfa452 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@mastra/libsql": "^0.16.4", "@mastra/memory": "^0.15.13", "axios": "^1.13.2", + "better-sqlite3": "^12.6.0", "cors": "^2.8.5", "express": "^5.1.0", "helmet": "^8.1.0", @@ -34,6 +35,7 @@ "zod": "^4.1.13" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/helmet": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb1d4f5..6946cbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: axios: specifier: ^1.13.2 version: 1.13.2 + better-sqlite3: + specifier: ^12.6.0 + version: 12.6.0 cors: specifier: ^2.8.5 version: 2.8.5 @@ -48,6 +51,9 @@ importers: specifier: ^4.1.13 version: 4.1.13 devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/cors': specifier: ^2.8.17 version: 2.8.19 @@ -1628,6 +1634,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -1859,9 +1868,19 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + better-sqlite3@12.6.0: + resolution: {integrity: sha512-FXI191x+D6UPWSze5IzZjhz+i9MK9nsuHsmTX9bXVl52k06AfZ2xql0lrgIUuzsMsJ7Vgl5kIptvDgBLIV3ZSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@1.20.4: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1879,6 +1898,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + builtins@5.1.0: resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==} @@ -1909,6 +1931,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -2041,6 +2066,14 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2181,6 +2214,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} @@ -2225,6 +2262,9 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2272,6 +2312,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.2: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} engines: {node: '>=14.14'} @@ -2319,6 +2362,9 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2434,12 +2480,18 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + import-in-the-middle@1.15.0: resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ip-regex@4.3.0: resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} engines: {node: '>=8'} @@ -2657,9 +2709,16 @@ packages: engines: {node: '>=4'} hasBin: true + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -2684,6 +2743,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -2692,6 +2754,10 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-abi@3.85.0: + resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + engines: {node: '>=10'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -2883,6 +2949,11 @@ packages: resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==} engines: {node: '>=15.0.0'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + prettier@3.7.4: resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} engines: {node: '>=14'} @@ -2941,6 +3012,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -2949,6 +3024,10 @@ packages: resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -3112,6 +3191,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -3142,6 +3227,9 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3150,6 +3238,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -3176,6 +3268,13 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tcp-port-used@1.0.2: resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==} @@ -3209,6 +3308,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -3263,6 +3365,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -5155,6 +5260,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.0.0 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -5443,8 +5552,23 @@ snapshots: dependencies: safe-buffer: 5.1.2 + better-sqlite3@12.6.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bignumber.js@9.3.1: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + body-parser@1.20.4: dependencies: bytes: 3.1.2 @@ -5488,6 +5612,11 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.2(browserslist@4.28.1) + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + builtins@5.1.0: dependencies: semver: 7.7.3 @@ -5517,6 +5646,8 @@ snapshots: chalk@5.6.2: {} + chownr@1.1.4: {} + cjs-module-lexer@1.4.3: {} cliui@8.0.1: @@ -5606,6 +5737,12 @@ snapshots: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -5763,6 +5900,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + express@4.22.1: dependencies: accepts: 1.3.8 @@ -5865,6 +6004,8 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -5920,6 +6061,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@11.3.2: dependencies: graceful-fs: 4.2.11 @@ -5984,6 +6127,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -6055,6 +6200,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + import-in-the-middle@1.15.0: dependencies: acorn: 8.15.0 @@ -6064,6 +6211,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + ip-regex@4.3.0: {} ipaddr.js@1.9.1: {} @@ -6271,8 +6420,12 @@ snapshots: mime@1.6.0: {} + mimic-response@3.1.0: {} + minimist@1.2.8: {} + mkdirp-classic@0.5.3: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -6300,10 +6453,16 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + negotiator@0.6.3: {} negotiator@1.0.0: {} + node-abi@3.85.0: + dependencies: + semver: 7.7.3 + node-domexception@1.0.0: {} node-fetch@2.7.0: @@ -6491,6 +6650,21 @@ snapshots: transitivePeerDependencies: - debug + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.85.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prettier@3.7.4: {} pretty-ms@9.3.0: @@ -6556,10 +6730,23 @@ snapshots: iconv-lite: 0.7.0 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-refresh@0.18.0: {} react@19.2.1: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + real-require@0.2.0: {} redis@5.10.0: @@ -6794,6 +6981,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sisteransi@1.0.5: {} sonic-boom@4.2.0: @@ -6816,12 +7011,18 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: {} + strip-json-comments@5.0.3: {} supports-color@7.2.0: @@ -6842,6 +7043,21 @@ snapshots: react: 19.2.1 use-sync-external-store: 1.6.0(react@19.2.1) + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tcp-port-used@1.0.2: dependencies: debug: 4.3.1 @@ -6877,6 +7093,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -6921,6 +7141,8 @@ snapshots: dependencies: react: 19.2.1 + util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} uuid@11.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d0eb205 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +ignoredBuiltDependencies: + - better-sqlite3 diff --git a/src/database/index.ts b/src/database/index.ts new file mode 100644 index 0000000..d029dec --- /dev/null +++ b/src/database/index.ts @@ -0,0 +1,123 @@ +import Database from 'better-sqlite3'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; +import type { DatabaseConfig } from './types.js'; + +let db: Database.Database | null = null; + +/** + * 获取数据库实例(进程级单例) + * @param config 数据库配置 + * @returns Database 实例 + */ +export function getDB(config?: DatabaseConfig): Database.Database { + if (db) { + return db; + } + + // 确定数据库路径 + const dbPath = resolveDatabasePath(config?.path); + + // 确保目录存在 + ensureDirectoryExists(dbPath); + + // 创建数据库实例 + db = new Database(dbPath, { + readonly: config?.readonly ?? false, + fileMustExist: config?.fileMustExist ?? false, + timeout: config?.timeout ?? 5000, + }); + + // 应用性能优化 PRAGMA + applyPragmas(db); + + return db; +} + +/** + * 解析数据库路径 + * 默认使用 data/app.db + */ +function resolveDatabasePath(customPath?: string): string { + if (customPath) { + return path.isAbsolute(customPath) + ? customPath + : path.resolve(process.cwd(), customPath); + } + + // 默认路径:项目根目录下的 data/app.db + const projectRoot = findProjectRoot(); + return path.join(projectRoot, 'data', 'app.db'); +} + +/** + * 查找项目根目录 + */ +function findProjectRoot(): string { + // 尝试从 import.meta.url 获取模块路径 + const modulePath = typeof import.meta !== 'undefined' + ? import.meta.url + : undefined; + + if (modulePath && modulePath.startsWith('file://')) { + const filePath = fileURLToPath(modulePath); + const dir = path.dirname(filePath); + // src/database -> 项目根目录 + return path.resolve(dir, '../..'); + } + + return process.cwd(); +} + +/** + * 确保目录存在 + */ +function ensureDirectoryExists(dbPath: string): void { + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * 应用性能优化 PRAGMA 配置 + */ +function applyPragmas(database: Database.Database): void { + // WAL 模式:提升并发写入性能 + database.pragma('journal_mode = WAL'); + + // 同步级别:平衡安全性与性能 + database.pragma('synchronous = NORMAL'); + + // 缓存大小:约 64MB(负数表示 KiB) + database.pragma('cache_size = -64000'); + + // 临时表存储方式:内存 + database.pragma('temp_store = MEMORY'); + + // 内存映射大小:256MB(根据机器内存调整) + // 注意:这可能需要根据实际环境调整 + // database.pragma('mmap_size = 268435456'); +} + +/** + * 关闭数据库连接 + * 通常在进程退出前调用 + */ +export function closeDB(): void { + if (db) { + db.close(); + db = null; + } +} + +/** + * 获取原始数据库实例(高级用法) + * 注意:直接操作数据库实例需要自行保证安全性 + */ +export function getRawDB(): Database.Database | null { + return db; +} + +export { Database }; diff --git a/src/database/migrate.ts b/src/database/migrate.ts new file mode 100644 index 0000000..c2c3096 --- /dev/null +++ b/src/database/migrate.ts @@ -0,0 +1,135 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { getDB } from './index.js'; +import type { MigrationResult, MigrationFile } from './types.js'; + +const MIGRATIONS_DIR = 'migrations'; + +/** + * 运行所有待执行的迁移 + * @param migrationsDir 迁移文件目录(相对于 src/database) + * @returns 迁移执行结果 + */ +export function runMigrations(migrationsDir: string = MIGRATIONS_DIR): MigrationResult { + const db = getDB(); + const result: MigrationResult = { + applied: [], + skipped: [], + }; + + // 1. 创建 migrations 表(如果不存在) + db.exec(` + CREATE TABLE IF NOT EXISTS migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + run_at TEXT NOT NULL + ) + `); + + // 2. 获取已执行的迁移列表 + const appliedMigrations = db.prepare( + 'SELECT name FROM migrations' + ).all(); + + const appliedSet = new Set(appliedMigrations.map((m) => m.name)); + + // 3. 扫描迁移目录 + const migrationFiles = getMigrationFiles(migrationsDir); + + // 4. 按文件名排序后执行 + for (const file of migrationFiles) { + if (appliedSet.has(file.name)) { + result.skipped.push(file.name); + continue; + } + + try { + executeMigration(db, file); + result.applied.push(file.name); + } catch (error) { + throw new Error(`Migration "${file.name}" failed: ${(error as Error).message}`); + } + } + + return result; +} + +/** + * 执行单个迁移 + */ +function executeMigration(db: any, file: MigrationFile): void { + const runMigration = db.transaction(() => { + // 执行迁移 SQL + db.exec(file.content); + + // 记录迁移已执行 + db.prepare( + 'INSERT INTO migrations (name, run_at) VALUES (?, datetime("now"))' + ).run(file.name); + }); + + runMigration(); +} + +/** + * 获取迁移文件列表 + */ +function getMigrationFiles(migrationsDir: string): MigrationFile[] { + const dir = resolveMigrationsDir(migrationsDir); + + if (!fs.existsSync(dir)) { + console.warn(`Migrations directory not found: ${dir}`); + return []; + } + + const files = fs.readdirSync(dir).filter( + (file) => file.endsWith('.sql') && /^\d+_/.test(file) + ); + + // 按文件名排序,确保按序号执行 + files.sort(); + + return files.map((name) => ({ + name, + content: fs.readFileSync(path.join(dir, name), 'utf8'), + })); +} + +/** + * 解析迁移目录的绝对路径 + */ +function resolveMigrationsDir(migrationsDir: string): string { + // 如果是绝对路径,直接使用 + if (path.isAbsolute(migrationsDir)) { + return migrationsDir; + } + + // 从当前模块位置解析 + if (typeof import.meta !== 'undefined' && import.meta.url) { + const modulePath = fileURLToPath(import.meta.url); + const moduleDir = path.dirname(modulePath); + return path.resolve(moduleDir, migrationsDir); + } + + // 兜底:相对于工作目录 + return path.resolve(process.cwd(), 'src/database', migrationsDir); +} + +/** + * 获取所有迁移状态 + */ +export function getMigrationStatus(): { applied: string[]; pending: string[] } { + const db = getDB(); + + const applied = db.prepare( + 'SELECT name FROM migrations ORDER BY name' + ).all().map((m) => m.name); + + const migrationFiles = getMigrationFiles(MIGRATIONS_DIR); + const pending = migrationFiles + .filter((f) => !applied.includes(f.name)) + .map((f) => f.name); + + return { applied, pending }; +} \ No newline at end of file diff --git a/src/database/migrations/001_init.sql b/src/database/migrations/001_init.sql new file mode 100644 index 0000000..b0516dc --- /dev/null +++ b/src/database/migrations/001_init.sql @@ -0,0 +1,33 @@ +-- 初始化迁移 +-- 创建基础表结构 + +-- 示例表:用户表 +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT DEFAULT NULL +); + +-- 示例表:系统配置表 +CREATE TABLE IF NOT EXISTS system_config ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); + +-- 插入初始配置 +INSERT OR IGNORE INTO system_config (key, value, description) VALUES + ('app_name', 'BattleMonAgent', '应用名称'), + ('version', '1.0.0', '应用版本'), + ('db_version', '001', '数据库版本'); diff --git a/src/database/migrations/init.ts b/src/database/migrations/init.ts new file mode 100644 index 0000000..731f678 --- /dev/null +++ b/src/database/migrations/init.ts @@ -0,0 +1,36 @@ +export const initSql = ` +-- 初始化迁移 +-- 创建基础表结构 + +-- 示例表:用户表 +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT DEFAULT NULL +); + +-- 示例表:系统配置表 +CREATE TABLE IF NOT EXISTS system_config ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); + +-- 插入初始配置 +INSERT OR IGNORE INTO system_config (key, value, description) VALUES + ('app_name', 'BattleMonAgent', '应用名称'), + ('version', '1.0.0', '应用版本'), + ('db_version', '001', '数据库版本'); + +` \ No newline at end of file diff --git a/src/database/types.ts b/src/database/types.ts new file mode 100644 index 0000000..6bd5f53 --- /dev/null +++ b/src/database/types.ts @@ -0,0 +1,44 @@ +import type Database from 'better-sqlite3'; + +/** + * 数据库配置选项 + */ +export interface DatabaseConfig { + /** 数据库文件路径 */ + path?: string; + /** 是否只读模式 */ + readonly?: boolean; + /** 文件不存在时是否报错 */ + fileMustExist?: boolean; + /** 等待锁超时时间 (ms) */ + timeout?: number; + /** 是否打印 SQL 日志 */ + verbose?: boolean; +} + +/** + * 迁移记录类型 + */ +export interface MigrationRecord { + id: number; + name: string; + run_at: string; +} + +/** + * 迁移文件接口 + */ +export interface MigrationFile { + /** 迁移文件名,如 '001_init.sql' */ + name: string; + /** 迁移 SQL 内容 */ + content: string; +} + +/** + * 迁移执行结果 + */ +export interface MigrationResult { + applied: string[]; + skipped: string[]; +} diff --git a/src/server/index.ts b/src/server/index.ts index c551926..c422421 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,10 +6,36 @@ import { mastra } from '../mastra'; import { agentRouter } from './routes/agent-routes'; import { responseFormatter } from './middleware/response-formatter'; import { errorHandler, notFoundHandler } from './middleware/error-handler'; +import { getDB, closeDB } from '../database'; +import { initSql } from '../database/migrations/init'; const app = express(); const PORT = process.env.PORT || 7328; +// 测试数据库连接 +function testDatabaseConnection(): boolean { + try { + const db = getDB({ + path: 'data/app.db', + readonly: false, + fileMustExist: false, + timeout: 5000, + }); + // 执行简单查询验证连接 + const result = db.prepare('SELECT * FROM system_config').all(); + + console.log('Database result:', result); + console.log('✅ Database connection established successfully'); + return true; + } catch (error) { + console.error('❌ Database connection failed:', error instanceof Error ? error.message : error); + return false; + } +} + +// Initialize database connection +const dbConnected = testDatabaseConnection(); + // Security middleware app.use(helmet()); @@ -69,12 +95,14 @@ const server = app.listen(PORT, () => { console.log(`🚀 Express server listening on port ${PORT}`); console.log(`📡 Health check: http://localhost:${PORT}/health`); console.log(`🤖 Agent API: http://localhost:${PORT}/api/agent`); + console.log(`🗄️ Database: ${dbConnected ? 'Connected' : 'Failed'}`); }); // Graceful shutdown process.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully'); server.close(() => { + closeDB(); console.log('Server closed'); process.exit(0); }); @@ -83,6 +111,7 @@ process.on('SIGTERM', () => { process.on('SIGINT', () => { console.log('SIGINT received, shutting down gracefully'); server.close(() => { + closeDB(); console.log('Server closed'); process.exit(0); });