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
157export interface Database {
158 users: UserTable;
159 passkeys: PasskeyTable;
160 sessions: SessionTable;
161 repositories: RepositoryTable;
162 issues: IssueTable;
163 issue_comments: IssueCommentTable;
164 issue_reactions: IssueReactionTable;
165 patches: PatchTable;
166 patch_comments: PatchCommentTable;
167 patch_reactions: PatchReactionTable;
168 ssh_keys: SshKeyTable;
169 releases: ReleaseTable;
170 release_assets: ReleaseAssetTable;
171 labels: LabelTable;
172 issue_labels: IssueLabelTable;
173 patch_labels: PatchLabelTable;
174}
175
176// Selectable row types (id is plain number, as returned by queries)
177export type UserRow = Selectable<UserTable>;
178export type PasskeyRow = Selectable<PasskeyTable>;
179export type SessionRow = Selectable<SessionTable>;
180export type RepositoryRow = Selectable<RepositoryTable>;
181export type IssueRow = Selectable<IssueTable>;
182export type IssueCommentRow = Selectable<IssueCommentTable>;
183export type IssueReactionRow = Selectable<IssueReactionTable>;
184export type PatchRow = Selectable<PatchTable>;
185export type PatchCommentRow = Selectable<PatchCommentTable>;
186export type PatchReactionRow = Selectable<PatchReactionTable>;
187export type SshKeyRow = Selectable<SshKeyTable>;
188export type ReleaseRow = Selectable<ReleaseTable>;
189export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
190export type LabelRow = Selectable<LabelTable>;
191
192let sqlite = new BunDatabase(paths.DB_PATH);
193sqlite.run("PRAGMA journal_mode=WAL");
194sqlite.run("PRAGMA foreign_keys=ON");
195
196// Migration: add is_pending and register_application columns to users if missing
197const userCols = sqlite
198 .query<{ name: string }, []>("PRAGMA table_info(users)")
199 .all();
200if (!userCols.some((c) => c.name === "is_pending")) {
201 sqlite.run(
202 "ALTER TABLE users ADD COLUMN is_pending INTEGER NOT NULL DEFAULT 0",
203 );
204}
205if (!userCols.some((c) => c.name === "register_application")) {
206 sqlite.run("ALTER TABLE users ADD COLUMN register_application TEXT");
207}
208
209// Migration: add version column if missing, then populate any empty values
210const patchCols = sqlite
211 .query<{ name: string }, []>("PRAGMA table_info(patches)")
212 .all();
213if (!patchCols.some((c) => c.name === "version")) {
214 sqlite.run(
215 "ALTER TABLE patches ADD COLUMN version TEXT NOT NULL DEFAULT ''",
216 );
217}
218sqlite.run(
219 "UPDATE patches SET version = lower(hex(randomblob(16))) WHERE version = ''",
220);
221
222export let db = new Kysely<Database>({
223 dialect: new BunSqliteDialect({ database: sqlite }),
224});
225
226/** Close the current DB and reopen from disk (used by tests after data wipe). */
227export function resetDb() {
228 try {
229 sqlite.close();
230 } catch {}
231 sqlite = new BunDatabase(paths.DB_PATH);
232 sqlite.run("PRAGMA journal_mode=WAL");
233 sqlite.run("PRAGMA foreign_keys=ON");
234 db = new Kysely<Database>({
235 dialect: new BunSqliteDialect({ database: sqlite }),
236 });
237}
238
239// Migration: add allow_user_labels column to repositories if missing
240const repoCols = sqlite
241 .query<{ name: string }, []>("PRAGMA table_info(repositories)")
242 .all();
243if (!repoCols.some((c) => c.name === "allow_user_labels")) {
244 sqlite.run(
245 "ALTER TABLE repositories ADD COLUMN allow_user_labels INTEGER NOT NULL DEFAULT 0",
246 );
247}
248
249// Migration: create labels tables if missing
250sqlite.run(`CREATE TABLE IF NOT EXISTS labels (
251 id INTEGER PRIMARY KEY AUTOINCREMENT,
252 repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
253 name TEXT NOT NULL,
254 color TEXT NOT NULL DEFAULT '#808080',
255 created_at TEXT NOT NULL,
256 UNIQUE(repo_id, name)
257)`);
258sqlite.run(`CREATE TABLE IF NOT EXISTS issue_labels (
259 issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
260 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
261 PRIMARY KEY (issue_id, label_id)
262)`);
263sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
264 patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
265 label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
266 PRIMARY KEY (patch_id, label_id)
267)`);
268
269export async function getRepo(name: string, isAdmin: boolean) {
270 const repo = await db
271 .selectFrom("repositories")
272 .selectAll()
273 .where("name", "=", name)
274 .executeTakeFirst();
275 if (!repo) return null;
276 if (repo.is_private && !isAdmin) return null;
277 return repo;
278}
279