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