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
287runMigrations(sqlite);
288
289/** Close the current DB and reopen from disk (used by tests after data wipe). */
290export function resetDb() {
291 try {
292 sqlite.close();
293 } catch {}
294 sqlite = new BunDatabase(paths.DB_PATH);
295 sqlite.run("PRAGMA journal_mode=WAL");
296 sqlite.run("PRAGMA foreign_keys=ON");
297 runMigrations(sqlite);
298 db = new Kysely<Database>({
299 dialect: new BunSqliteDialect({ database: sqlite }),
300 });
301}
302
303// Migration: create CI tables if missing
304sqlite.run(`CREATE TABLE IF NOT EXISTS ci_runs (
305 id INTEGER PRIMARY KEY AUTOINCREMENT,
306 repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
307 triggered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
308 trigger_source TEXT NOT NULL,
309 commit_sha TEXT,
310 commit_branch TEXT,
311 commit_tag TEXT,
312 status TEXT NOT NULL DEFAULT 'pending',
313 variable_overrides TEXT,
314 started_at TEXT,
315 finished_at TEXT,
316 created_at TEXT NOT NULL DEFAULT (datetime('now'))
317)`);
318sqlite.run(`CREATE TABLE IF NOT EXISTS ci_steps (
319 id INTEGER PRIMARY KEY AUTOINCREMENT,
320 run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
321 name TEXT NOT NULL,
322 status TEXT NOT NULL DEFAULT 'pending',
323 exit_code INTEGER,
324 started_at TEXT,
325 finished_at TEXT,
326 log TEXT NOT NULL DEFAULT ''
327)`);
328sqlite.run(`CREATE TABLE IF NOT EXISTS ci_artifacts (
329 id INTEGER PRIMARY KEY AUTOINCREMENT,
330 run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
331 filename TEXT NOT NULL,
332 size INTEGER NOT NULL,
333 created_at TEXT NOT NULL DEFAULT (datetime('now'))
334)`);
335sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets (
336 id INTEGER PRIMARY KEY AUTOINCREMENT,
337 repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
338 name TEXT NOT NULL,
339 value TEXT NOT NULL,
340 description TEXT,
341 created_at TEXT NOT NULL DEFAULT (datetime('now')),
342 UNIQUE(repo_id, name)
343)`);
344
345// Migration: add allow_user_labels column to repositories if missing
346const repoCols = sqlite
347 .query<{ name: string }, []>("PRAGMA table_info(repositories)")
348 .all();
349if (!repoCols.some((c) => c.name === "allow_user_labels")) {
350 sqlite.run(
351 "ALTER TABLE repositories ADD COLUMN allow_user_labels INTEGER NOT NULL DEFAULT 0",
352 );
353}
354
355// Migration: create labels tables if missing
356sqlite.run(`CREATE TABLE IF NOT EXISTS labels (
357 id INTEGER PRIMARY KEY AUTOINCREMENT,
358 repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
359 name TEXT NOT NULL,
360 color TEXT NOT NULL DEFAULT '#808080',
361 created_at TEXT NOT NULL,
362 UNIQUE(repo_id, name)
363)`);
364sqlite.run(`CREATE TABLE IF NOT EXISTS issue_labels (
365 issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
366 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
367 PRIMARY KEY (issue_id, label_id)
368)`);
369sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
370 patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
371 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
372 PRIMARY KEY (patch_id, label_id)
373)`);
374
375export async function getRepo(name: string, isAdmin: boolean) {
376 const repo = await db
377 .selectFrom("repositories")
378 .selectAll()
379 .where("name", "=", name)
380 .executeTakeFirst();
381 if (!repo) return null;
382 if (repo.is_private && !isAdmin) return null;
383 return repo;
384}
385