forked from tangled.org/core
this repo has no description
at master 33 kB view raw
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 label_definitions ( 470 -- identifiers 471 id integer primary key autoincrement, 472 did text not null, 473 rkey text not null, 474 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.definition' || '/' || rkey) stored, 475 476 -- content 477 name text not null, 478 value_type text not null check (value_type in ( 479 "null", 480 "boolean", 481 "integer", 482 "string" 483 )), 484 value_format text not null default "any", 485 value_enum text, -- comma separated list 486 scope text not null, -- comma separated list of nsid 487 color text, 488 multiple integer not null default 0, 489 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 490 491 -- constraints 492 unique (did, rkey) 493 unique (at_uri) 494 ); 495 496 -- ops are flattened, a record may contain several additions and deletions, but the table will include one row per add/del 497 create table if not exists label_ops ( 498 -- identifiers 499 id integer primary key autoincrement, 500 did text not null, 501 rkey text not null, 502 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.op' || '/' || rkey) stored, 503 504 -- content 505 subject text not null, 506 operation text not null check (operation in ("add", "del")), 507 operand_key text not null, 508 operand_value text not null, 509 -- we need two time values: performed is declared by the user, indexed is calculated by the av 510 performed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 511 indexed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 512 513 -- constraints 514 -- traditionally (did, rkey) pair should be unique, but not in this case 515 -- operand_key should reference a label definition 516 foreign key (operand_key) references label_definitions (at_uri) on delete cascade, 517 unique (did, rkey, subject, operand_key, operand_value) 518 ); 519 520 create table if not exists repo_labels ( 521 -- identifiers 522 id integer primary key autoincrement, 523 524 -- repo identifiers 525 repo_at text not null, 526 527 -- label to subscribe to 528 label_at text not null, 529 530 unique (repo_at, label_at) 531 ); 532 533 create table if not exists notifications ( 534 id integer primary key autoincrement, 535 recipient_did text not null, 536 actor_did text not null, 537 type text not null, 538 entity_type text not null, 539 entity_id text not null, 540 read integer not null default 0, 541 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 542 repo_id integer references repos(id), 543 issue_id integer references issues(id), 544 pull_id integer references pulls(id) 545 ); 546 547 create table if not exists notification_preferences ( 548 id integer primary key autoincrement, 549 user_did text not null unique, 550 repo_starred integer not null default 1, 551 issue_created integer not null default 1, 552 issue_commented integer not null default 1, 553 pull_created integer not null default 1, 554 pull_commented integer not null default 1, 555 followed integer not null default 1, 556 pull_merged integer not null default 1, 557 issue_closed integer not null default 1, 558 email_notifications integer not null default 0 559 ); 560 561 create table if not exists migrations ( 562 id integer primary key autoincrement, 563 name text unique 564 ); 565 566 -- indexes for better performance 567 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 568 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 569 create index if not exists idx_stars_created on stars(created); 570 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 571 `) 572 if err != nil { 573 return nil, err 574 } 575 576 // run migrations 577 runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 578 tx.Exec(` 579 alter table repos add column description text check (length(description) <= 200); 580 `) 581 return nil 582 }) 583 584 runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 585 // add unconstrained column 586 _, err := tx.Exec(` 587 alter table public_keys 588 add column rkey text; 589 `) 590 if err != nil { 591 return err 592 } 593 594 // backfill 595 _, err = tx.Exec(` 596 update public_keys 597 set rkey = '' 598 where rkey is null; 599 `) 600 if err != nil { 601 return err 602 } 603 604 return nil 605 }) 606 607 runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 608 _, err := tx.Exec(` 609 alter table comments drop column comment_at; 610 alter table comments add column rkey text; 611 `) 612 return err 613 }) 614 615 runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 616 _, err := tx.Exec(` 617 alter table comments add column deleted text; -- timestamp 618 alter table comments add column edited text; -- timestamp 619 `) 620 return err 621 }) 622 623 runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 624 _, err := tx.Exec(` 625 alter table pulls add column source_branch text; 626 alter table pulls add column source_repo_at text; 627 alter table pull_submissions add column source_rev text; 628 `) 629 return err 630 }) 631 632 runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 633 _, err := tx.Exec(` 634 alter table repos add column source text; 635 `) 636 return err 637 }) 638 639 // disable foreign-keys for the next migration 640 // NOTE: this cannot be done in a transaction, so it is run outside [0] 641 // 642 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 643 conn.ExecContext(ctx, "pragma foreign_keys = off;") 644 runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 645 _, err := tx.Exec(` 646 create table pulls_new ( 647 -- identifiers 648 id integer primary key autoincrement, 649 pull_id integer not null, 650 651 -- at identifiers 652 repo_at text not null, 653 owner_did text not null, 654 rkey text not null, 655 656 -- content 657 title text not null, 658 body text not null, 659 target_branch text not null, 660 state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 661 662 -- source info 663 source_branch text, 664 source_repo_at text, 665 666 -- stacking 667 stack_id text, 668 change_id text, 669 parent_change_id text, 670 671 -- meta 672 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 673 674 -- constraints 675 unique(repo_at, pull_id), 676 foreign key (repo_at) references repos(at_uri) on delete cascade 677 ); 678 679 insert into pulls_new ( 680 id, pull_id, 681 repo_at, owner_did, rkey, 682 title, body, target_branch, state, 683 source_branch, source_repo_at, 684 created 685 ) 686 select 687 id, pull_id, 688 repo_at, owner_did, rkey, 689 title, body, target_branch, state, 690 source_branch, source_repo_at, 691 created 692 FROM pulls; 693 694 drop table pulls; 695 alter table pulls_new rename to pulls; 696 `) 697 return err 698 }) 699 conn.ExecContext(ctx, "pragma foreign_keys = on;") 700 701 runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 702 tx.Exec(` 703 alter table repos add column spindle text; 704 `) 705 return nil 706 }) 707 708 // drop all knot secrets, add unique constraint to knots 709 // 710 // knots will henceforth use service auth for signed requests 711 runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 712 _, err := tx.Exec(` 713 create table registrations_new ( 714 id integer primary key autoincrement, 715 domain text not null, 716 did text not null, 717 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 718 registered text, 719 read_only integer not null default 0, 720 unique(domain, did) 721 ); 722 723 insert into registrations_new (id, domain, did, created, registered, read_only) 724 select id, domain, did, created, registered, 1 from registrations 725 where registered is not null; 726 727 drop table registrations; 728 alter table registrations_new rename to registrations; 729 `) 730 return err 731 }) 732 733 // recreate and add rkey + created columns with default constraint 734 runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 735 // create new table 736 // - repo_at instead of repo integer 737 // - rkey field 738 // - created field 739 _, err := tx.Exec(` 740 create table collaborators_new ( 741 -- identifiers for the record 742 id integer primary key autoincrement, 743 did text not null, 744 rkey text, 745 746 -- content 747 subject_did text not null, 748 repo_at text not null, 749 750 -- meta 751 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 752 753 -- constraints 754 foreign key (repo_at) references repos(at_uri) on delete cascade 755 ) 756 `) 757 if err != nil { 758 return err 759 } 760 761 // copy data 762 _, err = tx.Exec(` 763 insert into collaborators_new (id, did, rkey, subject_did, repo_at) 764 select 765 c.id, 766 r.did, 767 '', 768 c.did, 769 r.at_uri 770 from collaborators c 771 join repos r on c.repo = r.id 772 `) 773 if err != nil { 774 return err 775 } 776 777 // drop old table 778 _, err = tx.Exec(`drop table collaborators`) 779 if err != nil { 780 return err 781 } 782 783 // rename new table 784 _, err = tx.Exec(`alter table collaborators_new rename to collaborators`) 785 return err 786 }) 787 788 runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 789 _, err := tx.Exec(` 790 alter table issues add column rkey text not null default ''; 791 792 -- get last url section from issue_at and save to rkey column 793 update issues 794 set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), ''); 795 `) 796 return err 797 }) 798 799 // repurpose the read-only column to "needs-upgrade" 800 runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 801 _, err := tx.Exec(` 802 alter table registrations rename column read_only to needs_upgrade; 803 `) 804 return err 805 }) 806 807 // require all knots to upgrade after the release of total xrpc 808 runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 809 _, err := tx.Exec(` 810 update registrations set needs_upgrade = 1; 811 `) 812 return err 813 }) 814 815 // require all knots to upgrade after the release of total xrpc 816 runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 817 _, err := tx.Exec(` 818 alter table spindles add column needs_upgrade integer not null default 0; 819 `) 820 return err 821 }) 822 823 // remove issue_at from issues and replace with generated column 824 // 825 // this requires a full table recreation because stored columns 826 // cannot be added via alter 827 // 828 // couple other changes: 829 // - columns renamed to be more consistent 830 // - adds edited and deleted fields 831 // 832 // disable foreign-keys for the next migration 833 conn.ExecContext(ctx, "pragma foreign_keys = off;") 834 runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 835 _, err := tx.Exec(` 836 create table if not exists issues_new ( 837 -- identifiers 838 id integer primary key autoincrement, 839 did text not null, 840 rkey text not null, 841 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored, 842 843 -- at identifiers 844 repo_at text not null, 845 846 -- content 847 issue_id integer not null, 848 title text not null, 849 body text not null, 850 open integer not null default 1, 851 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 852 edited text, -- timestamp 853 deleted text, -- timestamp 854 855 unique(did, rkey), 856 unique(repo_at, issue_id), 857 unique(at_uri), 858 foreign key (repo_at) references repos(at_uri) on delete cascade 859 ); 860 `) 861 if err != nil { 862 return err 863 } 864 865 // transfer data 866 _, err = tx.Exec(` 867 insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created) 868 select 869 i.id, 870 i.owner_did, 871 i.rkey, 872 i.repo_at, 873 i.issue_id, 874 i.title, 875 i.body, 876 i.open, 877 i.created 878 from issues i; 879 `) 880 if err != nil { 881 return err 882 } 883 884 // drop old table 885 _, err = tx.Exec(`drop table issues`) 886 if err != nil { 887 return err 888 } 889 890 // rename new table 891 _, err = tx.Exec(`alter table issues_new rename to issues`) 892 return err 893 }) 894 conn.ExecContext(ctx, "pragma foreign_keys = on;") 895 896 // - renames the comments table to 'issue_comments' 897 // - rework issue comments to update constraints: 898 // * unique(did, rkey) 899 // * remove comment-id and just use the global ID 900 // * foreign key (repo_at, issue_id) 901 // - new columns 902 // * column "reply_to" which can be any other comment 903 // * column "at-uri" which is a generated column 904 runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 905 _, err := tx.Exec(` 906 create table if not exists issue_comments ( 907 -- identifiers 908 id integer primary key autoincrement, 909 did text not null, 910 rkey text, 911 at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored, 912 913 -- at identifiers 914 issue_at text not null, 915 reply_to text, -- at_uri of parent comment 916 917 -- content 918 body text not null, 919 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 920 edited text, 921 deleted text, 922 923 -- constraints 924 unique(did, rkey), 925 unique(at_uri), 926 foreign key (issue_at) references issues(at_uri) on delete cascade 927 ); 928 `) 929 if err != nil { 930 return err 931 } 932 933 // transfer data 934 _, err = tx.Exec(` 935 insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted) 936 select 937 c.id, 938 c.owner_did, 939 c.rkey, 940 i.at_uri, -- get at_uri from issues table 941 c.body, 942 c.created, 943 c.edited, 944 c.deleted 945 from comments c 946 join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id; 947 `) 948 if err != nil { 949 return err 950 } 951 952 // drop old table 953 _, err = tx.Exec(`drop table comments`) 954 return err 955 }) 956 957 // add generated at_uri column to pulls table 958 // 959 // this requires a full table recreation because stored columns 960 // cannot be added via alter 961 // 962 // disable foreign-keys for the next migration 963 conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 _, err := tx.Exec(` 966 create table if not exists pulls_new ( 967 -- identifiers 968 id integer primary key autoincrement, 969 pull_id integer not null, 970 at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 971 972 -- at identifiers 973 repo_at text not null, 974 owner_did text not null, 975 rkey text not null, 976 977 -- content 978 title text not null, 979 body text not null, 980 target_branch text not null, 981 state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 982 983 -- source info 984 source_branch text, 985 source_repo_at text, 986 987 -- stacking 988 stack_id text, 989 change_id text, 990 parent_change_id text, 991 992 -- meta 993 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 994 995 -- constraints 996 unique(repo_at, pull_id), 997 unique(at_uri), 998 foreign key (repo_at) references repos(at_uri) on delete cascade 999 ); 1000 `) 1001 if err != nil { 1002 return err 1003 } 1004 1005 // transfer data 1006 _, err = tx.Exec(` 1007 insert into pulls_new ( 1008 id, pull_id, repo_at, owner_did, rkey, 1009 title, body, target_branch, state, 1010 source_branch, source_repo_at, 1011 stack_id, change_id, parent_change_id, 1012 created 1013 ) 1014 select 1015 id, pull_id, repo_at, owner_did, rkey, 1016 title, body, target_branch, state, 1017 source_branch, source_repo_at, 1018 stack_id, change_id, parent_change_id, 1019 created 1020 from pulls; 1021 `) 1022 if err != nil { 1023 return err 1024 } 1025 1026 // drop old table 1027 _, err = tx.Exec(`drop table pulls`) 1028 if err != nil { 1029 return err 1030 } 1031 1032 // rename new table 1033 _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1034 return err 1035 }) 1036 conn.ExecContext(ctx, "pragma foreign_keys = on;") 1037 1038 // remove repo_at and pull_id from pull_submissions and replace with pull_at 1039 // 1040 // this requires a full table recreation because stored columns 1041 // cannot be added via alter 1042 // 1043 // disable foreign-keys for the next migration 1044 conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 _, err := tx.Exec(` 1047 create table if not exists pull_submissions_new ( 1048 -- identifiers 1049 id integer primary key autoincrement, 1050 pull_at text not null, 1051 1052 -- content, these are immutable, and require a resubmission to update 1053 round_number integer not null default 0, 1054 patch text, 1055 source_rev text, 1056 1057 -- meta 1058 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1059 1060 -- constraints 1061 unique(pull_at, round_number), 1062 foreign key (pull_at) references pulls(at_uri) on delete cascade 1063 ); 1064 `) 1065 if err != nil { 1066 return err 1067 } 1068 1069 // transfer data, constructing pull_at from pulls table 1070 _, err = tx.Exec(` 1071 insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1072 select 1073 ps.id, 1074 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1075 ps.round_number, 1076 ps.patch, 1077 ps.created 1078 from pull_submissions ps 1079 join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1080 `) 1081 if err != nil { 1082 return err 1083 } 1084 1085 // drop old table 1086 _, err = tx.Exec(`drop table pull_submissions`) 1087 if err != nil { 1088 return err 1089 } 1090 1091 // rename new table 1092 _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1093 return err 1094 }) 1095 conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 1097 return &DB{db}, nil 1098} 1099 1100type migrationFn = func(*sql.Tx) error 1101 1102func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 1103 tx, err := c.BeginTx(context.Background(), nil) 1104 if err != nil { 1105 return err 1106 } 1107 defer tx.Rollback() 1108 1109 var exists bool 1110 err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 1111 if err != nil { 1112 return err 1113 } 1114 1115 if !exists { 1116 // run migration 1117 err = migrationFn(tx) 1118 if err != nil { 1119 log.Printf("Failed to run migration %s: %v", name, err) 1120 return err 1121 } 1122 1123 // mark migration as complete 1124 _, err = tx.Exec("insert into migrations (name) values (?)", name) 1125 if err != nil { 1126 log.Printf("Failed to mark migration %s as complete: %v", name, err) 1127 return err 1128 } 1129 1130 // commit the transaction 1131 if err := tx.Commit(); err != nil { 1132 return err 1133 } 1134 1135 log.Printf("migration %s applied successfully", name) 1136 } else { 1137 log.Printf("skipped migration %s, already applied", name) 1138 } 1139 1140 return nil 1141} 1142 1143func (d *DB) Close() error { 1144 return d.DB.Close() 1145} 1146 1147type filter struct { 1148 key string 1149 arg any 1150 cmp string 1151} 1152 1153func newFilter(key, cmp string, arg any) filter { 1154 return filter{ 1155 key: key, 1156 arg: arg, 1157 cmp: cmp, 1158 } 1159} 1160 1161func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1162func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1163func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1164func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1165func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1166func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1167func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1168func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) } 1169func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) } 1170func FilterContains(key string, arg any) filter { 1171 return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 1172} 1173 1174func (f filter) Condition() string { 1175 rv := reflect.ValueOf(f.arg) 1176 kind := rv.Kind() 1177 1178 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 1179 if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1180 if rv.Len() == 0 { 1181 // always false 1182 return "1 = 0" 1183 } 1184 1185 placeholders := make([]string, rv.Len()) 1186 for i := range placeholders { 1187 placeholders[i] = "?" 1188 } 1189 1190 return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", ")) 1191 } 1192 1193 return fmt.Sprintf("%s %s ?", f.key, f.cmp) 1194} 1195 1196func (f filter) Arg() []any { 1197 rv := reflect.ValueOf(f.arg) 1198 kind := rv.Kind() 1199 if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1200 if rv.Len() == 0 { 1201 return nil 1202 } 1203 1204 out := make([]any, rv.Len()) 1205 for i := range rv.Len() { 1206 out[i] = rv.Index(i).Interface() 1207 } 1208 return out 1209 } 1210 1211 return []any{f.arg} 1212}