1package db
2
3import (
4 "context"
5 "database/sql"
6 "log/slog"
7 "strings"
8
9 _ "github.com/mattn/go-sqlite3"
10 "tangled.org/core/log"
11 "tangled.org/core/orm"
12)
13
14type DB struct {
15 *sql.DB
16 logger *slog.Logger
17}
18
19type Execer interface {
20 Query(query string, args ...any) (*sql.Rows, error)
21 QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
22 QueryRow(query string, args ...any) *sql.Row
23 QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
24 Exec(query string, args ...any) (sql.Result, error)
25 ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
26 Prepare(query string) (*sql.Stmt, error)
27 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
28}
29
30func Make(ctx context.Context, dbPath string) (*DB, error) {
31 // https://github.com/mattn/go-sqlite3#connection-string
32 opts := []string{
33 "_foreign_keys=1",
34 "_journal_mode=WAL",
35 "_synchronous=NORMAL",
36 "_auto_vacuum=incremental",
37 }
38
39 logger := log.FromContext(ctx)
40 logger = log.SubLogger(logger, "db")
41
42 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
43 if err != nil {
44 return nil, err
45 }
46
47 conn, err := db.Conn(ctx)
48 if err != nil {
49 return nil, err
50 }
51 defer conn.Close()
52
53 _, err = conn.ExecContext(ctx, `
54 create table if not exists registrations (
55 id integer primary key autoincrement,
56 domain text not null unique,
57 did text not null,
58 secret text not null,
59 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
60 registered text
61 );
62 create table if not exists public_keys (
63 id integer primary key autoincrement,
64 did text not null,
65 name text not null,
66 key text not null,
67 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
68 unique(did, name, key)
69 );
70 create table if not exists repos (
71 id integer primary key autoincrement,
72 did text not null,
73 name text not null,
74 knot text not null,
75 rkey text not null,
76 at_uri text not null unique,
77 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
78 unique(did, name, knot, rkey)
79 );
80 create table if not exists collaborators (
81 id integer primary key autoincrement,
82 did text not null,
83 repo integer not null,
84 foreign key (repo) references repos(id) on delete cascade
85 );
86 create table if not exists follows (
87 user_did text not null,
88 subject_did text not null,
89 rkey text not null,
90 followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
91 primary key (user_did, subject_did),
92 check (user_did <> subject_did)
93 );
94 create table if not exists issues (
95 id integer primary key autoincrement,
96 owner_did text not null,
97 repo_at text not null,
98 issue_id integer not null,
99 title text not null,
100 body text not null,
101 open integer not null default 1,
102 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
103 issue_at text,
104 unique(repo_at, issue_id),
105 foreign key (repo_at) references repos(at_uri) on delete cascade
106 );
107 create table if not exists comments (
108 id integer primary key autoincrement,
109 owner_did text not null,
110 issue_id integer not null,
111 repo_at text not null,
112 comment_id integer not null,
113 comment_at text not null,
114 body text not null,
115 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
116 unique(issue_id, comment_id),
117 foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
118 );
119 create table if not exists pulls (
120 -- identifiers
121 id integer primary key autoincrement,
122 pull_id integer not null,
123
124 -- at identifiers
125 repo_at text not null,
126 owner_did text not null,
127 rkey text not null,
128 pull_at text,
129
130 -- content
131 title text not null,
132 body text not null,
133 target_branch text not null,
134 state integer not null default 0 check (state in (0, 1, 2)), -- open, merged, closed
135
136 -- meta
137 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
138
139 -- constraints
140 unique(repo_at, pull_id),
141 foreign key (repo_at) references repos(at_uri) on delete cascade
142 );
143
144 -- every pull must have atleast 1 submission: the initial submission
145 create table if not exists pull_submissions (
146 -- identifiers
147 id integer primary key autoincrement,
148 pull_id integer not null,
149
150 -- at identifiers
151 repo_at text not null,
152
153 -- content, these are immutable, and require a resubmission to update
154 round_number integer not null default 0,
155 patch text,
156
157 -- meta
158 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
159
160 -- constraints
161 unique(repo_at, pull_id, round_number),
162 foreign key (repo_at, pull_id) references pulls(repo_at, pull_id) on delete cascade
163 );
164
165 create table if not exists pull_comments (
166 -- identifiers
167 id integer primary key autoincrement,
168 pull_id integer not null,
169 submission_id integer not null,
170
171 -- at identifiers
172 repo_at text not null,
173 owner_did text not null,
174 comment_at text not null,
175
176 -- content
177 body text not null,
178
179 -- meta
180 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
181
182 -- constraints
183 foreign key (repo_at, pull_id) references pulls(repo_at, pull_id) on delete cascade,
184 foreign key (submission_id) references pull_submissions(id) on delete cascade
185 );
186
187 create table if not exists _jetstream (
188 id integer primary key autoincrement,
189 last_time_us integer not null
190 );
191
192 create table if not exists repo_issue_seqs (
193 repo_at text primary key,
194 next_issue_id integer not null default 1
195 );
196
197 create table if not exists repo_pull_seqs (
198 repo_at text primary key,
199 next_pull_id integer not null default 1
200 );
201
202 create table if not exists stars (
203 id integer primary key autoincrement,
204 starred_by_did text not null,
205 repo_at text not null,
206 rkey text not null,
207 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
208 foreign key (repo_at) references repos(at_uri) on delete cascade,
209 unique(starred_by_did, repo_at)
210 );
211
212 create table if not exists reactions (
213 id integer primary key autoincrement,
214 reacted_by_did text not null,
215 thread_at text not null,
216 kind text not null,
217 rkey text not null,
218 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
219 unique(reacted_by_did, thread_at, kind)
220 );
221
222 create table if not exists emails (
223 id integer primary key autoincrement,
224 did text not null,
225 email text not null,
226 verified integer not null default 0,
227 verification_code text not null,
228 last_sent text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
229 is_primary integer not null default 0,
230 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
231 unique(did, email)
232 );
233
234 create table if not exists artifacts (
235 -- id
236 id integer primary key autoincrement,
237 did text not null,
238 rkey text not null,
239
240 -- meta
241 repo_at text not null,
242 tag binary(20) not null,
243 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
244
245 -- data
246 blob_cid text not null,
247 name text not null,
248 size integer not null default 0,
249 mimetype string not null default "*/*",
250
251 -- constraints
252 unique(did, rkey), -- record must be unique
253 unique(repo_at, tag, name), -- for a given tag object, each file must be unique
254 foreign key (repo_at) references repos(at_uri) on delete cascade
255 );
256
257 create table if not exists profile (
258 -- id
259 id integer primary key autoincrement,
260 did text not null,
261
262 -- data
263 avatar text,
264 description text not null,
265 include_bluesky integer not null default 0,
266 location text,
267
268 -- constraints
269 unique(did)
270 );
271 create table if not exists profile_links (
272 -- id
273 id integer primary key autoincrement,
274 did text not null,
275
276 -- data
277 link text not null,
278
279 -- constraints
280 foreign key (did) references profile(did) on delete cascade
281 );
282 create table if not exists profile_stats (
283 -- id
284 id integer primary key autoincrement,
285 did text not null,
286
287 -- data
288 kind text not null check (kind in (
289 "merged-pull-request-count",
290 "closed-pull-request-count",
291 "open-pull-request-count",
292 "open-issue-count",
293 "closed-issue-count",
294 "repository-count"
295 )),
296
297 -- constraints
298 foreign key (did) references profile(did) on delete cascade
299 );
300 create table if not exists profile_pinned_repositories (
301 -- id
302 id integer primary key autoincrement,
303 did text not null,
304
305 -- data
306 at_uri text not null,
307
308 -- constraints
309 unique(did, at_uri),
310 foreign key (did) references profile(did) on delete cascade,
311 foreign key (at_uri) references repos(at_uri) on delete cascade
312 );
313
314 create table if not exists oauth_requests (
315 id integer primary key autoincrement,
316 auth_server_iss text not null,
317 state text not null,
318 did text not null,
319 handle text not null,
320 pds_url text not null,
321 pkce_verifier text not null,
322 dpop_auth_server_nonce text not null,
323 dpop_private_jwk text not null
324 );
325
326 create table if not exists oauth_sessions (
327 id integer primary key autoincrement,
328 did text not null,
329 handle text not null,
330 pds_url text not null,
331 auth_server_iss text not null,
332 access_jwt text not null,
333 refresh_jwt text not null,
334 dpop_pds_nonce text,
335 dpop_auth_server_nonce text not null,
336 dpop_private_jwk text not null,
337 expiry text not null
338 );
339
340 create table if not exists punchcard (
341 did text not null,
342 date text not null, -- yyyy-mm-dd
343 count integer,
344 primary key (did, date)
345 );
346
347 create table if not exists spindles (
348 id integer primary key autoincrement,
349 owner text not null,
350 instance text not null,
351 verified text, -- time of verification
352 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
353
354 unique(owner, instance)
355 );
356
357 create table if not exists spindle_members (
358 -- identifiers for the record
359 id integer primary key autoincrement,
360 did text not null,
361 rkey text not null,
362
363 -- data
364 instance text not null,
365 subject text not null,
366 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
367
368 -- constraints
369 unique (did, instance, subject)
370 );
371
372 create table if not exists pipelines (
373 -- identifiers
374 id integer primary key autoincrement,
375 knot text not null,
376 rkey text not null,
377
378 repo_owner text not null,
379 repo_name text not null,
380
381 -- every pipeline must be associated with exactly one commit
382 sha text not null check (length(sha) = 40),
383 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
384
385 -- trigger data
386 trigger_id integer not null,
387
388 unique(knot, rkey),
389 foreign key (trigger_id) references triggers(id) on delete cascade
390 );
391
392 create table if not exists triggers (
393 -- primary key
394 id integer primary key autoincrement,
395
396 -- top-level fields
397 kind text not null,
398
399 -- pushTriggerData fields
400 push_ref text,
401 push_new_sha text check (length(push_new_sha) = 40),
402 push_old_sha text check (length(push_old_sha) = 40),
403
404 -- pullRequestTriggerData fields
405 pr_source_branch text,
406 pr_target_branch text,
407 pr_source_sha text check (length(pr_source_sha) = 40),
408 pr_action text
409 );
410
411 create table if not exists pipeline_statuses (
412 -- identifiers
413 id integer primary key autoincrement,
414 spindle text not null,
415 rkey text not null,
416
417 -- referenced pipeline. these form the (did, rkey) pair
418 pipeline_knot text not null,
419 pipeline_rkey text not null,
420
421 -- content
422 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
423 workflow text not null,
424 status text not null,
425 error text,
426 exit_code integer not null default 0,
427
428 unique (spindle, rkey),
429 foreign key (pipeline_knot, pipeline_rkey)
430 references pipelines (knot, rkey)
431 on delete cascade
432 );
433
434 create table if not exists repo_languages (
435 -- identifiers
436 id integer primary key autoincrement,
437
438 -- repo identifiers
439 repo_at text not null,
440 ref text not null,
441 is_default_ref integer not null default 0,
442
443 -- language breakdown
444 language text not null,
445 bytes integer not null check (bytes >= 0),
446
447 unique(repo_at, ref, language)
448 );
449
450 create table if not exists signups_inflight (
451 id integer primary key autoincrement,
452 email text not null unique,
453 invite_code text not null,
454 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
455 );
456
457 create table if not exists strings (
458 -- identifiers
459 did text not null,
460 rkey text not null,
461
462 -- content
463 filename text not null,
464 description text,
465 content text not null,
466 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
467 edited text,
468
469 primary key (did, rkey)
470 );
471
472 create table if not exists label_definitions (
473 -- identifiers
474 id integer primary key autoincrement,
475 did text not null,
476 rkey text not null,
477 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.definition' || '/' || rkey) stored,
478
479 -- content
480 name text not null,
481 value_type text not null check (value_type in (
482 "null",
483 "boolean",
484 "integer",
485 "string"
486 )),
487 value_format text not null default "any",
488 value_enum text, -- comma separated list
489 scope text not null, -- comma separated list of nsid
490 color text,
491 multiple integer not null default 0,
492 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
493
494 -- constraints
495 unique (did, rkey)
496 unique (at_uri)
497 );
498
499 -- ops are flattened, a record may contain several additions and deletions, but the table will include one row per add/del
500 create table if not exists label_ops (
501 -- identifiers
502 id integer primary key autoincrement,
503 did text not null,
504 rkey text not null,
505 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.op' || '/' || rkey) stored,
506
507 -- content
508 subject text not null,
509 operation text not null check (operation in ("add", "del")),
510 operand_key text not null,
511 operand_value text not null,
512 -- we need two time values: performed is declared by the user, indexed is calculated by the av
513 performed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
514 indexed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
515
516 -- constraints
517 -- traditionally (did, rkey) pair should be unique, but not in this case
518 -- operand_key should reference a label definition
519 foreign key (operand_key) references label_definitions (at_uri) on delete cascade,
520 unique (did, rkey, subject, operand_key, operand_value)
521 );
522
523 create table if not exists repo_labels (
524 -- identifiers
525 id integer primary key autoincrement,
526
527 -- repo identifiers
528 repo_at text not null,
529
530 -- label to subscribe to
531 label_at text not null,
532
533 unique (repo_at, label_at)
534 );
535
536 create table if not exists notifications (
537 id integer primary key autoincrement,
538 recipient_did text not null,
539 actor_did text not null,
540 type text not null,
541 entity_type text not null,
542 entity_id text not null,
543 read integer not null default 0,
544 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
545 repo_id integer references repos(id),
546 issue_id integer references issues(id),
547 pull_id integer references pulls(id)
548 );
549
550 create table if not exists notification_preferences (
551 id integer primary key autoincrement,
552 user_did text not null unique,
553 repo_starred integer not null default 1,
554 issue_created integer not null default 1,
555 issue_commented integer not null default 1,
556 pull_created integer not null default 1,
557 pull_commented integer not null default 1,
558 followed integer not null default 1,
559 pull_merged integer not null default 1,
560 issue_closed integer not null default 1,
561 email_notifications integer not null default 0
562 );
563
564 create table if not exists reference_links (
565 id integer primary key autoincrement,
566 from_at text not null,
567 to_at text not null,
568 unique (from_at, to_at)
569 );
570
571 create table if not exists knot_preferences (
572 id integer primary key autoincrement,
573 user_did text not null unique,
574 default_knot text
575 );
576
577 create table if not exists migrations (
578 id integer primary key autoincrement,
579 name text unique
580 );
581
582 -- indexes for better performance
583 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
584 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
585 create index if not exists idx_references_from_at on reference_links(from_at);
586 create index if not exists idx_references_to_at on reference_links(to_at);
587 `)
588 if err != nil {
589 return nil, err
590 }
591
592 // run migrations
593 orm.RunMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
594 tx.Exec(`
595 alter table repos add column description text check (length(description) <= 200);
596 `)
597 return nil
598 })
599
600 orm.RunMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
601 // add unconstrained column
602 _, err := tx.Exec(`
603 alter table public_keys
604 add column rkey text;
605 `)
606 if err != nil {
607 return err
608 }
609
610 // backfill
611 _, err = tx.Exec(`
612 update public_keys
613 set rkey = ''
614 where rkey is null;
615 `)
616 if err != nil {
617 return err
618 }
619
620 return nil
621 })
622
623 orm.RunMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
624 _, err := tx.Exec(`
625 alter table comments drop column comment_at;
626 alter table comments add column rkey text;
627 `)
628 return err
629 })
630
631 orm.RunMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
632 _, err := tx.Exec(`
633 alter table comments add column deleted text; -- timestamp
634 alter table comments add column edited text; -- timestamp
635 `)
636 return err
637 })
638
639 orm.RunMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
640 _, err := tx.Exec(`
641 alter table pulls add column source_branch text;
642 alter table pulls add column source_repo_at text;
643 alter table pull_submissions add column source_rev text;
644 `)
645 return err
646 })
647
648 orm.RunMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
649 _, err := tx.Exec(`
650 alter table repos add column source text;
651 `)
652 return err
653 })
654
655 // disable foreign-keys for the next migration
656 // NOTE: this cannot be done in a transaction, so it is run outside [0]
657 //
658 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
659 conn.ExecContext(ctx, "pragma foreign_keys = off;")
660 orm.RunMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
661 _, err := tx.Exec(`
662 create table pulls_new (
663 -- identifiers
664 id integer primary key autoincrement,
665 pull_id integer not null,
666
667 -- at identifiers
668 repo_at text not null,
669 owner_did text not null,
670 rkey text not null,
671
672 -- content
673 title text not null,
674 body text not null,
675 target_branch text not null,
676 state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
677
678 -- source info
679 source_branch text,
680 source_repo_at text,
681
682 -- stacking
683 stack_id text,
684 change_id text,
685 parent_change_id text,
686
687 -- meta
688 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
689
690 -- constraints
691 unique(repo_at, pull_id),
692 foreign key (repo_at) references repos(at_uri) on delete cascade
693 );
694
695 insert into pulls_new (
696 id, pull_id,
697 repo_at, owner_did, rkey,
698 title, body, target_branch, state,
699 source_branch, source_repo_at,
700 created
701 )
702 select
703 id, pull_id,
704 repo_at, owner_did, rkey,
705 title, body, target_branch, state,
706 source_branch, source_repo_at,
707 created
708 FROM pulls;
709
710 drop table pulls;
711 alter table pulls_new rename to pulls;
712 `)
713 return err
714 })
715 conn.ExecContext(ctx, "pragma foreign_keys = on;")
716
717 orm.RunMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
718 tx.Exec(`
719 alter table repos add column spindle text;
720 `)
721 return nil
722 })
723
724 // drop all knot secrets, add unique constraint to knots
725 //
726 // knots will henceforth use service auth for signed requests
727 orm.RunMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
728 _, err := tx.Exec(`
729 create table registrations_new (
730 id integer primary key autoincrement,
731 domain text not null,
732 did text not null,
733 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
734 registered text,
735 read_only integer not null default 0,
736 unique(domain, did)
737 );
738
739 insert into registrations_new (id, domain, did, created, registered, read_only)
740 select id, domain, did, created, registered, 1 from registrations
741 where registered is not null;
742
743 drop table registrations;
744 alter table registrations_new rename to registrations;
745 `)
746 return err
747 })
748
749 // recreate and add rkey + created columns with default constraint
750 orm.RunMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
751 // create new table
752 // - repo_at instead of repo integer
753 // - rkey field
754 // - created field
755 _, err := tx.Exec(`
756 create table collaborators_new (
757 -- identifiers for the record
758 id integer primary key autoincrement,
759 did text not null,
760 rkey text,
761
762 -- content
763 subject_did text not null,
764 repo_at text not null,
765
766 -- meta
767 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
768
769 -- constraints
770 foreign key (repo_at) references repos(at_uri) on delete cascade
771 )
772 `)
773 if err != nil {
774 return err
775 }
776
777 // copy data
778 _, err = tx.Exec(`
779 insert into collaborators_new (id, did, rkey, subject_did, repo_at)
780 select
781 c.id,
782 r.did,
783 '',
784 c.did,
785 r.at_uri
786 from collaborators c
787 join repos r on c.repo = r.id
788 `)
789 if err != nil {
790 return err
791 }
792
793 // drop old table
794 _, err = tx.Exec(`drop table collaborators`)
795 if err != nil {
796 return err
797 }
798
799 // rename new table
800 _, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
801 return err
802 })
803
804 orm.RunMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
805 _, err := tx.Exec(`
806 alter table issues add column rkey text not null default '';
807
808 -- get last url section from issue_at and save to rkey column
809 update issues
810 set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), '');
811 `)
812 return err
813 })
814
815 // repurpose the read-only column to "needs-upgrade"
816 orm.RunMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
817 _, err := tx.Exec(`
818 alter table registrations rename column read_only to needs_upgrade;
819 `)
820 return err
821 })
822
823 // require all knots to upgrade after the release of total xrpc
824 orm.RunMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
825 _, err := tx.Exec(`
826 update registrations set needs_upgrade = 1;
827 `)
828 return err
829 })
830
831 // require all knots to upgrade after the release of total xrpc
832 orm.RunMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
833 _, err := tx.Exec(`
834 alter table spindles add column needs_upgrade integer not null default 0;
835 `)
836 return err
837 })
838
839 // remove issue_at from issues and replace with generated column
840 //
841 // this requires a full table recreation because stored columns
842 // cannot be added via alter
843 //
844 // couple other changes:
845 // - columns renamed to be more consistent
846 // - adds edited and deleted fields
847 //
848 // disable foreign-keys for the next migration
849 conn.ExecContext(ctx, "pragma foreign_keys = off;")
850 orm.RunMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
851 _, err := tx.Exec(`
852 create table if not exists issues_new (
853 -- identifiers
854 id integer primary key autoincrement,
855 did text not null,
856 rkey text not null,
857 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored,
858
859 -- at identifiers
860 repo_at text not null,
861
862 -- content
863 issue_id integer not null,
864 title text not null,
865 body text not null,
866 open integer not null default 1,
867 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
868 edited text, -- timestamp
869 deleted text, -- timestamp
870
871 unique(did, rkey),
872 unique(repo_at, issue_id),
873 unique(at_uri),
874 foreign key (repo_at) references repos(at_uri) on delete cascade
875 );
876 `)
877 if err != nil {
878 return err
879 }
880
881 // transfer data
882 _, err = tx.Exec(`
883 insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created)
884 select
885 i.id,
886 i.owner_did,
887 i.rkey,
888 i.repo_at,
889 i.issue_id,
890 i.title,
891 i.body,
892 i.open,
893 i.created
894 from issues i;
895 `)
896 if err != nil {
897 return err
898 }
899
900 // drop old table
901 _, err = tx.Exec(`drop table issues`)
902 if err != nil {
903 return err
904 }
905
906 // rename new table
907 _, err = tx.Exec(`alter table issues_new rename to issues`)
908 return err
909 })
910 conn.ExecContext(ctx, "pragma foreign_keys = on;")
911
912 // - renames the comments table to 'issue_comments'
913 // - rework issue comments to update constraints:
914 // * unique(did, rkey)
915 // * remove comment-id and just use the global ID
916 // * foreign key (repo_at, issue_id)
917 // - new columns
918 // * column "reply_to" which can be any other comment
919 // * column "at-uri" which is a generated column
920 orm.RunMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
921 _, err := tx.Exec(`
922 create table if not exists issue_comments (
923 -- identifiers
924 id integer primary key autoincrement,
925 did text not null,
926 rkey text,
927 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored,
928
929 -- at identifiers
930 issue_at text not null,
931 reply_to text, -- at_uri of parent comment
932
933 -- content
934 body text not null,
935 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
936 edited text,
937 deleted text,
938
939 -- constraints
940 unique(did, rkey),
941 unique(at_uri),
942 foreign key (issue_at) references issues(at_uri) on delete cascade
943 );
944 `)
945 if err != nil {
946 return err
947 }
948
949 // transfer data
950 _, err = tx.Exec(`
951 insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted)
952 select
953 c.id,
954 c.owner_did,
955 c.rkey,
956 i.at_uri, -- get at_uri from issues table
957 c.body,
958 c.created,
959 c.edited,
960 c.deleted
961 from comments c
962 join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id;
963 `)
964 if err != nil {
965 return err
966 }
967
968 // drop old table
969 _, err = tx.Exec(`drop table comments`)
970 return err
971 })
972
973 // add generated at_uri column to pulls table
974 //
975 // this requires a full table recreation because stored columns
976 // cannot be added via alter
977 //
978 // disable foreign-keys for the next migration
979 conn.ExecContext(ctx, "pragma foreign_keys = off;")
980 orm.RunMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
981 _, err := tx.Exec(`
982 create table if not exists pulls_new (
983 -- identifiers
984 id integer primary key autoincrement,
985 pull_id integer not null,
986 at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
987
988 -- at identifiers
989 repo_at text not null,
990 owner_did text not null,
991 rkey text not null,
992
993 -- content
994 title text not null,
995 body text not null,
996 target_branch text not null,
997 state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
998
999 -- source info
1000 source_branch text,
1001 source_repo_at text,
1002
1003 -- stacking
1004 stack_id text,
1005 change_id text,
1006 parent_change_id text,
1007
1008 -- meta
1009 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1010
1011 -- constraints
1012 unique(repo_at, pull_id),
1013 unique(at_uri),
1014 foreign key (repo_at) references repos(at_uri) on delete cascade
1015 );
1016 `)
1017 if err != nil {
1018 return err
1019 }
1020
1021 // transfer data
1022 _, err = tx.Exec(`
1023 insert into pulls_new (
1024 id, pull_id, repo_at, owner_did, rkey,
1025 title, body, target_branch, state,
1026 source_branch, source_repo_at,
1027 stack_id, change_id, parent_change_id,
1028 created
1029 )
1030 select
1031 id, pull_id, repo_at, owner_did, rkey,
1032 title, body, target_branch, state,
1033 source_branch, source_repo_at,
1034 stack_id, change_id, parent_change_id,
1035 created
1036 from pulls;
1037 `)
1038 if err != nil {
1039 return err
1040 }
1041
1042 // drop old table
1043 _, err = tx.Exec(`drop table pulls`)
1044 if err != nil {
1045 return err
1046 }
1047
1048 // rename new table
1049 _, err = tx.Exec(`alter table pulls_new rename to pulls`)
1050 return err
1051 })
1052 conn.ExecContext(ctx, "pragma foreign_keys = on;")
1053
1054 // remove repo_at and pull_id from pull_submissions and replace with pull_at
1055 //
1056 // this requires a full table recreation because stored columns
1057 // cannot be added via alter
1058 //
1059 // disable foreign-keys for the next migration
1060 conn.ExecContext(ctx, "pragma foreign_keys = off;")
1061 orm.RunMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1062 _, err := tx.Exec(`
1063 create table if not exists pull_submissions_new (
1064 -- identifiers
1065 id integer primary key autoincrement,
1066 pull_at text not null,
1067
1068 -- content, these are immutable, and require a resubmission to update
1069 round_number integer not null default 0,
1070 patch text,
1071 source_rev text,
1072
1073 -- meta
1074 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1075
1076 -- constraints
1077 unique(pull_at, round_number),
1078 foreign key (pull_at) references pulls(at_uri) on delete cascade
1079 );
1080 `)
1081 if err != nil {
1082 return err
1083 }
1084
1085 // transfer data, constructing pull_at from pulls table
1086 _, err = tx.Exec(`
1087 insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1088 select
1089 ps.id,
1090 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1091 ps.round_number,
1092 ps.patch,
1093 ps.created
1094 from pull_submissions ps
1095 join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
1096 `)
1097 if err != nil {
1098 return err
1099 }
1100
1101 // drop old table
1102 _, err = tx.Exec(`drop table pull_submissions`)
1103 if err != nil {
1104 return err
1105 }
1106
1107 // rename new table
1108 _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
1109 return err
1110 })
1111 conn.ExecContext(ctx, "pragma foreign_keys = on;")
1112
1113 // knots may report the combined patch for a comparison, we can store that on the appview side
1114 // (but not on the pds record), because calculating the combined patch requires a git index
1115 orm.RunMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error {
1116 _, err := tx.Exec(`
1117 alter table pull_submissions add column combined text;
1118 `)
1119 return err
1120 })
1121
1122 orm.RunMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error {
1123 _, err := tx.Exec(`
1124 alter table profile add column pronouns text;
1125 `)
1126 return err
1127 })
1128
1129 orm.RunMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error {
1130 _, err := tx.Exec(`
1131 alter table repos add column website text;
1132 alter table repos add column topics text;
1133 `)
1134 return err
1135 })
1136
1137 orm.RunMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error {
1138 _, err := tx.Exec(`
1139 alter table notification_preferences add column user_mentioned integer not null default 1;
1140 `)
1141 return err
1142 })
1143
1144 // remove the foreign key constraints from stars.
1145 orm.RunMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error {
1146 _, err := tx.Exec(`
1147 create table stars_new (
1148 id integer primary key autoincrement,
1149 did text not null,
1150 rkey text not null,
1151
1152 subject_at text not null,
1153
1154 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1155 unique(did, rkey),
1156 unique(did, subject_at)
1157 );
1158
1159 insert into stars_new (
1160 id,
1161 did,
1162 rkey,
1163 subject_at,
1164 created
1165 )
1166 select
1167 id,
1168 starred_by_did,
1169 rkey,
1170 repo_at,
1171 created
1172 from stars;
1173
1174 drop table stars;
1175 alter table stars_new rename to stars;
1176
1177 create index if not exists idx_stars_created on stars(created);
1178 create index if not exists idx_stars_subject_at_created on stars(subject_at, created);
1179 `)
1180 return err
1181 })
1182
1183 orm.RunMigration(conn, logger, "add-avatar-to-profile", func(tx *sql.Tx) error {
1184 _, err := tx.Exec(`
1185 alter table profile add column avatar text;
1186 `)
1187 return err
1188 })
1189
1190 return &DB{
1191 db,
1192 logger,
1193 }, nil
1194}
1195
1196func (d *DB) Close() error {
1197 return d.DB.Close()
1198}