ework-web 0.2.0 → 0.3.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/src/store.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // issues + comments + labels + reactions + attachments + users. All write ops
3
3
  // run in transactions; per-project issue numbers are allocated atomically.
4
4
 
5
- import { rawDB } from "./db";
5
+ import { getDB } from "./db";
6
6
  import { createHash } from "node:crypto";
7
7
 
8
8
  export type UserKind = "human" | "bot" | "system";
@@ -101,36 +101,19 @@ export class StoreError extends Error {
101
101
  }
102
102
  }
103
103
 
104
- type DB = ReturnType<typeof rawDB>;
105
-
106
104
  function now(): string {
107
105
  return new Date().toISOString();
108
106
  }
109
107
 
110
- function tx<T>(db: DB, fn: () => T): T {
111
- db.exec("BEGIN");
112
- try {
113
- const r = fn();
114
- db.exec("COMMIT");
115
- return r;
116
- } catch (e) {
117
- try {
118
- db.exec("ROLLBACK");
119
- } catch {
120
- // already rolled back
121
- }
122
- throw e;
123
- }
124
- }
125
-
126
- export function ensureUser(login: string, kind: UserKind = "human"): UserRow {
127
- const db = rawDB();
128
- const existing = db.query("SELECT * FROM users WHERE login = ?").get(login) as UserRow | null;
108
+ export async function ensureUser(login: string, kind: UserKind = "human"): Promise<UserRow> {
109
+ const db = getDB();
110
+ const existing = await db.get<UserRow>("SELECT * FROM {{users}} WHERE login = ?", [login]);
129
111
  if (existing) return existing;
130
112
  const ts = now();
131
- db.query(
132
- "INSERT INTO users (login, kind, created_at, updated_at) VALUES (?, ?, ?, ?)"
133
- ).run(login, kind, ts, ts);
113
+ await db.run(
114
+ "INSERT INTO {{users}} (login, kind, created_at, updated_at) VALUES (?, ?, ?, ?)",
115
+ [login, kind, ts, ts]
116
+ );
134
117
  return {
135
118
  login,
136
119
  kind,
@@ -144,18 +127,19 @@ export function ensureUser(login: string, kind: UserKind = "human"): UserRow {
144
127
  };
145
128
  }
146
129
 
147
- export function getProject(owner: string, name: string): ProjectRow | null {
148
- return (rawDB().query("SELECT * FROM projects WHERE owner = ? AND name = ?").get(owner, name) as ProjectRow | null) ?? null;
130
+ export async function getProject(owner: string, name: string): Promise<ProjectRow | null> {
131
+ return (await getDB().get<ProjectRow>("SELECT * FROM {{projects}} WHERE owner = ? AND name = ?", [owner, name])) ?? null;
149
132
  }
150
133
 
151
- export function getProjectById(id: number): ProjectRow | null {
152
- return (rawDB().query("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | null) ?? null;
134
+ export async function getProjectById(id: number): Promise<ProjectRow | null> {
135
+ return (await getDB().get<ProjectRow>("SELECT * FROM {{projects}} WHERE id = ?", [id])) ?? null;
153
136
  }
154
137
 
155
- export function getProjectByIssueId(issueId: number): ProjectRow | null {
156
- const row = rawDB()
157
- .query("SELECT p.* FROM projects p JOIN issues i ON i.project_id = p.id WHERE i.id = ?")
158
- .get(issueId) as ProjectRow | null;
138
+ export async function getProjectByIssueId(issueId: number): Promise<ProjectRow | null> {
139
+ const row = await getDB().get<ProjectRow>(
140
+ "SELECT p.* FROM {{projects}} p JOIN {{issues}} i ON i.project_id = p.id WHERE i.id = ?",
141
+ [issueId]
142
+ );
159
143
  return row ?? null;
160
144
  }
161
145
 
@@ -164,36 +148,35 @@ export interface ProjectWithCounts extends ProjectRow {
164
148
  total_count: number;
165
149
  }
166
150
 
167
- export function listProjectsWithCounts(): ProjectWithCounts[] {
168
- return rawDB()
169
- .query(
170
- `SELECT p.*,
171
- COALESCE(SUM(CASE WHEN i.state = 'open' THEN 1 ELSE 0 END), 0) AS open_count,
172
- COUNT(i.id) AS total_count
173
- FROM projects p
174
- LEFT JOIN issues i ON i.project_id = p.id
175
- GROUP BY p.id
176
- ORDER BY p.updated_at DESC`
177
- )
178
- .all() as ProjectWithCounts[];
151
+ export async function listProjectsWithCounts(): Promise<ProjectWithCounts[]> {
152
+ return await getDB().all<ProjectWithCounts>(
153
+ `SELECT p.*,
154
+ COALESCE(SUM(CASE WHEN i.state = 'open' THEN 1 ELSE 0 END), 0) AS open_count,
155
+ COUNT(i.id) AS total_count
156
+ FROM {{projects}} p
157
+ LEFT JOIN {{issues}} i ON i.project_id = p.id
158
+ GROUP BY p.id
159
+ ORDER BY p.updated_at DESC`
160
+ );
179
161
  }
180
162
 
181
- export function createProject(owner: string, name: string, description: string): ProjectRow {
163
+ export async function createProject(owner: string, name: string, description: string): Promise<ProjectRow> {
182
164
  owner = owner.trim();
183
165
  name = name.trim();
184
166
  if (!/^[A-Za-z0-9_.-]+$/.test(owner)) throw new StoreError(400, "owner 含非法字符");
185
167
  if (!/^[A-Za-z0-9_.-]+$/.test(name)) throw new StoreError(400, "name 含非法字符");
186
- if (getProject(owner, name)) throw new StoreError(409, `项目 ${owner}/${name} 已存在`);
168
+ if (await getProject(owner, name)) throw new StoreError(409, `项目 ${owner}/${name} 已存在`);
187
169
  const ts = now();
188
- const db = rawDB();
189
- const info = db
190
- .query("INSERT INTO projects (owner, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
191
- .run(owner, name, description ?? "", ts, ts);
192
- return getProjectById(Number(info.lastInsertRowid))!;
170
+ const db = getDB();
171
+ const info = await db.run(
172
+ "INSERT INTO {{projects}} (owner, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
173
+ [owner, name, description ?? "", ts, ts]
174
+ );
175
+ return (await getProjectById(info.insertId))!;
193
176
  }
194
177
 
195
- export function touchProject(projectId: number): void {
196
- rawDB().query("UPDATE projects SET updated_at = ? WHERE id = ?").run(now(), projectId);
178
+ export async function touchProject(projectId: number): Promise<void> {
179
+ await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [now(), projectId]);
197
180
  }
198
181
 
199
182
  const MAX_UPSTREAM_URLS = 10;
@@ -214,7 +197,7 @@ export function getDefaultUpstreamUrl(project: Pick<ProjectRow, "upstream_urls">
214
197
  return urls.length > 0 ? urls[0]! : null;
215
198
  }
216
199
 
217
- export function setProjectUpstreamUrls(projectId: number, urls: string[]): string[] {
200
+ export async function setProjectUpstreamUrls(projectId: number, urls: string[]): Promise<string[]> {
218
201
  const seen = new Set<string>();
219
202
  const cleaned: string[] = [];
220
203
  for (const raw of urls) {
@@ -232,9 +215,10 @@ export function setProjectUpstreamUrls(projectId: number, urls: string[]): strin
232
215
  throw new StoreError(400, `上游 URL 数量超过上限(${MAX_UPSTREAM_URLS})`);
233
216
  }
234
217
  const ts = now();
235
- rawDB()
236
- .query("UPDATE projects SET upstream_urls = ?, updated_at = ? WHERE id = ?")
237
- .run(JSON.stringify(cleaned), ts, projectId);
218
+ await getDB().run(
219
+ "UPDATE {{projects}} SET upstream_urls = ?, updated_at = ? WHERE id = ?",
220
+ [JSON.stringify(cleaned), ts, projectId]
221
+ );
238
222
  return cleaned;
239
223
  }
240
224
 
@@ -242,14 +226,15 @@ export function setProjectUpstreamUrls(projectId: number, urls: string[]): strin
242
226
  // Same regex as OpencodeClient.listModels — keep in sync.
243
227
  const MODEL_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
244
228
 
245
- export function setProjectModel(projectId: number, model: string): string {
229
+ export async function setProjectModel(projectId: number, model: string): Promise<string> {
246
230
  const trimmed = String(model ?? "").trim();
247
231
  if (trimmed && !MODEL_RE.test(trimmed)) {
248
232
  throw new StoreError(400, `模型格式非法(须 provider/model): ${trimmed}`);
249
233
  }
250
- rawDB()
251
- .query("UPDATE projects SET model = ?, updated_at = ? WHERE id = ?")
252
- .run(trimmed, now(), projectId);
234
+ await getDB().run(
235
+ "UPDATE {{projects}} SET model = ?, updated_at = ? WHERE id = ?",
236
+ [trimmed, now(), projectId]
237
+ );
253
238
  return trimmed;
254
239
  }
255
240
 
@@ -262,13 +247,13 @@ export function resolveModel(projectModel: string, globalDefault: string): strin
262
247
  return String(globalDefault ?? "").trim();
263
248
  }
264
249
 
265
- export function listCachedModels(): CachedModel[] {
266
- const rows = rawDB().query("SELECT provider_model AS id, label FROM model_cache ORDER BY id").all() as CachedModel[];
250
+ export async function listCachedModels(): Promise<CachedModel[]> {
251
+ const rows = await getDB().all<CachedModel>("SELECT provider_model AS id, label FROM {{model_cache}} ORDER BY id");
267
252
  return rows;
268
253
  }
269
254
 
270
- export function replaceCachedModels(ids: string[]): void {
271
- const db = rawDB();
255
+ export async function replaceCachedModels(ids: string[]): Promise<void> {
256
+ const db = getDB();
272
257
  const ts = now();
273
258
  // Dedupe to avoid UNIQUE constraint violations — caller may pass raw
274
259
  // output from `opencode models` which could (theoretically) repeat.
@@ -280,38 +265,33 @@ export function replaceCachedModels(ids: string[]): void {
280
265
  unique.push(id);
281
266
  }
282
267
  }
283
- const tx = db.transaction(() => {
284
- db.exec("DELETE FROM model_cache");
285
- const stmt = db.query("INSERT INTO model_cache (provider_model, label, refreshed_at) VALUES (?, ?, ?)");
268
+ await db.transaction(async () => {
269
+ await db.exec("DELETE FROM {{model_cache}}");
286
270
  for (const id of unique) {
287
- stmt.run(id, id, ts);
271
+ await db.run("INSERT INTO {{model_cache}} (provider_model, label, refreshed_at) VALUES (?, ?, ?)", [id, id, ts]);
288
272
  }
289
273
  });
290
- tx();
291
274
  }
292
275
 
293
- export function getIssue(projectId: number, number: number): IssueRow | null {
276
+ export async function getIssue(projectId: number, number: number): Promise<IssueRow | null> {
294
277
  return (
295
- (rawDB()
296
- .query("SELECT * FROM issues WHERE project_id = ? AND number = ?")
297
- .get(projectId, number) as IssueRow | null) ?? null
278
+ (await getDB().get<IssueRow>("SELECT * FROM {{issues}} WHERE project_id = ? AND number = ?", [projectId, number])) ?? null
298
279
  );
299
280
  }
300
281
 
301
- export function getIssueById(id: number): IssueRow | null {
302
- return (rawDB().query("SELECT * FROM issues WHERE id = ?").get(id) as IssueRow | null) ?? null;
282
+ export async function getIssueById(id: number): Promise<IssueRow | null> {
283
+ return (await getDB().get<IssueRow>("SELECT * FROM {{issues}} WHERE id = ?", [id])) ?? null;
303
284
  }
304
285
 
305
- export function getIssueWithMeta(projectId: number, number: number): IssueWithMeta | null {
286
+ export async function getIssueWithMeta(projectId: number, number: number): Promise<IssueWithMeta | null> {
306
287
  return (
307
- (rawDB()
308
- .query(
309
- `SELECT i.*, p.owner AS project_owner, p.name AS project_name,
310
- (SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comment_count
311
- FROM issues i JOIN projects p ON p.id = i.project_id
312
- WHERE i.project_id = ? AND i.number = ?`
313
- )
314
- .get(projectId, number) as IssueWithMeta | null) ?? null
288
+ (await getDB().get<IssueWithMeta>(
289
+ `SELECT i.*, p.owner AS project_owner, p.name AS project_name,
290
+ (SELECT COUNT(*) FROM {{comments}} c WHERE c.issue_id = i.id) AS comment_count
291
+ FROM {{issues}} i JOIN {{projects}} p ON p.id = i.project_id
292
+ WHERE i.project_id = ? AND i.number = ?`,
293
+ [projectId, number]
294
+ )) ?? null
315
295
  );
316
296
  }
317
297
 
@@ -321,13 +301,13 @@ export interface ListIssuesOpts {
321
301
  limit?: number;
322
302
  }
323
303
 
324
- export function listIssues(projectId: number, opts: ListIssuesOpts = {}): IssueWithMeta[] {
304
+ export async function listIssues(projectId: number, opts: ListIssuesOpts = {}): Promise<IssueWithMeta[]> {
325
305
  const state = opts.state ?? "open";
326
306
  const q = (opts.q ?? "").trim();
327
307
  const limit = Math.min(opts.limit ?? 50, 200);
328
308
  let sql = `SELECT i.*, p.owner AS project_owner, p.name AS project_name,
329
- (SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comment_count
330
- FROM issues i JOIN projects p ON p.id = i.project_id
309
+ (SELECT COUNT(*) FROM {{comments}} c WHERE c.issue_id = i.id) AS comment_count
310
+ FROM {{issues}} i JOIN {{projects}} p ON p.id = i.project_id
331
311
  WHERE i.project_id = ?`;
332
312
  const args: (string | number)[] = [projectId];
333
313
  if (state !== "all") {
@@ -341,16 +321,16 @@ export function listIssues(projectId: number, opts: ListIssuesOpts = {}): IssueW
341
321
  }
342
322
  sql += " ORDER BY i.updated_at DESC LIMIT ?";
343
323
  args.push(limit);
344
- return rawDB().query(sql).all(...args) as IssueWithMeta[];
324
+ return await getDB().all<IssueWithMeta>(sql, args);
345
325
  }
346
326
 
347
- export function listAllIssues(opts: ListIssuesOpts = {}): IssueWithMeta[] {
327
+ export async function listAllIssues(opts: ListIssuesOpts = {}): Promise<IssueWithMeta[]> {
348
328
  const state = opts.state ?? "open";
349
329
  const q = (opts.q ?? "").trim();
350
330
  const limit = Math.min(opts.limit ?? 50, 200);
351
331
  let sql = `SELECT i.*, p.owner AS project_owner, p.name AS project_name,
352
- (SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comment_count
353
- FROM issues i JOIN projects p ON p.id = i.project_id
332
+ (SELECT COUNT(*) FROM {{comments}} c WHERE c.issue_id = i.id) AS comment_count
333
+ FROM {{issues}} i JOIN {{projects}} p ON p.id = i.project_id
354
334
  WHERE 1=1`;
355
335
  const args: (string | number)[] = [];
356
336
  if (state !== "all") {
@@ -364,7 +344,7 @@ export function listAllIssues(opts: ListIssuesOpts = {}): IssueWithMeta[] {
364
344
  }
365
345
  sql += " ORDER BY i.updated_at DESC LIMIT ?";
366
346
  args.push(limit);
367
- return rawDB().query(sql).all(...args) as IssueWithMeta[];
347
+ return await getDB().all<IssueWithMeta>(sql, args);
368
348
  }
369
349
 
370
350
  export interface CreateIssueOpts {
@@ -381,56 +361,51 @@ function isoOr(value: string | undefined, fallback: string): string {
381
361
  return new Date(parsed).toISOString();
382
362
  }
383
363
 
384
- export function createIssue(
364
+ export async function createIssue(
385
365
  projectId: number,
386
366
  title: string,
387
367
  body: string,
388
368
  author: string,
389
369
  opts: CreateIssueOpts = {}
390
- ): IssueRow {
370
+ ): Promise<IssueRow> {
391
371
  title = title.trim();
392
372
  if (!title) throw new StoreError(400, "标题不能为空");
393
373
  if (title.length > 255) throw new StoreError(400, "标题过长(≤255)");
394
374
  if (body.length > 65536) throw new StoreError(413, "正文过长(≤65536)");
395
- ensureUser(author);
375
+ await ensureUser(author);
396
376
  const createdAt = isoOr(opts.createdAt, now());
397
377
  const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
398
378
  const state: "open" | "closed" = opts.state ?? "open";
399
379
  const closedAt = state === "closed" ? isoOr(opts.closedAt ?? undefined, updatedAt) : null;
400
- const db = rawDB();
401
- return tx(db, () => {
402
- const next = (
403
- db.query("SELECT COALESCE(MAX(number), 0) + 1 AS n FROM issues WHERE project_id = ?").get(projectId) as {
404
- n: number;
405
- }
406
- ).n;
407
- const info = db
408
- .query(
409
- "INSERT INTO issues (project_id, number, title, body, state, author, created_at, updated_at, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
410
- )
411
- .run(projectId, next, title, body, state, author, createdAt, updatedAt, closedAt);
412
- db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(updatedAt, projectId);
413
- return getIssueById(Number(info.lastInsertRowid))!;
380
+ return await getDB().transaction(async () => {
381
+ const next = (await getDB().get<{ n: number }>(
382
+ "SELECT COALESCE(MAX(number), 0) + 1 AS n FROM {{issues}} WHERE project_id = ?", [projectId]
383
+ ))!;
384
+ const info = await getDB().run(
385
+ "INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
386
+ [projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt]
387
+ );
388
+ await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, projectId]);
389
+ return (await getIssueById(info.insertId))!;
414
390
  });
415
391
  }
416
392
 
417
- export function setIssueState(
393
+ export async function setIssueState(
418
394
  issueId: number,
419
395
  state: "open" | "closed",
420
396
  opts: { closedAt?: string; updatedAt?: string } = {}
421
- ): void {
397
+ ): Promise<void> {
422
398
  const ts = opts.updatedAt ? isoOr(opts.updatedAt, now()) : now();
423
399
  const closedAt = state === "closed" ? isoOr(opts.closedAt ?? undefined, ts) : null;
424
- const db = rawDB();
425
- tx(db, () => {
426
- db.query("UPDATE issues SET state = ?, updated_at = ?, closed_at = ? WHERE id = ?").run(
400
+ await getDB().transaction(async () => {
401
+ await getDB().run("UPDATE {{issues}} SET state = ?, updated_at = ?, closed_at = ? WHERE id = ?", [
427
402
  state,
428
403
  ts,
429
404
  closedAt,
430
- issueId
431
- );
432
- const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number };
433
- db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(ts, row.project_id);
405
+ issueId,
406
+ ]);
407
+ const row = await getDB().get<{ project_id: number }>("SELECT project_id FROM {{issues}} WHERE id = ?", [issueId]);
408
+ await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [ts, row!.project_id]);
434
409
  });
435
410
  }
436
411
 
@@ -442,7 +417,7 @@ export interface IssuePatch {
442
417
  updatedAt?: string;
443
418
  }
444
419
 
445
- export function editIssue(issueId: number, patch: IssuePatch): void {
420
+ export async function editIssue(issueId: number, patch: IssuePatch): Promise<void> {
446
421
  const sets: string[] = [];
447
422
  const args: (string | number | null)[] = [];
448
423
  if (patch.title !== undefined) {
@@ -478,59 +453,55 @@ export function editIssue(issueId: number, patch: IssuePatch): void {
478
453
  args.push(now());
479
454
  }
480
455
  args.push(issueId);
481
- const db = rawDB();
482
- tx(db, () => {
483
- db.query(`UPDATE issues SET ${sets.join(", ")} WHERE id = ?`).run(...args);
484
- const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number } | null;
485
- if (row) db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(now(), row.project_id);
456
+ await getDB().transaction(async () => {
457
+ await getDB().run(`UPDATE {{issues}} SET ${sets.join(", ")} WHERE id = ?`, args);
458
+ const row = await getDB().get<{ project_id: number }>("SELECT project_id FROM {{issues}} WHERE id = ?", [issueId]);
459
+ if (row) await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [now(), row.project_id]);
486
460
  });
487
461
  }
488
462
 
489
- export function countComments(issueId: number): number {
490
- const row = rawDB().query("SELECT COUNT(*) AS n FROM comments WHERE issue_id = ?").get(issueId) as { n: number };
491
- return row.n;
463
+ export async function countComments(issueId: number): Promise<number> {
464
+ const row = await getDB().get<{ n: number }>("SELECT COUNT(*) AS n FROM {{comments}} WHERE issue_id = ?", [issueId]);
465
+ return row!.n;
492
466
  }
493
467
 
494
- export function listCommentsPage(issueId: number, page: number, pageSize: number): { rows: CommentRow[]; page: number } {
495
- const total = countComments(issueId);
468
+ export async function listCommentsPage(issueId: number, page: number, pageSize: number): Promise<{ rows: CommentRow[]; page: number }> {
469
+ const total = await countComments(issueId);
496
470
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
497
471
  const clamped = Math.min(Math.max(1, page), totalPages);
498
472
  const offset = (clamped - 1) * pageSize;
499
- const rows = rawDB()
500
- .query(
501
- `SELECT c.*, u.kind AS author_kind
502
- FROM comments c
503
- LEFT JOIN users u ON u.login = c.author
504
- WHERE c.issue_id = ?
505
- ORDER BY c.created_at ASC, c.id ASC
506
- LIMIT ? OFFSET ?`
507
- )
508
- .all(issueId, pageSize, offset) as CommentRow[];
473
+ const rows = await getDB().all<CommentRow>(
474
+ `SELECT c.*, u.kind AS author_kind
475
+ FROM {{comments}} c
476
+ LEFT JOIN {{users}} u ON u.login = c.author
477
+ WHERE c.issue_id = ?
478
+ ORDER BY c.created_at ASC, c.id ASC
479
+ LIMIT ? OFFSET ?`,
480
+ [issueId, pageSize, offset]
481
+ );
509
482
  return { rows, page: clamped };
510
483
  }
511
484
 
512
- export function listCommentsSince(issueId: number, sinceISO: string): CommentRow[] {
513
- return rawDB()
514
- .query(
515
- `SELECT c.*, u.kind AS author_kind
516
- FROM comments c
517
- LEFT JOIN users u ON u.login = c.author
518
- WHERE c.issue_id = ? AND c.created_at > ?
519
- ORDER BY c.created_at ASC, c.id ASC`
520
- )
521
- .all(issueId, sinceISO) as CommentRow[];
485
+ export async function listCommentsSince(issueId: number, sinceISO: string): Promise<CommentRow[]> {
486
+ return await getDB().all<CommentRow>(
487
+ `SELECT c.*, u.kind AS author_kind
488
+ FROM {{comments}} c
489
+ LEFT JOIN {{users}} u ON u.login = c.author
490
+ WHERE c.issue_id = ? AND c.created_at > ?
491
+ ORDER BY c.created_at ASC, c.id ASC`,
492
+ [issueId, sinceISO]
493
+ );
522
494
  }
523
495
 
524
- export function listCommentsForIssue(issueId: number): CommentRow[] {
525
- return rawDB()
526
- .query(
527
- `SELECT c.*, u.kind AS author_kind
528
- FROM comments c
529
- LEFT JOIN users u ON u.login = c.author
530
- WHERE c.issue_id = ?
531
- ORDER BY c.created_at ASC, c.id ASC`
532
- )
533
- .all(issueId) as CommentRow[];
496
+ export async function listCommentsForIssue(issueId: number): Promise<CommentRow[]> {
497
+ return await getDB().all<CommentRow>(
498
+ `SELECT c.*, u.kind AS author_kind
499
+ FROM {{comments}} c
500
+ LEFT JOIN {{users}} u ON u.login = c.author
501
+ WHERE c.issue_id = ?
502
+ ORDER BY c.created_at ASC, c.id ASC`,
503
+ [issueId]
504
+ );
534
505
  }
535
506
 
536
507
  export interface CreateCommentOpts {
@@ -538,57 +509,56 @@ export interface CreateCommentOpts {
538
509
  updatedAt?: string;
539
510
  }
540
511
 
541
- export function postComment(
512
+ export async function postComment(
542
513
  issueId: number,
543
514
  body: string,
544
515
  author: string,
545
516
  opts: CreateCommentOpts = {}
546
- ): CommentRow {
517
+ ): Promise<CommentRow> {
547
518
  if (!body || !body.trim()) throw new StoreError(400, "评论不能为空");
548
519
  if (body.length > 65536) throw new StoreError(413, "评论过长(≤65536)");
549
- ensureUser(author);
520
+ await ensureUser(author);
550
521
  const createdAt = isoOr(opts.createdAt, now());
551
522
  const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
552
- const db = rawDB();
553
- return tx(db, () => {
554
- const info = db
555
- .query("INSERT INTO comments (issue_id, author, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
556
- .run(issueId, author, body, createdAt, updatedAt);
557
- db.query("UPDATE issues SET updated_at = ? WHERE id = ?").run(updatedAt, issueId);
558
- const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number };
559
- db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(updatedAt, row.project_id);
560
- return db.query(
523
+ return await getDB().transaction(async () => {
524
+ const info = await getDB().run(
525
+ "INSERT INTO {{comments}} (issue_id, author, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
526
+ [issueId, author, body, createdAt, updatedAt]
527
+ );
528
+ await getDB().run("UPDATE {{issues}} SET updated_at = ? WHERE id = ?", [updatedAt, issueId]);
529
+ const row = await getDB().get<{ project_id: number }>("SELECT project_id FROM {{issues}} WHERE id = ?", [issueId]);
530
+ await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, row!.project_id]);
531
+ return (await getDB().get<CommentRow>(
561
532
  `SELECT c.*, u.kind AS author_kind
562
- FROM comments c LEFT JOIN users u ON u.login = c.author
563
- WHERE c.id = ?`
564
- ).get(Number(info.lastInsertRowid)) as CommentRow;
533
+ FROM {{comments}} c LEFT JOIN {{users}} u ON u.login = c.author
534
+ WHERE c.id = ?`,
535
+ [info.insertId]
536
+ ))!;
565
537
  });
566
538
  }
567
539
 
568
- export function getComment(commentId: number): CommentRow | null {
569
- const row = rawDB().query(
540
+ export async function getComment(commentId: number): Promise<CommentRow | null> {
541
+ const row = await getDB().get<CommentRow>(
570
542
  `SELECT c.*, u.kind AS author_kind
571
- FROM comments c LEFT JOIN users u ON u.login = c.author
572
- WHERE c.id = ?`
573
- ).get(commentId) as CommentRow | undefined;
543
+ FROM {{comments}} c LEFT JOIN {{users}} u ON u.login = c.author
544
+ WHERE c.id = ?`,
545
+ [commentId]
546
+ );
574
547
  return row ?? null;
575
548
  }
576
549
 
577
- export function editComment(commentId: number, body: string): CommentRow {
550
+ export async function editComment(commentId: number, body: string): Promise<CommentRow> {
578
551
  const trimmed = String(body ?? "");
579
552
  if (!trimmed.trim()) throw new StoreError(400, "评论不能为空");
580
553
  if (trimmed.length > 65536) throw new StoreError(413, "评论过长(≤65536)");
581
554
  const ts = now();
582
- const db = rawDB();
583
- const info = db
584
- .query("UPDATE comments SET body = ?, updated_at = ? WHERE id = ?")
585
- .run(trimmed, ts, commentId);
555
+ const info = await getDB().run("UPDATE {{comments}} SET body = ?, updated_at = ? WHERE id = ?", [trimmed, ts, commentId]);
586
556
  if (info.changes === 0) throw new StoreError(404, `评论 ${commentId} 不存在`);
587
- return getComment(commentId)!;
557
+ return (await getComment(commentId))!;
588
558
  }
589
559
 
590
- export function deleteComment(commentId: number): void {
591
- const info = rawDB().query("DELETE FROM comments WHERE id = ?").run(commentId);
560
+ export async function deleteComment(commentId: number): Promise<void> {
561
+ const info = await getDB().run("DELETE FROM {{comments}} WHERE id = ?", [commentId]);
592
562
  if (info.changes === 0) throw new StoreError(404, `评论 ${commentId} 不存在`);
593
563
  }
594
564
 
@@ -598,80 +568,74 @@ export interface ReactionAgg {
598
568
  n: number;
599
569
  }
600
570
 
601
- export function listReactionsFor(commentIds: number[]): ReactionAgg[] {
571
+ export async function listReactionsFor(commentIds: number[]): Promise<ReactionAgg[]> {
602
572
  if (commentIds.length === 0) return [];
603
573
  const placeholders = commentIds.map(() => "?").join(",");
604
- return rawDB()
605
- .query(
606
- `SELECT comment_id, content, COUNT(*) AS n
607
- FROM reactions WHERE comment_id IN (${placeholders})
608
- GROUP BY comment_id, content`
609
- )
610
- .all(...commentIds) as ReactionAgg[];
574
+ return await getDB().all<ReactionAgg>(
575
+ `SELECT comment_id, content, COUNT(*) AS n
576
+ FROM {{reactions}} WHERE comment_id IN (${placeholders})
577
+ GROUP BY comment_id, content`,
578
+ commentIds
579
+ );
611
580
  }
612
581
 
613
- export function addReaction(commentId: number, userLogin: string, content: string): void {
582
+ export async function addReaction(commentId: number, userLogin: string, content: string): Promise<void> {
614
583
  if (!content || content.length > 32) throw new StoreError(400, "非法的 reaction content");
615
- ensureUser(userLogin);
616
- rawDB()
617
- .query("INSERT OR IGNORE INTO reactions (comment_id, user_login, content) VALUES (?, ?, ?)")
618
- .run(commentId, userLogin, content);
584
+ await ensureUser(userLogin);
585
+ await getDB().run(
586
+ "INSERT OR IGNORE INTO {{reactions}} (comment_id, user_login, content) VALUES (?, ?, ?)",
587
+ [commentId, userLogin, content]
588
+ );
619
589
  }
620
590
 
621
- export function removeReaction(commentId: number, userLogin: string, content: string): void {
622
- rawDB()
623
- .query("DELETE FROM reactions WHERE comment_id = ? AND user_login = ? AND content = ?")
624
- .run(commentId, userLogin, content);
591
+ export async function removeReaction(commentId: number, userLogin: string, content: string): Promise<void> {
592
+ await getDB().run(
593
+ "DELETE FROM {{reactions}} WHERE comment_id = ? AND user_login = ? AND content = ?",
594
+ [commentId, userLogin, content]
595
+ );
625
596
  }
626
597
 
627
- export function listLabels(projectId: number): LabelRow[] {
628
- return rawDB().query("SELECT * FROM labels WHERE project_id = ? ORDER BY name").all(projectId) as LabelRow[];
598
+ export async function listLabels(projectId: number): Promise<LabelRow[]> {
599
+ return await getDB().all<LabelRow>("SELECT * FROM {{labels}} WHERE project_id = ? ORDER BY name", [projectId]);
629
600
  }
630
601
 
631
- export function listLabelsForIssue(issueId: number): LabelRow[] {
632
- return rawDB()
633
- .query(
634
- `SELECT l.* FROM labels l
635
- JOIN issue_labels il ON il.label_id = l.id
636
- WHERE il.issue_id = ? ORDER BY l.name`
637
- )
638
- .all(issueId) as LabelRow[];
602
+ export async function listLabelsForIssue(issueId: number): Promise<LabelRow[]> {
603
+ return await getDB().all<LabelRow>(
604
+ `SELECT l.* FROM {{labels}} l
605
+ JOIN {{issue_labels}} il ON il.label_id = l.id
606
+ WHERE il.issue_id = ? ORDER BY l.name`,
607
+ [issueId]
608
+ );
639
609
  }
640
610
 
641
- export function createLabel(projectId: number, name: string, color: string): LabelRow {
611
+ export async function createLabel(projectId: number, name: string, color: string): Promise<LabelRow> {
642
612
  name = name.trim();
643
613
  if (!name) throw new StoreError(400, "标签名不能为空");
644
614
  if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new StoreError(400, "颜色须为 #RRGGBB");
645
- const db = rawDB();
646
- const info = db
647
- .query("INSERT INTO labels (project_id, name, color) VALUES (?, ?, ?)")
648
- .run(projectId, name, color);
649
- return db.query("SELECT * FROM labels WHERE id = ?").get(Number(info.lastInsertRowid)) as LabelRow;
615
+ const info = await getDB().run("INSERT INTO {{labels}} (project_id, name, color) VALUES (?, ?, ?)", [projectId, name, color]);
616
+ return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [info.insertId]))!;
650
617
  }
651
618
 
652
- export function setIssueLabel(issueId: number, labelId: number, on: boolean): void {
653
- const db = rawDB();
619
+ export async function setIssueLabel(issueId: number, labelId: number, on: boolean): Promise<void> {
654
620
  if (on) {
655
- db.query("INSERT OR IGNORE INTO issue_labels (issue_id, label_id) VALUES (?, ?)").run(issueId, labelId);
621
+ await getDB().run("INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)", [issueId, labelId]);
656
622
  } else {
657
- db.query("DELETE FROM issue_labels WHERE issue_id = ? AND label_id = ?").run(issueId, labelId);
623
+ await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, labelId]);
658
624
  }
659
625
  }
660
626
 
661
- export function createAttachment(a: Omit<AttachmentRow, "created_at">): AttachmentRow {
627
+ export async function createAttachment(a: Omit<AttachmentRow, "created_at">): Promise<AttachmentRow> {
662
628
  const ts = now();
663
- const db = rawDB();
664
- db
665
- .query(
666
- `INSERT INTO attachments (uuid, issue_id, filename, content_type, size, blob_path, uploaded_by, created_at)
667
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
668
- )
669
- .run(a.uuid, a.issue_id, a.filename, a.content_type, a.size, a.blob_path, a.uploaded_by, ts);
670
- return db.query("SELECT * FROM attachments WHERE uuid = ?").get(a.uuid) as AttachmentRow;
629
+ await getDB().run(
630
+ `INSERT INTO {{attachments}} (uuid, issue_id, filename, content_type, size, blob_path, uploaded_by, created_at)
631
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
632
+ [a.uuid, a.issue_id, a.filename, a.content_type, a.size, a.blob_path, a.uploaded_by, ts]
633
+ );
634
+ return (await getDB().get<AttachmentRow>("SELECT * FROM {{attachments}} WHERE uuid = ?", [a.uuid]))!;
671
635
  }
672
636
 
673
- export function getAttachment(uuid: string): AttachmentRow | null {
674
- return (rawDB().query("SELECT * FROM attachments WHERE uuid = ?").get(uuid) as AttachmentRow | null) ?? null;
637
+ export async function getAttachment(uuid: string): Promise<AttachmentRow | null> {
638
+ return (await getDB().get<AttachmentRow>("SELECT * FROM {{attachments}} WHERE uuid = ?", [uuid])) ?? null;
675
639
  }
676
640
 
677
641
  const LOGIN_RE = /^[A-Za-z0-9_-]{1,64}$/;
@@ -693,45 +657,44 @@ export async function createUser(input: CreateUserInput): Promise<UserRow> {
693
657
  if (input.password.length > 200) {
694
658
  throw new StoreError(400, "密码过长(≤200)");
695
659
  }
696
- const db = rawDB();
697
- if (db.query("SELECT 1 FROM users WHERE login = ?").get(login)) {
660
+ const db = getDB();
661
+ if (await db.get("SELECT 1 FROM {{users}} WHERE login = ?", [login])) {
698
662
  throw new StoreError(409, `用户 ${login} 已存在`);
699
663
  }
700
664
  const ts = now();
701
665
  const hash = await hashPassword(input.password);
702
- db.query(
703
- `INSERT INTO users (login, kind, display_name, password_hash, email, is_admin, is_active, created_at, updated_at)
704
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
705
- ).run(
706
- login,
707
- input.kind ?? "human",
708
- input.display_name ?? null,
709
- hash,
710
- input.email ?? null,
711
- input.is_admin ? 1 : 0,
712
- input.is_active === false ? 0 : 1,
713
- ts,
714
- ts
666
+ await db.run(
667
+ `INSERT INTO {{users}} (login, kind, display_name, password_hash, email, is_admin, is_active, created_at, updated_at)
668
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
669
+ [
670
+ login,
671
+ input.kind ?? "human",
672
+ input.display_name ?? null,
673
+ hash,
674
+ input.email ?? null,
675
+ input.is_admin ? 1 : 0,
676
+ input.is_active === false ? 0 : 1,
677
+ ts,
678
+ ts
679
+ ]
715
680
  );
716
- return getUserByLogin(login)!;
681
+ return (await getUserByLogin(login))!;
717
682
  }
718
683
 
719
- export function getUserByLogin(login: string): UserRow | null {
720
- return (rawDB().query("SELECT * FROM users WHERE login = ?").get(login) as UserRow | null) ?? null;
684
+ export async function getUserByLogin(login: string): Promise<UserRow | null> {
685
+ return (await getDB().get<UserRow>("SELECT * FROM {{users}} WHERE login = ?", [login])) ?? null;
721
686
  }
722
687
 
723
- export function listUsers(): UserRow[] {
724
- return rawDB()
725
- .query("SELECT * FROM users ORDER BY is_admin DESC, created_at ASC")
726
- .all() as UserRow[];
688
+ export async function listUsers(): Promise<UserRow[]> {
689
+ return await getDB().all<UserRow>("SELECT * FROM {{users}} ORDER BY is_admin DESC, created_at ASC");
727
690
  }
728
691
 
729
- export function listAdmins(): UserRow[] {
730
- return rawDB().query("SELECT * FROM users WHERE is_admin = 1").all() as UserRow[];
692
+ export async function listAdmins(): Promise<UserRow[]> {
693
+ return await getDB().all<UserRow>("SELECT * FROM {{users}} WHERE is_admin = 1");
731
694
  }
732
695
 
733
696
  export async function verifyUserPassword(login: string, password: string): Promise<UserRow | null> {
734
- const user = getUserByLogin(login);
697
+ const user = await getUserByLogin(login);
735
698
  if (!user || !user.password_hash || !user.is_active) return null;
736
699
  const ok = await Bun.password.verify(password, user.password_hash);
737
700
  return ok ? user : null;
@@ -745,47 +708,46 @@ export interface UpdateUserPatch {
745
708
  kind?: UserKind;
746
709
  }
747
710
 
748
- export function updateUser(login: string, patch: UpdateUserPatch): UserRow {
749
- const existing = getUserByLogin(login);
711
+ export async function updateUser(login: string, patch: UpdateUserPatch): Promise<UserRow> {
712
+ const existing = await getUserByLogin(login);
750
713
  if (!existing) throw new StoreError(404, `用户 ${login} 不存在`);
751
714
  const ts = now();
752
- const db = rawDB();
753
- db.query(
754
- `UPDATE users SET
715
+ const db = getDB();
716
+ await db.run(
717
+ `UPDATE {{users}} SET
755
718
  email = COALESCE(?, email),
756
719
  display_name = COALESCE(?, display_name),
757
720
  is_admin = COALESCE(?, is_admin),
758
721
  is_active = COALESCE(?, is_active),
759
722
  kind = COALESCE(?, kind),
760
723
  updated_at = ?
761
- WHERE login = ?`
762
- ).run(
763
- patch.email !== undefined ? patch.email : null,
764
- patch.display_name !== undefined ? patch.display_name : null,
765
- patch.is_admin !== undefined ? (patch.is_admin ? 1 : 0) : null,
766
- patch.is_active !== undefined ? (patch.is_active ? 1 : 0) : null,
767
- patch.kind ?? null,
768
- ts,
769
- login
724
+ WHERE login = ?`,
725
+ [
726
+ patch.email !== undefined ? patch.email : null,
727
+ patch.display_name !== undefined ? patch.display_name : null,
728
+ patch.is_admin !== undefined ? (patch.is_admin ? 1 : 0) : null,
729
+ patch.is_active !== undefined ? (patch.is_active ? 1 : 0) : null,
730
+ patch.kind ?? null,
731
+ ts,
732
+ login
733
+ ]
770
734
  );
771
- return getUserByLogin(login)!;
735
+ return (await getUserByLogin(login))!;
772
736
  }
773
737
 
774
738
  export async function setUserPassword(login: string, newPassword: string): Promise<void> {
775
739
  if (newPassword.length < 8) throw new StoreError(400, "密码至少 8 位");
776
740
  if (newPassword.length > 200) throw new StoreError(400, "密码过长(≤200)");
777
- const existing = getUserByLogin(login);
741
+ const existing = await getUserByLogin(login);
778
742
  if (!existing) throw new StoreError(404, `用户 ${login} 不存在`);
779
743
  const ts = now();
780
744
  const hash = await hashPassword(newPassword);
781
- rawDB()
782
- .query("UPDATE users SET password_hash = ?, updated_at = ? WHERE login = ?")
783
- .run(hash, ts, login);
745
+ await getDB().run("UPDATE {{users}} SET password_hash = ?, updated_at = ? WHERE login = ?", [hash, ts, login]);
784
746
  }
785
747
 
786
- export function countAdmins(): number {
787
- const row = rawDB().query("SELECT COUNT(*) AS n FROM users WHERE is_admin = 1").get() as { n: number };
788
- return row.n;
748
+ export async function countAdmins(): Promise<number> {
749
+ const row = await getDB().get<{ n: number }>("SELECT COUNT(*) AS n FROM {{users}} WHERE is_admin = 1");
750
+ return row!.n;
789
751
  }
790
752
 
791
753
  export interface PatRow {
@@ -914,7 +876,7 @@ function ctEqualBuf(a: Uint8Array, b: Uint8Array): boolean {
914
876
  export async function createPat(input: CreatePatInput): Promise<CreatePatResult> {
915
877
  const login = input.user_login.trim();
916
878
  const name = input.name.trim();
917
- const user = getUserByLogin(login);
879
+ const user = await getUserByLogin(login);
918
880
  if (!user) throw new StoreError(404, `用户 ${login} 不存在`);
919
881
  if (!PAT_NAME_RE.test(name)) throw new StoreError(400, "token 名称含非法字符或长度不在 1-64 内");
920
882
  const scopes = input.scopes ?? ["read", "write"];
@@ -934,27 +896,26 @@ export async function createPat(input: CreatePatInput): Promise<CreatePatResult>
934
896
  const tokenHash = sha256Hex(salt + plaintext);
935
897
  const lastEight = plaintext.slice(-8);
936
898
  const ts = now();
937
- const db = rawDB();
938
- const info = db
939
- .query(
940
- `INSERT INTO personal_access_tokens
941
- (user_login, name, salt, token_hash, token_last_eight, scopes, ip_allowlist, expires_at, created_at)
942
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
943
- )
944
- .run(login, name, salt, tokenHash, lastEight, JSON.stringify(scopes), JSON.stringify(ipAllowlist), expiresAt, ts);
945
- const row = getPat(Number(info.lastInsertRowid));
899
+ const info = await getDB().run(
900
+ `INSERT INTO {{personal_access_tokens}}
901
+ (user_login, name, salt, token_hash, token_last_eight, scopes, ip_allowlist, expires_at, created_at)
902
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
903
+ [login, name, salt, tokenHash, lastEight, JSON.stringify(scopes), JSON.stringify(ipAllowlist), expiresAt, ts]
904
+ );
905
+ const row = await getPat(info.insertId);
946
906
  if (!row) throw new StoreError(500, "PAT 创建后无法读取");
947
907
  return { row, plaintext };
948
908
  }
949
909
 
950
- export function getPat(id: number): PatRow | null {
951
- return (rawDB().query("SELECT * FROM personal_access_tokens WHERE id = ?").get(id) as PatRow | null) ?? null;
910
+ export async function getPat(id: number): Promise<PatRow | null> {
911
+ return (await getDB().get<PatRow>("SELECT * FROM {{personal_access_tokens}} WHERE id = ?", [id])) ?? null;
952
912
  }
953
913
 
954
- export function listPatsForUser(login: string): PatRow[] {
955
- return rawDB()
956
- .query("SELECT * FROM personal_access_tokens WHERE user_login = ? ORDER BY created_at DESC")
957
- .all(login) as PatRow[];
914
+ export async function listPatsForUser(login: string): Promise<PatRow[]> {
915
+ return await getDB().all<PatRow>(
916
+ "SELECT * FROM {{personal_access_tokens}} WHERE user_login = ? ORDER BY created_at DESC",
917
+ [login]
918
+ );
958
919
  }
959
920
 
960
921
  export interface PatWithUser extends PatRow {
@@ -963,44 +924,39 @@ export interface PatWithUser extends PatRow {
963
924
  user_is_active: number | null;
964
925
  }
965
926
 
966
- export function listAllPatsWithUsers(): PatWithUser[] {
967
- return rawDB()
968
- .query(
969
- `SELECT p.*, u.kind AS user_kind, u.is_admin AS user_is_admin, u.is_active AS user_is_active
970
- FROM personal_access_tokens p
971
- LEFT JOIN users u ON u.login = p.user_login
972
- ORDER BY p.created_at DESC`
973
- )
974
- .all() as PatWithUser[];
927
+ export async function listAllPatsWithUsers(): Promise<PatWithUser[]> {
928
+ return await getDB().all<PatWithUser>(
929
+ `SELECT p.*, u.kind AS user_kind, u.is_admin AS user_is_admin, u.is_active AS user_is_active
930
+ FROM {{personal_access_tokens}} p
931
+ LEFT JOIN {{users}} u ON u.login = p.user_login
932
+ ORDER BY p.created_at DESC`
933
+ );
975
934
  }
976
935
 
977
- export function revokePat(id: number, login: string): void {
978
- const row = getPat(id);
936
+ export async function revokePat(id: number, login: string): Promise<void> {
937
+ const row = await getPat(id);
979
938
  if (!row) throw new StoreError(404, "token 不存在");
980
939
  if (row.user_login !== login) throw new StoreError(403, "无权操作他人 token");
981
940
  if (row.revoked_at) return;
982
- rawDB()
983
- .query("UPDATE personal_access_tokens SET revoked_at = ? WHERE id = ?")
984
- .run(now(), id);
941
+ await getDB().run("UPDATE {{personal_access_tokens}} SET revoked_at = ? WHERE id = ?", [now(), id]);
985
942
  }
986
943
 
987
944
  // Admin override: revoke any token regardless of owner. Caller MUST check
988
945
  // ctx.user.is_admin === 1 before invoking.
989
- export function revokePatAsAdmin(id: number): void {
990
- const row = getPat(id);
946
+ export async function revokePatAsAdmin(id: number): Promise<void> {
947
+ const row = await getPat(id);
991
948
  if (!row) throw new StoreError(404, "token 不存在");
992
949
  if (row.revoked_at) return;
993
- rawDB()
994
- .query("UPDATE personal_access_tokens SET revoked_at = ? WHERE id = ?")
995
- .run(now(), id);
950
+ await getDB().run("UPDATE {{personal_access_tokens}} SET revoked_at = ? WHERE id = ?", [now(), id]);
996
951
  }
997
952
 
998
953
  export async function verifyPat(rawToken: string, ip?: string | null): Promise<UserRow | null> {
999
954
  if (!/^[0-9a-f]{40}$/.test(rawToken)) return null;
1000
955
  const lastEight = rawToken.slice(-8);
1001
- const candidates = rawDB()
1002
- .query("SELECT * FROM personal_access_tokens WHERE token_last_eight = ?")
1003
- .all(lastEight) as PatRow[];
956
+ const candidates = await getDB().all<PatRow>(
957
+ "SELECT * FROM {{personal_access_tokens}} WHERE token_last_eight = ?",
958
+ [lastEight]
959
+ );
1004
960
  const nowMs = Date.now();
1005
961
  for (const row of candidates) {
1006
962
  if (row.revoked_at) continue;
@@ -1009,11 +965,9 @@ export async function verifyPat(rawToken: string, ip?: string | null): Promise<U
1009
965
  if (!ctEqualBuf(Buffer.from(expected, "hex"), Buffer.from(row.token_hash, "hex"))) continue;
1010
966
  const allowlist = parsePatIpAllowlist(row.ip_allowlist ?? "[]");
1011
967
  if (!ipAllowedBy(ip, allowlist)) continue;
1012
- const user = getUserByLogin(row.user_login);
968
+ const user = await getUserByLogin(row.user_login);
1013
969
  if (!user || !user.is_active) return null;
1014
- rawDB()
1015
- .query("UPDATE personal_access_tokens SET last_used_at = ? WHERE id = ?")
1016
- .run(now(), row.id);
970
+ await getDB().run("UPDATE {{personal_access_tokens}} SET last_used_at = ? WHERE id = ?", [now(), row.id]);
1017
971
  return user;
1018
972
  }
1019
973
  return null;
@@ -1036,28 +990,29 @@ export interface ProjectMemberWithUser extends ProjectMembership {
1036
990
  user_is_active: number;
1037
991
  }
1038
992
 
1039
- export function listProjectMembersWithUsers(projectId: number): ProjectMemberWithUser[] {
1040
- return rawDB()
1041
- .query(
1042
- `SELECT m.*, u.kind AS user_kind, u.display_name AS user_display_name, u.is_active AS user_is_active
1043
- FROM project_members m JOIN users u ON u.login = m.user_login
1044
- WHERE m.project_id = ?
1045
- ORDER BY CASE m.role WHEN 'admin' THEN 0 WHEN 'writer' THEN 1 ELSE 2 END, m.created_at ASC`
1046
- )
1047
- .all(projectId) as ProjectMemberWithUser[];
993
+ export async function listProjectMembersWithUsers(projectId: number): Promise<ProjectMemberWithUser[]> {
994
+ return await getDB().all<ProjectMemberWithUser>(
995
+ `SELECT m.*, u.kind AS user_kind, u.display_name AS user_display_name, u.is_active AS user_is_active
996
+ FROM {{project_members}} m JOIN {{users}} u ON u.login = m.user_login
997
+ WHERE m.project_id = ?
998
+ ORDER BY CASE m.role WHEN 'admin' THEN 0 WHEN 'writer' THEN 1 ELSE 2 END, m.created_at ASC`,
999
+ [projectId]
1000
+ );
1048
1001
  }
1049
1002
 
1050
- export function listMembershipsForUser(userLogin: string): ProjectMembership[] {
1051
- return rawDB()
1052
- .query("SELECT * FROM project_members WHERE user_login = ? ORDER BY created_at ASC")
1053
- .all(userLogin) as ProjectMembership[];
1003
+ export async function listMembershipsForUser(userLogin: string): Promise<ProjectMembership[]> {
1004
+ return await getDB().all<ProjectMembership>(
1005
+ "SELECT * FROM {{project_members}} WHERE user_login = ? ORDER BY created_at ASC",
1006
+ [userLogin]
1007
+ );
1054
1008
  }
1055
1009
 
1056
- export function getProjectMembership(projectId: number, userLogin: string): ProjectMembership | null {
1010
+ export async function getProjectMembership(projectId: number, userLogin: string): Promise<ProjectMembership | null> {
1057
1011
  return (
1058
- (rawDB()
1059
- .query("SELECT * FROM project_members WHERE project_id = ? AND user_login = ?")
1060
- .get(projectId, userLogin) as ProjectMembership | null) ?? null
1012
+ (await getDB().get<ProjectMembership>(
1013
+ "SELECT * FROM {{project_members}} WHERE project_id = ? AND user_login = ?",
1014
+ [projectId, userLogin]
1015
+ )) ?? null
1061
1016
  );
1062
1017
  }
1063
1018
 
@@ -1065,66 +1020,71 @@ export function getProjectMembership(projectId: number, userLogin: string): Proj
1065
1020
  // "admin" (the highest project role) so caller's canWrite/canAdmin work without
1066
1021
  // a separate code path. Caller still needs to check user.is_admin separately
1067
1022
  // for site-wide admin powers (user management, etc.).
1068
- export function getRoleOnProject(projectId: number, user: { login: string; is_admin: number } | null): ProjectRole | null {
1023
+ export async function getRoleOnProject(projectId: number, user: { login: string; is_admin: number } | null): Promise<ProjectRole | null> {
1069
1024
  if (!user) return null;
1070
1025
  if (user.is_admin === 1) return "admin";
1071
- const m = getProjectMembership(projectId, user.login);
1026
+ const m = await getProjectMembership(projectId, user.login);
1072
1027
  return m?.role ?? null;
1073
1028
  }
1074
1029
 
1075
- export function canWriteProject(projectId: number, user: { login: string; is_admin: number } | null): boolean {
1076
- const r = getRoleOnProject(projectId, user);
1030
+ export async function canWriteProject(projectId: number, user: { login: string; is_admin: number } | null): Promise<boolean> {
1031
+ const r = await getRoleOnProject(projectId, user);
1077
1032
  return r !== null && ROLE_RANK[r] >= ROLE_RANK.writer;
1078
1033
  }
1079
1034
 
1080
- export function canAdminProject(projectId: number, user: { login: string; is_admin: number } | null): boolean {
1081
- const r = getRoleOnProject(projectId, user);
1035
+ export async function canAdminProject(projectId: number, user: { login: string; is_admin: number } | null): Promise<boolean> {
1036
+ const r = await getRoleOnProject(projectId, user);
1082
1037
  return r === "admin";
1083
1038
  }
1084
1039
 
1085
- export function addProjectMember(projectId: number, userLogin: string, role: ProjectRole): ProjectMembership {
1040
+ export async function addProjectMember(projectId: number, userLogin: string, role: ProjectRole): Promise<ProjectMembership> {
1086
1041
  const login = userLogin.trim();
1087
- if (!getUserByLogin(login)) throw new StoreError(404, `用户 ${login} 不存在`);
1042
+ if (!(await getUserByLogin(login))) throw new StoreError(404, `用户 ${login} 不存在`);
1088
1043
  const ts = now();
1089
- const db = rawDB();
1090
- db.query(
1091
- "INSERT OR IGNORE INTO project_members (project_id, user_login, role, created_at) VALUES (?, ?, ?, ?)"
1092
- ).run(projectId, login, role, ts);
1093
- return getProjectMembership(projectId, login)!;
1044
+ await getDB().run(
1045
+ "INSERT OR IGNORE INTO {{project_members}} (project_id, user_login, role, created_at) VALUES (?, ?, ?, ?)",
1046
+ [projectId, login, role, ts]
1047
+ );
1048
+ return (await getProjectMembership(projectId, login))!;
1094
1049
  }
1095
1050
 
1096
- export function setProjectMemberRole(projectId: number, userLogin: string, role: ProjectRole): void {
1097
- const m = getProjectMembership(projectId, userLogin);
1051
+ export async function setProjectMemberRole(projectId: number, userLogin: string, role: ProjectRole): Promise<void> {
1052
+ const m = await getProjectMembership(projectId, userLogin);
1098
1053
  if (!m) throw new StoreError(404, `用户 ${userLogin} 不是该项目成员`);
1099
- rawDB()
1100
- .query("UPDATE project_members SET role = ? WHERE project_id = ? AND user_login = ?")
1101
- .run(role, projectId, userLogin);
1054
+ await getDB().run(
1055
+ "UPDATE {{project_members}} SET role = ? WHERE project_id = ? AND user_login = ?",
1056
+ [role, projectId, userLogin]
1057
+ );
1102
1058
  }
1103
1059
 
1104
- export function removeProjectMember(projectId: number, userLogin: string): void {
1105
- rawDB()
1106
- .query("DELETE FROM project_members WHERE project_id = ? AND user_login = ?")
1107
- .run(projectId, userLogin);
1060
+ export async function removeProjectMember(projectId: number, userLogin: string): Promise<void> {
1061
+ await getDB().run(
1062
+ "DELETE FROM {{project_members}} WHERE project_id = ? AND user_login = ?",
1063
+ [projectId, userLogin]
1064
+ );
1108
1065
  }
1109
1066
 
1110
- export function countProjectAdmins(projectId: number): number {
1111
- const row = rawDB()
1112
- .query("SELECT COUNT(*) AS n FROM project_members WHERE project_id = ? AND role = 'admin'")
1113
- .get(projectId) as { n: number };
1114
- return row.n;
1067
+ export async function countProjectAdmins(projectId: number): Promise<number> {
1068
+ const row = await getDB().get<{ n: number }>(
1069
+ "SELECT COUNT(*) AS n FROM {{project_members}} WHERE project_id = ? AND role = 'admin'",
1070
+ [projectId]
1071
+ );
1072
+ return row!.n;
1115
1073
  }
1116
1074
 
1117
1075
  // Idempotent bootstrap: if the project has zero members, add `login` as admin.
1118
1076
  // Called from project-create flow + lazily from project settings page when a
1119
1077
  // site-admin first opens it on an old (pre-RBAC) project.
1120
- export function ensureProjectBootstrapAdmin(projectId: number, login: string): void {
1121
- const existing = rawDB()
1122
- .query("SELECT COUNT(*) AS n FROM project_members WHERE project_id = ?")
1123
- .get(projectId) as { n: number };
1124
- if (existing.n > 0) return;
1125
- if (!getUserByLogin(login)) return;
1078
+ export async function ensureProjectBootstrapAdmin(projectId: number, login: string): Promise<void> {
1079
+ const existing = await getDB().get<{ n: number }>(
1080
+ "SELECT COUNT(*) AS n FROM {{project_members}} WHERE project_id = ?",
1081
+ [projectId]
1082
+ );
1083
+ if (existing!.n > 0) return;
1084
+ if (!(await getUserByLogin(login))) return;
1126
1085
  const ts = now();
1127
- rawDB()
1128
- .query("INSERT OR IGNORE INTO project_members (project_id, user_login, role, created_at) VALUES (?, ?, 'admin', ?)")
1129
- .run(projectId, login, ts);
1086
+ await getDB().run(
1087
+ "INSERT OR IGNORE INTO {{project_members}} (project_id, user_login, role, created_at) VALUES (?, ?, 'admin', ?)",
1088
+ [projectId, login, ts]
1089
+ );
1130
1090
  }