@yunzai-ng/core 0.1.0 → 0.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/dist/config/core-config.d.ts +1 -1
- package/dist/store/sql.js +4 -4
- package/package.json +12 -2
- package/src/adapter/accounts.ts +1 -1
- package/src/adapter/bots.ts +1 -1
- package/src/adapter/host.ts +1 -1
- package/src/adapter/login.ts +1 -1
- package/src/kernel/app.ts +843 -843
- package/src/kernel/policy.ts +120 -120
- package/src/kernel/runtime.ts +1 -1
- package/src/pipeline/event.ts +1 -1
- package/src/platform/system.ts +1 -1
- package/src/plugin/market.ts +1 -1
- package/src/plugin/services.ts +1 -1
- package/src/render/registry.ts +1 -1
- package/src/scheduler/index.ts +1 -1
- package/src/server/auth.ts +1 -1
- package/src/server/files.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/store/index.ts +156 -156
- package/src/store/kv.ts +256 -256
- package/src/store/sql.ts +324 -324
package/src/store/sql.ts
CHANGED
|
@@ -1,324 +1,324 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 模块职责:SQLite 关系存储(语句缓存、异步事务、迁移)
|
|
3
|
-
* 依赖方向:动态依赖可选原生模块 better-sqlite3;依赖 util/{lru,queue}、类型包
|
|
4
|
-
* 生命周期:`openSql()` 打开,`close()` 关闭
|
|
5
|
-
* 注意事项:**不引 ORM**,只暴露 run/all/get/transaction/migrate。
|
|
6
|
-
*
|
|
7
|
-
* **事务手写 BEGIN/COMMIT 并用互斥锁串行化**:better-sqlite3 的 `db.transaction()`
|
|
8
|
-
* 只收同步函数,而本框架的接口是异步的(要容得下别的驱动)。一条连接上开不了两个
|
|
9
|
-
* 事务,故事务之间必须排队;嵌套事务改用 SAVEPOINT,不重复抢锁。
|
|
10
|
-
*
|
|
11
|
-
* **语句缓存有界。** `prepare()` 有真实开销,但无上限的缓存在动态拼 SQL 的插件手里
|
|
12
|
-
* 就是内存泄漏。
|
|
13
|
-
*/
|
|
14
|
-
import type { SqlHandle, SqlMigration, SqlParam } from "@yunzai-ng/types"
|
|
15
|
-
import { dirname } from "node:path"
|
|
16
|
-
import { LruCache } from "../util/lru.js"
|
|
17
|
-
import { Semaphore } from "../util/queue.js"
|
|
18
|
-
import { ensureDir } from "../util/fs.js"
|
|
19
|
-
|
|
20
|
-
/** 语句缓存条目上限 */
|
|
21
|
-
const STMT_CACHE_MAX = 200
|
|
22
|
-
|
|
23
|
-
/** 迁移记录表名 */
|
|
24
|
-
const MIGRATION_TABLE = "_yzng_migrations"
|
|
25
|
-
|
|
26
|
-
/** better-sqlite3 的语句最小面 */
|
|
27
|
-
interface SqliteStatement {
|
|
28
|
-
/** 执行不取行 */
|
|
29
|
-
run(...params: SqlParam[]): { changes: number; lastInsertRowid: number | bigint }
|
|
30
|
-
/** 取全部行 */
|
|
31
|
-
all(...params: SqlParam[]): unknown[]
|
|
32
|
-
/** 取首行 */
|
|
33
|
-
get(...params: SqlParam[]): unknown
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** better-sqlite3 的连接最小面 */
|
|
37
|
-
interface SqliteDb {
|
|
38
|
-
/** 预编译语句 */
|
|
39
|
-
prepare(sql: string): SqliteStatement
|
|
40
|
-
/** 直接执行脚本(可含多条语句,不支持绑定参数) */
|
|
41
|
-
exec(sql: string): void
|
|
42
|
-
/** 读写 pragma */
|
|
43
|
-
pragma(source: string): unknown
|
|
44
|
-
/** 关闭连接 */
|
|
45
|
-
close(): void
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** better-sqlite3 的构造签名 */
|
|
49
|
-
type SqliteCtor = new (file: string, opts?: { readonly?: boolean }) => SqliteDb
|
|
50
|
-
|
|
51
|
-
/** 一条连接上的共享状态 */
|
|
52
|
-
interface SqlState {
|
|
53
|
-
/** 连接 */
|
|
54
|
-
db: SqliteDb
|
|
55
|
-
/** 语句缓存 */
|
|
56
|
-
stmts: LruCache<SqliteStatement>
|
|
57
|
-
/** 事务互斥锁 */
|
|
58
|
-
mutex: Semaphore
|
|
59
|
-
/** SAVEPOINT 命名计数器 */
|
|
60
|
-
savepoint: number
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* 尝试加载 better-sqlite3
|
|
65
|
-
* @returns 构造函数;模块不可用时 undefined
|
|
66
|
-
*/
|
|
67
|
-
export async function loadSqlite(): Promise<SqliteCtor | undefined> {
|
|
68
|
-
try {
|
|
69
|
-
const mod = (await import("better-sqlite3")) as unknown as { default?: SqliteCtor }
|
|
70
|
-
return mod.default
|
|
71
|
-
} catch {
|
|
72
|
-
return undefined
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* SQLite 句柄
|
|
78
|
-
*
|
|
79
|
-
* 根句柄由 `openSql()` 创建;事务内传给回调的是 `depth > 0` 的子句柄,
|
|
80
|
-
* 它们共享同一条连接,因此**不能**在事务回调里持有子句柄留到事务外使用。
|
|
81
|
-
*/
|
|
82
|
-
export class SqliteHandle implements SqlHandle {
|
|
83
|
-
/** 共享状态 */
|
|
84
|
-
readonly #state: SqlState
|
|
85
|
-
/** 事务嵌套深度,0 表示不在事务中 */
|
|
86
|
-
readonly #depth: number
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* @param state 共享状态
|
|
90
|
-
* @param depth 事务嵌套深度
|
|
91
|
-
*/
|
|
92
|
-
constructor(state: SqlState, depth = 0) {
|
|
93
|
-
this.#state = state
|
|
94
|
-
this.#depth = depth
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* 执行不返回行的语句
|
|
99
|
-
*
|
|
100
|
-
* 不带参数且含多条语句时走 `exec()`,方便迁移里一次建表 + 建索引;
|
|
101
|
-
* 这种情况下无法报告受影响行数,返回 0。
|
|
102
|
-
* @param sql SQL 文本
|
|
103
|
-
* @param params 绑定参数
|
|
104
|
-
* @returns 受影响行数与最后插入 id
|
|
105
|
-
*/
|
|
106
|
-
async run(sql: string, params: SqlParam[] = []): Promise<{ changes: number; lastInsertRowid: number | bigint }> {
|
|
107
|
-
if (params.length === 0 && isMultiStatement(sql)) {
|
|
108
|
-
this.#state.db.exec(sql)
|
|
109
|
-
return { changes: 0, lastInsertRowid: 0 }
|
|
110
|
-
}
|
|
111
|
-
return this.#prepare(sql).run(...params)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* 查询多行
|
|
116
|
-
* @param sql SQL 文本
|
|
117
|
-
* @param params 绑定参数
|
|
118
|
-
* @returns 结果行数组
|
|
119
|
-
*/
|
|
120
|
-
async all<T = Record<string, unknown>>(sql: string, params: SqlParam[] = []): Promise<T[]> {
|
|
121
|
-
return this.#prepare(sql).all(...params) as T[]
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* 查询首行
|
|
126
|
-
* @param sql SQL 文本
|
|
127
|
-
* @param params 绑定参数
|
|
128
|
-
* @returns 首行;无结果时 undefined
|
|
129
|
-
*/
|
|
130
|
-
async get<T = Record<string, unknown>>(sql: string, params: SqlParam[] = []): Promise<T | undefined> {
|
|
131
|
-
return (this.#prepare(sql).get(...params) as T | undefined) ?? undefined
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* 在事务中执行
|
|
136
|
-
*
|
|
137
|
-
* 最外层用 `BEGIN IMMEDIATE`(立刻拿写锁,避免升级锁时才发现冲突而整段重来);
|
|
138
|
-
* 嵌套层用 SAVEPOINT,实现"内层回滚不牵连外层"。
|
|
139
|
-
* @param fn 事务体,抛错即回滚
|
|
140
|
-
* @returns 事务体的返回值
|
|
141
|
-
*/
|
|
142
|
-
async transaction<T>(fn: (tx: SqlHandle) => Promise<T>): Promise<T> {
|
|
143
|
-
if (this.#depth > 0) return this.#savepoint(fn)
|
|
144
|
-
// 同一条连接上不能并发开事务,排队等前一个事务结束
|
|
145
|
-
return this.#state.mutex.use(async () => {
|
|
146
|
-
const tx = new SqliteHandle(this.#state, 1)
|
|
147
|
-
this.#state.db.exec("BEGIN IMMEDIATE")
|
|
148
|
-
try {
|
|
149
|
-
const result = await fn(tx)
|
|
150
|
-
this.#state.db.exec("COMMIT")
|
|
151
|
-
return result
|
|
152
|
-
} catch (err) {
|
|
153
|
-
// 回滚本身也可能失败(连接已断),此时保留原始错误更有诊断价值
|
|
154
|
-
try {
|
|
155
|
-
this.#state.db.exec("ROLLBACK")
|
|
156
|
-
} catch {
|
|
157
|
-
/* 忽略 */
|
|
158
|
-
}
|
|
159
|
-
throw err
|
|
160
|
-
}
|
|
161
|
-
})
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* 应用迁移
|
|
166
|
-
*
|
|
167
|
-
* 每条迁移单独一个事务:失败只回滚这一条,已成功的保持生效,修好后重跑从断点继续。
|
|
168
|
-
* 版本号落在迁移表里,故"这个库到哪一版了"有确定答案,不必靠"存在则忽略"的 try/catch。
|
|
169
|
-
* @param migrations 迁移列表
|
|
170
|
-
* @throws 版本号未严格递增,或某条迁移执行失败
|
|
171
|
-
*/
|
|
172
|
-
async migrate(migrations: SqlMigration[]): Promise<void> {
|
|
173
|
-
await this.run(
|
|
174
|
-
`CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} (
|
|
175
|
-
version INTEGER PRIMARY KEY,
|
|
176
|
-
name TEXT NOT NULL,
|
|
177
|
-
applied_at INTEGER NOT NULL
|
|
178
|
-
)`
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
const sorted = [...migrations].sort((a, b) => a.version - b.version)
|
|
182
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
183
|
-
if (sorted[i]!.version === sorted[i - 1]!.version) {
|
|
184
|
-
throw new Error(`迁移版本号重复:${sorted[i]!.version}`)
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const rows = await this.all<{ version: number }>(`SELECT version FROM ${MIGRATION_TABLE}`)
|
|
189
|
-
const applied = new Set(rows.map(r => r.version))
|
|
190
|
-
|
|
191
|
-
for (const migration of sorted) {
|
|
192
|
-
if (applied.has(migration.version)) continue
|
|
193
|
-
await this.transaction(async tx => {
|
|
194
|
-
await migration.up(tx)
|
|
195
|
-
await tx.run(`INSERT INTO ${MIGRATION_TABLE} (version, name, applied_at) VALUES (?, ?, ?)`, [
|
|
196
|
-
migration.version,
|
|
197
|
-
migration.name,
|
|
198
|
-
Date.now()
|
|
199
|
-
])
|
|
200
|
-
})
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* 嵌套事务:SAVEPOINT
|
|
206
|
-
* @param fn 事务体
|
|
207
|
-
* @returns 事务体的返回值
|
|
208
|
-
*/
|
|
209
|
-
async #savepoint<T>(fn: (tx: SqlHandle) => Promise<T>): Promise<T> {
|
|
210
|
-
const name = `yzng_sp_${++this.#state.savepoint}`
|
|
211
|
-
const tx = new SqliteHandle(this.#state, this.#depth + 1)
|
|
212
|
-
this.#state.db.exec(`SAVEPOINT ${name}`)
|
|
213
|
-
try {
|
|
214
|
-
const result = await fn(tx)
|
|
215
|
-
this.#state.db.exec(`RELEASE ${name}`)
|
|
216
|
-
return result
|
|
217
|
-
} catch (err) {
|
|
218
|
-
try {
|
|
219
|
-
this.#state.db.exec(`ROLLBACK TO ${name}`)
|
|
220
|
-
this.#state.db.exec(`RELEASE ${name}`)
|
|
221
|
-
} catch {
|
|
222
|
-
/* 忽略 */
|
|
223
|
-
}
|
|
224
|
-
throw err
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* 取(并缓存)预编译语句
|
|
230
|
-
* @param sql SQL 文本
|
|
231
|
-
* @returns 预编译语句
|
|
232
|
-
*/
|
|
233
|
-
#prepare(sql: string): SqliteStatement {
|
|
234
|
-
const cached = this.#state.stmts.get(sql)
|
|
235
|
-
if (cached) return cached
|
|
236
|
-
const stmt = this.#state.db.prepare(sql)
|
|
237
|
-
this.#state.stmts.set(sql, stmt)
|
|
238
|
-
return stmt
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** 打开 SQL 存储的参数 */
|
|
243
|
-
export interface OpenSqlOptions {
|
|
244
|
-
/** 数据库文件绝对路径 */
|
|
245
|
-
file: string
|
|
246
|
-
/** 构造函数,缺省自动加载 */
|
|
247
|
-
ctor?: SqliteCtor
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** 已打开的 SQL 存储 */
|
|
251
|
-
export interface SqlStore {
|
|
252
|
-
/** 句柄 */
|
|
253
|
-
readonly handle: SqlHandle
|
|
254
|
-
/** 数据库文件路径 */
|
|
255
|
-
readonly file: string
|
|
256
|
-
/** 关闭连接 */
|
|
257
|
-
close(): void
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* 打开 SQLite 数据库
|
|
262
|
-
* @param opts 参数
|
|
263
|
-
* @returns 已打开的 SQL 存储;原生模块不可用时 undefined(调用方应降级为纯 KV)
|
|
264
|
-
*/
|
|
265
|
-
export async function openSql(opts: OpenSqlOptions): Promise<SqlStore | undefined> {
|
|
266
|
-
const ctor = opts.ctor ?? (await loadSqlite())
|
|
267
|
-
if (!ctor) return undefined
|
|
268
|
-
|
|
269
|
-
await ensureDir(dirname(opts.file))
|
|
270
|
-
const db = new ctor(opts.file)
|
|
271
|
-
|
|
272
|
-
// WAL:读不阻塞写,且断电时不会像 rollback journal 那样留下半个事务
|
|
273
|
-
db.pragma("journal_mode = WAL")
|
|
274
|
-
// NORMAL 在 WAL 下依然崩溃安全(只可能丢最后一个未 checkpoint 的事务),
|
|
275
|
-
// 却比 FULL 快一个数量级;机器人数据不是账本,这个取舍是合适的
|
|
276
|
-
db.pragma("synchronous = NORMAL")
|
|
277
|
-
db.pragma("foreign_keys = ON")
|
|
278
|
-
// 被别的连接短暂锁住时等一会儿再报错,而不是立刻抛 SQLITE_BUSY
|
|
279
|
-
db.pragma("busy_timeout = 5000")
|
|
280
|
-
|
|
281
|
-
const state: SqlState = {
|
|
282
|
-
db,
|
|
283
|
-
stmts: new LruCache<SqliteStatement>({ max: STMT_CACHE_MAX }),
|
|
284
|
-
mutex: new Semaphore(1),
|
|
285
|
-
savepoint: 0
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return {
|
|
289
|
-
handle: new SqliteHandle(state),
|
|
290
|
-
file: opts.file,
|
|
291
|
-
close: () => {
|
|
292
|
-
// 缓存里的语句必须先丢掉:连接关了之后再用它们会直接崩进程
|
|
293
|
-
state.stmts.clear()
|
|
294
|
-
db.close()
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* 判断 SQL 是否包含多条语句
|
|
301
|
-
*
|
|
302
|
-
* 只做粗判:跳过字符串字面量后看是否还有 `;` 跟着非空白内容。
|
|
303
|
-
* 粗判失败的后果仅仅是走 `prepare()` 然后由 SQLite 自己报错,可接受。
|
|
304
|
-
* @param sql SQL 文本
|
|
305
|
-
* @returns 是否多语句
|
|
306
|
-
*/
|
|
307
|
-
function isMultiStatement(sql: string): boolean {
|
|
308
|
-
let inString = false
|
|
309
|
-
let quote = ""
|
|
310
|
-
for (let i = 0; i < sql.length; i++) {
|
|
311
|
-
const ch = sql[i]!
|
|
312
|
-
if (inString) {
|
|
313
|
-
if (ch === quote) inString = false
|
|
314
|
-
continue
|
|
315
|
-
}
|
|
316
|
-
if (ch === "'" || ch === '"') {
|
|
317
|
-
inString = true
|
|
318
|
-
quote = ch
|
|
319
|
-
continue
|
|
320
|
-
}
|
|
321
|
-
if (ch === ";" && sql.slice(i + 1).trim() !== "") return true
|
|
322
|
-
}
|
|
323
|
-
return false
|
|
324
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 模块职责:SQLite 关系存储(语句缓存、异步事务、迁移)
|
|
3
|
+
* 依赖方向:动态依赖可选原生模块 better-sqlite3;依赖 util/{lru,queue}、类型包
|
|
4
|
+
* 生命周期:`openSql()` 打开,`close()` 关闭
|
|
5
|
+
* 注意事项:**不引 ORM**,只暴露 run/all/get/transaction/migrate。
|
|
6
|
+
*
|
|
7
|
+
* **事务手写 BEGIN/COMMIT 并用互斥锁串行化**:better-sqlite3 的 `db.transaction()`
|
|
8
|
+
* 只收同步函数,而本框架的接口是异步的(要容得下别的驱动)。一条连接上开不了两个
|
|
9
|
+
* 事务,故事务之间必须排队;嵌套事务改用 SAVEPOINT,不重复抢锁。
|
|
10
|
+
*
|
|
11
|
+
* **语句缓存有界。** `prepare()` 有真实开销,但无上限的缓存在动态拼 SQL 的插件手里
|
|
12
|
+
* 就是内存泄漏。
|
|
13
|
+
*/
|
|
14
|
+
import type { SqlHandle, SqlMigration, SqlParam } from "@yunzai-ng/types"
|
|
15
|
+
import { dirname } from "node:path"
|
|
16
|
+
import { LruCache } from "../util/lru.js"
|
|
17
|
+
import { Semaphore } from "../util/queue.js"
|
|
18
|
+
import { ensureDir } from "../util/fs.js"
|
|
19
|
+
|
|
20
|
+
/** 语句缓存条目上限 */
|
|
21
|
+
const STMT_CACHE_MAX = 200
|
|
22
|
+
|
|
23
|
+
/** 迁移记录表名 */
|
|
24
|
+
const MIGRATION_TABLE = "_yzng_migrations"
|
|
25
|
+
|
|
26
|
+
/** better-sqlite3 的语句最小面 */
|
|
27
|
+
interface SqliteStatement {
|
|
28
|
+
/** 执行不取行 */
|
|
29
|
+
run(...params: SqlParam[]): { changes: number; lastInsertRowid: number | bigint }
|
|
30
|
+
/** 取全部行 */
|
|
31
|
+
all(...params: SqlParam[]): unknown[]
|
|
32
|
+
/** 取首行 */
|
|
33
|
+
get(...params: SqlParam[]): unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** better-sqlite3 的连接最小面 */
|
|
37
|
+
interface SqliteDb {
|
|
38
|
+
/** 预编译语句 */
|
|
39
|
+
prepare(sql: string): SqliteStatement
|
|
40
|
+
/** 直接执行脚本(可含多条语句,不支持绑定参数) */
|
|
41
|
+
exec(sql: string): void
|
|
42
|
+
/** 读写 pragma */
|
|
43
|
+
pragma(source: string): unknown
|
|
44
|
+
/** 关闭连接 */
|
|
45
|
+
close(): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** better-sqlite3 的构造签名 */
|
|
49
|
+
type SqliteCtor = new (file: string, opts?: { readonly?: boolean }) => SqliteDb
|
|
50
|
+
|
|
51
|
+
/** 一条连接上的共享状态 */
|
|
52
|
+
interface SqlState {
|
|
53
|
+
/** 连接 */
|
|
54
|
+
db: SqliteDb
|
|
55
|
+
/** 语句缓存 */
|
|
56
|
+
stmts: LruCache<SqliteStatement>
|
|
57
|
+
/** 事务互斥锁 */
|
|
58
|
+
mutex: Semaphore
|
|
59
|
+
/** SAVEPOINT 命名计数器 */
|
|
60
|
+
savepoint: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 尝试加载 better-sqlite3
|
|
65
|
+
* @returns 构造函数;模块不可用时 undefined
|
|
66
|
+
*/
|
|
67
|
+
export async function loadSqlite(): Promise<SqliteCtor | undefined> {
|
|
68
|
+
try {
|
|
69
|
+
const mod = (await import("better-sqlite3")) as unknown as { default?: SqliteCtor }
|
|
70
|
+
return mod.default
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* SQLite 句柄
|
|
78
|
+
*
|
|
79
|
+
* 根句柄由 `openSql()` 创建;事务内传给回调的是 `depth > 0` 的子句柄,
|
|
80
|
+
* 它们共享同一条连接,因此**不能**在事务回调里持有子句柄留到事务外使用。
|
|
81
|
+
*/
|
|
82
|
+
export class SqliteHandle implements SqlHandle {
|
|
83
|
+
/** 共享状态 */
|
|
84
|
+
readonly #state: SqlState
|
|
85
|
+
/** 事务嵌套深度,0 表示不在事务中 */
|
|
86
|
+
readonly #depth: number
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param state 共享状态
|
|
90
|
+
* @param depth 事务嵌套深度
|
|
91
|
+
*/
|
|
92
|
+
constructor(state: SqlState, depth = 0) {
|
|
93
|
+
this.#state = state
|
|
94
|
+
this.#depth = depth
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 执行不返回行的语句
|
|
99
|
+
*
|
|
100
|
+
* 不带参数且含多条语句时走 `exec()`,方便迁移里一次建表 + 建索引;
|
|
101
|
+
* 这种情况下无法报告受影响行数,返回 0。
|
|
102
|
+
* @param sql SQL 文本
|
|
103
|
+
* @param params 绑定参数
|
|
104
|
+
* @returns 受影响行数与最后插入 id
|
|
105
|
+
*/
|
|
106
|
+
async run(sql: string, params: SqlParam[] = []): Promise<{ changes: number; lastInsertRowid: number | bigint }> {
|
|
107
|
+
if (params.length === 0 && isMultiStatement(sql)) {
|
|
108
|
+
this.#state.db.exec(sql)
|
|
109
|
+
return { changes: 0, lastInsertRowid: 0 }
|
|
110
|
+
}
|
|
111
|
+
return this.#prepare(sql).run(...params)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 查询多行
|
|
116
|
+
* @param sql SQL 文本
|
|
117
|
+
* @param params 绑定参数
|
|
118
|
+
* @returns 结果行数组
|
|
119
|
+
*/
|
|
120
|
+
async all<T = Record<string, unknown>>(sql: string, params: SqlParam[] = []): Promise<T[]> {
|
|
121
|
+
return this.#prepare(sql).all(...params) as T[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 查询首行
|
|
126
|
+
* @param sql SQL 文本
|
|
127
|
+
* @param params 绑定参数
|
|
128
|
+
* @returns 首行;无结果时 undefined
|
|
129
|
+
*/
|
|
130
|
+
async get<T = Record<string, unknown>>(sql: string, params: SqlParam[] = []): Promise<T | undefined> {
|
|
131
|
+
return (this.#prepare(sql).get(...params) as T | undefined) ?? undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 在事务中执行
|
|
136
|
+
*
|
|
137
|
+
* 最外层用 `BEGIN IMMEDIATE`(立刻拿写锁,避免升级锁时才发现冲突而整段重来);
|
|
138
|
+
* 嵌套层用 SAVEPOINT,实现"内层回滚不牵连外层"。
|
|
139
|
+
* @param fn 事务体,抛错即回滚
|
|
140
|
+
* @returns 事务体的返回值
|
|
141
|
+
*/
|
|
142
|
+
async transaction<T>(fn: (tx: SqlHandle) => Promise<T>): Promise<T> {
|
|
143
|
+
if (this.#depth > 0) return this.#savepoint(fn)
|
|
144
|
+
// 同一条连接上不能并发开事务,排队等前一个事务结束
|
|
145
|
+
return this.#state.mutex.use(async () => {
|
|
146
|
+
const tx = new SqliteHandle(this.#state, 1)
|
|
147
|
+
this.#state.db.exec("BEGIN IMMEDIATE")
|
|
148
|
+
try {
|
|
149
|
+
const result = await fn(tx)
|
|
150
|
+
this.#state.db.exec("COMMIT")
|
|
151
|
+
return result
|
|
152
|
+
} catch (err) {
|
|
153
|
+
// 回滚本身也可能失败(连接已断),此时保留原始错误更有诊断价值
|
|
154
|
+
try {
|
|
155
|
+
this.#state.db.exec("ROLLBACK")
|
|
156
|
+
} catch {
|
|
157
|
+
/* 忽略 */
|
|
158
|
+
}
|
|
159
|
+
throw err
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 应用迁移
|
|
166
|
+
*
|
|
167
|
+
* 每条迁移单独一个事务:失败只回滚这一条,已成功的保持生效,修好后重跑从断点继续。
|
|
168
|
+
* 版本号落在迁移表里,故"这个库到哪一版了"有确定答案,不必靠"存在则忽略"的 try/catch。
|
|
169
|
+
* @param migrations 迁移列表
|
|
170
|
+
* @throws 版本号未严格递增,或某条迁移执行失败
|
|
171
|
+
*/
|
|
172
|
+
async migrate(migrations: SqlMigration[]): Promise<void> {
|
|
173
|
+
await this.run(
|
|
174
|
+
`CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} (
|
|
175
|
+
version INTEGER PRIMARY KEY,
|
|
176
|
+
name TEXT NOT NULL,
|
|
177
|
+
applied_at INTEGER NOT NULL
|
|
178
|
+
)`
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const sorted = [...migrations].sort((a, b) => a.version - b.version)
|
|
182
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
183
|
+
if (sorted[i]!.version === sorted[i - 1]!.version) {
|
|
184
|
+
throw new Error(`迁移版本号重复:${sorted[i]!.version}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const rows = await this.all<{ version: number }>(`SELECT version FROM ${MIGRATION_TABLE}`)
|
|
189
|
+
const applied = new Set(rows.map(r => r.version))
|
|
190
|
+
|
|
191
|
+
for (const migration of sorted) {
|
|
192
|
+
if (applied.has(migration.version)) continue
|
|
193
|
+
await this.transaction(async tx => {
|
|
194
|
+
await migration.up(tx)
|
|
195
|
+
await tx.run(`INSERT INTO ${MIGRATION_TABLE} (version, name, applied_at) VALUES (?, ?, ?)`, [
|
|
196
|
+
migration.version,
|
|
197
|
+
migration.name,
|
|
198
|
+
Date.now()
|
|
199
|
+
])
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 嵌套事务:SAVEPOINT
|
|
206
|
+
* @param fn 事务体
|
|
207
|
+
* @returns 事务体的返回值
|
|
208
|
+
*/
|
|
209
|
+
async #savepoint<T>(fn: (tx: SqlHandle) => Promise<T>): Promise<T> {
|
|
210
|
+
const name = `yzng_sp_${++this.#state.savepoint}`
|
|
211
|
+
const tx = new SqliteHandle(this.#state, this.#depth + 1)
|
|
212
|
+
this.#state.db.exec(`SAVEPOINT ${name}`)
|
|
213
|
+
try {
|
|
214
|
+
const result = await fn(tx)
|
|
215
|
+
this.#state.db.exec(`RELEASE ${name}`)
|
|
216
|
+
return result
|
|
217
|
+
} catch (err) {
|
|
218
|
+
try {
|
|
219
|
+
this.#state.db.exec(`ROLLBACK TO ${name}`)
|
|
220
|
+
this.#state.db.exec(`RELEASE ${name}`)
|
|
221
|
+
} catch {
|
|
222
|
+
/* 忽略 */
|
|
223
|
+
}
|
|
224
|
+
throw err
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 取(并缓存)预编译语句
|
|
230
|
+
* @param sql SQL 文本
|
|
231
|
+
* @returns 预编译语句
|
|
232
|
+
*/
|
|
233
|
+
#prepare(sql: string): SqliteStatement {
|
|
234
|
+
const cached = this.#state.stmts.get(sql)
|
|
235
|
+
if (cached) return cached
|
|
236
|
+
const stmt = this.#state.db.prepare(sql)
|
|
237
|
+
this.#state.stmts.set(sql, stmt)
|
|
238
|
+
return stmt
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 打开 SQL 存储的参数 */
|
|
243
|
+
export interface OpenSqlOptions {
|
|
244
|
+
/** 数据库文件绝对路径 */
|
|
245
|
+
file: string
|
|
246
|
+
/** 构造函数,缺省自动加载 */
|
|
247
|
+
ctor?: SqliteCtor
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 已打开的 SQL 存储 */
|
|
251
|
+
export interface SqlStore {
|
|
252
|
+
/** 句柄 */
|
|
253
|
+
readonly handle: SqlHandle
|
|
254
|
+
/** 数据库文件路径 */
|
|
255
|
+
readonly file: string
|
|
256
|
+
/** 关闭连接 */
|
|
257
|
+
close(): void
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 打开 SQLite 数据库
|
|
262
|
+
* @param opts 参数
|
|
263
|
+
* @returns 已打开的 SQL 存储;原生模块不可用时 undefined(调用方应降级为纯 KV)
|
|
264
|
+
*/
|
|
265
|
+
export async function openSql(opts: OpenSqlOptions): Promise<SqlStore | undefined> {
|
|
266
|
+
const ctor = opts.ctor ?? (await loadSqlite())
|
|
267
|
+
if (!ctor) return undefined
|
|
268
|
+
|
|
269
|
+
await ensureDir(dirname(opts.file))
|
|
270
|
+
const db = new ctor(opts.file)
|
|
271
|
+
|
|
272
|
+
// WAL:读不阻塞写,且断电时不会像 rollback journal 那样留下半个事务
|
|
273
|
+
db.pragma("journal_mode = WAL")
|
|
274
|
+
// NORMAL 在 WAL 下依然崩溃安全(只可能丢最后一个未 checkpoint 的事务),
|
|
275
|
+
// 却比 FULL 快一个数量级;机器人数据不是账本,这个取舍是合适的
|
|
276
|
+
db.pragma("synchronous = NORMAL")
|
|
277
|
+
db.pragma("foreign_keys = ON")
|
|
278
|
+
// 被别的连接短暂锁住时等一会儿再报错,而不是立刻抛 SQLITE_BUSY
|
|
279
|
+
db.pragma("busy_timeout = 5000")
|
|
280
|
+
|
|
281
|
+
const state: SqlState = {
|
|
282
|
+
db,
|
|
283
|
+
stmts: new LruCache<SqliteStatement>({ max: STMT_CACHE_MAX }),
|
|
284
|
+
mutex: new Semaphore(1),
|
|
285
|
+
savepoint: 0
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
handle: new SqliteHandle(state),
|
|
290
|
+
file: opts.file,
|
|
291
|
+
close: () => {
|
|
292
|
+
// 缓存里的语句必须先丢掉:连接关了之后再用它们会直接崩进程
|
|
293
|
+
state.stmts.clear()
|
|
294
|
+
db.close()
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 判断 SQL 是否包含多条语句
|
|
301
|
+
*
|
|
302
|
+
* 只做粗判:跳过字符串字面量后看是否还有 `;` 跟着非空白内容。
|
|
303
|
+
* 粗判失败的后果仅仅是走 `prepare()` 然后由 SQLite 自己报错,可接受。
|
|
304
|
+
* @param sql SQL 文本
|
|
305
|
+
* @returns 是否多语句
|
|
306
|
+
*/
|
|
307
|
+
function isMultiStatement(sql: string): boolean {
|
|
308
|
+
let inString = false
|
|
309
|
+
let quote = ""
|
|
310
|
+
for (let i = 0; i < sql.length; i++) {
|
|
311
|
+
const ch = sql[i]!
|
|
312
|
+
if (inString) {
|
|
313
|
+
if (ch === quote) inString = false
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
if (ch === "'" || ch === '"') {
|
|
317
|
+
inString = true
|
|
318
|
+
quote = ch
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (ch === ";" && sql.slice(i + 1).trim() !== "") return true
|
|
322
|
+
}
|
|
323
|
+
return false
|
|
324
|
+
}
|