forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
this repo has no description
fork
Configure Feed
Select the types of activity you want to include in your feed.
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 // https://github.com/mattn/go-sqlite3#connection-string
31 opts := []string{
32 "_foreign_keys=1",
33 "_journal_mode=WAL",
34 "_synchronous=NORMAL",
35 "_auto_vacuum=incremental",
36 }
37
38 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
39 if err != nil {
40 return nil, err
41 }
42
43 ctx := context.Background()
44
45 conn, err := db.Conn(ctx)
46 if err != nil {
47 return nil, err
48 }
49 defer conn.Close()
50
51 _, err = conn.ExecContext(ctx, `
52 create table if not exists registrations (
53 id integer primary key autoincrement,
54 domain text not null unique,
55 did text not null,
56 secret text not null,
57 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
58 registered text
59 );
60 create table if not exists public_keys (
61 id integer primary key autoincrement,
62 did text not null,
63 name text not null,
64 key text not null,
65 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
66 unique(did, name, key)
67 );
68 create table if not exists repos (
69 id integer primary key autoincrement,
70 did text not null,
71 name text not null,
72 knot text not null,
73 rkey text not null,
74 at_uri text not null unique,
75 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
76 unique(did, name, knot, rkey)
77 );
78 create table if not exists collaborators (
79 id integer primary key autoincrement,
80 did text not null,
81 repo integer not null,
82 foreign key (repo) references repos(id) on delete cascade
83 );
84 create table if not exists follows (
85 user_did text not null,
86 subject_did text not null,
87 rkey text not null,
88 followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
89 primary key (user_did, subject_did),
90 check (user_did <> subject_did)
91 );
92 create table if not exists issues (
93 id integer primary key autoincrement,
94 owner_did text not null,
95 repo_at text not null,
96 issue_id integer not null,
97 title text not null,
98 body text not null,
99 open integer not null default 1,
100 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
101 issue_at text,
102 unique(repo_at, issue_id),
103 foreign key (repo_at) references repos(at_uri) on delete cascade
104 );
105 create table if not exists comments (
106 id integer primary key autoincrement,
107 owner_did text not null,
108 issue_id integer not null,
109 repo_at text not null,
110 comment_id integer not null,
111 comment_at text not null,
112 body text not null,
113 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
114 unique(issue_id, comment_id),
115 foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
116 );
117 create table if not exists pulls (
118 -- identifiers
119 id integer primary key autoincrement,
120 pull_id integer not null,
121
122 -- at identifiers
123 repo_at text not null,
124 owner_did text not null,
125 rkey text not null,
126 pull_at text,
127
128 -- content
129 title text not null,
130 body text not null,
131 target_branch text not null,
132 state integer not null default 0 check (state in (0, 1, 2)), -- open, merged, closed
133
134 -- meta
135 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
136
137 -- constraints
138 unique(repo_at, pull_id),
139 foreign key (repo_at) references repos(at_uri) on delete cascade
140 );
141
142 -- every pull must have atleast 1 submission: the initial submission
143 create table if not exists pull_submissions (
144 -- identifiers
145 id integer primary key autoincrement,
146 pull_id integer not null,
147
148 -- at identifiers
149 repo_at text not null,
150
151 -- content, these are immutable, and require a resubmission to update
152 round_number integer not null default 0,
153 patch text,
154
155 -- meta
156 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
157
158 -- constraints
159 unique(repo_at, pull_id, round_number),
160 foreign key (repo_at, pull_id) references pulls(repo_at, pull_id) on delete cascade
161 );
162
163 create table if not exists pull_comments (
164 -- identifiers
165 id integer primary key autoincrement,
166 pull_id integer not null,
167 submission_id integer not null,
168
169 -- at identifiers
170 repo_at text not null,
171 owner_did text not null,
172 comment_at text not null,
173
174 -- content
175 body text not null,
176
177 -- meta
178 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
179
180 -- constraints
181 foreign key (repo_at, pull_id) references pulls(repo_at, pull_id) on delete cascade,
182 foreign key (submission_id) references pull_submissions(id) on delete cascade
183 );
184
185 create table if not exists _jetstream (
186 id integer primary key autoincrement,
187 last_time_us integer not null
188 );
189
190 create table if not exists repo_issue_seqs (
191 repo_at text primary key,
192 next_issue_id integer not null default 1
193 );
194
195 create table if not exists repo_pull_seqs (
196 repo_at text primary key,
197 next_pull_id integer not null default 1
198 );
199
200 create table if not exists stars (
201 id integer primary key autoincrement,
202 starred_by_did text not null,
203 repo_at text not null,
204 rkey text not null,
205 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
206 foreign key (repo_at) references repos(at_uri) on delete cascade,
207 unique(starred_by_did, repo_at)
208 );
209
210 create table if not exists reactions (
211 id integer primary key autoincrement,
212 reacted_by_did text not null,
213 thread_at text not null,
214 kind text not null,
215 rkey text not null,
216 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
217 unique(reacted_by_did, thread_at, kind)
218 );
219
220 create table if not exists emails (
221 id integer primary key autoincrement,
222 did text not null,
223 email text not null,
224 verified integer not null default 0,
225 verification_code text not null,
226 last_sent text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
227 is_primary integer not null default 0,
228 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
229 unique(did, email)
230 );
231
232 create table if not exists artifacts (
233 -- id
234 id integer primary key autoincrement,
235 did text not null,
236 rkey text not null,
237
238 -- meta
239 repo_at text not null,
240 tag binary(20) not null,
241 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
242
243 -- data
244 blob_cid text not null,
245 name text not null,
246 size integer not null default 0,
247 mimetype string not null default "*/*",
248
249 -- constraints
250 unique(did, rkey), -- record must be unique
251 unique(repo_at, tag, name), -- for a given tag object, each file must be unique
252 foreign key (repo_at) references repos(at_uri) on delete cascade
253 );
254
255 create table if not exists profile (
256 -- id
257 id integer primary key autoincrement,
258 did text not null,
259
260 -- data
261 description text not null,
262 include_bluesky integer not null default 0,
263 location text,
264
265 -- constraints
266 unique(did)
267 );
268 create table if not exists profile_links (
269 -- id
270 id integer primary key autoincrement,
271 did text not null,
272
273 -- data
274 link text not null,
275
276 -- constraints
277 foreign key (did) references profile(did) on delete cascade
278 );
279 create table if not exists profile_stats (
280 -- id
281 id integer primary key autoincrement,
282 did text not null,
283
284 -- data
285 kind text not null check (kind in (
286 "merged-pull-request-count",
287 "closed-pull-request-count",
288 "open-pull-request-count",
289 "open-issue-count",
290 "closed-issue-count",
291 "repository-count"
292 )),
293
294 -- constraints
295 foreign key (did) references profile(did) on delete cascade
296 );
297 create table if not exists profile_pinned_repositories (
298 -- id
299 id integer primary key autoincrement,
300 did text not null,
301
302 -- data
303 at_uri text not null,
304
305 -- constraints
306 unique(did, at_uri),
307 foreign key (did) references profile(did) on delete cascade,
308 foreign key (at_uri) references repos(at_uri) on delete cascade
309 );
310
311 create table if not exists oauth_requests (
312 id integer primary key autoincrement,
313 auth_server_iss text not null,
314 state text not null,
315 did text not null,
316 handle text not null,
317 pds_url text not null,
318 pkce_verifier text not null,
319 dpop_auth_server_nonce text not null,
320 dpop_private_jwk text not null
321 );
322
323 create table if not exists oauth_sessions (
324 id integer primary key autoincrement,
325 did text not null,
326 handle text not null,
327 pds_url text not null,
328 auth_server_iss text not null,
329 access_jwt text not null,
330 refresh_jwt text not null,
331 dpop_pds_nonce text,
332 dpop_auth_server_nonce text not null,
333 dpop_private_jwk text not null,
334 expiry text not null
335 );
336
337 create table if not exists punchcard (
338 did text not null,
339 date text not null, -- yyyy-mm-dd
340 count integer,
341 primary key (did, date)
342 );
343
344 create table if not exists spindles (
345 id integer primary key autoincrement,
346 owner text not null,
347 instance text not null,
348 verified text, -- time of verification
349 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
350
351 unique(owner, instance)
352 );
353
354 create table if not exists spindle_members (
355 -- identifiers for the record
356 id integer primary key autoincrement,
357 did text not null,
358 rkey text not null,
359
360 -- data
361 instance text not null,
362 subject text not null,
363 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
364
365 -- constraints
366 unique (did, instance, subject)
367 );
368
369 create table if not exists pipelines (
370 -- identifiers
371 id integer primary key autoincrement,
372 knot text not null,
373 rkey text not null,
374
375 repo_owner text not null,
376 repo_name text not null,
377
378 -- every pipeline must be associated with exactly one commit
379 sha text not null check (length(sha) = 40),
380 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
381
382 -- trigger data
383 trigger_id integer not null,
384
385 unique(knot, rkey),
386 foreign key (trigger_id) references triggers(id) on delete cascade
387 );
388
389 create table if not exists triggers (
390 -- primary key
391 id integer primary key autoincrement,
392
393 -- top-level fields
394 kind text not null,
395
396 -- pushTriggerData fields
397 push_ref text,
398 push_new_sha text check (length(push_new_sha) = 40),
399 push_old_sha text check (length(push_old_sha) = 40),
400
401 -- pullRequestTriggerData fields
402 pr_source_branch text,
403 pr_target_branch text,
404 pr_source_sha text check (length(pr_source_sha) = 40),
405 pr_action text
406 );
407
408 create table if not exists pipeline_statuses (
409 -- identifiers
410 id integer primary key autoincrement,
411 spindle text not null,
412 rkey text not null,
413
414 -- referenced pipeline. these form the (did, rkey) pair
415 pipeline_knot text not null,
416 pipeline_rkey text not null,
417
418 -- content
419 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
420 workflow text not null,
421 status text not null,
422 error text,
423 exit_code integer not null default 0,
424
425 unique (spindle, rkey),
426 foreign key (pipeline_knot, pipeline_rkey)
427 references pipelines (knot, rkey)
428 on delete cascade
429 );
430
431 create table if not exists repo_languages (
432 -- identifiers
433 id integer primary key autoincrement,
434
435 -- repo identifiers
436 repo_at text not null,
437 ref text not null,
438 is_default_ref integer not null default 0,
439
440 -- language breakdown
441 language text not null,
442 bytes integer not null check (bytes >= 0),
443
444 unique(repo_at, ref, language)
445 );
446
447 create table if not exists signups_inflight (
448 id integer primary key autoincrement,
449 email text not null unique,
450 invite_code text not null,
451 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
452 );
453
454 create table if not exists strings (
455 -- identifiers
456 did text not null,
457 rkey text not null,
458
459 -- content
460 filename text not null,
461 description text,
462 content text not null,
463 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
464 edited text,
465
466 primary key (did, rkey)
467 );
468
469 create table if not exists migrations (
470 id integer primary key autoincrement,
471 name text unique
472 );
473
474 -- indexes for better star query performance
475 create index if not exists idx_stars_created on stars(created);
476 create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
477 `)
478 if err != nil {
479 return nil, err
480 }
481
482 // run migrations
483 runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
484 tx.Exec(`
485 alter table repos add column description text check (length(description) <= 200);
486 `)
487 return nil
488 })
489
490 runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
491 // add unconstrained column
492 _, err := tx.Exec(`
493 alter table public_keys
494 add column rkey text;
495 `)
496 if err != nil {
497 return err
498 }
499
500 // backfill
501 _, err = tx.Exec(`
502 update public_keys
503 set rkey = ''
504 where rkey is null;
505 `)
506 if err != nil {
507 return err
508 }
509
510 return nil
511 })
512
513 runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
514 _, err := tx.Exec(`
515 alter table comments drop column comment_at;
516 alter table comments add column rkey text;
517 `)
518 return err
519 })
520
521 runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
522 _, err := tx.Exec(`
523 alter table comments add column deleted text; -- timestamp
524 alter table comments add column edited text; -- timestamp
525 `)
526 return err
527 })
528
529 runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
530 _, err := tx.Exec(`
531 alter table pulls add column source_branch text;
532 alter table pulls add column source_repo_at text;
533 alter table pull_submissions add column source_rev text;
534 `)
535 return err
536 })
537
538 runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
539 _, err := tx.Exec(`
540 alter table repos add column source text;
541 `)
542 return err
543 })
544
545 // disable foreign-keys for the next migration
546 // NOTE: this cannot be done in a transaction, so it is run outside [0]
547 //
548 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
549 conn.ExecContext(ctx, "pragma foreign_keys = off;")
550 runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
551 _, err := tx.Exec(`
552 create table pulls_new (
553 -- identifiers
554 id integer primary key autoincrement,
555 pull_id integer not null,
556
557 -- at identifiers
558 repo_at text not null,
559 owner_did text not null,
560 rkey text not null,
561
562 -- content
563 title text not null,
564 body text not null,
565 target_branch text not null,
566 state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
567
568 -- source info
569 source_branch text,
570 source_repo_at text,
571
572 -- stacking
573 stack_id text,
574 change_id text,
575 parent_change_id text,
576
577 -- meta
578 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
579
580 -- constraints
581 unique(repo_at, pull_id),
582 foreign key (repo_at) references repos(at_uri) on delete cascade
583 );
584
585 insert into pulls_new (
586 id, pull_id,
587 repo_at, owner_did, rkey,
588 title, body, target_branch, state,
589 source_branch, source_repo_at,
590 created
591 )
592 select
593 id, pull_id,
594 repo_at, owner_did, rkey,
595 title, body, target_branch, state,
596 source_branch, source_repo_at,
597 created
598 FROM pulls;
599
600 drop table pulls;
601 alter table pulls_new rename to pulls;
602 `)
603 return err
604 })
605 conn.ExecContext(ctx, "pragma foreign_keys = on;")
606
607 // run migrations
608 runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
609 tx.Exec(`
610 alter table repos add column spindle text;
611 `)
612 return nil
613 })
614
615 // drop all knot secrets, add unique constraint to knots
616 //
617 // knots will henceforth use service auth for signed requests
618 runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error {
619 _, err := tx.Exec(`
620 create table registrations_new (
621 id integer primary key autoincrement,
622 domain text not null,
623 did text not null,
624 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
625 registered text,
626 read_only integer not null default 0,
627 unique(domain, did)
628 );
629
630 insert into registrations_new (id, domain, did, created, registered, read_only)
631 select id, domain, did, created, registered, 1 from registrations
632 where registered is not null;
633
634 drop table registrations;
635 alter table registrations_new rename to registrations;
636 `)
637 return err
638 })
639
640 // recreate and add rkey + created columns with default constraint
641 runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
642 // create new table
643 // - repo_at instead of repo integer
644 // - rkey field
645 // - created field
646 _, err := tx.Exec(`
647 create table collaborators_new (
648 -- identifiers for the record
649 id integer primary key autoincrement,
650 did text not null,
651 rkey text,
652
653 -- content
654 subject_did text not null,
655 repo_at text not null,
656
657 -- meta
658 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
659
660 -- constraints
661 foreign key (repo_at) references repos(at_uri) on delete cascade
662 )
663 `)
664 if err != nil {
665 return err
666 }
667
668 // copy data
669 _, err = tx.Exec(`
670 insert into collaborators_new (id, did, rkey, subject_did, repo_at)
671 select
672 c.id,
673 r.did,
674 '',
675 c.did,
676 r.at_uri
677 from collaborators c
678 join repos r on c.repo = r.id
679 `)
680 if err != nil {
681 return err
682 }
683
684 // drop old table
685 _, err = tx.Exec(`drop table collaborators`)
686 if err != nil {
687 return err
688 }
689
690 // rename new table
691 _, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
692 return err
693 })
694
695 runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
696 _, err := tx.Exec(`
697 alter table issues add column rkey text not null default '';
698
699 -- get last url section from issue_at and save to rkey column
700 update issues
701 set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), '');
702 `)
703 return err
704 })
705
706 // repurpose the read-only column to "needs-upgrade"
707 runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
708 _, err := tx.Exec(`
709 alter table registrations rename column read_only to needs_upgrade;
710 `)
711 return err
712 })
713
714 // require all knots to upgrade after the release of total xrpc
715 runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
716 _, err := tx.Exec(`
717 update registrations set needs_upgrade = 1;
718 `)
719 return err
720 })
721
722 // require all knots to upgrade after the release of total xrpc
723 runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
724 _, err := tx.Exec(`
725 alter table spindles add column needs_upgrade integer not null default 0;
726 `)
727 if err != nil {
728 return err
729 }
730
731 _, err = tx.Exec(`
732 update spindles set needs_upgrade = 1;
733 `)
734 return err
735 })
736
737 // remove issue_at from issues and replace with generated column
738 //
739 // this requires a full table recreation because stored columns
740 // cannot be added via alter
741 //
742 // couple other changes:
743 // - columns renamed to be more consistent
744 // - adds edited and deleted fields
745 //
746 // disable foreign-keys for the next migration
747 conn.ExecContext(ctx, "pragma foreign_keys = off;")
748 runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
749 _, err := tx.Exec(`
750 create table if not exists issues_new (
751 -- identifiers
752 id integer primary key autoincrement,
753 did text not null,
754 rkey text not null,
755 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored,
756
757 -- at identifiers
758 repo_at text not null,
759
760 -- content
761 issue_id integer not null,
762 title text not null,
763 body text not null,
764 open integer not null default 1,
765 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
766 edited text, -- timestamp
767 deleted text, -- timestamp
768
769 unique(did, rkey),
770 unique(repo_at, issue_id),
771 unique(at_uri),
772 foreign key (repo_at) references repos(at_uri) on delete cascade
773 );
774 `)
775 if err != nil {
776 return err
777 }
778
779 // transfer data
780 _, err = tx.Exec(`
781 insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created)
782 select
783 i.id,
784 i.owner_did,
785 i.rkey,
786 i.repo_at,
787 i.issue_id,
788 i.title,
789 i.body,
790 i.open,
791 i.created
792 from issues i;
793 `)
794 if err != nil {
795 return err
796 }
797
798 // drop old table
799 _, err = tx.Exec(`drop table issues`)
800 if err != nil {
801 return err
802 }
803
804 // rename new table
805 _, err = tx.Exec(`alter table issues_new rename to issues`)
806 return err
807 })
808 conn.ExecContext(ctx, "pragma foreign_keys = on;")
809
810 // - renames the comments table to 'issue_comments'
811 // - rework issue comments to update constraints:
812 // * unique(did, rkey)
813 // * remove comment-id and just use the global ID
814 // * foreign key (repo_at, issue_id)
815 // - new columns
816 // * column "reply_to" which can be any other comment
817 // * column "at-uri" which is a generated column
818 runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
819 _, err := tx.Exec(`
820 create table if not exists issue_comments (
821 -- identifiers
822 id integer primary key autoincrement,
823 did text not null,
824 rkey text,
825 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored,
826
827 -- at identifiers
828 issue_at text not null,
829 reply_to text, -- at_uri of parent comment
830
831 -- content
832 body text not null,
833 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
834 edited text,
835 deleted text,
836
837 -- constraints
838 unique(did, rkey),
839 unique(at_uri),
840 foreign key (issue_at) references issues(at_uri) on delete cascade
841 );
842 `)
843 if err != nil {
844 return err
845 }
846
847 // transfer data
848 _, err = tx.Exec(`
849 insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted)
850 select
851 c.id,
852 c.owner_did,
853 c.rkey,
854 i.at_uri, -- get at_uri from issues table
855 c.body,
856 c.created,
857 c.edited,
858 c.deleted
859 from comments c
860 join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id;
861 `)
862 if err != nil {
863 return err
864 }
865
866 // drop old table
867 _, err = tx.Exec(`drop table comments`)
868 return err
869 })
870
871 return &DB{db}, nil
872}
873
874type migrationFn = func(*sql.Tx) error
875
876func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
877 tx, err := c.BeginTx(context.Background(), nil)
878 if err != nil {
879 return err
880 }
881 defer tx.Rollback()
882
883 var exists bool
884 err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists)
885 if err != nil {
886 return err
887 }
888
889 if !exists {
890 // run migration
891 err = migrationFn(tx)
892 if err != nil {
893 log.Printf("Failed to run migration %s: %v", name, err)
894 return err
895 }
896
897 // mark migration as complete
898 _, err = tx.Exec("insert into migrations (name) values (?)", name)
899 if err != nil {
900 log.Printf("Failed to mark migration %s as complete: %v", name, err)
901 return err
902 }
903
904 // commit the transaction
905 if err := tx.Commit(); err != nil {
906 return err
907 }
908
909 log.Printf("migration %s applied successfully", name)
910 } else {
911 log.Printf("skipped migration %s, already applied", name)
912 }
913
914 return nil
915}
916
917func (d *DB) Close() error {
918 return d.DB.Close()
919}
920
921type filter struct {
922 key string
923 arg any
924 cmp string
925}
926
927func newFilter(key, cmp string, arg any) filter {
928 return filter{
929 key: key,
930 arg: arg,
931 cmp: cmp,
932 }
933}
934
935func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
936func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
937func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
938func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
939func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
940func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
941func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
942
943func (f filter) Condition() string {
944 rv := reflect.ValueOf(f.arg)
945 kind := rv.Kind()
946
947 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
948 if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
949 if rv.Len() == 0 {
950 // always false
951 return "1 = 0"
952 }
953
954 placeholders := make([]string, rv.Len())
955 for i := range placeholders {
956 placeholders[i] = "?"
957 }
958
959 return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", "))
960 }
961
962 return fmt.Sprintf("%s %s ?", f.key, f.cmp)
963}
964
965func (f filter) Arg() []any {
966 rv := reflect.ValueOf(f.arg)
967 kind := rv.Kind()
968 if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
969 if rv.Len() == 0 {
970 return nil
971 }
972
973 out := make([]any, rv.Len())
974 for i := range rv.Len() {
975 out[i] = rv.Index(i).Interface()
976 }
977 return out
978 }
979
980 return []any{f.arg}
981}