this repo has no description
1package db
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "log"
8 "reflect"
9 "strings"
10
11 _ "github.com/mattn/go-sqlite3"
12)
13
14type DB struct {
15 *sql.DB
16}
17
18type Execer interface {
19 Query(query string, args ...any) (*sql.Rows, error)
20 QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
21 QueryRow(query string, args ...any) *sql.Row
22 QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
23 Exec(query string, args ...any) (sql.Result, error)
24 ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
25 Prepare(query string) (*sql.Stmt, error)
26 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
27}
28
29func Make(dbPath string) (*DB, error) {
30 db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
31 if err != nil {
32 return nil, err
33 }
34 _, err = db.Exec(`
35 pragma journal_mode = WAL;
36 pragma synchronous = normal;
37 pragma temp_store = memory;
38 pragma mmap_size = 30000000000;
39 pragma page_size = 32768;
40 pragma auto_vacuum = incremental;
41 pragma busy_timeout = 5000;
42
43 create table if not exists registrations (
44 id integer primary key autoincrement,
45 domain text not null unique,
46 did text not null,
47 secret text not null,
48 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
49 registered text
50 );
51 create table if not exists public_keys (
52 id integer primary key autoincrement,
53 did text not null,
54 name text not null,
55 key text not null,
56 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
57 unique(did, name, key)
58 );
59 create table if not exists repos (
60 id integer primary key autoincrement,
61 did text not null,
62 name text not null,
63 knot text not null,
64 rkey text not null,
65 at_uri text not null unique,
66 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
67 unique(did, name, knot, rkey)
68 );
69 create table if not exists collaborators (
70 id integer primary key autoincrement,
71 did text not null,
72 repo integer not null,
73 foreign key (repo) references repos(id) on delete cascade
74 );
75 create table if not exists follows (
76 user_did text not null,
77 subject_did text not null,
78 rkey text not null,
79 followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
80 primary key (user_did, subject_did),
81 check (user_did <> subject_did)
82 );
83 create table if not exists issues (
84 id integer primary key autoincrement,
85 owner_did text not null,
86 repo_at text not null,
87 issue_id integer not null,
88 title text not null,
89 body text not null,
90 open integer not null default 1,
91 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
92 issue_at text,
93 unique(repo_at, issue_id),
94 foreign key (repo_at) references repos(at_uri) on delete cascade
95 );
96 create table if not exists comments (
97 id integer primary key autoincrement,
98 owner_did text not null,
99 issue_id integer not null,
100 repo_at text not null,
101 comment_id integer not null,
102 comment_at text not null,
103 body text not null,
104 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
105 unique(issue_id, comment_id),
106 foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
107 );
108 create table if not exists pulls (
109 -- identifiers
110 id integer primary key autoincrement,
111 pull_id integer not null,
112
113 -- at identifiers
114 repo_at text not null,
115 owner_did text not null,
116 rkey text not null,
117 pull_at text,
118
119 -- content
120 title text not null,
121 body text not null,
122 target_branch text not null,
123 state integer not null default 0 check (state in (0, 1, 2)), -- open, merged, closed
124
125 -- meta
126 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
127
128 -- constraints
129 unique(repo_at, pull_id),
130 foreign key (repo_at) references repos(at_uri) on delete cascade
131 );
132
133 -- every pull must have atleast 1 submission: the initial submission
134 create table if not exists pull_submissions (
135 -- identifiers
136 id integer primary key autoincrement,
137 pull_id integer not null,
138
139 -- at identifiers
140 repo_at text not null,
141
142 -- content, these are immutable, and require a resubmission to update
143 round_number integer not null default 0,
144 patch text,
145
146 -- meta
147 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
148
149 -- constraints
150 unique(repo_at, pull_id, round_number),
151 foreign key (repo_at, pull_id) references pulls(repo_at, pull_id) on delete cascade
152 );
153
154 create table if not exists pull_comments (
155 -- identifiers
156 id integer primary key autoincrement,
157 pull_id integer not null,
158 submission_id integer not null,
159
160 -- at identifiers
161 repo_at text not null,
162 owner_did text not null,
163 comment_at text not null,
164
165 -- content
166 body text not null,
167
168 -- meta
169 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
170
171 -- constraints
172 foreign key (repo_at, pull_id) references pulls(repo_at, pull_id) on delete cascade,
173 foreign key (submission_id) references pull_submissions(id) on delete cascade
174 );
175
176 create table if not exists _jetstream (
177 id integer primary key autoincrement,
178 last_time_us integer not null
179 );
180
181 create table if not exists repo_issue_seqs (
182 repo_at text primary key,
183 next_issue_id integer not null default 1
184 );
185
186 create table if not exists repo_pull_seqs (
187 repo_at text primary key,
188 next_pull_id integer not null default 1
189 );
190
191 create table if not exists stars (
192 id integer primary key autoincrement,
193 starred_by_did text not null,
194 repo_at text not null,
195 rkey text not null,
196 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
197 foreign key (repo_at) references repos(at_uri) on delete cascade,
198 unique(starred_by_did, repo_at)
199 );
200
201 create table if not exists reactions (
202 id integer primary key autoincrement,
203 reacted_by_did text not null,
204 thread_at text not null,
205 kind text not null,
206 rkey text not null,
207 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
208 unique(reacted_by_did, thread_at, kind)
209 );
210
211 create table if not exists emails (
212 id integer primary key autoincrement,
213 did text not null,
214 email text not null,
215 verified integer not null default 0,
216 verification_code text not null,
217 last_sent text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
218 is_primary integer not null default 0,
219 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
220 unique(did, email)
221 );
222
223 create table if not exists artifacts (
224 -- id
225 id integer primary key autoincrement,
226 did text not null,
227 rkey text not null,
228
229 -- meta
230 repo_at text not null,
231 tag binary(20) not null,
232 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
233
234 -- data
235 blob_cid text not null,
236 name text not null,
237 size integer not null default 0,
238 mimetype string not null default "*/*",
239
240 -- constraints
241 unique(did, rkey), -- record must be unique
242 unique(repo_at, tag, name), -- for a given tag object, each file must be unique
243 foreign key (repo_at) references repos(at_uri) on delete cascade
244 );
245
246 create table if not exists profile (
247 -- id
248 id integer primary key autoincrement,
249 did text not null,
250
251 -- data
252 description text not null,
253 include_bluesky integer not null default 0,
254 location text,
255
256 -- constraints
257 unique(did)
258 );
259 create table if not exists profile_links (
260 -- id
261 id integer primary key autoincrement,
262 did text not null,
263
264 -- data
265 link text not null,
266
267 -- constraints
268 foreign key (did) references profile(did) on delete cascade
269 );
270 create table if not exists profile_stats (
271 -- id
272 id integer primary key autoincrement,
273 did text not null,
274
275 -- data
276 kind text not null check (kind in (
277 "merged-pull-request-count",
278 "closed-pull-request-count",
279 "open-pull-request-count",
280 "open-issue-count",
281 "closed-issue-count",
282 "repository-count"
283 )),
284
285 -- constraints
286 foreign key (did) references profile(did) on delete cascade
287 );
288 create table if not exists profile_pinned_repositories (
289 -- id
290 id integer primary key autoincrement,
291 did text not null,
292
293 -- data
294 at_uri text not null,
295
296 -- constraints
297 unique(did, at_uri),
298 foreign key (did) references profile(did) on delete cascade,
299 foreign key (at_uri) references repos(at_uri) on delete cascade
300 );
301
302 create table if not exists oauth_requests (
303 id integer primary key autoincrement,
304 auth_server_iss text not null,
305 state text not null,
306 did text not null,
307 handle text not null,
308 pds_url text not null,
309 pkce_verifier text not null,
310 dpop_auth_server_nonce text not null,
311 dpop_private_jwk text not null
312 );
313
314 create table if not exists oauth_sessions (
315 id integer primary key autoincrement,
316 did text not null,
317 handle text not null,
318 pds_url text not null,
319 auth_server_iss text not null,
320 access_jwt text not null,
321 refresh_jwt text not null,
322 dpop_pds_nonce text,
323 dpop_auth_server_nonce text not null,
324 dpop_private_jwk text not null,
325 expiry text not null
326 );
327
328 create table if not exists punchcard (
329 did text not null,
330 date text not null, -- yyyy-mm-dd
331 count integer,
332 primary key (did, date)
333 );
334
335 create table if not exists spindles (
336 id integer primary key autoincrement,
337 owner text not null,
338 instance text not null,
339 verified text, -- time of verification
340 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
341
342 unique(owner, instance)
343 );
344
345 create table if not exists spindle_members (
346 -- identifiers for the record
347 id integer primary key autoincrement,
348 did text not null,
349 rkey text not null,
350
351 -- data
352 instance text not null,
353 subject text not null,
354 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
355
356 -- constraints
357 unique (did, instance, subject)
358 );
359
360 create table if not exists pipelines (
361 -- identifiers
362 id integer primary key autoincrement,
363 knot text not null,
364 rkey text not null,
365
366 repo_owner text not null,
367 repo_name text not null,
368
369 -- every pipeline must be associated with exactly one commit
370 sha text not null check (length(sha) = 40),
371 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
372
373 -- trigger data
374 trigger_id integer not null,
375
376 unique(knot, rkey),
377 foreign key (trigger_id) references triggers(id) on delete cascade
378 );
379
380 create table if not exists triggers (
381 -- primary key
382 id integer primary key autoincrement,
383
384 -- top-level fields
385 kind text not null,
386
387 -- pushTriggerData fields
388 push_ref text,
389 push_new_sha text check (length(push_new_sha) = 40),
390 push_old_sha text check (length(push_old_sha) = 40),
391
392 -- pullRequestTriggerData fields
393 pr_source_branch text,
394 pr_target_branch text,
395 pr_source_sha text check (length(pr_source_sha) = 40),
396 pr_action text
397 );
398
399 create table if not exists pipeline_statuses (
400 -- identifiers
401 id integer primary key autoincrement,
402 spindle text not null,
403 rkey text not null,
404
405 -- referenced pipeline. these form the (did, rkey) pair
406 pipeline_knot text not null,
407 pipeline_rkey text not null,
408
409 -- content
410 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
411 workflow text not null,
412 status text not null,
413 error text,
414 exit_code integer not null default 0,
415
416 unique (spindle, rkey),
417 foreign key (pipeline_knot, pipeline_rkey)
418 references pipelines (knot, rkey)
419 on delete cascade
420 );
421
422 create table if not exists repo_languages (
423 -- identifiers
424 id integer primary key autoincrement,
425
426 -- repo identifiers
427 repo_at text not null,
428 ref text not null,
429 is_default_ref integer not null default 0,
430
431 -- language breakdown
432 language text not null,
433 bytes integer not null check (bytes >= 0),
434
435 unique(repo_at, ref, language)
436 );
437
438 create table if not exists signups_inflight (
439 id integer primary key autoincrement,
440 email text not null unique,
441 invite_code text not null,
442 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
443 );
444
445 create table if not exists strings (
446 -- identifiers
447 did text not null,
448 rkey text not null,
449
450 -- content
451 filename text not null,
452 description text,
453 content text not null,
454 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
455 edited text,
456
457 primary key (did, rkey)
458 );
459
460 create table if not exists migrations (
461 id integer primary key autoincrement,
462 name text unique
463 );
464 `)
465 if err != nil {
466 return nil, err
467 }
468
469 // run migrations
470 runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error {
471 tx.Exec(`
472 alter table repos add column description text check (length(description) <= 200);
473 `)
474 return nil
475 })
476
477 runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
478 // add unconstrained column
479 _, err := tx.Exec(`
480 alter table public_keys
481 add column rkey text;
482 `)
483 if err != nil {
484 return err
485 }
486
487 // backfill
488 _, err = tx.Exec(`
489 update public_keys
490 set rkey = ''
491 where rkey is null;
492 `)
493 if err != nil {
494 return err
495 }
496
497 return nil
498 })
499
500 runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error {
501 _, err := tx.Exec(`
502 alter table comments drop column comment_at;
503 alter table comments add column rkey text;
504 `)
505 return err
506 })
507
508 runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
509 _, err := tx.Exec(`
510 alter table comments add column deleted text; -- timestamp
511 alter table comments add column edited text; -- timestamp
512 `)
513 return err
514 })
515
516 runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
517 _, err := tx.Exec(`
518 alter table pulls add column source_branch text;
519 alter table pulls add column source_repo_at text;
520 alter table pull_submissions add column source_rev text;
521 `)
522 return err
523 })
524
525 runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error {
526 _, err := tx.Exec(`
527 alter table repos add column source text;
528 `)
529 return err
530 })
531
532 // disable foreign-keys for the next migration
533 // NOTE: this cannot be done in a transaction, so it is run outside [0]
534 //
535 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
536 db.Exec("pragma foreign_keys = off;")
537 runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
538 _, err := tx.Exec(`
539 create table pulls_new (
540 -- identifiers
541 id integer primary key autoincrement,
542 pull_id integer not null,
543
544 -- at identifiers
545 repo_at text not null,
546 owner_did text not null,
547 rkey text not null,
548
549 -- content
550 title text not null,
551 body text not null,
552 target_branch text not null,
553 state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
554
555 -- source info
556 source_branch text,
557 source_repo_at text,
558
559 -- stacking
560 stack_id text,
561 change_id text,
562 parent_change_id text,
563
564 -- meta
565 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
566
567 -- constraints
568 unique(repo_at, pull_id),
569 foreign key (repo_at) references repos(at_uri) on delete cascade
570 );
571
572 insert into pulls_new (
573 id, pull_id,
574 repo_at, owner_did, rkey,
575 title, body, target_branch, state,
576 source_branch, source_repo_at,
577 created
578 )
579 select
580 id, pull_id,
581 repo_at, owner_did, rkey,
582 title, body, target_branch, state,
583 source_branch, source_repo_at,
584 created
585 FROM pulls;
586
587 drop table pulls;
588 alter table pulls_new rename to pulls;
589 `)
590 return err
591 })
592 db.Exec("pragma foreign_keys = on;")
593
594 // run migrations
595 runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error {
596 tx.Exec(`
597 alter table repos add column spindle text;
598 `)
599 return nil
600 })
601
602 // recreate and add rkey + created columns with default constraint
603 runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error {
604 // create new table
605 // - repo_at instead of repo integer
606 // - rkey field
607 // - created field
608 _, err := tx.Exec(`
609 create table collaborators_new (
610 -- identifiers for the record
611 id integer primary key autoincrement,
612 did text not null,
613 rkey text,
614
615 -- content
616 subject_did text not null,
617 repo_at text not null,
618
619 -- meta
620 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
621
622 -- constraints
623 foreign key (repo_at) references repos(at_uri) on delete cascade
624 )
625 `)
626 if err != nil {
627 return err
628 }
629
630 // copy data
631 _, err = tx.Exec(`
632 insert into collaborators_new (id, did, rkey, subject_did, repo_at)
633 select
634 c.id,
635 r.did,
636 '',
637 c.did,
638 r.at_uri
639 from collaborators c
640 join repos r on c.repo = r.id
641 `)
642 if err != nil {
643 return err
644 }
645
646 // drop old table
647 _, err = tx.Exec(`drop table collaborators`)
648 if err != nil {
649 return err
650 }
651
652 // rename new table
653 _, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
654 return err
655 })
656
657 return &DB{db}, nil
658}
659
660type migrationFn = func(*sql.Tx) error
661
662func runMigration(d *sql.DB, name string, migrationFn migrationFn) error {
663 tx, err := d.Begin()
664 if err != nil {
665 return err
666 }
667 defer tx.Rollback()
668
669 var exists bool
670 err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
671 if err != nil {
672 return err
673 }
674
675 if !exists {
676 // run migration
677 err = migrationFn(tx)
678 if err != nil {
679 log.Printf("Failed to run migration %s: %v", name, err)
680 return err
681 }
682
683 // mark migration as complete
684 _, err = tx.Exec("insert into migrations (name) values (?)", name)
685 if err != nil {
686 log.Printf("Failed to mark migration %s as complete: %v", name, err)
687 return err
688 }
689
690 // commit the transaction
691 if err := tx.Commit(); err != nil {
692 return err
693 }
694
695 log.Printf("migration %s applied successfully", name)
696 } else {
697 log.Printf("skipped migration %s, already applied", name)
698 }
699
700 return nil
701}
702
703type filter struct {
704 key string
705 arg any
706 cmp string
707}
708
709func newFilter(key, cmp string, arg any) filter {
710 return filter{
711 key: key,
712 arg: arg,
713 cmp: cmp,
714 }
715}
716
717func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
718func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
719func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
720func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
721func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
722func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
723func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
724
725func (f filter) Condition() string {
726 rv := reflect.ValueOf(f.arg)
727 kind := rv.Kind()
728
729 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
730 if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
731 if rv.Len() == 0 {
732 // always false
733 return "1 = 0"
734 }
735
736 placeholders := make([]string, rv.Len())
737 for i := range placeholders {
738 placeholders[i] = "?"
739 }
740
741 return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", "))
742 }
743
744 return fmt.Sprintf("%s %s ?", f.key, f.cmp)
745}
746
747func (f filter) Arg() []any {
748 rv := reflect.ValueOf(f.arg)
749 kind := rv.Kind()
750 if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
751 if rv.Len() == 0 {
752 return nil
753 }
754
755 out := make([]any, rv.Len())
756 for i := range rv.Len() {
757 out[i] = rv.Index(i).Interface()
758 }
759 return out
760 }
761
762 return []any{f.arg}
763}