index.ts
Raw
1import { Database as BunDatabase } from "bun:sqlite";
2import { type Generated, Kysely, type Selectable } from "kysely";
3import { BunSqliteDialect } from "kysely-bun-sqlite";
4
5import { paths } from "../constants.ts";
6
7interface UserTable {
8 id: Generated<number>;
9 username: string;
10 password_hash: string | null;
11 created_at: string;
12 avatar_version: Generated<number>;
13 is_pending: Generated<number>;
14 register_application: string | null;
15}
16
17interface PasskeyTable {
18 id: Generated<number>;
19 user_id: number;
20 credential_id: string;
21 public_key: string;
22 counter: number;
23 created_at: string;
24}
25
26interface SessionTable {
27 id: string;
28 user_id: number;
29 expires_at: string;
30 created_at: string;
31}
32
33interface RepositoryTable {
34 id: Generated<number>;
35 name: string;
36 description: string | null;
37 is_private: number;
38 is_pinned: Generated<number>;
39 default_branch: string;
40 created_at: string;
41 issue_seq: Generated<number>;
42 patch_seq: Generated<number>;
43 issue_template: string | null;
44 patch_template: string | null;
45 allow_user_labels: Generated<number>;
46}
47
48interface IssueTable {
49 id: Generated<number>;
50 repo_id: number;
51 author_id: number | null;
52 number: number;
53 title: string;
54 body: string;
55 status: string;
56 created_at: string;
57 updated_at: string;
58 edited_at: string | null;
59}
60
61interface IssueCommentTable {
62 id: Generated<number>;
63 issue_id: number;
64 author_id: number | null;
65 body: string;
66 created_at: string;
67 edited_at: string | null;
68}
69
70interface IssueReactionTable {
71 id: Generated<number>;
72 issue_id: number;
73 comment_id: number | null;
74 user_id: number;
75 emoji: string;
76}
77
78interface PatchTable {
79 id: Generated<number>;
80 repo_id: number;
81 author_id: number | null;
82 number: number;
83 title: string;
84 description: string;
85 patch_content: string;
86 status: string;
87 author_name: string;
88 author_email: string;
89 created_at: string;
90 updated_at: string;
91 edited_at: string | null;
92 version: string;
93}
94
95interface PatchCommentTable {
96 id: Generated<number>;
97 patch_id: number;
98 author_id: number | null;
99 body: string;
100 created_at: string;
101 edited_at: string | null;
102}
103
104interface PatchReactionTable {
105 id: Generated<number>;
106 patch_id: number;
107 comment_id: number | null;
108 user_id: number;
109 emoji: string;
110}
111
112interface SshKeyTable {
113 id: Generated<number>;
114 user_id: number;
115 name: string;
116 public_key: string;
117 fingerprint: string;
118 created_at: string;
119}
120
121interface ReleaseTable {
122 id: Generated<number>;
123 repo_id: number;
124 tag_name: string | null;
125 name: string;
126 notes: string | null;
127 include_source_code: number;
128 created_at: string;
129}
130
131interface ReleaseAssetTable {
132 id: Generated<number>;
133 release_id: number;
134 filename: string;
135 size: number;
136 created_at: string;
137}
138
139interface LabelTable {
140 id: Generated<number>;
141 repo_id: number;
142 name: string;
143 color: string;
144 created_at: string;
145}
146
147interface IssueLabelTable {
148 issue_id: number;
149 label_id: number;
150}
151
152interface PatchLabelTable {
153 patch_id: number;
154 label_id: number;
155}
156
157interface CiRunTable {
158 id: Generated<number>;
159 repo_id: number;
160 triggered_by: number | null;
161 trigger_source: string;
162 commit_sha: string | null;
163 commit_branch: string | null;
164 commit_tag: string | null;
165 status: string;
166 variable_overrides: string | null;
167 started_at: string | null;
168 finished_at: string | null;
169 created_at: Generated<string>;
170 repo_run_id: number | null;
171}
172
173interface CiStepTable {
174 id: Generated<number>;
175 run_id: number;
176 name: string;
177 status: string;
178 exit_code: number | null;
179 started_at: string | null;
180 finished_at: string | null;
181 log: Generated<string>;
182}
183
184interface CiArtifactTable {
185 id: Generated<number>;
186 run_id: number;
187 filename: string;
188 size: number;
189 created_at: Generated<string>;
190}
191
192interface CiSecretTable {
193 id: Generated<number>;
194 repo_id: number;
195 name: string;
196 value: string;
197 description: string | null;
198 created_at: Generated<string>;
199}
200
201export interface Database {
202 users: UserTable;
203 passkeys: PasskeyTable;
204 sessions: SessionTable;
205 repositories: RepositoryTable;
206 issues: IssueTable;
207 issue_comments: IssueCommentTable;
208 issue_reactions: IssueReactionTable;
209 patches: PatchTable;
210 patch_comments: PatchCommentTable;
211 patch_reactions: PatchReactionTable;
212 ssh_keys: SshKeyTable;
213 releases: ReleaseTable;
214 release_assets: ReleaseAssetTable;
215 labels: LabelTable;
216 issue_labels: IssueLabelTable;
217 patch_labels: PatchLabelTable;
218 ci_runs: CiRunTable;
219 ci_steps: CiStepTable;
220 ci_artifacts: CiArtifactTable;
221 ci_secrets: CiSecretTable;
222}
223
224// Selectable row types (id is plain number, as returned by queries)
225export type UserRow = Selectable<UserTable>;
226export type PasskeyRow = Selectable<PasskeyTable>;
227export type SessionRow = Selectable<SessionTable>;
228export type RepositoryRow = Selectable<RepositoryTable>;
229export type IssueRow = Selectable<IssueTable>;
230export type IssueCommentRow = Selectable<IssueCommentTable>;
231export type IssueReactionRow = Selectable<IssueReactionTable>;
232export type PatchRow = Selectable<PatchTable>;
233export type PatchCommentRow = Selectable<PatchCommentTable>;
234export type PatchReactionRow = Selectable<PatchReactionTable>;
235export type SshKeyRow = Selectable<SshKeyTable>;
236export type ReleaseRow = Selectable<ReleaseTable>;
237export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
238export type LabelRow = Selectable<LabelTable>;
239export type CiRunRow = Selectable<CiRunTable>;
240export type CiStepRow = Selectable<CiStepTable>;
241export type CiArtifactRow = Selectable<CiArtifactTable>;
242export type CiSecretRow = Selectable<CiSecretTable>;
243
244let sqlite = new BunDatabase(paths.DB_PATH);
245sqlite.run("PRAGMA journal_mode=WAL");
246sqlite.run("PRAGMA foreign_keys=ON");
247
248// Migration: add is_pending and register_application columns to users if missing
249const userCols = sqlite
250 .query<{ name: string }, []>("PRAGMA table_info(users)")
251 .all();
252if (!userCols.some((c) => c.name === "is_pending")) {
253 sqlite.run(
254 "ALTER TABLE users ADD COLUMN is_pending INTEGER NOT NULL DEFAULT 0",
255 );
256}
257if (!userCols.some((c) => c.name === "register_application")) {
258 sqlite.run("ALTER TABLE users ADD COLUMN register_application TEXT");
259}
260
261// Migration: add version column if missing, then populate any empty values
262const patchCols = sqlite
263 .query<{ name: string }, []>("PRAGMA table_info(patches)")
264 .all();
265if (!patchCols.some((c) => c.name === "version")) {
266 sqlite.run(
267 "ALTER TABLE patches ADD COLUMN version TEXT NOT NULL DEFAULT ''",
268 );
269}
270sqlite.run(
271 "UPDATE patches SET version = lower(hex(randomblob(16))) WHERE version = ''",
272);
273
274export let db = new Kysely<Database>({
275 dialect: new BunSqliteDialect({ database: sqlite }),
276});
277
278function runMigrations(s: InstanceType<typeof BunDatabase>) {
279 const ciRunCols = s
280 .query<{ name: string }, []>("PRAGMA table_info(ci_runs)")
281 .all();
282 if (!ciRunCols.some((c) => c.name === "repo_run_id")) {
283 s.run("ALTER TABLE ci_runs ADD COLUMN repo_run_id INTEGER");
284 }
285}
286
287// NOTE: runMigrations() alters ci_runs, so it must run *after* the
288// `CREATE TABLE IF NOT EXISTS ci_runs` block below — otherwise upgrading a
289// database created before the CI tables existed would ALTER a missing table
290// and throw at import. The call is intentionally placed at the end of the
291// migration section, not here.
292
293/** Close the current DB and reopen from disk (used by tests after data wipe). */
294export function resetDb() {
295 try {
296 sqlite.close();
297 } catch {}
298 sqlite = new BunDatabase(paths.DB_PATH);
299 sqlite.run("PRAGMA journal_mode=WAL");
300 sqlite.run("PRAGMA foreign_keys=ON");
301 runMigrations(sqlite);
302 db = new Kysely<Database>({
303 dialect: new BunSqliteDialect({ database: sqlite }),
304 });
305}
306
307// Migration: create CI tables if missing
308sqlite.run(`CREATE TABLE IF NOT EXISTS ci_runs (
309 id INTEGER PRIMARY KEY AUTOINCREMENT,
310 repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
311 triggered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
312 trigger_source TEXT NOT NULL,
313 commit_sha TEXT,
314 commit_branch TEXT,
315 commit_tag TEXT,
316 status TEXT NOT NULL DEFAULT 'pending',
317 variable_overrides TEXT,
318 started_at TEXT,
319 finished_at TEXT,
320 created_at TEXT NOT NULL DEFAULT (datetime('now'))
321)`);
322sqlite.run(`CREATE TABLE IF NOT EXISTS ci_steps (
323 id INTEGER PRIMARY KEY AUTOINCREMENT,
324 run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
325 name TEXT NOT NULL,
326 status TEXT NOT NULL DEFAULT 'pending',
327 exit_code INTEGER,
328 started_at TEXT,
329 finished_at TEXT,
330 log TEXT NOT NULL DEFAULT ''
331)`);
332sqlite.run(`CREATE TABLE IF NOT EXISTS ci_artifacts (
333 id INTEGER PRIMARY KEY AUTOINCREMENT,
334 run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
335 filename TEXT NOT NULL,
336 size INTEGER NOT NULL,
337 created_at TEXT NOT NULL DEFAULT (datetime('now'))
338)`);
339sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets (
340 id INTEGER PRIMARY KEY AUTOINCREMENT,
341 repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
342 name TEXT NOT NULL,
343 value TEXT NOT NULL,
344 description TEXT,
345 created_at TEXT NOT NULL DEFAULT (datetime('now')),
346 UNIQUE(repo_id, name)
347)`);
348
349// Run column-level migrations now that the CI tables are guaranteed to exist
350// (see the note above runMigrations).
351runMigrations(sqlite);
352
353// Migration: add allow_user_labels column to repositories if missing
354const repoCols = sqlite
355 .query<{ name: string }, []>("PRAGMA table_info(repositories)")
356 .all();
357if (!repoCols.some((c) => c.name === "allow_user_labels")) {
358 sqlite.run(
359 "ALTER TABLE repositories ADD COLUMN allow_user_labels INTEGER NOT NULL DEFAULT 0",
360 );
361}
362
363// Migration: create labels tables if missing
364sqlite.run(`CREATE TABLE IF NOT EXISTS labels (
365 id INTEGER PRIMARY KEY AUTOINCREMENT,
366 repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
367 name TEXT NOT NULL,
368 color TEXT NOT NULL DEFAULT '#808080',
369 created_at TEXT NOT NULL,
370 UNIQUE(repo_id, name)
371)`);
372sqlite.run(`CREATE TABLE IF NOT EXISTS issue_labels (
373 issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
374 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
375 PRIMARY KEY (issue_id, label_id)
376)`);
377sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
378 patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
379 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
380 PRIMARY KEY (patch_id, label_id)
381)`);
382
383// Indexes for common query patterns (safe to run repeatedly)
384sqlite.run("CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id)");
385sqlite.run(
386 "CREATE INDEX IF NOT EXISTS idx_issues_author_id ON issues(author_id)",
387);
388sqlite.run(
389 "CREATE INDEX IF NOT EXISTS idx_patches_repo_id ON patches(repo_id)",
390);
391sqlite.run(
392 "CREATE INDEX IF NOT EXISTS idx_patches_author_id ON patches(author_id)",
393);
394sqlite.run(
395 "CREATE INDEX IF NOT EXISTS idx_ci_runs_repo_id ON ci_runs(repo_id)",
396);
397sqlite.run(
398 "CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)",
399);
400sqlite.run(
401 "CREATE INDEX IF NOT EXISTS idx_ssh_keys_user_id ON ssh_keys(user_id)",
402);
403sqlite.run(
404 "CREATE INDEX IF NOT EXISTS idx_issue_labels_label_id ON issue_labels(label_id)",
405);
406sqlite.run(
407 "CREATE INDEX IF NOT EXISTS idx_patch_labels_label_id ON patch_labels(label_id)",
408);
409sqlite.run("CREATE INDEX IF NOT EXISTS idx_labels_repo_id ON labels(repo_id)");
410sqlite.run(
411 "CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)",
412);
413sqlite.run(
414 "CREATE INDEX IF NOT EXISTS idx_issues_repo_status ON issues(repo_id, status)",
415);
416sqlite.run(
417 "CREATE INDEX IF NOT EXISTS idx_patches_repo_status ON patches(repo_id, status)",
418);
419
420// Clean up expired sessions on startup
421sqlite.run("DELETE FROM sessions WHERE expires_at < datetime('now')");
422
423export async function getRepo(name: string, isAdmin: boolean) {
424 const repo = await db
425 .selectFrom("repositories")
426 .selectAll()
427 .where("name", "=", name)
428 .executeTakeFirst();
429 if (!repo) return null;
430 if (repo.is_private && !isAdmin) return null;
431 return repo;
432}
433