vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
| 1 | // Package index holds the derived read model. Everything in it can be thrown |
| 2 | // away and rebuilt from the repos on disk, which is why it is safe for it to be |
| 3 | // the layer that knows about SQL. |
| 4 | package index |
| 5 | |
| 6 | import ( |
| 7 | "crypto/sha256" |
| 8 | "database/sql" |
| 9 | "encoding/hex" |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "time" |
| 13 | |
| 14 | _ "github.com/mattn/go-sqlite3" |
| 15 | |
| 16 | "rodent.software/vole/forge/repo" |
| 17 | ) |
| 18 | |
| 19 | type Entry struct { |
| 20 | ID string |
| 21 | Name string |
| 22 | Description string |
| 23 | DefaultBranch string |
| 24 | Created time.Time |
| 25 | ACLSHA string |
| 26 | ACLPrincipals int |
| 27 | ACLDefault string |
| 28 | // The default branch's summary, maintained at push time. A repository with no |
| 29 | // commits has an empty HeadSHA. |
| 30 | HeadSHA string |
| 31 | HeadSubject string |
| 32 | HeadAuthor string |
| 33 | HeadWhen int64 |
| 34 | Language string |
| 35 | LangClass string |
| 36 | } |
| 37 | |
| 38 | type Store struct{ db *sql.DB } |
| 39 | |
| 40 | func Open(path string) (*Store, error) { |
| 41 | dsn := path + "?_busy_timeout=5000&_journal_mode=WAL" |
| 42 | db, err := sql.Open("sqlite3", dsn) |
| 43 | if err != nil { |
| 44 | return nil, err |
| 45 | } |
| 46 | db.SetMaxOpenConns(4) |
| 47 | s := &Store{db: db} |
| 48 | if _, err := db.Exec(` |
| 49 | CREATE TABLE IF NOT EXISTS repos ( |
| 50 | id TEXT PRIMARY KEY, |
| 51 | name TEXT NOT NULL UNIQUE, |
| 52 | description TEXT NOT NULL DEFAULT '', |
| 53 | default_branch TEXT NOT NULL DEFAULT 'main', |
| 54 | created INTEGER NOT NULL, |
| 55 | acl_sha TEXT NOT NULL DEFAULT '', |
| 56 | acl_principals INTEGER NOT NULL DEFAULT 0, |
| 57 | acl_default TEXT NOT NULL DEFAULT 'none', |
| 58 | head_sha TEXT NOT NULL DEFAULT '', |
| 59 | head_subject TEXT NOT NULL DEFAULT '', |
| 60 | head_author TEXT NOT NULL DEFAULT '', |
| 61 | head_when INTEGER NOT NULL DEFAULT 0, |
| 62 | language TEXT NOT NULL DEFAULT '', |
| 63 | lang_class TEXT NOT NULL DEFAULT '' |
| 64 | );`); err != nil { |
| 65 | db.Close() |
| 66 | return nil, err |
| 67 | } |
| 68 | // Added after the first release of the index; the table is disposable, so a |
| 69 | // failed ALTER (already present) is not an error. |
| 70 | _, _ = db.Exec(`ALTER TABLE repos ADD COLUMN issues_sha TEXT NOT NULL DEFAULT ''`) |
| 71 | // events_count is the number of feed events a repository has, so an |
| 72 | // unfiltered pager gets its total without counting the table. |
| 73 | _, _ = db.Exec(`ALTER TABLE repos ADD COLUMN events_count INTEGER NOT NULL DEFAULT 0`) |
| 74 | // The head summary is the default branch's tip and the language it is mostly |
| 75 | // made of, refreshed when a push lands rather than on every page read. |
| 76 | for _, col := range []string{ |
| 77 | "head_sha TEXT NOT NULL DEFAULT ''", |
| 78 | "head_subject TEXT NOT NULL DEFAULT ''", |
| 79 | "head_author TEXT NOT NULL DEFAULT ''", |
| 80 | "head_when INTEGER NOT NULL DEFAULT 0", |
| 81 | "language TEXT NOT NULL DEFAULT ''", |
| 82 | "lang_class TEXT NOT NULL DEFAULT ''", |
| 83 | } { |
| 84 | _, _ = db.Exec(`ALTER TABLE repos ADD COLUMN ` + col) |
| 85 | } |
| 86 | // The overview's git-derived summaries, recorded at push time. They are |
| 87 | // keyed by repo id and replaced together with the head they describe. |
| 88 | if _, err := db.Exec(` |
| 89 | CREATE TABLE IF NOT EXISTS repo_languages ( |
| 90 | repo_id TEXT NOT NULL, |
| 91 | name TEXT NOT NULL, |
| 92 | bytes INTEGER NOT NULL, |
| 93 | PRIMARY KEY (repo_id, name) |
| 94 | ); |
| 95 | CREATE TABLE IF NOT EXISTS repo_commits ( |
| 96 | repo_id TEXT NOT NULL, |
| 97 | pos INTEGER NOT NULL, |
| 98 | sha TEXT NOT NULL, |
| 99 | subject TEXT NOT NULL, |
| 100 | author TEXT NOT NULL, |
| 101 | when_at INTEGER NOT NULL, |
| 102 | PRIMARY KEY (repo_id, pos) |
| 103 | ); |
| 104 | CREATE TABLE IF NOT EXISTS repo_tags ( |
| 105 | repo_id TEXT NOT NULL, |
| 106 | name TEXT NOT NULL, |
| 107 | oid TEXT NOT NULL, |
| 108 | commit_sha TEXT NOT NULL, |
| 109 | annotated INTEGER NOT NULL, |
| 110 | tagger TEXT NOT NULL, |
| 111 | when_at INTEGER NOT NULL, |
| 112 | subject TEXT NOT NULL, |
| 113 | PRIMARY KEY (repo_id, name) |
| 114 | );`); err != nil { |
| 115 | db.Close() |
| 116 | return nil, err |
| 117 | } |
| 118 | if _, err := db.Exec(` |
| 119 | CREATE TABLE IF NOT EXISTS issues ( |
| 120 | repo_id TEXT NOT NULL, |
| 121 | id TEXT NOT NULL, |
| 122 | title TEXT NOT NULL, |
| 123 | state TEXT NOT NULL, |
| 124 | author TEXT NOT NULL, |
| 125 | created INTEGER NOT NULL, |
| 126 | labels TEXT NOT NULL DEFAULT '', |
| 127 | comments INTEGER NOT NULL DEFAULT 0, |
| 128 | PRIMARY KEY (repo_id, id) |
| 129 | );`); err != nil { |
| 130 | db.Close() |
| 131 | return nil, err |
| 132 | } |
| 133 | // Body text is indexed so search does not have to touch git per query. |
| 134 | _, _ = db.Exec(`ALTER TABLE issues ADD COLUMN body TEXT NOT NULL DEFAULT ''`) |
| 135 | // Assignees are denormalised onto the row the way labels are: the list page |
| 136 | // shows them without a join, and the filter table below is what queries use. |
| 137 | _, _ = db.Exec(`ALTER TABLE issues ADD COLUMN assignees TEXT NOT NULL DEFAULT ''`) |
| 138 | // updated is when the metadata branch last touched the issue: an edit, a |
| 139 | // comment, or a state change. The branch keeps no such field — the commit |
| 140 | // carries the time — so the index derives it from `git log` and stores it, |
| 141 | // which is what lets the list sort by activity without walking history per |
| 142 | // page. The index is derived: a stale value is repaired by a rebuild. |
| 143 | _, _ = db.Exec(`ALTER TABLE issues ADD COLUMN updated INTEGER NOT NULL DEFAULT 0`) |
| 144 | if _, err := db.Exec(` |
| 145 | CREATE TABLE IF NOT EXISTS issue_labels ( |
| 146 | repo_id TEXT NOT NULL, |
| 147 | issue_id TEXT NOT NULL, |
| 148 | label TEXT NOT NULL, |
| 149 | PRIMARY KEY (repo_id, issue_id, label) |
| 150 | ); |
| 151 | CREATE INDEX IF NOT EXISTS issue_labels_lookup ON issue_labels(repo_id, label); |
| 152 | CREATE TABLE IF NOT EXISTS issue_assignees ( |
| 153 | repo_id TEXT NOT NULL, |
| 154 | issue_id TEXT NOT NULL, |
| 155 | assignee TEXT NOT NULL, |
| 156 | PRIMARY KEY (repo_id, issue_id, assignee) |
| 157 | ); |
| 158 | CREATE INDEX IF NOT EXISTS issue_assignees_lookup ON issue_assignees(repo_id, assignee);`); err != nil { |
| 159 | db.Close() |
| 160 | return nil, err |
| 161 | } |
| 162 | // entity_numbers is the `#42` alias. It is derived from history |
| 163 | // (metadata.LoadNumbers), never written back to the metadata branch, and the |
| 164 | // primary key on the number is what makes a duplicate impossible rather than |
| 165 | // merely unlikely. |
| 166 | if _, err := db.Exec(` |
| 167 | CREATE TABLE IF NOT EXISTS entity_numbers ( |
| 168 | repo_id TEXT NOT NULL, |
| 169 | number INTEGER NOT NULL, |
| 170 | kind TEXT NOT NULL, |
| 171 | id TEXT NOT NULL, |
| 172 | PRIMARY KEY (repo_id, number) |
| 173 | ); |
| 174 | CREATE INDEX IF NOT EXISTS entity_numbers_id ON entity_numbers(repo_id, id); |
| 175 | |
| 176 | CREATE TABLE IF NOT EXISTS requests ( |
| 177 | repo_id TEXT NOT NULL, |
| 178 | id TEXT NOT NULL, |
| 179 | title TEXT NOT NULL, |
| 180 | state TEXT NOT NULL, |
| 181 | author TEXT NOT NULL, |
| 182 | created INTEGER NOT NULL, |
| 183 | base TEXT NOT NULL DEFAULT '', |
| 184 | head TEXT NOT NULL DEFAULT '', |
| 185 | head_ref TEXT NOT NULL DEFAULT '', |
| 186 | merged_base TEXT NOT NULL DEFAULT '', |
| 187 | merged_head TEXT NOT NULL DEFAULT '', |
| 188 | merged_by TEXT NOT NULL DEFAULT '', |
| 189 | merged_at INTEGER NOT NULL DEFAULT 0, |
| 190 | body TEXT NOT NULL DEFAULT '', |
| 191 | comments INTEGER NOT NULL DEFAULT 0, |
| 192 | PRIMARY KEY (repo_id, id) |
| 193 | ); |
| 194 | -- A push asks whether any open request follows the branch it moved, so that a |
| 195 | -- branch nobody is proposing from costs nothing. |
| 196 | CREATE INDEX IF NOT EXISTS requests_head_ref ON requests(repo_id, head_ref); |
| 197 | CREATE TABLE IF NOT EXISTS request_closes ( |
| 198 | repo_id TEXT NOT NULL, |
| 199 | request_id TEXT NOT NULL, |
| 200 | issue_id TEXT NOT NULL, |
| 201 | -- position keeps the order the request's file lists them in, so a rebuilt |
| 202 | -- row answers with the same list the branch does. |
| 203 | position INTEGER NOT NULL DEFAULT 0, |
| 204 | PRIMARY KEY (repo_id, request_id, issue_id) |
| 205 | ); |
| 206 | CREATE INDEX IF NOT EXISTS request_closes_issue ON request_closes(repo_id, issue_id); |
| 207 | CREATE TABLE IF NOT EXISTS request_approvals ( |
| 208 | repo_id TEXT NOT NULL, |
| 209 | request_id TEXT NOT NULL, |
| 210 | principal TEXT NOT NULL, |
| 211 | head TEXT NOT NULL, |
| 212 | base TEXT NOT NULL, |
| 213 | created INTEGER NOT NULL DEFAULT 0, |
| 214 | PRIMARY KEY (repo_id, request_id, principal) |
| 215 | ); |
| 216 | |
| 217 | -- comments holds the discussion on both kinds of entity. The entity row carries |
| 218 | -- the count a list shows; these rows are what search reads and what a future |
| 219 | -- thread renderer can page through. |
| 220 | CREATE TABLE IF NOT EXISTS comments ( |
| 221 | repo_id TEXT NOT NULL, |
| 222 | kind TEXT NOT NULL, |
| 223 | parent TEXT NOT NULL, |
| 224 | id TEXT NOT NULL, |
| 225 | author TEXT NOT NULL, |
| 226 | created INTEGER NOT NULL, |
| 227 | body TEXT NOT NULL DEFAULT '', |
| 228 | reply_to TEXT NOT NULL DEFAULT '', |
| 229 | deleted INTEGER NOT NULL DEFAULT 0, |
| 230 | path TEXT NOT NULL DEFAULT '', |
| 231 | side TEXT NOT NULL DEFAULT '', |
| 232 | line INTEGER NOT NULL DEFAULT 0, |
| 233 | blob TEXT NOT NULL DEFAULT '', |
| 234 | PRIMARY KEY (repo_id, kind, parent, id) |
| 235 | ); |
| 236 | CREATE INDEX IF NOT EXISTS comments_parent ON comments(repo_id, kind, parent); |
| 237 | |
| 238 | -- artifacts are the release pointers: releases/<tag>/<name> on the metadata |
| 239 | -- branch. The size is the pointer's, which is the artifact's real size, so a |
| 240 | -- release page lists its files without reading the branch. |
| 241 | CREATE TABLE IF NOT EXISTS artifacts ( |
| 242 | repo_id TEXT NOT NULL, |
| 243 | tag TEXT NOT NULL, |
| 244 | name TEXT NOT NULL, |
| 245 | oid TEXT NOT NULL, |
| 246 | size INTEGER NOT NULL DEFAULT 0, |
| 247 | PRIMARY KEY (repo_id, tag, name) |
| 248 | ); |
| 249 | |
| 250 | -- state_changes is the closed/reopened/merged history the activity feed shows. |
| 251 | -- It is derived from the metadata commit subjects, and keyed by the commit so a |
| 252 | -- refresh that sees the same range twice cannot duplicate an event. |
| 253 | CREATE TABLE IF NOT EXISTS state_changes ( |
| 254 | repo_id TEXT NOT NULL, |
| 255 | sha TEXT NOT NULL, |
| 256 | kind TEXT NOT NULL, |
| 257 | id TEXT NOT NULL, |
| 258 | state TEXT NOT NULL, |
| 259 | actor TEXT NOT NULL, |
| 260 | at INTEGER NOT NULL, |
| 261 | -- ordinal is the order the commits entered the branch. Commit times are |
| 262 | -- seconds, so two changes in one second need this to keep their order. |
| 263 | ordinal INTEGER NOT NULL DEFAULT 0, |
| 264 | PRIMARY KEY (repo_id, sha) |
| 265 | ); |
| 266 | CREATE INDEX IF NOT EXISTS state_changes_recent ON state_changes(repo_id, ordinal DESC); |
| 267 | |
| 268 | -- events is the activity feed as rows: one per issue, change and release, and |
| 269 | -- one per comment, approval and state change. It is the read model the feed |
| 270 | -- pages paginate; the row carries the title and assignees the feed shows, and |
| 271 | -- ids rather than URLs so a repository rename cannot leave a dead link. |
| 272 | CREATE TABLE IF NOT EXISTS events ( |
| 273 | repo_id TEXT NOT NULL, |
| 274 | id TEXT NOT NULL, |
| 275 | kind TEXT NOT NULL, -- issue | change | release |
| 276 | type TEXT NOT NULL, -- open | comment | approval | state | release |
| 277 | at INTEGER NOT NULL, |
| 278 | actor TEXT NOT NULL, |
| 279 | title TEXT NOT NULL, |
| 280 | assignees TEXT NOT NULL DEFAULT '', |
| 281 | entity_id TEXT NOT NULL, |
| 282 | parent_id TEXT NOT NULL DEFAULT '', |
| 283 | comment_id TEXT NOT NULL DEFAULT '', |
| 284 | state TEXT NOT NULL DEFAULT '', |
| 285 | PRIMARY KEY (repo_id, id) |
| 286 | ); |
| 287 | -- The feed's query: one repository, newest first. The id breaks a tie inside a |
| 288 | -- second, so a page boundary cannot repeat or skip an event. |
| 289 | CREATE INDEX IF NOT EXISTS events_repo ON events(repo_id, at DESC, id DESC); |
| 290 | -- The instance feed orders across repositories; this index lets SQLite scan in |
| 291 | -- time order and filter by repository rather than sorting every visible event. |
| 292 | CREATE INDEX IF NOT EXISTS events_time ON events(at DESC, id DESC); |
| 293 | CREATE INDEX IF NOT EXISTS events_entity ON events(repo_id, entity_id); |
| 294 | -- A profile reads one account's events across the repositories the viewer can |
| 295 | -- read. Without this the page would scan every repository's history and filter, |
| 296 | -- which is the cost the (repository, time) index exists to avoid. |
| 297 | CREATE INDEX IF NOT EXISTS events_actor ON events(actor, at DESC, id DESC);`); err != nil { |
| 298 | db.Close() |
| 299 | return nil, err |
| 300 | } |
| 301 | // updated is when the metadata branch last touched the request, derived from |
| 302 | // the commits that changed its directory. It is the same disposable shape as |
| 303 | // an issue's: a stale value is repaired by a rebuild. |
| 304 | _, _ = db.Exec(`ALTER TABLE requests ADD COLUMN updated INTEGER NOT NULL DEFAULT 0`) |
| 305 | // search is the universal search surface: one row for every issue, change |
| 306 | // request and comment, with the text FTS5 tokenizes. The first four columns are |
| 307 | // UNINDEXED because they identify the result rather than match it; title and |
| 308 | // body are what a query looks at. FTS5 is a build choice (the bundled SQLite |
| 309 | // needs -tags sqlite_fts5, see README.md), so its absence is reported as that |
| 310 | // rather than as a bare "no such module". |
| 311 | if _, err := db.Exec(` |
| 312 | CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5( |
| 313 | repo_id UNINDEXED, |
| 314 | kind UNINDEXED, |
| 315 | entity_id UNINDEXED, |
| 316 | parent_id UNINDEXED, |
| 317 | title, |
| 318 | body, |
| 319 | author UNINDEXED, |
| 320 | tokenize = 'unicode61 remove_diacritics 2' |
| 321 | );`); err != nil { |
| 322 | db.Close() |
| 323 | return nil, fmt.Errorf("index: cannot create the search table: SQLite has no FTS5 module (build with -tags sqlite_fts5): %w", err) |
| 324 | } |
| 325 | if err := s.migrate(); err != nil { |
| 326 | db.Close() |
| 327 | return nil, err |
| 328 | } |
| 329 | return s, nil |
| 330 | } |
| 331 | |
| 332 | // schemaVersion is the shape the rows in this file were derived from. It is not |
| 333 | // a migration chain: the index is disposable, so a bump drops the derived rows |
| 334 | // and leaves every repo with an empty issues_sha, which makes the next read |
| 335 | // rebuild that repo from git. A stale row is worse than a rebuild, and a rebuild is the |
| 336 | // operation the index already has to survive. |
| 337 | const schemaVersion = 14 |
| 338 | |
| 339 | func (s *Store) migrate() error { |
| 340 | var v int |
| 341 | if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil { |
| 342 | return err |
| 343 | } |
| 344 | if v == schemaVersion { |
| 345 | return nil |
| 346 | } |
| 347 | if _, err := s.db.Exec(` |
| 348 | DELETE FROM issues; |
| 349 | DELETE FROM issue_labels; |
| 350 | DELETE FROM issue_assignees; |
| 351 | DELETE FROM entity_numbers; |
| 352 | DELETE FROM requests; |
| 353 | DELETE FROM request_closes; |
| 354 | DELETE FROM request_approvals; |
| 355 | DELETE FROM comments; |
| 356 | DELETE FROM artifacts; |
| 357 | DELETE FROM state_changes; |
| 358 | DELETE FROM events; |
| 359 | DELETE FROM repo_languages; |
| 360 | DELETE FROM repo_commits; |
| 361 | DROP TABLE IF EXISTS repo_contributors; |
| 362 | DELETE FROM repo_tags; |
| 363 | DELETE FROM search; |
| 364 | UPDATE repos SET issues_sha = '', events_count = 0, |
| 365 | head_sha = '', head_subject = '', head_author = '', head_when = 0, |
| 366 | language = '', lang_class = '';`); err != nil { |
| 367 | return err |
| 368 | } |
| 369 | _, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion)) |
| 370 | return err |
| 371 | } |
| 372 | |
| 373 | // IssueRow is the indexed view of an issue: enough to list and link, not enough |
| 374 | // to render. The detail page reads the issue from git so bodies are never stale. |
| 375 | type IssueRow struct { |
| 376 | ID string |
| 377 | Title string |
| 378 | State string |
| 379 | Author string |
| 380 | Created time.Time |
| 381 | Labels []string |
| 382 | Assignees []string |
| 383 | Comments int |
| 384 | // Updated is when the metadata branch last touched the issue: the newest |
| 385 | // commit over its directory, which covers an edit, a comment, a label change, |
| 386 | // and a close or reopen. It is at least Created, so a freshly filed issue |
| 387 | // reads as just updated rather than as never touched. |
| 388 | Updated time.Time |
| 389 | // Number is the `#42` alias from entity_numbers, or 0 when the branch has not |
| 390 | // been indexed yet. It is a display alias; ID remains the identity. |
| 391 | Number int |
| 392 | } |
| 393 | |
| 394 | func (s *Store) Close() error { return s.db.Close() } |
| 395 | |
| 396 | // Rebuild discards the index and reconstructs it from the repo store. This is |
| 397 | // the operation that proves the index is derived and not authoritative. |
| 398 | func (s *Store) Rebuild(st *repo.Store) (int, error) { |
| 399 | repos, err := st.List() |
| 400 | if err != nil { |
| 401 | return 0, err |
| 402 | } |
| 403 | tx, err := s.db.Begin() |
| 404 | if err != nil { |
| 405 | return 0, err |
| 406 | } |
| 407 | defer tx.Rollback() |
| 408 | if _, err := tx.Exec(`DELETE FROM repos`); err != nil { |
| 409 | return 0, err |
| 410 | } |
| 411 | // A rebuild is a replacement, not a merge: rows for a repository that no |
| 412 | // longer exists must not survive it. |
| 413 | for _, table := range []string{ |
| 414 | "issues", "issue_labels", "issue_assignees", "requests", "request_closes", |
| 415 | "request_approvals", "comments", "artifacts", "state_changes", "events", "search", "entity_numbers", |
| 416 | "repo_languages", "repo_commits", "repo_tags", |
| 417 | } { |
| 418 | if _, err := tx.Exec(`DELETE FROM ` + table); err != nil { |
| 419 | return 0, err |
| 420 | } |
| 421 | } |
| 422 | for _, m := range repos { |
| 423 | e, err := entryFor(m) |
| 424 | if err != nil { |
| 425 | return 0, err |
| 426 | } |
| 427 | if err := insert(tx, e); err != nil { |
| 428 | return 0, err |
| 429 | } |
| 430 | } |
| 431 | if err := tx.Commit(); err != nil { |
| 432 | return 0, err |
| 433 | } |
| 434 | // Derived rows are indexed per repo after the repo rows exist, because every |
| 435 | // table is keyed by repo id. |
| 436 | for _, m := range repos { |
| 437 | if err := s.IndexRepo(m); err != nil { |
| 438 | return 0, err |
| 439 | } |
| 440 | } |
| 441 | return len(repos), nil |
| 442 | } |
| 443 | |
| 444 | // Upsert refreshes one repo without a full rebuild. |
| 445 | func (s *Store) Upsert(m *repo.Meta) error { |
| 446 | e, err := entryFor(m) |
| 447 | if err != nil { |
| 448 | return err |
| 449 | } |
| 450 | return insert(s.db, e) |
| 451 | } |
| 452 | |
| 453 | func (s *Store) Remove(id string) error { |
| 454 | // A removed repository must leave no rows behind: every table is keyed by |
| 455 | // repo id and nothing else would ever delete them. |
| 456 | tx, err := s.db.Begin() |
| 457 | if err != nil { |
| 458 | return err |
| 459 | } |
| 460 | defer tx.Rollback() |
| 461 | for _, table := range []string{ |
| 462 | "issues", "issue_labels", "issue_assignees", "requests", "request_closes", |
| 463 | "request_approvals", "comments", "artifacts", "state_changes", "events", "search", |
| 464 | "entity_numbers", "repo_languages", "repo_commits", "repo_tags", |
| 465 | } { |
| 466 | if _, err := tx.Exec(`DELETE FROM `+table+` WHERE repo_id=?`, id); err != nil { |
| 467 | return err |
| 468 | } |
| 469 | } |
| 470 | if _, err := tx.Exec(`DELETE FROM repos WHERE id=?`, id); err != nil { |
| 471 | return err |
| 472 | } |
| 473 | return tx.Commit() |
| 474 | } |
| 475 | |
| 476 | func (s *Store) List() ([]Entry, error) { |
| 477 | rows, err := s.db.Query(`SELECT id, name, description, default_branch, created, acl_sha, acl_principals, acl_default, |
| 478 | head_sha, head_subject, head_author, head_when, language, lang_class |
| 479 | FROM repos ORDER BY name`) |
| 480 | if err != nil { |
| 481 | return nil, err |
| 482 | } |
| 483 | defer rows.Close() |
| 484 | var out []Entry |
| 485 | for rows.Next() { |
| 486 | var e Entry |
| 487 | var created int64 |
| 488 | if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.DefaultBranch, &created, &e.ACLSHA, &e.ACLPrincipals, &e.ACLDefault, |
| 489 | &e.HeadSHA, &e.HeadSubject, &e.HeadAuthor, &e.HeadWhen, &e.Language, &e.LangClass); err != nil { |
| 490 | return nil, err |
| 491 | } |
| 492 | e.Created = time.Unix(created, 0).UTC() |
| 493 | out = append(out, e) |
| 494 | } |
| 495 | return out, rows.Err() |
| 496 | } |
| 497 | |
| 498 | func (s *Store) Get(name string) (*Entry, error) { |
| 499 | var e Entry |
| 500 | var created int64 |
| 501 | err := s.db.QueryRow(`SELECT id, name, description, default_branch, created, acl_sha, acl_principals, acl_default, |
| 502 | head_sha, head_subject, head_author, head_when, language, lang_class |
| 503 | FROM repos WHERE name=?`, name). |
| 504 | Scan(&e.ID, &e.Name, &e.Description, &e.DefaultBranch, &created, &e.ACLSHA, &e.ACLPrincipals, &e.ACLDefault, |
| 505 | &e.HeadSHA, &e.HeadSubject, &e.HeadAuthor, &e.HeadWhen, &e.Language, &e.LangClass) |
| 506 | if errors.Is(err, sql.ErrNoRows) { |
| 507 | return nil, osErrNotExist |
| 508 | } |
| 509 | if err != nil { |
| 510 | return nil, err |
| 511 | } |
| 512 | e.Created = time.Unix(created, 0).UTC() |
| 513 | return &e, nil |
| 514 | } |
| 515 | |
| 516 | var osErrNotExist = fmt.Errorf("index: repo not found") |
| 517 | |
| 518 | func entryFor(m *repo.Meta) (Entry, error) { |
| 519 | e := Entry{ |
| 520 | ID: m.ID, |
| 521 | Name: m.Name, |
| 522 | Description: m.Description, |
| 523 | DefaultBranch: m.DefaultBranch, |
| 524 | Created: m.Created, |
| 525 | } |
| 526 | a, err := m.ACL() |
| 527 | if err != nil { |
| 528 | return e, err |
| 529 | } |
| 530 | e.ACLDefault = a.Default |
| 531 | e.ACLPrincipals = len(a.Entries) |
| 532 | // A digest rather than a commit id: the rules live in the instance database now, so |
| 533 | // the thing worth recording is which version of them this row was built from. |
| 534 | if raw, err := a.Bytes(); err == nil { |
| 535 | sum := sha256.Sum256(raw) |
| 536 | e.ACLSHA = hex.EncodeToString(sum[:]) |
| 537 | } |
| 538 | return e, nil |
| 539 | } |
| 540 | |
| 541 | type execer interface { |
| 542 | Exec(query string, args ...any) (sql.Result, error) |
| 543 | } |
| 544 | |
| 545 | func insert(x execer, e Entry) error { |
| 546 | _, err := x.Exec(`INSERT INTO repos(id, name, description, default_branch, created, acl_sha, acl_principals, acl_default) |
| 547 | VALUES(?,?,?,?,?,?,?,?) |
| 548 | ON CONFLICT(id) DO UPDATE SET |
| 549 | name=excluded.name, description=excluded.description, default_branch=excluded.default_branch, |
| 550 | acl_sha=excluded.acl_sha, acl_principals=excluded.acl_principals, acl_default=excluded.acl_default`, |
| 551 | e.ID, e.Name, e.Description, e.DefaultBranch, e.Created.Unix(), e.ACLSHA, e.ACLPrincipals, e.ACLDefault) |
| 552 | return err |
| 553 | } |
| 554 |