Postgres, step 3: the hub builds and passes its tests on PostgreSQL 3b1bc27fe805
A postgres feature builds the hub for PostgreSQL 16 instead of SQLite, set by [database] url (or OXYHUB_DATABASE_URL), with schema "*" giving each data directory a schema of its own so the tests share one database. The schema is migrations-pg/0001_schema.sql, SQLite's 0001-0072 as one: flags BOOLEAN, names compared without case CITEXT, ids identity columns, search a tsvector with a GIN index. tests/schema.rs fails when the two schemas disagree. The queries stay shared. Where the engines differ the code chooses by feature: connecting and migrating, the write lock (an advisory lock in place of BEGIN IMMEDIATE), search (ts_headline in place of FTS5's snippet), JSON (db::json_text and json_each), and the catalog backups read. Backups write flags as 0 or 1 and blobs as hex on both, so a run from either restores into either. Fixed on the way, for both: boolean SUMs and a boolean written as a count (repositories.required_approvals), an ELSE 0 on a flag, SQLite's two-argument MAX, a HAVING on aliases, a SUM read as an integer, flags read and bound as integers, and the tests' own ? placeholders. All 247 tests pass on each engine. docs/postgres.md says how to build, configure, change the schema and test. Refs #211
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 12:58 UTC
- Signature
- ✔ signed by Claude Code
- Hash
3b1bc27fe805521f486e8fc71d528404f354ed1c899c2fb2fef039ac7b0896cf- Depends on
- 070a7b64b368 0b203bbf7cc3 0e04dfc20211 18e381ea88c2 19bb31ff1f5c 1edac4b68c6d 1f78655a0466 20b8fe85888b 25ab8c73514a 2a0c09c09716 34beb1a7e376 36e5a9c15a84 3776b70d9b17 3ff52534f69b 417184c73f4e 42fa95540468 462f88d161ce 4a4400c5812d 501d8b268dd4 5d90e0e045e2 5dead1f4f26d 5ef6cf72cd19 62cdf34daec1 6ca0b971ff07 745d225de344 7e01a7665aa5 83940d53664f 8ddebaa7e856 92a797b20912 93501da2a162 93be9ce4653e 941abdd6bb4c 95b525b0b977 97b18869acf5 9a777fec4ed9 9aaa88b2d9c8 a2fbad481dd7 a36bab6b9ffc b64e2537d702 b6b7e4cf6b24 bff9106edcba c0095389312b c79a1111dd56 c98935537c57 cb71e18eb6f9 d23464c0ac60 d7ce41acf22c d7d182467382 df5c69cbd047 ea14c4ec5c3f eae4a97475f1 eb0c44a9ad37 f4d6ef418a96
- Depended on by
- 40a43921ef42 0bc95135954d a22e4aec2e28 1c52d940b4a0 1ec29691b67a aeed63c00ad7 046e3352cdfd 0468394734e9 951aed50c2f5 faf5246c900e 1000b40a2629 8501fd5815b9 ab4df6aae179 439ecd8708a1 ca0c443c30cf dd327d76062d 5588bb3631d2 b349e15a3eab 367f34b8e590 166f6e9bf4f1 cb7b3cd45dc0 0ee5c3fb32eb a9f1c0bf403a 13b27cb2200c 4a68f1064b08 86a69b53aff0 e97500b70f9a 8dcaeeb848c6 05f1a85a2842 4f40eed0588f 965b950f9b96 8aa5ef384ef1 53a0fb2e4ac6 107eaba52c9d dc71378db1dc 4e2cad00a484
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 131 atoms, 4 tree operations, 23 files
Browse the tree at this change
added crates/hub/migrations-pg/0001_schema.sql +864 −0
| 1 | + | -- The hub's schema on Postgres: one migration equal to SQLite's 0001-0072. |
|
| 2 | + | -- Generated from the migrated SQLite schema, then reviewed; later changes |
|
| 3 | + | -- are written for both engines. citext is made by db::open, in public. |
|
| 4 | + | ||
| 5 | + | CREATE TABLE repositories ( |
|
| 6 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 7 | + | owner TEXT NOT NULL, |
|
| 8 | + | name TEXT NOT NULL, |
|
| 9 | + | description TEXT NOT NULL DEFAULT '', |
|
| 10 | + | default_bookmark TEXT NOT NULL DEFAULT 'main', |
|
| 11 | + | visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'internal', 'private')), |
|
| 12 | + | created_ms BIGINT NOT NULL, |
|
| 13 | + | updated_ms BIGINT NOT NULL, |
|
| 14 | + | required_approvals BIGINT NOT NULL DEFAULT 0, |
|
| 15 | + | require_owners BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 16 | + | allow_conflicts BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 17 | + | required_checks TEXT NOT NULL DEFAULT '', |
|
| 18 | + | git_mirror BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 19 | + | git_mirror_state TEXT NOT NULL DEFAULT '', |
|
| 20 | + | forked_from BIGINT REFERENCES repositories(id) ON DELETE SET NULL, |
|
| 21 | + | topics TEXT NOT NULL DEFAULT '', |
|
| 22 | + | flow TEXT NOT NULL DEFAULT '', |
|
| 23 | + | archived BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 24 | + | archived_reason TEXT NOT NULL DEFAULT '', |
|
| 25 | + | diffusion TEXT NOT NULL DEFAULT '', |
|
| 26 | + | diffusion_land TEXT NOT NULL DEFAULT '', |
|
| 27 | + | backup TEXT NOT NULL DEFAULT '', |
|
| 28 | + | UNIQUE (owner, name) |
|
| 29 | + | ); |
|
| 30 | + | CREATE TABLE accounts ( |
|
| 31 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 32 | + | username CITEXT NOT NULL UNIQUE, |
|
| 33 | + | display_name TEXT NOT NULL DEFAULT '', |
|
| 34 | + | email TEXT NOT NULL DEFAULT '', |
|
| 35 | + | email_verified BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 36 | + | password_hash TEXT NOT NULL, |
|
| 37 | + | session_salt TEXT NOT NULL, |
|
| 38 | + | totp_secret TEXT, |
|
| 39 | + | recovery_codes TEXT NOT NULL DEFAULT '', |
|
| 40 | + | is_admin BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 41 | + | banned BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 42 | + | created_ms BIGINT NOT NULL, |
|
| 43 | + | last_seen_ms BIGINT NOT NULL DEFAULT 0, |
|
| 44 | + | email_notifications BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 45 | + | unsubscribe_key TEXT NOT NULL DEFAULT '', |
|
| 46 | + | bio TEXT NOT NULL DEFAULT '', |
|
| 47 | + | website TEXT NOT NULL DEFAULT '', |
|
| 48 | + | location TEXT NOT NULL DEFAULT '', |
|
| 49 | + | avatar TEXT NOT NULL DEFAULT '', |
|
| 50 | + | disabled BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 51 | + | bluesky TEXT NOT NULL DEFAULT '', |
|
| 52 | + | mastodon TEXT NOT NULL DEFAULT '', |
|
| 53 | + | github TEXT NOT NULL DEFAULT '' |
|
| 54 | + | ); |
|
| 55 | + | CREATE TABLE pushes ( |
|
| 56 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 57 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 58 | + | at_ms BIGINT NOT NULL, |
|
| 59 | + | reference TEXT NOT NULL, |
|
| 60 | + | ok BOOLEAN NOT NULL, |
|
| 61 | + | reason TEXT NOT NULL DEFAULT '', |
|
| 62 | + | old_state TEXT NOT NULL DEFAULT '', |
|
| 63 | + | new_state TEXT NOT NULL DEFAULT '', |
|
| 64 | + | heads BIGINT NOT NULL DEFAULT 0, |
|
| 65 | + | changes BIGINT NOT NULL DEFAULT 0, |
|
| 66 | + | account_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 67 | + | pusher TEXT NOT NULL DEFAULT '', |
|
| 68 | + | via TEXT NOT NULL DEFAULT '' |
|
| 69 | + | ); |
|
| 70 | + | CREATE TABLE organisations ( |
|
| 71 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 72 | + | name CITEXT NOT NULL UNIQUE, |
|
| 73 | + | display_name TEXT NOT NULL DEFAULT '', |
|
| 74 | + | description TEXT NOT NULL DEFAULT '', |
|
| 75 | + | default_role TEXT NOT NULL DEFAULT 'read' CHECK (default_role IN ('none', 'read', 'triage', 'write', 'maintain', 'admin')), |
|
| 76 | + | created_ms BIGINT NOT NULL, |
|
| 77 | + | avatar TEXT NOT NULL DEFAULT '' |
|
| 78 | + | ); |
|
| 79 | + | CREATE TABLE org_members ( |
|
| 80 | + | org_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, |
|
| 81 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 82 | + | role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')), |
|
| 83 | + | PRIMARY KEY (org_id, account_id) |
|
| 84 | + | ); |
|
| 85 | + | CREATE TABLE teams ( |
|
| 86 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 87 | + | org_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, |
|
| 88 | + | name CITEXT NOT NULL, |
|
| 89 | + | UNIQUE (org_id, name) |
|
| 90 | + | ); |
|
| 91 | + | CREATE TABLE team_members ( |
|
| 92 | + | team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, |
|
| 93 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 94 | + | PRIMARY KEY (team_id, account_id) |
|
| 95 | + | ); |
|
| 96 | + | CREATE TABLE collaborators ( |
|
| 97 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 98 | + | account_id BIGINT REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 99 | + | team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE, |
|
| 100 | + | role TEXT NOT NULL CHECK (role IN ('read', 'triage', 'write', 'maintain', 'admin')), |
|
| 101 | + | CHECK ((account_id IS NULL) <> (team_id IS NULL)) |
|
| 102 | + | ); |
|
| 103 | + | CREATE TABLE tokens ( |
|
| 104 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 105 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 106 | + | name TEXT NOT NULL, |
|
| 107 | + | hash TEXT NOT NULL UNIQUE, |
|
| 108 | + | scopes TEXT NOT NULL, |
|
| 109 | + | repos TEXT NOT NULL DEFAULT '', |
|
| 110 | + | created_ms BIGINT NOT NULL, |
|
| 111 | + | expires_ms BIGINT, |
|
| 112 | + | last_used_ms BIGINT, |
|
| 113 | + | scopes_note TEXT NOT NULL DEFAULT '', |
|
| 114 | + | renew_ms BIGINT, |
|
| 115 | + | agent BOOLEAN NOT NULL DEFAULT FALSE |
|
| 116 | + | ); |
|
| 117 | + | CREATE TABLE identities ( |
|
| 118 | + | key TEXT PRIMARY KEY, |
|
| 119 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 120 | + | name TEXT NOT NULL, |
|
| 121 | + | email TEXT NOT NULL, |
|
| 122 | + | object BYTEA NOT NULL, |
|
| 123 | + | linked_ms BIGINT NOT NULL |
|
| 124 | + | ); |
|
| 125 | + | CREATE TABLE challenges ( |
|
| 126 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 127 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 128 | + | bytes TEXT NOT NULL, |
|
| 129 | + | object BYTEA NOT NULL DEFAULT ''::bytea, |
|
| 130 | + | created_ms BIGINT NOT NULL |
|
| 131 | + | ); |
|
| 132 | + | CREATE TABLE invitations ( |
|
| 133 | + | code TEXT PRIMARY KEY, |
|
| 134 | + | created_by BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 135 | + | used_by BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 136 | + | created_ms BIGINT NOT NULL |
|
| 137 | + | ); |
|
| 138 | + | CREATE TABLE audit ( |
|
| 139 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 140 | + | at_ms BIGINT NOT NULL, |
|
| 141 | + | actor_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 142 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 143 | + | action TEXT NOT NULL, |
|
| 144 | + | target TEXT NOT NULL DEFAULT '', |
|
| 145 | + | detail TEXT NOT NULL DEFAULT '' |
|
| 146 | + | ); |
|
| 147 | + | CREATE TABLE settings ( |
|
| 148 | + | key TEXT PRIMARY KEY, |
|
| 149 | + | value TEXT NOT NULL |
|
| 150 | + | ); |
|
| 151 | + | CREATE TABLE numbers ( |
|
| 152 | + | repo_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 153 | + | next BIGINT NOT NULL DEFAULT 1 |
|
| 154 | + | ); |
|
| 155 | + | CREATE TABLE proposals ( |
|
| 156 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 157 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 158 | + | number BIGINT NOT NULL, |
|
| 159 | + | title TEXT NOT NULL, |
|
| 160 | + | body TEXT NOT NULL DEFAULT '', |
|
| 161 | + | author_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 162 | + | author TEXT NOT NULL DEFAULT '', |
|
| 163 | + | state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('draft', 'open', 'landed', 'closed')), |
|
| 164 | + | target TEXT NOT NULL, |
|
| 165 | + | source_kind TEXT NOT NULL CHECK (source_kind IN ('bookmark', 'fork', 'changes')), |
|
| 166 | + | source_repo_id BIGINT REFERENCES repositories(id) ON DELETE SET NULL, |
|
| 167 | + | source_bookmark TEXT NOT NULL DEFAULT '', |
|
| 168 | + | source_changes TEXT NOT NULL DEFAULT '', |
|
| 169 | + | projected_state TEXT NOT NULL DEFAULT '', |
|
| 170 | + | projected_heads TEXT NOT NULL DEFAULT '', |
|
| 171 | + | projected_target TEXT NOT NULL DEFAULT '', |
|
| 172 | + | projected_changes TEXT NOT NULL DEFAULT '', |
|
| 173 | + | conflicted BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 174 | + | landed_state TEXT NOT NULL DEFAULT '', |
|
| 175 | + | created_ms BIGINT NOT NULL, |
|
| 176 | + | updated_ms BIGINT NOT NULL, |
|
| 177 | + | landed_by TEXT NOT NULL DEFAULT '', |
|
| 178 | + | landed_via TEXT NOT NULL DEFAULT '', |
|
| 179 | + | landed_ms BIGINT NOT NULL DEFAULT 0, |
|
| 180 | + | UNIQUE (repo_id, number) |
|
| 181 | + | ); |
|
| 182 | + | CREATE TABLE proposal_comments ( |
|
| 183 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 184 | + | proposal_id BIGINT NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, |
|
| 185 | + | author_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 186 | + | author TEXT NOT NULL DEFAULT '', |
|
| 187 | + | body TEXT NOT NULL, |
|
| 188 | + | path TEXT NOT NULL DEFAULT '', |
|
| 189 | + | anchor_change TEXT NOT NULL DEFAULT '', |
|
| 190 | + | anchor_index BIGINT NOT NULL DEFAULT 0, |
|
| 191 | + | resolved BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 192 | + | created_ms BIGINT NOT NULL, |
|
| 193 | + | via TEXT NOT NULL DEFAULT '' |
|
| 194 | + | ); |
|
| 195 | + | CREATE TABLE proposal_events ( |
|
| 196 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 197 | + | proposal_id BIGINT NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, |
|
| 198 | + | at_ms BIGINT NOT NULL, |
|
| 199 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 200 | + | event TEXT NOT NULL, |
|
| 201 | + | detail TEXT NOT NULL DEFAULT '' |
|
| 202 | + | ); |
|
| 203 | + | CREATE TABLE milestones ( |
|
| 204 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 205 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 206 | + | title TEXT NOT NULL, |
|
| 207 | + | description TEXT NOT NULL DEFAULT '', |
|
| 208 | + | due_ms BIGINT, |
|
| 209 | + | state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')), |
|
| 210 | + | UNIQUE (repo_id, title) |
|
| 211 | + | ); |
|
| 212 | + | CREATE TABLE issues ( |
|
| 213 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 214 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 215 | + | number BIGINT NOT NULL, |
|
| 216 | + | title TEXT NOT NULL, |
|
| 217 | + | body TEXT NOT NULL DEFAULT '', |
|
| 218 | + | author_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 219 | + | author TEXT NOT NULL DEFAULT '', |
|
| 220 | + | state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')), |
|
| 221 | + | reason TEXT NOT NULL DEFAULT '' CHECK (reason IN ('', 'completed', 'not_planned')), |
|
| 222 | + | locked BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 223 | + | pinned BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 224 | + | milestone_id BIGINT REFERENCES milestones(id) ON DELETE SET NULL, |
|
| 225 | + | created_ms BIGINT NOT NULL, |
|
| 226 | + | updated_ms BIGINT NOT NULL, |
|
| 227 | + | closed_ms BIGINT, |
|
| 228 | + | UNIQUE (repo_id, number) |
|
| 229 | + | ); |
|
| 230 | + | CREATE TABLE issue_comments ( |
|
| 231 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 232 | + | issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, |
|
| 233 | + | author_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 234 | + | author TEXT NOT NULL DEFAULT '', |
|
| 235 | + | body TEXT NOT NULL, |
|
| 236 | + | created_ms BIGINT NOT NULL, |
|
| 237 | + | edited_ms BIGINT |
|
| 238 | + | ); |
|
| 239 | + | CREATE TABLE issue_events ( |
|
| 240 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 241 | + | issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, |
|
| 242 | + | at_ms BIGINT NOT NULL, |
|
| 243 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 244 | + | event TEXT NOT NULL, |
|
| 245 | + | detail TEXT NOT NULL DEFAULT '' |
|
| 246 | + | ); |
|
| 247 | + | CREATE TABLE labels ( |
|
| 248 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 249 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 250 | + | name CITEXT NOT NULL, |
|
| 251 | + | colour TEXT NOT NULL DEFAULT '#0072ff', |
|
| 252 | + | description TEXT NOT NULL DEFAULT '', |
|
| 253 | + | UNIQUE (repo_id, name) |
|
| 254 | + | ); |
|
| 255 | + | CREATE TABLE issue_labels ( |
|
| 256 | + | issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, |
|
| 257 | + | label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, |
|
| 258 | + | PRIMARY KEY (issue_id, label_id) |
|
| 259 | + | ); |
|
| 260 | + | CREATE TABLE issue_assignees ( |
|
| 261 | + | issue_id BIGINT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, |
|
| 262 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 263 | + | PRIMARY KEY (issue_id, account_id) |
|
| 264 | + | ); |
|
| 265 | + | CREATE TABLE proposal_labels ( |
|
| 266 | + | proposal_id BIGINT NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, |
|
| 267 | + | label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, |
|
| 268 | + | PRIMARY KEY (proposal_id, label_id) |
|
| 269 | + | ); |
|
| 270 | + | CREATE TABLE notifications ( |
|
| 271 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 272 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 273 | + | at_ms BIGINT NOT NULL, |
|
| 274 | + | kind TEXT NOT NULL, |
|
| 275 | + | title TEXT NOT NULL, |
|
| 276 | + | url TEXT NOT NULL, |
|
| 277 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 278 | + | read BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 279 | + | repo_id BIGINT REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 280 | + | thread TEXT NOT NULL DEFAULT '', |
|
| 281 | + | reason TEXT NOT NULL DEFAULT 'participating' CHECK (reason IN ('participating', 'watching')), |
|
| 282 | + | done BOOLEAN NOT NULL DEFAULT FALSE |
|
| 283 | + | ); |
|
| 284 | + | CREATE TABLE watches ( |
|
| 285 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 286 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 287 | + | muted BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 288 | + | PRIMARY KEY (account_id, repo_id) |
|
| 289 | + | ); |
|
| 290 | + | CREATE TABLE subscriptions ( |
|
| 291 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 292 | + | kind TEXT NOT NULL CHECK (kind IN ('issue', 'proposal')), |
|
| 293 | + | target_id BIGINT NOT NULL, |
|
| 294 | + | PRIMARY KEY (account_id, kind, target_id) |
|
| 295 | + | ); |
|
| 296 | + | CREATE TABLE hooks ( |
|
| 297 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 298 | + | repo_id BIGINT REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 299 | + | org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE, |
|
| 300 | + | url TEXT NOT NULL, |
|
| 301 | + | secret TEXT NOT NULL DEFAULT '', |
|
| 302 | + | events TEXT NOT NULL DEFAULT '*', |
|
| 303 | + | active BOOLEAN NOT NULL DEFAULT TRUE, |
|
| 304 | + | created_ms BIGINT NOT NULL, |
|
| 305 | + | format TEXT NOT NULL DEFAULT 'oxygenhub' CHECK (format IN ('oxygenhub', 'discord', 'slack')), |
|
| 306 | + | CHECK ((repo_id IS NULL) <> (org_id IS NULL)) |
|
| 307 | + | ); |
|
| 308 | + | CREATE TABLE deliveries ( |
|
| 309 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 310 | + | hook_id BIGINT NOT NULL REFERENCES hooks(id) ON DELETE CASCADE, |
|
| 311 | + | delivery TEXT NOT NULL, |
|
| 312 | + | event TEXT NOT NULL, |
|
| 313 | + | payload TEXT NOT NULL, |
|
| 314 | + | created_ms BIGINT NOT NULL, |
|
| 315 | + | attempts BIGINT NOT NULL DEFAULT 0, |
|
| 316 | + | next_ms BIGINT NOT NULL, |
|
| 317 | + | status BIGINT, |
|
| 318 | + | response TEXT NOT NULL DEFAULT '', |
|
| 319 | + | delivered_ms BIGINT |
|
| 320 | + | ); |
|
| 321 | + | CREATE TABLE checks ( |
|
| 322 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 323 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 324 | + | state TEXT NOT NULL, |
|
| 325 | + | name TEXT NOT NULL, |
|
| 326 | + | status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'passed', 'failed', 'cancelled', 'skipped')), |
|
| 327 | + | summary TEXT NOT NULL DEFAULT '', |
|
| 328 | + | url TEXT NOT NULL DEFAULT '', |
|
| 329 | + | reporter_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 330 | + | reporter TEXT NOT NULL DEFAULT '', |
|
| 331 | + | seal TEXT NOT NULL DEFAULT '', |
|
| 332 | + | started_ms BIGINT NOT NULL, |
|
| 333 | + | finished_ms BIGINT, |
|
| 334 | + | UNIQUE (repo_id, state, name) |
|
| 335 | + | ); |
|
| 336 | + | CREATE TABLE releases ( |
|
| 337 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 338 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 339 | + | tag TEXT NOT NULL, |
|
| 340 | + | title TEXT NOT NULL, |
|
| 341 | + | notes TEXT NOT NULL DEFAULT '', |
|
| 342 | + | prerelease BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 343 | + | author_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 344 | + | author TEXT NOT NULL DEFAULT '', |
|
| 345 | + | created_ms BIGINT NOT NULL, |
|
| 346 | + | UNIQUE (repo_id, tag) |
|
| 347 | + | ); |
|
| 348 | + | CREATE TABLE release_assets ( |
|
| 349 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 350 | + | release_id BIGINT NOT NULL REFERENCES releases(id) ON DELETE CASCADE, |
|
| 351 | + | name TEXT NOT NULL, |
|
| 352 | + | size BIGINT NOT NULL, |
|
| 353 | + | hash TEXT NOT NULL, |
|
| 354 | + | content_type TEXT NOT NULL DEFAULT 'application/octet-stream', |
|
| 355 | + | downloads BIGINT NOT NULL DEFAULT 0, |
|
| 356 | + | created_ms BIGINT NOT NULL, |
|
| 357 | + | UNIQUE (release_id, name) |
|
| 358 | + | ); |
|
| 359 | + | CREATE TABLE stars ( |
|
| 360 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 361 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 362 | + | at_ms BIGINT NOT NULL, |
|
| 363 | + | PRIMARY KEY (account_id, repo_id) |
|
| 364 | + | ); |
|
| 365 | + | CREATE TABLE flow_runs ( |
|
| 366 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 367 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 368 | + | bookmark TEXT NOT NULL, |
|
| 369 | + | state TEXT NOT NULL, |
|
| 370 | + | heads TEXT NOT NULL, |
|
| 371 | + | pipeline_hash TEXT NOT NULL DEFAULT '', |
|
| 372 | + | status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'passed', 'failed', 'error', 'cancelled')), |
|
| 373 | + | error TEXT NOT NULL DEFAULT '', |
|
| 374 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 375 | + | created_ms BIGINT NOT NULL, |
|
| 376 | + | finished_ms BIGINT, |
|
| 377 | + | number BIGINT NOT NULL DEFAULT 0, |
|
| 378 | + | trigger TEXT NOT NULL DEFAULT 'push' |
|
| 379 | + | ); |
|
| 380 | + | CREATE TABLE flow_jobs ( |
|
| 381 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 382 | + | run_id BIGINT NOT NULL REFERENCES flow_runs(id) ON DELETE CASCADE, |
|
| 383 | + | name TEXT NOT NULL, |
|
| 384 | + | depth BIGINT NOT NULL, |
|
| 385 | + | depends TEXT NOT NULL, |
|
| 386 | + | spec TEXT NOT NULL, |
|
| 387 | + | sandbox TEXT NOT NULL, |
|
| 388 | + | runtime TEXT NOT NULL, |
|
| 389 | + | status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'passed', 'failed', 'skipped', 'error', 'cancelled')), |
|
| 390 | + | runner TEXT NOT NULL DEFAULT '', |
|
| 391 | + | token_hash TEXT NOT NULL DEFAULT '', |
|
| 392 | + | started_ms BIGINT, |
|
| 393 | + | finished_ms BIGINT, |
|
| 394 | + | exit_code BIGINT, |
|
| 395 | + | error TEXT NOT NULL DEFAULT '', |
|
| 396 | + | receipt TEXT NOT NULL DEFAULT '', |
|
| 397 | + | receipt_hash TEXT NOT NULL DEFAULT '', |
|
| 398 | + | log_count BIGINT NOT NULL DEFAULT 0, |
|
| 399 | + | also_read TEXT NOT NULL DEFAULT '', |
|
| 400 | + | receipt_attest TEXT NOT NULL DEFAULT '', |
|
| 401 | + | receipt_signer TEXT NOT NULL DEFAULT '', |
|
| 402 | + | receipt_note TEXT NOT NULL DEFAULT '', |
|
| 403 | + | runner_id BIGINT, |
|
| 404 | + | timeout_s BIGINT NOT NULL DEFAULT 0, |
|
| 405 | + | waiting TEXT NOT NULL DEFAULT '', |
|
| 406 | + | peak_disk_mib BIGINT, |
|
| 407 | + | peak_memory_mib BIGINT, |
|
| 408 | + | need_disk_mib BIGINT, |
|
| 409 | + | placed TEXT NOT NULL DEFAULT '', |
|
| 410 | + | approval TEXT NOT NULL DEFAULT '', |
|
| 411 | + | approved_by TEXT NOT NULL DEFAULT '', |
|
| 412 | + | approved_ms BIGINT, |
|
| 413 | + | environment TEXT NOT NULL DEFAULT '', |
|
| 414 | + | approval_seal TEXT NOT NULL DEFAULT '', |
|
| 415 | + | UNIQUE (run_id, name) |
|
| 416 | + | ); |
|
| 417 | + | CREATE TABLE flow_logs ( |
|
| 418 | + | job_id BIGINT NOT NULL REFERENCES flow_jobs(id) ON DELETE CASCADE, |
|
| 419 | + | seq BIGINT NOT NULL, |
|
| 420 | + | t BIGINT NOT NULL, |
|
| 421 | + | stream TEXT NOT NULL, |
|
| 422 | + | text TEXT NOT NULL, |
|
| 423 | + | PRIMARY KEY (job_id, seq) |
|
| 424 | + | ); |
|
| 425 | + | CREATE TABLE account_links ( |
|
| 426 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 427 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 428 | + | kind TEXT NOT NULL CHECK (kind IN ('reset', 'verify')), |
|
| 429 | + | hash TEXT NOT NULL UNIQUE, |
|
| 430 | + | email TEXT NOT NULL DEFAULT '', |
|
| 431 | + | created_by BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 432 | + | created_ms BIGINT NOT NULL, |
|
| 433 | + | expires_ms BIGINT NOT NULL, |
|
| 434 | + | used_ms BIGINT |
|
| 435 | + | ); |
|
| 436 | + | CREATE TABLE projects ( |
|
| 437 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 438 | + | owner TEXT NOT NULL, |
|
| 439 | + | number BIGINT NOT NULL, |
|
| 440 | + | title TEXT NOT NULL, |
|
| 441 | + | description TEXT NOT NULL DEFAULT '', |
|
| 442 | + | public BOOLEAN NOT NULL DEFAULT TRUE, |
|
| 443 | + | closed BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 444 | + | created_ms BIGINT NOT NULL, |
|
| 445 | + | updated_ms BIGINT NOT NULL, |
|
| 446 | + | UNIQUE (owner, number) |
|
| 447 | + | ); |
|
| 448 | + | CREATE TABLE project_columns ( |
|
| 449 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 450 | + | project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 451 | + | name TEXT NOT NULL, |
|
| 452 | + | position BIGINT NOT NULL |
|
| 453 | + | ); |
|
| 454 | + | CREATE TABLE project_cards ( |
|
| 455 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 456 | + | project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 457 | + | column_id BIGINT NOT NULL REFERENCES project_columns(id) ON DELETE CASCADE, |
|
| 458 | + | position BIGINT NOT NULL, |
|
| 459 | + | kind TEXT NOT NULL CHECK (kind IN ('issue', 'proposal', 'note')), |
|
| 460 | + | repo_id BIGINT REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 461 | + | number BIGINT, |
|
| 462 | + | note TEXT NOT NULL DEFAULT '', |
|
| 463 | + | added_by TEXT NOT NULL DEFAULT '', |
|
| 464 | + | created_ms BIGINT NOT NULL |
|
| 465 | + | ); |
|
| 466 | + | CREATE TABLE flow_runner_keys ( |
|
| 467 | + | token_hash TEXT PRIMARY KEY, |
|
| 468 | + | key TEXT NOT NULL, |
|
| 469 | + | name TEXT NOT NULL, |
|
| 470 | + | first_ms BIGINT NOT NULL |
|
| 471 | + | ); |
|
| 472 | + | CREATE TABLE flow_artifacts ( |
|
| 473 | + | job_id BIGINT NOT NULL REFERENCES flow_jobs(id) ON DELETE CASCADE, |
|
| 474 | + | name TEXT NOT NULL, |
|
| 475 | + | path TEXT NOT NULL, |
|
| 476 | + | blob TEXT NOT NULL, |
|
| 477 | + | size BIGINT NOT NULL, |
|
| 478 | + | content TEXT NOT NULL, |
|
| 479 | + | created_ms BIGINT NOT NULL, |
|
| 480 | + | PRIMARY KEY (job_id, name) |
|
| 481 | + | ); |
|
| 482 | + | CREATE TABLE flow_runners ( |
|
| 483 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 484 | + | name TEXT NOT NULL, |
|
| 485 | + | token_hash TEXT NOT NULL UNIQUE, |
|
| 486 | + | source TEXT NOT NULL, |
|
| 487 | + | key TEXT NOT NULL DEFAULT '', |
|
| 488 | + | key_name TEXT NOT NULL DEFAULT '', |
|
| 489 | + | arch TEXT NOT NULL DEFAULT '', |
|
| 490 | + | os TEXT NOT NULL DEFAULT '', |
|
| 491 | + | version TEXT NOT NULL DEFAULT '', |
|
| 492 | + | labels TEXT NOT NULL DEFAULT '', |
|
| 493 | + | max_jobs BIGINT NOT NULL DEFAULT 1, |
|
| 494 | + | memory_mib BIGINT, |
|
| 495 | + | cpus BIGINT, |
|
| 496 | + | state TEXT NOT NULL DEFAULT 'active', |
|
| 497 | + | created_ms BIGINT NOT NULL, |
|
| 498 | + | created_by TEXT NOT NULL DEFAULT '', |
|
| 499 | + | last_seen_ms BIGINT, |
|
| 500 | + | scope_owner TEXT NOT NULL DEFAULT '', |
|
| 501 | + | scope_repo_id BIGINT, |
|
| 502 | + | admin_labels TEXT NOT NULL DEFAULT '', |
|
| 503 | + | timeout_minutes BIGINT, |
|
| 504 | + | tiers TEXT NOT NULL DEFAULT '', |
|
| 505 | + | images TEXT NOT NULL DEFAULT '', |
|
| 506 | + | profile TEXT NOT NULL DEFAULT '', |
|
| 507 | + | fetches_images BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 508 | + | free_disk_mib BIGINT, |
|
| 509 | + | load_x100 BIGINT, |
|
| 510 | + | speed BIGINT, |
|
| 511 | + | speed_class BIGINT, |
|
| 512 | + | kernel TEXT NOT NULL DEFAULT '', |
|
| 513 | + | state_ms BIGINT NOT NULL DEFAULT 0 |
|
| 514 | + | ); |
|
| 515 | + | CREATE TABLE flow_runner_registrations ( |
|
| 516 | + | token_hash TEXT PRIMARY KEY, |
|
| 517 | + | created_by TEXT NOT NULL, |
|
| 518 | + | created_ms BIGINT NOT NULL, |
|
| 519 | + | expires_ms BIGINT NOT NULL, |
|
| 520 | + | used_ms BIGINT, |
|
| 521 | + | runner_id BIGINT, |
|
| 522 | + | scope_owner TEXT NOT NULL DEFAULT '', |
|
| 523 | + | scope_repo_id BIGINT |
|
| 524 | + | ); |
|
| 525 | + | CREATE TABLE device_codes ( |
|
| 526 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 527 | + | device_hash TEXT NOT NULL UNIQUE, |
|
| 528 | + | user_code TEXT NOT NULL UNIQUE, |
|
| 529 | + | client TEXT NOT NULL, |
|
| 530 | + | scopes TEXT NOT NULL, |
|
| 531 | + | created_ms BIGINT NOT NULL, |
|
| 532 | + | expires_ms BIGINT NOT NULL, |
|
| 533 | + | last_poll_ms BIGINT NOT NULL DEFAULT 0, |
|
| 534 | + | account_id BIGINT REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 535 | + | approved_scopes TEXT NOT NULL DEFAULT '', |
|
| 536 | + | denied BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 537 | + | requester TEXT NOT NULL DEFAULT '' |
|
| 538 | + | ); |
|
| 539 | + | CREATE TABLE proposal_review_requests ( |
|
| 540 | + | proposal_id BIGINT NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, |
|
| 541 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 542 | + | requested_by TEXT NOT NULL, |
|
| 543 | + | requested_ms BIGINT NOT NULL, |
|
| 544 | + | PRIMARY KEY (proposal_id, account_id) |
|
| 545 | + | ); |
|
| 546 | + | CREATE TABLE retired_usernames ( |
|
| 547 | + | username CITEXT PRIMARY KEY, |
|
| 548 | + | retired_ms BIGINT NOT NULL |
|
| 549 | + | ); |
|
| 550 | + | CREATE TABLE proposal_reviews ( |
|
| 551 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 552 | + | proposal_id BIGINT NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, |
|
| 553 | + | author_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 554 | + | author TEXT NOT NULL DEFAULT '', |
|
| 555 | + | verdict TEXT NOT NULL CHECK (verdict IN ('comment', 'changes', 'approve')), |
|
| 556 | + | body TEXT NOT NULL DEFAULT '', |
|
| 557 | + | created_ms BIGINT NOT NULL, |
|
| 558 | + | via TEXT NOT NULL DEFAULT '' |
|
| 559 | + | ); |
|
| 560 | + | CREATE TABLE assistants ( |
|
| 561 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 562 | + | scope TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')), |
|
| 563 | + | scope_id BIGINT NOT NULL DEFAULT 0, |
|
| 564 | + | name CITEXT NOT NULL, |
|
| 565 | + | endpoint TEXT NOT NULL, |
|
| 566 | + | api TEXT NOT NULL, |
|
| 567 | + | model TEXT NOT NULL, |
|
| 568 | + | headers TEXT NOT NULL DEFAULT '', |
|
| 569 | + | token BYTEA NOT NULL, |
|
| 570 | + | enabled BOOLEAN NOT NULL DEFAULT TRUE, |
|
| 571 | + | budget BIGINT NOT NULL DEFAULT 120000, |
|
| 572 | + | created_ms BIGINT NOT NULL, |
|
| 573 | + | updated_ms BIGINT NOT NULL, |
|
| 574 | + | UNIQUE (scope, scope_id, name) |
|
| 575 | + | ); |
|
| 576 | + | CREATE TABLE assistant_prompts ( |
|
| 577 | + | scope TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')), |
|
| 578 | + | scope_id BIGINT NOT NULL DEFAULT 0, |
|
| 579 | + | prompt TEXT NOT NULL, |
|
| 580 | + | updated_ms BIGINT NOT NULL, |
|
| 581 | + | PRIMARY KEY (scope, scope_id) |
|
| 582 | + | ); |
|
| 583 | + | CREATE TABLE flow_variables ( |
|
| 584 | + | scope TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')), |
|
| 585 | + | scope_id BIGINT NOT NULL DEFAULT 0, |
|
| 586 | + | name TEXT NOT NULL, |
|
| 587 | + | value TEXT NOT NULL, |
|
| 588 | + | updated_ms BIGINT NOT NULL, |
|
| 589 | + | PRIMARY KEY (scope, scope_id, name) |
|
| 590 | + | ); |
|
| 591 | + | CREATE TABLE flow_secrets ( |
|
| 592 | + | scope TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')), |
|
| 593 | + | scope_id BIGINT NOT NULL DEFAULT 0, |
|
| 594 | + | name TEXT NOT NULL, |
|
| 595 | + | value BYTEA NOT NULL, |
|
| 596 | + | updated_ms BIGINT NOT NULL, |
|
| 597 | + | PRIMARY KEY (scope, scope_id, name) |
|
| 598 | + | ); |
|
| 599 | + | CREATE TABLE repo_redirects ( |
|
| 600 | + | old_owner TEXT NOT NULL, |
|
| 601 | + | old_name TEXT NOT NULL, |
|
| 602 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 603 | + | moved_ms BIGINT NOT NULL, |
|
| 604 | + | PRIMARY KEY (old_owner, old_name) |
|
| 605 | + | ); |
|
| 606 | + | CREATE TABLE account_logins ( |
|
| 607 | + | provider TEXT NOT NULL, |
|
| 608 | + | subject TEXT NOT NULL, |
|
| 609 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 610 | + | login TEXT NOT NULL DEFAULT '', |
|
| 611 | + | linked_ms BIGINT NOT NULL, |
|
| 612 | + | PRIMARY KEY (provider, subject) |
|
| 613 | + | ); |
|
| 614 | + | CREATE TABLE flow_images ( |
|
| 615 | + | hash TEXT PRIMARY KEY, |
|
| 616 | + | arch TEXT NOT NULL, |
|
| 617 | + | seen_ms BIGINT NOT NULL |
|
| 618 | + | ); |
|
| 619 | + | CREATE TABLE flow_declines ( |
|
| 620 | + | job_id BIGINT NOT NULL REFERENCES flow_jobs(id) ON DELETE CASCADE, |
|
| 621 | + | runner_id BIGINT NOT NULL, |
|
| 622 | + | runner TEXT NOT NULL, |
|
| 623 | + | profile TEXT NOT NULL, |
|
| 624 | + | reason TEXT NOT NULL, |
|
| 625 | + | ms BIGINT NOT NULL, |
|
| 626 | + | PRIMARY KEY (job_id, runner_id) |
|
| 627 | + | ); |
|
| 628 | + | CREATE TABLE repo_storage ( |
|
| 629 | + | repo_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 630 | + | url TEXT NOT NULL, |
|
| 631 | + | credentials BYTEA NOT NULL, |
|
| 632 | + | created_ms BIGINT NOT NULL, |
|
| 633 | + | checked_ms BIGINT, |
|
| 634 | + | check_result TEXT NOT NULL DEFAULT '' |
|
| 635 | + | ); |
|
| 636 | + | CREATE TABLE org_storage_policy ( |
|
| 637 | + | org_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY REFERENCES organisations(id) ON DELETE CASCADE, |
|
| 638 | + | require_bucket BOOLEAN NOT NULL DEFAULT FALSE, |
|
| 639 | + | allowed TEXT NOT NULL DEFAULT '' |
|
| 640 | + | ); |
|
| 641 | + | CREATE TABLE coverage_settings ( |
|
| 642 | + | repo_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 643 | + | min DOUBLE PRECISION, |
|
| 644 | + | max_drop DOUBLE PRECISION |
|
| 645 | + | ); |
|
| 646 | + | CREATE TABLE test_reports ( |
|
| 647 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 648 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 649 | + | run_id BIGINT NOT NULL REFERENCES flow_runs(id) ON DELETE CASCADE, |
|
| 650 | + | job_id BIGINT NOT NULL UNIQUE, |
|
| 651 | + | job TEXT NOT NULL, |
|
| 652 | + | run_number BIGINT NOT NULL, |
|
| 653 | + | bookmark TEXT NOT NULL, |
|
| 654 | + | state TEXT NOT NULL, |
|
| 655 | + | blob TEXT NOT NULL, |
|
| 656 | + | tests BIGINT NOT NULL, |
|
| 657 | + | passed BIGINT NOT NULL, |
|
| 658 | + | failed BIGINT NOT NULL, |
|
| 659 | + | errors BIGINT NOT NULL, |
|
| 660 | + | skipped BIGINT NOT NULL, |
|
| 661 | + | time_ms BIGINT NOT NULL, |
|
| 662 | + | created_ms BIGINT NOT NULL |
|
| 663 | + | ); |
|
| 664 | + | CREATE TABLE test_cases ( |
|
| 665 | + | report_id BIGINT NOT NULL REFERENCES test_reports(id) ON DELETE CASCADE, |
|
| 666 | + | idx BIGINT NOT NULL, |
|
| 667 | + | suite TEXT NOT NULL, |
|
| 668 | + | classname TEXT NOT NULL, |
|
| 669 | + | name TEXT NOT NULL, |
|
| 670 | + | status TEXT NOT NULL, |
|
| 671 | + | time_ms BIGINT NOT NULL, |
|
| 672 | + | message TEXT NOT NULL, |
|
| 673 | + | output TEXT NOT NULL, |
|
| 674 | + | PRIMARY KEY (report_id, idx) |
|
| 675 | + | ); |
|
| 676 | + | CREATE TABLE project_redirects ( |
|
| 677 | + | owner TEXT NOT NULL, |
|
| 678 | + | number BIGINT NOT NULL, |
|
| 679 | + | project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 680 | + | PRIMARY KEY (owner, number) |
|
| 681 | + | ); |
|
| 682 | + | CREATE TABLE reports ( |
|
| 683 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 684 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 685 | + | name TEXT NOT NULL, |
|
| 686 | + | kind TEXT NOT NULL, |
|
| 687 | + | title TEXT NOT NULL, |
|
| 688 | + | summary TEXT NOT NULL, |
|
| 689 | + | context TEXT NOT NULL, |
|
| 690 | + | run_id BIGINT REFERENCES flow_runs(id) ON DELETE CASCADE, |
|
| 691 | + | job_id BIGINT, |
|
| 692 | + | run_number BIGINT, |
|
| 693 | + | job TEXT NOT NULL, |
|
| 694 | + | bookmark TEXT NOT NULL, |
|
| 695 | + | state TEXT NOT NULL, |
|
| 696 | + | manifest TEXT, |
|
| 697 | + | files BIGINT NOT NULL, |
|
| 698 | + | bytes BIGINT NOT NULL, |
|
| 699 | + | created_ms BIGINT NOT NULL, |
|
| 700 | + | files_expired_ms BIGINT |
|
| 701 | + | ); |
|
| 702 | + | CREATE TABLE report_metrics ( |
|
| 703 | + | report_id BIGINT NOT NULL REFERENCES reports(id) ON DELETE CASCADE, |
|
| 704 | + | key TEXT NOT NULL, |
|
| 705 | + | value DOUBLE PRECISION NOT NULL, |
|
| 706 | + | unit TEXT NOT NULL, |
|
| 707 | + | better TEXT NOT NULL, |
|
| 708 | + | fail_above DOUBLE PRECISION, |
|
| 709 | + | fail_below DOUBLE PRECISION, |
|
| 710 | + | PRIMARY KEY (report_id, key) |
|
| 711 | + | ); |
|
| 712 | + | CREATE TABLE report_files ( |
|
| 713 | + | report_id BIGINT NOT NULL REFERENCES reports(id) ON DELETE CASCADE, |
|
| 714 | + | path TEXT NOT NULL, |
|
| 715 | + | blob TEXT NOT NULL, |
|
| 716 | + | size BIGINT NOT NULL, |
|
| 717 | + | PRIMARY KEY (report_id, path) |
|
| 718 | + | ); |
|
| 719 | + | CREATE TABLE jet_images ( |
|
| 720 | + | hash TEXT PRIMARY KEY, |
|
| 721 | + | size BIGINT NOT NULL, |
|
| 722 | + | arch TEXT NOT NULL, |
|
| 723 | + | chunks BIGINT NOT NULL, |
|
| 724 | + | uploader TEXT NOT NULL, |
|
| 725 | + | created_ms BIGINT NOT NULL |
|
| 726 | + | ); |
|
| 727 | + | CREATE TABLE coverage_reports ( |
|
| 728 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 729 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 730 | + | run_id BIGINT REFERENCES flow_runs(id) ON DELETE CASCADE, |
|
| 731 | + | job_id BIGINT UNIQUE, |
|
| 732 | + | job TEXT NOT NULL, |
|
| 733 | + | run_number BIGINT NOT NULL, |
|
| 734 | + | bookmark TEXT NOT NULL, |
|
| 735 | + | state TEXT NOT NULL, |
|
| 736 | + | heads TEXT NOT NULL, |
|
| 737 | + | blob TEXT NOT NULL, |
|
| 738 | + | format TEXT NOT NULL, |
|
| 739 | + | lines_found BIGINT NOT NULL, |
|
| 740 | + | lines_hit BIGINT NOT NULL, |
|
| 741 | + | functions_found BIGINT NOT NULL, |
|
| 742 | + | functions_hit BIGINT NOT NULL, |
|
| 743 | + | branches_found BIGINT NOT NULL, |
|
| 744 | + | branches_hit BIGINT NOT NULL, |
|
| 745 | + | hits BIGINT NOT NULL DEFAULT 0, |
|
| 746 | + | files BIGINT NOT NULL, |
|
| 747 | + | dropped BIGINT NOT NULL, |
|
| 748 | + | created_ms BIGINT NOT NULL, |
|
| 749 | + | uploader TEXT NOT NULL DEFAULT '' |
|
| 750 | + | ); |
|
| 751 | + | CREATE TABLE coverage_files ( |
|
| 752 | + | report_id BIGINT NOT NULL REFERENCES "coverage_reports"(id) ON DELETE CASCADE, |
|
| 753 | + | path TEXT NOT NULL, |
|
| 754 | + | lines_found BIGINT NOT NULL, |
|
| 755 | + | lines_hit BIGINT NOT NULL, |
|
| 756 | + | functions_found BIGINT NOT NULL, |
|
| 757 | + | functions_hit BIGINT NOT NULL, |
|
| 758 | + | branches_found BIGINT NOT NULL, |
|
| 759 | + | branches_hit BIGINT NOT NULL, |
|
| 760 | + | PRIMARY KEY (report_id, path) |
|
| 761 | + | ); |
|
| 762 | + | CREATE TABLE diffusion_pins ( |
|
| 763 | + | consumer_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 764 | + | bookmark TEXT NOT NULL, |
|
| 765 | + | path TEXT NOT NULL, |
|
| 766 | + | upstream TEXT NOT NULL, |
|
| 767 | + | heads TEXT NOT NULL, |
|
| 768 | + | updated_ms BIGINT NOT NULL, |
|
| 769 | + | upstream_repo_id BIGINT, |
|
| 770 | + | PRIMARY KEY (consumer_id, bookmark, path) |
|
| 771 | + | ); |
|
| 772 | + | CREATE TABLE backup_settings ( |
|
| 773 | + | owner TEXT PRIMARY KEY, |
|
| 774 | + | kind TEXT NOT NULL, |
|
| 775 | + | every TEXT NOT NULL DEFAULT 'off', |
|
| 776 | + | destination TEXT NOT NULL DEFAULT 'dir', |
|
| 777 | + | target TEXT NOT NULL DEFAULT '', |
|
| 778 | + | prefix TEXT NOT NULL DEFAULT '', |
|
| 779 | + | provider TEXT NOT NULL DEFAULT '', |
|
| 780 | + | keep_days BIGINT NOT NULL DEFAULT 30, |
|
| 781 | + | updated_ms BIGINT NOT NULL, |
|
| 782 | + | region TEXT NOT NULL DEFAULT '' |
|
| 783 | + | ); |
|
| 784 | + | CREATE TABLE backup_runs ( |
|
| 785 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 786 | + | owner TEXT NOT NULL, |
|
| 787 | + | started_ms BIGINT NOT NULL, |
|
| 788 | + | finished_ms BIGINT, |
|
| 789 | + | status TEXT NOT NULL, |
|
| 790 | + | at TEXT NOT NULL DEFAULT '', |
|
| 791 | + | repos BIGINT NOT NULL DEFAULT 0, |
|
| 792 | + | files BIGINT NOT NULL DEFAULT 0, |
|
| 793 | + | sent BIGINT NOT NULL DEFAULT 0, |
|
| 794 | + | bytes BIGINT NOT NULL DEFAULT 0, |
|
| 795 | + | detail TEXT NOT NULL DEFAULT '', |
|
| 796 | + | trigger TEXT NOT NULL DEFAULT 'schedule' |
|
| 797 | + | ); |
|
| 798 | + | CREATE TABLE sessions ( |
|
| 799 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 800 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 801 | + | hash TEXT NOT NULL UNIQUE, |
|
| 802 | + | salt TEXT NOT NULL, |
|
| 803 | + | created_ms BIGINT NOT NULL, |
|
| 804 | + | seen_ms BIGINT NOT NULL, |
|
| 805 | + | address TEXT NOT NULL DEFAULT '', |
|
| 806 | + | agent TEXT NOT NULL DEFAULT '' |
|
| 807 | + | ); |
|
| 808 | + | CREATE TABLE diffusion_indexed ( |
|
| 809 | + | consumer_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 810 | + | bookmark TEXT NOT NULL, |
|
| 811 | + | heads TEXT NOT NULL, |
|
| 812 | + | updated_ms BIGINT NOT NULL, |
|
| 813 | + | PRIMARY KEY (consumer_id, bookmark) |
|
| 814 | + | ); |
|
| 815 | + | CREATE INDEX account_links_by_account ON account_links (account_id, kind, created_ms DESC); |
|
| 816 | + | CREATE INDEX account_logins_by_account ON account_logins (account_id); |
|
| 817 | + | CREATE INDEX audit_by_time ON audit (at_ms DESC); |
|
| 818 | + | CREATE INDEX backup_runs_by_owner ON backup_runs (owner, id DESC); |
|
| 819 | + | CREATE INDEX checks_by_state ON checks (repo_id, state); |
|
| 820 | + | CREATE UNIQUE INDEX collaborators_account ON collaborators (repo_id, account_id) WHERE account_id IS NOT NULL; |
|
| 821 | + | CREATE UNIQUE INDEX collaborators_team ON collaborators (repo_id, team_id) WHERE team_id IS NOT NULL; |
|
| 822 | + | CREATE INDEX coverage_reports_bookmark ON coverage_reports (repo_id, bookmark, created_ms); |
|
| 823 | + | CREATE INDEX deliveries_by_hook ON deliveries (hook_id, created_ms DESC); |
|
| 824 | + | CREATE INDEX deliveries_due ON deliveries (next_ms) WHERE delivered_ms IS NULL; |
|
| 825 | + | CREATE INDEX diffusion_pins_by_upstream ON diffusion_pins (upstream); |
|
| 826 | + | CREATE INDEX flow_artifacts_content ON flow_artifacts(content); |
|
| 827 | + | CREATE INDEX flow_declines_runner ON flow_declines (runner_id, ms); |
|
| 828 | + | CREATE INDEX flow_jobs_environment ON flow_jobs(environment) WHERE environment != ''; |
|
| 829 | + | CREATE INDEX flow_jobs_queued ON flow_jobs (status, id); |
|
| 830 | + | CREATE INDEX flow_jobs_runner ON flow_jobs (runner_id); |
|
| 831 | + | CREATE INDEX flow_jobs_token ON flow_jobs (token_hash) WHERE token_hash <> ''; |
|
| 832 | + | CREATE INDEX flow_runs_by_repo ON flow_runs (repo_id, id DESC); |
|
| 833 | + | CREATE UNIQUE INDEX flow_runs_number ON flow_runs (repo_id, number); |
|
| 834 | + | CREATE INDEX issue_comments_by_issue ON issue_comments (issue_id, created_ms); |
|
| 835 | + | CREATE INDEX notifications_by_account ON notifications (account_id, read, at_ms DESC); |
|
| 836 | + | CREATE INDEX notifications_by_thread ON notifications (account_id, done, thread, at_ms DESC); |
|
| 837 | + | CREATE INDEX project_cards_by_column ON project_cards (column_id, position); |
|
| 838 | + | CREATE UNIQUE INDEX project_cards_once ON project_cards (project_id, repo_id, number) WHERE kind <> 'note'; |
|
| 839 | + | CREATE INDEX project_columns_by_project ON project_columns (project_id, position); |
|
| 840 | + | CREATE INDEX proposal_comments_by_proposal ON proposal_comments (proposal_id, created_ms); |
|
| 841 | + | CREATE INDEX proposal_reviews_by_proposal ON proposal_reviews (proposal_id, created_ms); |
|
| 842 | + | CREATE INDEX pushes_by_repo ON pushes (repo_id, at_ms DESC); |
|
| 843 | + | CREATE INDEX report_files_blob ON report_files (blob); |
|
| 844 | + | CREATE INDEX reports_history ON reports (repo_id, name, bookmark, created_ms); |
|
| 845 | + | CREATE UNIQUE INDEX reports_job ON reports (job_id, name); |
|
| 846 | + | CREATE INDEX repositories_forks ON repositories (forked_from) WHERE forked_from IS NOT NULL; |
|
| 847 | + | CREATE INDEX sessions_account ON sessions(account_id); |
|
| 848 | + | CREATE INDEX stars_by_repo ON stars (repo_id); |
|
| 849 | + | CREATE INDEX test_cases_name ON test_cases (classname, name); |
|
| 850 | + | CREATE INDEX test_reports_bookmark ON test_reports (repo_id, bookmark, created_ms); |
|
| 851 | + | ||
| 852 | + | -- Full-text search (SQLite: an FTS5 table with the porter tokenizer): |
|
| 853 | + | -- English stemming, titles weighted above bodies. |
|
| 854 | + | CREATE TABLE search ( |
|
| 855 | + | kind TEXT NOT NULL, |
|
| 856 | + | repo_id BIGINT NOT NULL, |
|
| 857 | + | target_id BIGINT NOT NULL, |
|
| 858 | + | number BIGINT NOT NULL, |
|
| 859 | + | title TEXT NOT NULL, |
|
| 860 | + | body TEXT NOT NULL, |
|
| 861 | + | doc tsvector GENERATED ALWAYS AS (setweight(to_tsvector('english', title), 'A') || setweight(to_tsvector('english', body), 'B')) STORED |
|
| 862 | + | ); |
|
| 863 | + | CREATE INDEX search_doc ON search USING GIN (doc); |
|
| 864 | + | CREATE INDEX search_target ON search (kind, target_id); |
added crates/hub/tests/schema.rs +64 −0
| 1 | + | //! The two schemas agree (#211): every table and column SQLite's |
|
| 2 | + | //! migrations make, `migrations-pg` makes too, so a change written for one |
|
| 3 | + | //! engine and not the other fails here. Postgres builds only, as the |
|
| 4 | + | //! Postgres tests run (`OXYHUB_DATABASE_URL`, `OXYHUB_DATABASE_SCHEMA=*`). |
|
| 5 | + | #![cfg(feature = "postgres")] |
|
| 6 | + | ||
| 7 | + | use oxygenhub::config::Config; |
|
| 8 | + | use std::collections::{BTreeMap, BTreeSet}; |
|
| 9 | + | ||
| 10 | + | type Tables = BTreeMap<String, BTreeSet<String>>; |
|
| 11 | + | ||
| 12 | + | /// The full-text index is each engine's own (FTS5's shadow tables, a |
|
| 13 | + | /// generated tsvector), and the migrations' ledger is sqlx's. |
|
| 14 | + | fn compared(table: &str) -> bool { |
|
| 15 | + | !table.starts_with("search") && !table.starts_with("_sqlx") |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | async fn sqlite() -> Tables { |
|
| 19 | + | use sqlx::Connection; |
|
| 20 | + | let mut conn = sqlx::SqliteConnection::connect("sqlite::memory:").await.unwrap(); |
|
| 21 | + | sqlx::migrate!("./migrations").run(&mut conn).await.unwrap(); |
|
| 22 | + | let names: Vec<String> = |
|
| 23 | + | sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").fetch_all(&mut conn).await.unwrap(); |
|
| 24 | + | let mut out = Tables::new(); |
|
| 25 | + | for t in names.into_iter().filter(|t| compared(t)) { |
|
| 26 | + | let cols: Vec<String> = |
|
| 27 | + | sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{t}')")).fetch_all(&mut conn).await.unwrap(); |
|
| 28 | + | out.insert(t, cols.into_iter().collect()); |
|
| 29 | + | } |
|
| 30 | + | out |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | async fn postgres() -> Tables { |
|
| 34 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 35 | + | let config = Config::load(dir.path()).unwrap(); |
|
| 36 | + | let pool = oxygenhub::db::open(&config).await.unwrap(); |
|
| 37 | + | let rows: Vec<(String, String)> = sqlx::query_as( |
|
| 38 | + | "SELECT CAST(t.relname AS TEXT), CAST(a.attname AS TEXT) FROM pg_attribute a JOIN pg_class t ON t.oid = a.attrelid \ |
|
| 39 | + | WHERE t.relkind = 'r' AND t.relnamespace = CAST(current_schema() AS regnamespace) AND a.attnum > 0 AND NOT a.attisdropped", |
|
| 40 | + | ) |
|
| 41 | + | .fetch_all(&pool) |
|
| 42 | + | .await |
|
| 43 | + | .unwrap(); |
|
| 44 | + | let mut out = Tables::new(); |
|
| 45 | + | for (t, c) in rows.into_iter().filter(|(t, _)| compared(t)) { |
|
| 46 | + | out.entry(t).or_default().insert(c); |
|
| 47 | + | } |
|
| 48 | + | out |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | #[tokio::test] |
|
| 52 | + | async fn the_postgres_schema_has_every_table_and_column_sqlite_has() { |
|
| 53 | + | let (lite, pg) = (sqlite().await, postgres().await); |
|
| 54 | + | let only = |a: &Tables, b: &Tables| -> Vec<String> { |
|
| 55 | + | a.iter() |
|
| 56 | + | .flat_map(|(t, cols)| match b.get(t) { |
|
| 57 | + | None => vec![format!("table {t}")], |
|
| 58 | + | Some(other) => cols.difference(other).map(|c| format!("{t}.{c}")).collect(), |
|
| 59 | + | }) |
|
| 60 | + | .collect() |
|
| 61 | + | }; |
|
| 62 | + | assert_eq!(only(&lite, &pg), Vec::<String>::new(), "in SQLite's migrations but not migrations-pg"); |
|
| 63 | + | assert_eq!(only(&pg, &lite), Vec::<String>::new(), "in migrations-pg but not SQLite's migrations"); |
|
| 64 | + | } |
added docs/postgres.md +65 −0
| 1 | + | # OxygenHub on PostgreSQL |
|
| 2 | + | ||
| 3 | + | A hub keeps its records in SQLite (`<data>/hub.db`) unless it is built with |
|
| 4 | + | the `postgres` feature, when it keeps them in PostgreSQL 16 or later |
|
| 5 | + | (oxygenhub#211). The engine is chosen when the hub is built, not when it |
|
| 6 | + | starts: one binary speaks one engine. |
|
| 7 | + | ||
| 8 | + | ## Build and configure |
|
| 9 | + | ||
| 10 | + | ```sh |
|
| 11 | + | cargo build --release -p oxygenhub --features postgres |
|
| 12 | + | ``` |
|
| 13 | + | ||
| 14 | + | ```toml |
|
| 15 | + | # <data>/hub.toml |
|
| 16 | + | [database] |
|
| 17 | + | url = "postgres://[email protected]/hub" # the password from PGPASSWORD or the URL |
|
| 18 | + | schema = "" # empty: public |
|
| 19 | + | ``` |
|
| 20 | + | ||
| 21 | + | `OXYHUB_DATABASE_URL` and `OXYHUB_DATABASE_SCHEMA` override the file. The |
|
| 22 | + | hub makes the `citext` extension (in `public`) and its schema if they are |
|
| 23 | + | missing, then applies `crates/hub/migrations-pg`. Repositories, keys and |
|
| 24 | + | uploads stay in the data directory as before. |
|
| 25 | + | ||
| 26 | + | `schema = "*"` gives each data directory a schema of its own (`hub_<dir>`), |
|
| 27 | + | so many hubs can share one database. The tests use it. |
|
| 28 | + | ||
| 29 | + | ## Two schemas, one set of queries |
|
| 30 | + | ||
| 31 | + | The queries are the same on both engines: `$n` placeholders, `ON CONFLICT`, |
|
| 32 | + | `TRUE`/`FALSE`, `RETURNING`, `CASE` for counting flags, window functions, |
|
| 33 | + | `lower()` for comparing names. Postgres is stricter than SQLite, so: |
|
| 34 | + | ||
| 35 | + | - A flag column is `BOOLEAN` on Postgres and `INTEGER` on SQLite. Bind and |
|
| 36 | + | read it as `bool`, and compare it with `TRUE`/`FALSE`, never `1`/`0`. |
|
| 37 | + | - `SUM` of a `BIGINT` is `NUMERIC` on Postgres: `CAST(SUM(…) AS BIGINT)`. |
|
| 38 | + | - Every column a `GROUP BY` query returns is grouped or aggregated. |
|
| 39 | + | - `HAVING` and `WHERE` cannot name a `SELECT` alias: repeat the expression or |
|
| 40 | + | select from a subquery. |
|
| 41 | + | ||
| 42 | + | The few places that differ choose by `cfg(feature = "postgres")`: |
|
| 43 | + | connecting and migrating (`db::open`), the write lock (`db::begin_write`: |
|
| 44 | + | `BEGIN IMMEDIATE`, or an advisory lock), full-text search (FTS5, or a |
|
| 45 | + | `tsvector` with `ts_headline`), JSON (`db::json_text`, `json_each`), and the |
|
| 46 | + | catalog backups read (`backups::catalog`). A backup taken on either engine |
|
| 47 | + | restores into either: flags are written as 0 or 1, blobs as hex. |
|
| 48 | + | ||
| 49 | + | ## Changing the schema |
|
| 50 | + | ||
| 51 | + | `migrations-pg/0001_schema.sql` is SQLite's 0001–0072 as one. Each later |
|
| 52 | + | change is two files with the same number, one in `migrations` (SQLite) and |
|
| 53 | + | one in `migrations-pg`. `tests/schema.rs` fails when the two disagree about a |
|
| 54 | + | table or column. |
|
| 55 | + | ||
| 56 | + | ## Testing on Postgres |
|
| 57 | + | ||
| 58 | + | ```sh |
|
| 59 | + | docker run -d --name oxyhub-pg-test -e POSTGRES_USER=hub -e POSTGRES_PASSWORD=test \ |
|
| 60 | + | -p 127.0.0.1:55432:5432 postgres:16-alpine |
|
| 61 | + | OXYHUB_DATABASE_URL=postgres://hub:[email protected]:55432/hub OXYHUB_DATABASE_SCHEMA='*' \ |
|
| 62 | + | cargo test -p oxygenhub --features postgres |
|
| 63 | + | ``` |
|
| 64 | + | ||
| 65 | + | Each test makes its own schema; drop the `hub_%` schemas between runs. |
modified Cargo.lock +1 −0
| … | 3493 unchanged lines |
||
| 3494 | 3494 | "serde_json", |
|
| 3495 | 3495 | "sha2 0.10.9", |
|
| 3496 | 3496 | "sqlx-core", |
|
| 3497 | + | "sqlx-postgres", |
|
| 3497 | 3498 | "sqlx-sqlite", |
|
| 3498 | 3499 | "syn 2.0.119", |
|
| 3499 | 3500 | "tokio", |
|
| … | 1369 unchanged lines |
modified crates/hub/Cargo.toml +4 −0
| … | 78 unchanged lines |
||
| 79 | 79 | futures.workspace = true |
|
| 80 | 80 | tokio-stream.workspace = true |
|
| 81 | 81 | ||
| 82 | + | [features] |
|
| 83 | + | # PostgreSQL instead of SQLite (#211): `[database] url` in hub.toml. |
|
| 84 | + | postgres = ["sqlx/postgres"] |
|
| 85 | + | ||
| 82 | 86 | [dev-dependencies] |
|
| 83 | 87 | tempfile.workspace = true |
|
| 84 | 88 | tower = { version = "0.5", features = ["util"] } |
|
| … | 1 unchanged lines |
modified crates/hub/src/backups.rs +190 −40
| … | 953 unchanged lines |
||
| 954 | 954 | } |
|
| 955 | 955 | ||
| 956 | 956 | async fn tables(pool: &Pool) -> Result<Vec<String>> { |
|
| 957 | - | let mut t: Vec<String> = |
|
| 958 | - | sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").fetch_all(pool).await?; |
|
| 957 | + | let mut t = catalog::tables(pool).await?; |
|
| 959 | 958 | t.retain(|t| !private_table(t)); |
|
| 960 | 959 | t.sort(); |
|
| 961 | 960 | Ok(t) |
|
| 962 | 961 | } |
|
| 963 | 962 | ||
| 964 | 963 | async fn columns(pool: &Pool, table: &str) -> Result<Vec<String>> { |
|
| 965 | - | Ok(sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(pool).await?) |
|
| 964 | + | Ok(table_cols(pool, table).await?.into_iter().map(|c| c.name).collect()) |
|
| 966 | 965 | } |
|
| 967 | 966 | ||
| 968 | 967 | /// The rows of `table` matching `cond`, as a JSON array, private columns |
|
| 969 | - | /// left out and blobs as hex. |
|
| 968 | + | /// left out, blobs as hex and flags as 0 or 1, whichever engine wrote them, |
|
| 969 | + | /// so a run from either restores into either. |
|
| 970 | 970 | async fn rows(pool: &Pool, table: &str, cond: &str) -> Result<serde_json::Value> { |
|
| 971 | - | let cols = columns(pool, table).await?; |
|
| 972 | - | let fields: Vec<String> = cols |
|
| 973 | - | .iter() |
|
| 974 | - | .filter(|c| !private_column(c)) |
|
| 975 | - | .map(|c| format!("'{c}', CASE WHEN typeof(\"{c}\") = 'blob' THEN hex(\"{c}\") ELSE \"{c}\" END")) |
|
| 976 | - | .collect(); |
|
| 977 | - | if fields.is_empty() { |
|
| 971 | + | let cols: Vec<Col> = table_cols(pool, table).await?.into_iter().filter(|c| !private_column(&c.name)).collect(); |
|
| 972 | + | if cols.is_empty() { |
|
| 978 | 973 | return Ok(serde_json::json!([])); |
|
| 979 | 974 | } |
|
| 980 | - | let sql = format!("SELECT json_group_array(json_object({})) FROM \"{table}\" WHERE {cond}", fields.join(", ")); |
|
| 981 | - | let text: String = sqlx::query_scalar(&sql).fetch_one(pool).await?; |
|
| 975 | + | let text: String = sqlx::query_scalar(&catalog::rows_sql(table, &cols, cond)).fetch_one(pool).await?; |
|
| 982 | 976 | Ok(serde_json::from_str(&text)?) |
|
| 983 | 977 | } |
|
| 984 | 978 | ||
| 979 | + | /// What backups ask of the database's own catalog, per engine. Column |
|
| 980 | + | /// types are given in SQLite's words (INTEGER, TEXT, BLOB, REAL) plus |
|
| 981 | + | /// BOOLEAN, which only Postgres has. |
|
| 982 | + | #[cfg(not(feature = "postgres"))] |
|
| 983 | + | mod catalog { |
|
| 984 | + | use super::{Col, Pool, Result}; |
|
| 985 | + | ||
| 986 | + | pub async fn tables(pool: &Pool) -> Result<Vec<String>> { |
|
| 987 | + | Ok(sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").fetch_all(pool).await?) |
|
| 988 | + | } |
|
| 989 | + | ||
| 990 | + | pub async fn cols(pool: &Pool, table: &str) -> Result<Vec<Col>> { |
|
| 991 | + | let rows: Vec<(String, String, i64, Option<String>, i64)> = |
|
| 992 | + | sqlx::query_as(&format!("SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('{table}')")) |
|
| 993 | + | .fetch_all(pool) |
|
| 994 | + | .await?; |
|
| 995 | + | Ok(rows.into_iter().map(|(name, ty, notnull, default, pk)| Col { name, ty, notnull: notnull != 0, default, pk }).collect()) |
|
| 996 | + | } |
|
| 997 | + | ||
| 998 | + | /// `(parent table, column, parent column)` for each reference. |
|
| 999 | + | pub async fn fks(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> { |
|
| 1000 | + | Ok(sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{table}')")).fetch_all(pool).await?) |
|
| 1001 | + | } |
|
| 1002 | + | ||
| 1003 | + | pub fn rows_sql(table: &str, cols: &[Col], cond: &str) -> String { |
|
| 1004 | + | let fields: Vec<String> = |
|
| 1005 | + | cols.iter().map(|c| format!("'{0}', CASE WHEN typeof(\"{0}\") = 'blob' THEN hex(\"{0}\") ELSE \"{0}\" END", c.name)).collect(); |
|
| 1006 | + | format!("SELECT json_group_array(json_object({})) FROM \"{table}\" WHERE {cond}", fields.join(", ")) |
|
| 1007 | + | } |
|
| 1008 | + | ||
| 1009 | + | /// How a restored row goes in when its key is taken: `INSERT …` then this. |
|
| 1010 | + | pub fn insert(verb: super::Verb, _cols: &[String]) -> (&'static str, String) { |
|
| 1011 | + | match verb { |
|
| 1012 | + | super::Verb::Replace => ("INSERT OR REPLACE", String::new()), |
|
| 1013 | + | super::Verb::New => ("INSERT", String::new()), |
|
| 1014 | + | super::Verb::Ignore => ("INSERT OR IGNORE", String::new()), |
|
| 1015 | + | } |
|
| 1016 | + | } |
|
| 1017 | + | } |
|
| 1018 | + | ||
| 1019 | + | #[cfg(feature = "postgres")] |
|
| 1020 | + | mod catalog { |
|
| 1021 | + | use super::{Col, Pool, Result}; |
|
| 1022 | + | ||
| 1023 | + | pub async fn tables(pool: &Pool) -> Result<Vec<String>> { |
|
| 1024 | + | Ok(sqlx::query_scalar( |
|
| 1025 | + | "SELECT CAST(relname AS TEXT) FROM pg_class WHERE relkind = 'r' AND relnamespace = CAST(current_schema() AS regnamespace)", |
|
| 1026 | + | ) |
|
| 1027 | + | .fetch_all(pool) |
|
| 1028 | + | .await?) |
|
| 1029 | + | } |
|
| 1030 | + | ||
| 1031 | + | /// Generated columns (the search index's) are left out: they are made, |
|
| 1032 | + | /// never restored. |
|
| 1033 | + | pub async fn cols(pool: &Pool, table: &str) -> Result<Vec<Col>> { |
|
| 1034 | + | // pg_catalog, not information_schema: many times faster. |
|
| 1035 | + | let rows: Vec<(String, String, bool, Option<String>, i64)> = sqlx::query_as( |
|
| 1036 | + | "SELECT CAST(a.attname AS TEXT), format_type(a.atttypid, a.atttypmod), a.attnotnull, pg_get_expr(d.adbin, d.adrelid), \ |
|
| 1037 | + | CAST(COALESCE((SELECT array_position(k.conkey, a.attnum) FROM pg_constraint k WHERE k.conrelid = t.oid AND k.contype = 'p'), 0) AS BIGINT) \ |
|
| 1038 | + | FROM pg_attribute a JOIN pg_class t ON t.oid = a.attrelid \ |
|
| 1039 | + | LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum \ |
|
| 1040 | + | WHERE t.relname = $1 AND t.relnamespace = CAST(current_schema() AS regnamespace) \ |
|
| 1041 | + | AND a.attnum > 0 AND NOT a.attisdropped AND a.attgenerated = '' ORDER BY a.attnum", |
|
| 1042 | + | ) |
|
| 1043 | + | .bind(table) |
|
| 1044 | + | .fetch_all(pool) |
|
| 1045 | + | .await?; |
|
| 1046 | + | Ok(rows |
|
| 1047 | + | .into_iter() |
|
| 1048 | + | .map(|(name, ty, notnull, default, pk)| { |
|
| 1049 | + | let ty = match ty.as_str() { |
|
| 1050 | + | "bigint" | "integer" | "smallint" => "INTEGER", |
|
| 1051 | + | "bytea" => "BLOB", |
|
| 1052 | + | "boolean" => "BOOLEAN", |
|
| 1053 | + | "double precision" | "real" => "REAL", |
|
| 1054 | + | _ => "TEXT", |
|
| 1055 | + | }; |
|
| 1056 | + | // An identity column's default is the sequence, as SQLite's |
|
| 1057 | + | // rowid: none to give. |
|
| 1058 | + | let default = default.filter(|d| !d.starts_with("nextval(")); |
|
| 1059 | + | Col { name, ty: ty.to_string(), notnull, default, pk } |
|
| 1060 | + | }) |
|
| 1061 | + | .collect()) |
|
| 1062 | + | } |
|
| 1063 | + | ||
| 1064 | + | /// `(parent table, column, parent column)` for each reference. |
|
| 1065 | + | pub async fn fks(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> { |
|
| 1066 | + | Ok(sqlx::query_as( |
|
| 1067 | + | "SELECT CAST(p.relname AS TEXT), CAST(a.attname AS TEXT), CAST(pa.attname AS TEXT) FROM pg_constraint k \ |
|
| 1068 | + | JOIN pg_class t ON t.oid = k.conrelid JOIN pg_class p ON p.oid = k.confrelid \ |
|
| 1069 | + | JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = k.conkey[1] \ |
|
| 1070 | + | JOIN pg_attribute pa ON pa.attrelid = k.confrelid AND pa.attnum = k.confkey[1] \ |
|
| 1071 | + | WHERE k.contype = 'f' AND t.relname = $1 AND t.relnamespace = CAST(current_schema() AS regnamespace)", |
|
| 1072 | + | ) |
|
| 1073 | + | .bind(table) |
|
| 1074 | + | .fetch_all(pool) |
|
| 1075 | + | .await?) |
|
| 1076 | + | } |
|
| 1077 | + | ||
| 1078 | + | pub fn rows_sql(table: &str, cols: &[Col], cond: &str) -> String { |
|
| 1079 | + | let fields: Vec<String> = cols |
|
| 1080 | + | .iter() |
|
| 1081 | + | .map(|c| match c.ty.as_str() { |
|
| 1082 | + | "BLOB" => format!("upper(encode(\"{0}\", 'hex')) AS \"{0}\"", c.name), |
|
| 1083 | + | "BOOLEAN" => format!("CASE WHEN \"{0}\" THEN 1 ELSE 0 END AS \"{0}\"", c.name), |
|
| 1084 | + | _ => format!("\"{0}\"", c.name), |
|
| 1085 | + | }) |
|
| 1086 | + | .collect(); |
|
| 1087 | + | format!("SELECT CAST(COALESCE(json_agg(r), '[]') AS TEXT) FROM (SELECT {} FROM \"{table}\" WHERE {cond}) r", fields.join(", ")) |
|
| 1088 | + | } |
|
| 1089 | + | ||
| 1090 | + | /// How a restored row goes in when its key is taken: `INSERT …` then this. |
|
| 1091 | + | pub fn insert(verb: super::Verb, cols: &[String]) -> (&'static str, String) { |
|
| 1092 | + | match verb { |
|
| 1093 | + | super::Verb::Replace => { |
|
| 1094 | + | let set: Vec<String> = cols.iter().filter(|c| *c != "repo_id").map(|c| format!("\"{c}\" = excluded.\"{c}\"")).collect(); |
|
| 1095 | + | let tail = if set.is_empty() { |
|
| 1096 | + | " ON CONFLICT (repo_id) DO NOTHING".to_string() |
|
| 1097 | + | } else { |
|
| 1098 | + | format!(" ON CONFLICT (repo_id) DO UPDATE SET {}", set.join(", ")) |
|
| 1099 | + | }; |
|
| 1100 | + | ("INSERT", tail) |
|
| 1101 | + | } |
|
| 1102 | + | super::Verb::New => ("INSERT", String::new()), |
|
| 1103 | + | super::Verb::Ignore => ("INSERT", " ON CONFLICT DO NOTHING".to_string()), |
|
| 1104 | + | } |
|
| 1105 | + | } |
|
| 1106 | + | } |
|
| 1107 | + | ||
| 1108 | + | /// How restored rows meet the rows already there. |
|
| 1109 | + | #[derive(Clone, Copy)] |
|
| 1110 | + | enum Verb { |
|
| 1111 | + | /// One row per repository: the restored one wins. |
|
| 1112 | + | Replace, |
|
| 1113 | + | /// A row with an id of its own, given a new one. |
|
| 1114 | + | New, |
|
| 1115 | + | /// Kept as it is when already there. |
|
| 1116 | + | Ignore, |
|
| 1117 | + | } |
|
| 1118 | + | ||
| 985 | 1119 | /// Every row about something: the tables with column `key` equal to `id`, |
|
| 986 | 1120 | /// then, three levels deep, the tables whose foreign keys point at rows |
|
| 987 | 1121 | /// already taken (an issue's comments, a proposal's reviews and their |
|
| … | 12 unchanged lines |
||
| 1000 | 1134 | if conds.iter().any(|(c, _)| c == t) || more.iter().any(|(c, _): &(String, String)| c == t) { |
|
| 1001 | 1135 | continue; |
|
| 1002 | 1136 | } |
|
| 1003 | - | let fks: Vec<(String, String, String)> = |
|
| 1004 | - | sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{t}')")).fetch_all(pool).await?; |
|
| 1137 | + | let fks = catalog::fks(pool, t).await?; |
|
| 1005 | 1138 | if let Some((parent, from, to, pcond)) = fks |
|
| 1006 | 1139 | .iter() |
|
| 1007 | 1140 | .find_map(|(p, f, to)| conds.iter().find(|(c, _)| c == p).map(|(_, pc)| (p.clone(), f.clone(), to.clone(), pc.clone()))) |
|
| … | 26 unchanged lines |
||
| 1034 | 1167 | const NOT_RESTORED: [&str; 5] = ["search", "notifications", "deliveries", "flow_declines", NAMES]; |
|
| 1035 | 1168 | ||
| 1036 | 1169 | async fn fk_list(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> { |
|
| 1037 | - | Ok(sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{table}')")).fetch_all(pool).await?) |
|
| 1170 | + | catalog::fks(pool, table).await |
|
| 1038 | 1171 | } |
|
| 1039 | 1172 | ||
| 1040 | 1173 | /// The names behind the account, team and organisation ids that `rec`'s |
|
| … | 54 unchanged lines |
||
| 1095 | 1228 | } |
|
| 1096 | 1229 | ||
| 1097 | 1230 | async fn table_cols(pool: &Pool, table: &str) -> Result<Vec<Col>> { |
|
| 1098 | - | let rows: Vec<(String, String, i64, Option<String>, i64)> = |
|
| 1099 | - | sqlx::query_as(&format!("SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('{table}')")) |
|
| 1100 | - | .fetch_all(pool) |
|
| 1101 | - | .await?; |
|
| 1102 | - | Ok(rows.into_iter().map(|(name, ty, notnull, default, pk)| Col { name, ty, notnull: notnull != 0, default, pk }).collect()) |
|
| 1231 | + | catalog::cols(pool, table).await |
|
| 1103 | 1232 | } |
|
| 1104 | 1233 | ||
| 1105 | - | /// Bind one value of a backed-up row: blobs were written as hex. |
|
| 1234 | + | /// Bind one value of a backed-up row to a column of type `ty`: blobs were |
|
| 1235 | + | /// written as hex, flags as 0 or 1 (a Postgres BOOLEAN takes them as such). |
|
| 1106 | 1236 | fn bind_json<'q>( |
|
| 1107 | - | q: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>, |
|
| 1237 | + | q: sqlx::query::Query<'q, crate::db::Db, <crate::db::Db as sqlx::Database>::Arguments<'q>>, |
|
| 1108 | 1238 | v: serde_json::Value, |
|
| 1109 | - | blob: bool, |
|
| 1110 | - | ) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> { |
|
| 1239 | + | ty: &str, |
|
| 1240 | + | ) -> sqlx::query::Query<'q, crate::db::Db, <crate::db::Db as sqlx::Database>::Arguments<'q>> { |
|
| 1241 | + | let blob = ty.eq_ignore_ascii_case("BLOB"); |
|
| 1242 | + | if ty == "BOOLEAN" { |
|
| 1243 | + | return match v { |
|
| 1244 | + | serde_json::Value::Null => q.bind(None::<bool>), |
|
| 1245 | + | serde_json::Value::Bool(b) => q.bind(b), |
|
| 1246 | + | serde_json::Value::Number(n) => q.bind(n.as_f64().unwrap_or(0.0) != 0.0), |
|
| 1247 | + | other => q.bind(matches!(other.as_str(), Some("1" | "true"))), |
|
| 1248 | + | }; |
|
| 1249 | + | } |
|
| 1111 | 1250 | match v { |
|
| 1112 | 1251 | serde_json::Value::Null => q.bind(None::<i64>), |
|
| 1113 | 1252 | serde_json::Value::Bool(b) => q.bind(b as i64), |
|
| … | 66 unchanged lines |
||
| 1180 | 1319 | let keys: Vec<&str> = cols.iter().filter(|c| c.pk > 0).map(|c| c.name.as_str()).collect(); |
|
| 1181 | 1320 | let by_id = keys == ["id"] && cols.iter().any(|c| c.name == "id" && c.ty.eq_ignore_ascii_case("INTEGER")); |
|
| 1182 | 1321 | let verb = if keys == ["repo_id"] { |
|
| 1183 | - | "INSERT OR REPLACE" |
|
| 1322 | + | Verb::Replace |
|
| 1184 | 1323 | } else if by_id { |
|
| 1185 | - | "INSERT" |
|
| 1324 | + | Verb::New |
|
| 1186 | 1325 | } else { |
|
| 1187 | - | "INSERT OR IGNORE" |
|
| 1326 | + | Verb::Ignore |
|
| 1188 | 1327 | }; |
|
| 1189 | 1328 | let mut rows: Vec<&serde_json::Map<String, serde_json::Value>> = |
|
| 1190 | 1329 | obj[table].as_array().into_iter().flatten().filter_map(|r| r.as_object()).collect(); |
|
| … | 18 unchanged lines |
||
| 1209 | 1348 | v = now.map(serde_json::Value::from); |
|
| 1210 | 1349 | } |
|
| 1211 | 1350 | } |
|
| 1212 | - | let blob = c.ty.to_ascii_uppercase().contains("BLOB"); |
|
| 1213 | 1351 | match v { |
|
| 1214 | 1352 | Some(v) => { |
|
| 1215 | 1353 | names.push(c.name.clone()); |
|
| 1216 | - | values.push((v, blob)); |
|
| 1354 | + | values.push((v, c.ty.as_str())); |
|
| 1217 | 1355 | } |
|
| 1218 | 1356 | // Never backed up (a secret): blank here, to enter again. |
|
| 1219 | 1357 | None if c.notnull && c.default.is_none() => { |
|
| 1220 | 1358 | names.push(c.name.clone()); |
|
| 1221 | - | let blank = if c.ty.to_ascii_uppercase().contains("INT") { serde_json::json!(0) } else { serde_json::json!("") }; |
|
| 1222 | - | values.push((blank, false)); |
|
| 1359 | + | let blank = if c.ty.to_ascii_uppercase().contains("INT") || c.ty == "BOOLEAN" { |
|
| 1360 | + | serde_json::json!(0) |
|
| 1361 | + | } else { |
|
| 1362 | + | serde_json::json!("") |
|
| 1363 | + | }; |
|
| 1364 | + | values.push((blank, c.ty.as_str())); |
|
| 1223 | 1365 | } |
|
| 1224 | 1366 | None => {} |
|
| 1225 | 1367 | } |
|
| 1226 | 1368 | } |
|
| 1369 | + | // A row with a new id says what it is (a plain INSERT, so one row). |
|
| 1370 | + | let (insert, tail) = catalog::insert(verb, &names); |
|
| 1227 | 1371 | let sql = format!( |
|
| 1228 | - | "{verb} INTO \"{table}\" ({}) VALUES ({})", |
|
| 1372 | + | "{insert} INTO \"{table}\" ({}) VALUES ({}){tail}{}", |
|
| 1229 | 1373 | names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", "), |
|
| 1230 | - | (1..=names.len()).map(|i| format!("${i}")).collect::<Vec<_>>().join(", ") |
|
| 1374 | + | (1..=names.len()).map(|i| format!("${i}")).collect::<Vec<_>>().join(", "), |
|
| 1375 | + | if by_id { " RETURNING id" } else { "" } |
|
| 1231 | 1376 | ); |
|
| 1232 | 1377 | let mut q = sqlx::query(&sql); |
|
| 1233 | - | for (v, blob) in values { |
|
| 1234 | - | q = bind_json(q, v, blob); |
|
| 1378 | + | for (v, ty) in values { |
|
| 1379 | + | q = bind_json(q, v, ty); |
|
| 1235 | 1380 | } |
|
| 1236 | - | let done = q.execute(&mut *tx).await.with_context(|| format!("restore a row of {table}"))?; |
|
| 1237 | - | if let (true, Some(was)) = (by_id, row.get("id").and_then(|v| v.as_i64())) { |
|
| 1238 | - | maps.entry(table.clone()).or_default().insert(was, done.last_insert_rowid()); |
|
| 1381 | + | let context = || format!("restore a row of {table}"); |
|
| 1382 | + | if by_id { |
|
| 1383 | + | let made: i64 = sqlx::Row::try_get(&q.fetch_one(&mut *tx).await.with_context(context)?, 0)?; |
|
| 1384 | + | if let Some(was) = row.get("id").and_then(|v| v.as_i64()) { |
|
| 1385 | + | maps.entry(table.clone()).or_default().insert(was, made); |
|
| 1386 | + | } |
|
| 1387 | + | } else { |
|
| 1388 | + | q.execute(&mut *tx).await.with_context(context)?; |
|
| 1239 | 1389 | } |
|
| 1240 | 1390 | } |
|
| 1241 | 1391 | } |
|
| … | 476 unchanged lines |
||
| 1718 | 1868 | ); |
|
| 1719 | 1869 | let mut q = sqlx::query(&sql); |
|
| 1720 | 1870 | for (c, v) in set { |
|
| 1721 | - | q = bind_json(q, v, c.ty.to_ascii_uppercase().contains("BLOB")); |
|
| 1871 | + | q = bind_json(q, v, &c.ty); |
|
| 1722 | 1872 | } |
|
| 1723 | 1873 | q.bind(made.id).execute(&app.db).await?; |
|
| 1724 | 1874 | } |
|
| … | 86 unchanged lines |
modified crates/hub/src/config.rs +20 −0
| … | 23 unchanged lines |
||
| 24 | 24 | pub diffusion: Diffusion, |
|
| 25 | 25 | pub backups: Backups, |
|
| 26 | 26 | pub outbound: Outbound, |
|
| 27 | + | pub database: Database, |
|
| 27 | 28 | } |
|
| 28 | 29 | ||
| 30 | + | /// Where a hub built with the `postgres` feature keeps its tables (#211); a |
|
| 31 | + | /// SQLite build keeps them in `<data>/hub.db` and ignores this. |
|
| 32 | + | #[derive(Clone, Debug, Default, Deserialize)] |
|
| 33 | + | #[serde(default, deny_unknown_fields)] |
|
| 34 | + | pub struct Database { |
|
| 35 | + | /// `postgres://user@host/db`; the password from the URL or `PGPASSWORD`. |
|
| 36 | + | pub url: String, |
|
| 37 | + | /// The schema to use, made if missing; empty for `public`, `*` for one |
|
| 38 | + | /// per data directory (named after it), as the tests use. |
|
| 39 | + | pub schema: String, |
|
| 40 | + | } |
|
| 41 | + | ||
| 29 | 42 | /// Outbound requests to addresses people give (oxygenhub#219): private |
|
| 30 | 43 | /// and local addresses are refused unless listed here, as `scheme://host` |
|
| 31 | 44 | /// (any port) or `scheme://host:port`, e.g. a local Ollama: |
|
| … | 276 unchanged lines |
||
| 308 | 321 | diffusion: Diffusion::default(), |
|
| 309 | 322 | backups: Backups::default(), |
|
| 310 | 323 | outbound: Outbound::default(), |
|
| 324 | + | database: Database::default(), |
|
| 311 | 325 | } |
|
| 312 | 326 | } |
|
| 313 | 327 | } |
|
| … | 18 unchanged lines |
||
| 332 | 346 | ||
| 333 | 347 | /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file. |
|
| 334 | 348 | pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) { |
|
| 349 | + | if let Some(v) = get("OXYHUB_DATABASE_URL") { |
|
| 350 | + | self.database.url = v; |
|
| 351 | + | } |
|
| 352 | + | if let Some(v) = get("OXYHUB_DATABASE_SCHEMA") { |
|
| 353 | + | self.database.schema = v; |
|
| 354 | + | } |
|
| 335 | 355 | if let Some(v) = get("OXYHUB_SITE_NAME") { |
|
| 336 | 356 | self.site.name = v; |
|
| 337 | 357 | } |
|
| … | 99 unchanged lines |
modified crates/hub/src/db/accounts.rs +2 −2
| … | 181 unchanged lines |
||
| 182 | 182 | pub async fn set_profile(pool: &Pool, id: i64, display_name: &str, email: &str) -> sqlx::Result<()> { |
|
| 183 | 183 | // A changed address is unverified until its own link is used. |
|
| 184 | 184 | sqlx::query( |
|
| 185 | - | "UPDATE accounts SET display_name = $1, email_verified = CASE WHEN email = $2 THEN email_verified ELSE 0 END, email = $3 WHERE id = $4", |
|
| 185 | + | "UPDATE accounts SET display_name = $1, email_verified = CASE WHEN email = $2 THEN email_verified ELSE FALSE END, email = $3 WHERE id = $4", |
|
| 186 | 186 | ) |
|
| 187 | 187 | .bind(display_name) |
|
| 188 | 188 | .bind(email) |
|
| … | 379 unchanged lines |
||
| 568 | 568 | pub async fn touch_token(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 569 | 569 | sqlx::query( |
|
| 570 | 570 | "UPDATE tokens SET last_used_ms = $1, \ |
|
| 571 | - | expires_ms = CASE WHEN renew_ms IS NULL THEN expires_ms ELSE MAX(COALESCE(expires_ms, 0), $1 + renew_ms) END \ |
|
| 571 | + | expires_ms = CASE WHEN renew_ms IS NULL THEN expires_ms WHEN COALESCE(expires_ms, 0) > $1 + renew_ms THEN expires_ms ELSE $1 + renew_ms END \ |
|
| 572 | 572 | WHERE id = $2", |
|
| 573 | 573 | ) |
|
| 574 | 574 | .bind(now_ms()) |
|
| … | 384 unchanged lines |
modified crates/hub/src/db/activity.rs +32 −15
| … | 39 unchanged lines |
||
| 40 | 40 | serde_json::to_string(ids).unwrap_or_else(|_| "[]".into()) |
|
| 41 | 41 | } |
|
| 42 | 42 | ||
| 43 | + | /// The SQL with each `SELECT value FROM json_each($n)` (the ids bound as a |
|
| 44 | + | /// JSON array) in this engine's words: Postgres has `json_array_elements_text`. |
|
| 45 | + | fn each(sql: &'static str) -> std::borrow::Cow<'static, str> { |
|
| 46 | + | if cfg!(feature = "postgres") { |
|
| 47 | + | let mut out = sql.to_string(); |
|
| 48 | + | for n in 1..=4 { |
|
| 49 | + | out = out.replace( |
|
| 50 | + | &format!("SELECT value FROM json_each(${n})"), |
|
| 51 | + | &format!("SELECT CAST(value AS BIGINT) FROM json_array_elements_text(CAST(${n} AS json)) AS t(value)"), |
|
| 52 | + | ); |
|
| 53 | + | } |
|
| 54 | + | out.into() |
|
| 55 | + | } else { |
|
| 56 | + | sql.into() |
|
| 57 | + | } |
|
| 58 | + | } |
|
| 59 | + | ||
| 43 | 60 | /// The newest `limit` events across the repositories in `repo_ids`. |
|
| 44 | 61 | pub async fn feed(pool: &Pool, repo_ids: &[i64], limit: i64) -> sqlx::Result<Vec<Activity>> { |
|
| 45 | 62 | feed_by(pool, repo_ids, None, limit).await |
|
| … | 2 unchanged lines |
||
| 48 | 65 | /// Like `feed`, only what `actor` did when one is given (profile pages). |
|
| 49 | 66 | pub async fn feed_by(pool: &Pool, repo_ids: &[i64], actor: Option<&str>, limit: i64) -> sqlx::Result<Vec<Activity>> { |
|
| 50 | 67 | sqlx::query_as( |
|
| 51 | - | "WITH seen(id) AS (SELECT value FROM json_each($1)), ev AS ( \ |
|
| 68 | + | &each("WITH seen(id) AS (SELECT value FROM json_each($1)), ev AS ( \ |
|
| 52 | 69 | SELECT repo_id, at_ms, pusher AS actor, 'push' AS kind, reference AS event, '' AS title, 0 AS number FROM pushes WHERE ok = TRUE \ |
|
| 53 | 70 | UNION ALL SELECT repo_id, created_ms, author, 'release', 'published', tag, 0 FROM releases \ |
|
| 54 | 71 | UNION ALL SELECT p.repo_id, e.at_ms, e.actor, 'proposal', e.event, p.title, p.number FROM proposal_events e JOIN proposals p ON p.id = e.proposal_id \ |
|
| … | 1 unchanged lines |
||
| 56 | 73 | SELECT r.owner, r.name, ev.at_ms, ev.actor, ev.kind, ev.event, ev.title, ev.number \ |
|
| 57 | 74 | FROM ev JOIN repositories r ON r.id = ev.repo_id WHERE ev.repo_id IN (SELECT id FROM seen) \ |
|
| 58 | 75 | AND ($2 IS NULL OR lower(ev.actor) = lower($3)) \ |
|
| 59 | - | ORDER BY ev.at_ms DESC LIMIT $4", |
|
| 76 | + | ORDER BY ev.at_ms DESC LIMIT $4"), |
|
| 60 | 77 | ) |
|
| 61 | 78 | .bind(ids_json(repo_ids)) |
|
| 62 | 79 | .bind(actor) |
|
| … | 19 unchanged lines |
||
| 82 | 99 | /// Open proposals in `repo_ids` by someone else that `account_id` has |
|
| 83 | 100 | /// not reviewed yet: the review queue. |
|
| 84 | 101 | pub async fn awaiting_review(pool: &Pool, account_id: i64, repo_ids: &[i64]) -> sqlx::Result<Vec<ProposalLine>> { |
|
| 85 | - | sqlx::query_as( |
|
| 102 | + | sqlx::query_as(&each( |
|
| 86 | 103 | "SELECT r.owner, r.name, p.number, p.title, p.author, p.state, p.conflicted, p.updated_ms \ |
|
| 87 | 104 | FROM proposals p JOIN repositories r ON r.id = p.repo_id \ |
|
| 88 | 105 | WHERE p.repo_id IN (SELECT value FROM json_each($1)) AND p.state = 'open' \ |
|
| 89 | 106 | AND (p.author_id IS NULL OR p.author_id != $2) \ |
|
| 90 | 107 | AND NOT EXISTS (SELECT 1 FROM proposal_reviews v WHERE v.proposal_id = p.id AND v.author_id = $3) \ |
|
| 91 | 108 | ORDER BY p.updated_ms DESC", |
|
| 92 | - | ) |
|
| 109 | + | )) |
|
| 93 | 110 | .bind(ids_json(repo_ids)) |
|
| 94 | 111 | .bind(account_id) |
|
| 95 | 112 | .bind(account_id) |
|
| … | 4 unchanged lines |
||
| 100 | 117 | /// `account_id`'s own open and draft proposals in repositories it can |
|
| 101 | 118 | /// still see. |
|
| 102 | 119 | pub async fn authored(pool: &Pool, account_id: i64, repo_ids: &[i64]) -> sqlx::Result<Vec<ProposalLine>> { |
|
| 103 | - | sqlx::query_as( |
|
| 120 | + | sqlx::query_as(&each( |
|
| 104 | 121 | "SELECT r.owner, r.name, p.number, p.title, p.author, p.state, p.conflicted, p.updated_ms \ |
|
| 105 | 122 | FROM proposals p JOIN repositories r ON r.id = p.repo_id \ |
|
| 106 | 123 | WHERE p.author_id = $1 AND p.state IN ('open', 'draft') AND p.repo_id IN (SELECT value FROM json_each($2)) \ |
|
| 107 | 124 | ORDER BY p.updated_ms DESC", |
|
| 108 | - | ) |
|
| 125 | + | )) |
|
| 109 | 126 | .bind(account_id) |
|
| 110 | 127 | .bind(ids_json(repo_ids)) |
|
| 111 | 128 | .fetch_all(pool) |
|
| … | 12 unchanged lines |
||
| 124 | 141 | ||
| 125 | 142 | /// Open issues assigned to `account_id`, in repositories it can see. |
|
| 126 | 143 | pub async fn assigned(pool: &Pool, account_id: i64, repo_ids: &[i64]) -> sqlx::Result<Vec<IssueLine>> { |
|
| 127 | - | sqlx::query_as( |
|
| 144 | + | sqlx::query_as(&each( |
|
| 128 | 145 | "SELECT r.owner, r.name, i.number, i.title, i.updated_ms \ |
|
| 129 | 146 | FROM issues i JOIN issue_assignees a ON a.issue_id = i.id JOIN repositories r ON r.id = i.repo_id \ |
|
| 130 | 147 | WHERE a.account_id = $1 AND i.state = 'open' AND i.repo_id IN (SELECT value FROM json_each($2)) \ |
|
| 131 | 148 | ORDER BY i.updated_ms DESC", |
|
| 132 | - | ) |
|
| 149 | + | )) |
|
| 133 | 150 | .bind(account_id) |
|
| 134 | 151 | .bind(ids_json(repo_ids)) |
|
| 135 | 152 | .fetch_all(pool) |
|
| … | 14 unchanged lines |
||
| 150 | 167 | /// The events since `since_ms` in `repo_ids`, newest first, at most `limit`. |
|
| 151 | 168 | pub async fn sky(pool: &Pool, repo_ids: &[i64], since_ms: i64, limit: i64) -> sqlx::Result<Vec<SkyEvent>> { |
|
| 152 | 169 | sqlx::query_as( |
|
| 153 | - | "WITH seen(id) AS (SELECT value FROM json_each($1)), ev AS ( \ |
|
| 154 | - | SELECT repo_id, at_ms, 0 AS kind, MAX(changes, 1) AS n FROM pushes WHERE ok = TRUE \ |
|
| 170 | + | &each("WITH seen(id) AS (SELECT value FROM json_each($1)), ev AS ( \ |
|
| 171 | + | SELECT repo_id, at_ms, CAST(0 AS BIGINT) AS kind, CASE WHEN changes > 1 THEN changes ELSE 1 END AS n FROM pushes WHERE ok = TRUE \ |
|
| 155 | 172 | UNION ALL SELECT p.repo_id, e.at_ms, 1, 1 FROM proposal_events e JOIN proposals p ON p.id = e.proposal_id \ |
|
| 156 | 173 | UNION ALL SELECT i.repo_id, e.at_ms, 2, 1 FROM issue_events e JOIN issues i ON i.id = e.issue_id \ |
|
| 157 | 174 | UNION ALL SELECT repo_id, created_ms, 3, 1 FROM releases \ |
|
| 158 | 175 | UNION ALL SELECT repo_id, created_ms, 4, 1 FROM flow_runs) \ |
|
| 159 | 176 | SELECT repo_id, at_ms, kind, n FROM ev WHERE repo_id IN (SELECT id FROM seen) AND at_ms >= $2 \ |
|
| 160 | - | ORDER BY at_ms DESC LIMIT $3", |
|
| 177 | + | ORDER BY at_ms DESC LIMIT $3"), |
|
| 161 | 178 | ) |
|
| 162 | 179 | .bind(ids_json(repo_ids)) |
|
| 163 | 180 | .bind(since_ms) |
|
| … | 8 unchanged lines |
||
| 172 | 189 | /// failed pushes and pushes that brought nothing. |
|
| 173 | 190 | pub async fn pulse(pool: &Pool, account_id: i64, repo_ids: &[i64], since_ms: i64) -> sqlx::Result<Vec<(i64, i64, i64, String)>> { |
|
| 174 | 191 | let ids = serde_json::to_string(repo_ids).unwrap_or_else(|_| "[]".into()); |
|
| 175 | - | sqlx::query_as( |
|
| 192 | + | sqlx::query_as(&each( |
|
| 176 | 193 | "SELECT repo_id, at_ms, changes, via FROM pushes \ |
|
| 177 | 194 | WHERE account_id = $1 AND ok = TRUE AND changes > 0 AND at_ms >= $2 AND reason NOT LIKE 'landed%' \ |
|
| 178 | 195 | AND repo_id IN (SELECT value FROM json_each($3)) ORDER BY at_ms", |
|
| 179 | - | ) |
|
| 196 | + | )) |
|
| 180 | 197 | .bind(account_id) |
|
| 181 | 198 | .bind(since_ms) |
|
| 182 | 199 | .bind(ids) |
|
| … | 5 unchanged lines |
||
| 188 | 205 | /// see: `(repo_id, tag, created_ms)`, to label the pulse's peaks. |
|
| 189 | 206 | pub async fn releases_by(pool: &Pool, account_id: i64, repo_ids: &[i64], since_ms: i64) -> sqlx::Result<Vec<(i64, String, i64)>> { |
|
| 190 | 207 | let ids = serde_json::to_string(repo_ids).unwrap_or_else(|_| "[]".into()); |
|
| 191 | - | sqlx::query_as( |
|
| 208 | + | sqlx::query_as(&each( |
|
| 192 | 209 | "SELECT repo_id, tag, created_ms FROM releases WHERE author_id = $1 AND created_ms >= $2 \ |
|
| 193 | 210 | AND repo_id IN (SELECT value FROM json_each($3)) ORDER BY created_ms", |
|
| 194 | - | ) |
|
| 211 | + | )) |
|
| 195 | 212 | .bind(account_id) |
|
| 196 | 213 | .bind(since_ms) |
|
| 197 | 214 | .bind(ids) |
|
| … | 3 unchanged lines |
modified crates/hub/src/db/flow.rs +7 −6
| … | 459 unchanged lines |
||
| 460 | 460 | /// jobs passed on (oxygenhub#173), for the jobs before the hub learned at |
|
| 461 | 461 | /// each pass. Never overrides a known architecture. |
|
| 462 | 462 | pub async fn learn_archs_from_runs(pool: &Pool) -> sqlx::Result<u64> { |
|
| 463 | - | let r = sqlx::query( |
|
| 463 | + | let rootfs = crate::db::json_text("j.sandbox", "rootfs"); |
|
| 464 | + | let r = sqlx::query(&format!( |
|
| 464 | 465 | "INSERT INTO flow_images (hash, arch, seen_ms) |
|
| 465 | - | SELECT lower(json_extract(j.sandbox, '$.rootfs')), MIN(r.arch), $1 FROM flow_jobs j JOIN flow_runners r ON r.id = j.runner_id |
|
| 466 | - | WHERE j.status = 'passed' AND r.arch != '' AND length(json_extract(j.sandbox, '$.rootfs')) = 64 |
|
| 467 | - | GROUP BY lower(json_extract(j.sandbox, '$.rootfs')) |
|
| 468 | - | ON CONFLICT (hash) DO NOTHING", |
|
| 469 | - | ) |
|
| 466 | + | SELECT lower({rootfs}), MIN(r.arch), $1 FROM flow_jobs j JOIN flow_runners r ON r.id = j.runner_id |
|
| 467 | + | WHERE j.status = 'passed' AND r.arch != '' AND length({rootfs}) = 64 |
|
| 468 | + | GROUP BY lower({rootfs}) |
|
| 469 | + | ON CONFLICT (hash) DO NOTHING" |
|
| 470 | + | )) |
|
| 470 | 471 | .bind(now_ms()) |
|
| 471 | 472 | .execute(pool) |
|
| 472 | 473 | .await?; |
|
| … | 673 unchanged lines |
modified crates/hub/src/db/mod.rs +139 −43
| 1 | 1 | //! SQLite through `sqlx` (hub spec §3.1, §17.2): WAL, foreign keys on, |
|
| 2 | - | //! migrations embedded and applied at start. One module per table group. |
|
| 2 | + | //! migrations embedded and applied at start; or PostgreSQL, built with the |
|
| 3 | + | //! `postgres` feature (#211). One module per table group. |
|
| 3 | 4 | ||
| 4 | 5 | pub mod accounts; |
|
| 5 | 6 | pub mod activity; |
|
| … | 8 unchanged lines |
||
| 14 | 15 | pub mod runners; |
|
| 15 | 16 | pub mod sessions; |
|
| 16 | 17 | ||
| 17 | - | use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; |
|
| 18 | - | use sqlx::SqlitePool; |
|
| 19 | - | use std::path::Path; |
|
| 18 | + | #[cfg(not(feature = "postgres"))] |
|
| 19 | + | pub use sqlite::{begin_write, open, Db}; |
|
| 20 | 20 | ||
| 21 | - | pub type Pool = SqlitePool; |
|
| 21 | + | #[cfg(feature = "postgres")] |
|
| 22 | + | pub use postgres::{begin_write, open, Db}; |
|
| 22 | 23 | ||
| 23 | - | /// Open (creating if needed) and migrate the database at `path`. |
|
| 24 | - | /// How long a statement waits for SQLite's write lock. Most writers are |
|
| 25 | - | /// background clients (runners appending logs, webhook deliveries), and a |
|
| 26 | - | /// commit can stall for seconds while a build saturates the host's disk, so |
|
| 27 | - | /// waiting beats failing. |
|
| 28 | - | const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); |
|
| 24 | + | pub type Pool = sqlx::Pool<Db>; |
|
| 29 | 25 | ||
| 30 | - | pub async fn open(path: &Path) -> anyhow::Result<Pool> { |
|
| 31 | - | let options = SqliteConnectOptions::new() |
|
| 32 | - | .filename(path) |
|
| 33 | - | .create_if_missing(true) |
|
| 34 | - | .journal_mode(SqliteJournalMode::Wal) |
|
| 35 | - | .synchronous(SqliteSynchronous::Normal) |
|
| 36 | - | .foreign_keys(true) |
|
| 37 | - | .busy_timeout(BUSY_TIMEOUT); |
|
| 38 | - | migrate(options.clone()).await?; |
|
| 39 | - | let pool = SqlitePoolOptions::new().max_connections(8).connect_with(options).await?; |
|
| 40 | - | Ok(pool) |
|
| 26 | + | #[cfg(not(feature = "postgres"))] |
|
| 27 | + | mod sqlite { |
|
| 28 | + | use super::Pool; |
|
| 29 | + | use crate::config::Config; |
|
| 30 | + | use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; |
|
| 31 | + | ||
| 32 | + | pub type Db = sqlx::Sqlite; |
|
| 33 | + | ||
| 34 | + | /// How long a statement waits for SQLite's write lock. Most writers are |
|
| 35 | + | /// background clients (runners appending logs, webhook deliveries), and a |
|
| 36 | + | /// commit can stall for seconds while a build saturates the host's disk, so |
|
| 37 | + | /// waiting beats failing. |
|
| 38 | + | const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); |
|
| 39 | + | ||
| 40 | + | /// Open (creating if needed) and migrate `<data>/hub.db`. |
|
| 41 | + | pub async fn open(config: &Config) -> anyhow::Result<Pool> { |
|
| 42 | + | let options = SqliteConnectOptions::new() |
|
| 43 | + | .filename(config.db_path()) |
|
| 44 | + | .create_if_missing(true) |
|
| 45 | + | .journal_mode(SqliteJournalMode::Wal) |
|
| 46 | + | .synchronous(SqliteSynchronous::Normal) |
|
| 47 | + | .foreign_keys(true) |
|
| 48 | + | .busy_timeout(BUSY_TIMEOUT); |
|
| 49 | + | migrate(options.clone()).await?; |
|
| 50 | + | let pool = SqlitePoolOptions::new().max_connections(8).connect_with(options).await?; |
|
| 51 | + | Ok(pool) |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | /// Apply the migrations on a connection of their own with foreign keys |
|
| 55 | + | /// off, as SQLite's way to change a table (rebuild it, drop the old one, |
|
| 56 | + | /// rename) needs: with them on, dropping a table its children reference |
|
| 57 | + | /// deletes the children too. Every reference must hold afterwards. |
|
| 58 | + | async fn migrate(options: SqliteConnectOptions) -> anyhow::Result<()> { |
|
| 59 | + | use sqlx::Connection; |
|
| 60 | + | let mut conn = sqlx::SqliteConnection::connect_with(&options.foreign_keys(false)).await?; |
|
| 61 | + | sqlx::migrate!("./migrations").run(&mut conn).await?; |
|
| 62 | + | let broken: Vec<(String,)> = sqlx::query_as("SELECT \"table\" FROM pragma_foreign_key_check").fetch_all(&mut conn).await?; |
|
| 63 | + | if let Some((table,)) = broken.first() { |
|
| 64 | + | anyhow::bail!("after the migrations, {} rows break a foreign key (the first in {table})", broken.len()); |
|
| 65 | + | } |
|
| 66 | + | conn.close().await?; |
|
| 67 | + | Ok(()) |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | /// A write transaction that takes SQLite's write lock at `BEGIN` |
|
| 71 | + | /// (`BEGIN IMMEDIATE`). A deferred transaction that reads and then writes |
|
| 72 | + | /// fails at once with "database is locked" when another writer holds the |
|
| 73 | + | /// lock, whatever the busy timeout; an immediate one waits its turn. |
|
| 74 | + | pub async fn begin_write(pool: &Pool) -> sqlx::Result<sqlx::Transaction<'static, Db>> { |
|
| 75 | + | pool.begin_with("BEGIN IMMEDIATE").await |
|
| 76 | + | } |
|
| 41 | 77 | } |
|
| 78 | + | ||
| 79 | + | /// PostgreSQL (#211), for a hub on more than one host or a managed database. |
|
| 80 | + | /// The same queries as SQLite's; the schema is `migrations-pg`, which starts |
|
| 81 | + | /// as SQLite's 0001-0072 in one and takes each later change written for it. |
|
| 82 | + | #[cfg(feature = "postgres")] |
|
| 83 | + | mod postgres { |
|
| 84 | + | use super::Pool; |
|
| 85 | + | use crate::config::Config; |
|
| 86 | + | use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; |
|
| 87 | + | use sqlx::Executor; |
|
| 42 | 88 | ||
| 43 | - | /// Apply the migrations on a connection of their own with foreign keys |
|
| 44 | - | /// off, as SQLite's way to change a table (rebuild it, drop the old one, |
|
| 45 | - | /// rename) needs: with them on, dropping a table its children reference |
|
| 46 | - | /// deletes the children too. Every reference must hold afterwards. |
|
| 47 | - | async fn migrate(options: SqliteConnectOptions) -> anyhow::Result<()> { |
|
| 48 | - | use sqlx::Connection; |
|
| 49 | - | let mut conn = sqlx::SqliteConnection::connect_with(&options.foreign_keys(false)).await?; |
|
| 50 | - | sqlx::migrate!("./migrations").run(&mut conn).await?; |
|
| 51 | - | let broken: Vec<(String,)> = sqlx::query_as("SELECT \"table\" FROM pragma_foreign_key_check").fetch_all(&mut conn).await?; |
|
| 52 | - | if let Some((table,)) = broken.first() { |
|
| 53 | - | anyhow::bail!("after the migrations, {} rows break a foreign key (the first in {table})", broken.len()); |
|
| 89 | + | pub type Db = sqlx::Postgres; |
|
| 90 | + | ||
| 91 | + | /// Connect to `[database] url`, use its schema, and migrate. |
|
| 92 | + | pub async fn open(config: &Config) -> anyhow::Result<Pool> { |
|
| 93 | + | if config.database.url.is_empty() { |
|
| 94 | + | anyhow::bail!("this hub is built for PostgreSQL: set [database] url (or OXYHUB_DATABASE_URL)"); |
|
| 95 | + | } |
|
| 96 | + | let options: PgConnectOptions = config.database.url.parse()?; |
|
| 97 | + | let schema = schema_name(config); |
|
| 98 | + | if let Some(schema) = &schema { |
|
| 99 | + | let pool = PgPoolOptions::new().max_connections(1).connect_with(options.clone()).await?; |
|
| 100 | + | pool.execute(format!("CREATE SCHEMA IF NOT EXISTS \"{schema}\"").as_str()).await?; |
|
| 101 | + | pool.close().await; |
|
| 102 | + | } |
|
| 103 | + | let path = match &schema { |
|
| 104 | + | // Plain (letters, digits, `_`), so it needs no quoting here. |
|
| 105 | + | Some(s) => format!("{s},public"), |
|
| 106 | + | None => "public".to_string(), |
|
| 107 | + | }; |
|
| 108 | + | let options = options.options([("search_path", path.as_str())]); |
|
| 109 | + | let pool = PgPoolOptions::new().max_connections(16).connect_with(options).await?; |
|
| 110 | + | extension(&pool).await?; |
|
| 111 | + | sqlx::migrate!("./migrations-pg").run(&pool).await?; |
|
| 112 | + | Ok(pool) |
|
| 54 | 113 | } |
|
| 55 | - | conn.close().await?; |
|
| 56 | - | Ok(()) |
|
| 114 | + | ||
| 115 | + | /// `citext`, for names compared without case, made once in `public` so |
|
| 116 | + | /// every schema sees it. Two hubs starting at once may race to make it. |
|
| 117 | + | async fn extension(pool: &Pool) -> anyhow::Result<()> { |
|
| 118 | + | let made = pool.execute("CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public").await; |
|
| 119 | + | if let Err(e) = made { |
|
| 120 | + | let there: bool = |
|
| 121 | + | sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'citext')").fetch_one(pool).await?; |
|
| 122 | + | if !there { |
|
| 123 | + | return Err(e.into()); |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | Ok(()) |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | /// The schema to use: none for `public`; `*` names it after the data |
|
| 130 | + | /// directory, so hubs (and tests) can share one database. |
|
| 131 | + | fn schema_name(config: &Config) -> Option<String> { |
|
| 132 | + | let s = match config.database.schema.as_str() { |
|
| 133 | + | "" | "public" => return None, |
|
| 134 | + | "*" => { |
|
| 135 | + | let dir = config.data.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); |
|
| 136 | + | format!("hub_{dir}") |
|
| 137 | + | } |
|
| 138 | + | s => s.to_string(), |
|
| 139 | + | }; |
|
| 140 | + | Some(s.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' }).collect()) |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | /// One writer at a time, as SQLite's `BEGIN IMMEDIATE` gives: callers |
|
| 144 | + | /// read and then write (the next number, a check before an insert) and |
|
| 145 | + | /// rely on nothing changing between. A transaction-scoped advisory lock. |
|
| 146 | + | pub async fn begin_write(pool: &Pool) -> sqlx::Result<sqlx::Transaction<'static, Db>> { |
|
| 147 | + | let mut tx = pool.begin().await?; |
|
| 148 | + | sqlx::query("SELECT pg_advisory_xact_lock(7300211)").execute(&mut *tx).await?; |
|
| 149 | + | Ok(tx) |
|
| 150 | + | } |
|
| 57 | 151 | } |
|
| 58 | 152 | ||
| 153 | + | /// SQL for the text at `key` of the JSON object in column `col` (null when |
|
| 154 | + | /// the column is empty or has no such key). |
|
| 155 | + | pub fn json_text(col: &str, key: &str) -> String { |
|
| 156 | + | if cfg!(feature = "postgres") { |
|
| 157 | + | format!("(CAST(NULLIF({col}, '') AS json) ->> '{key}')") |
|
| 158 | + | } else { |
|
| 159 | + | format!("json_extract({col}, '$.{key}')") |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | ||
| 59 | 163 | /// Milliseconds since the epoch, the hub's timestamp unit (as `oxy`'s). |
|
| 60 | 164 | pub fn now_ms() -> i64 { |
|
| 61 | 165 | std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) |
|
| 62 | - | } |
|
| 63 | - | ||
| 64 | - | /// A write transaction that takes SQLite's write lock at `BEGIN` |
|
| 65 | - | /// (`BEGIN IMMEDIATE`). A deferred transaction that reads and then writes |
|
| 66 | - | /// fails at once with "database is locked" when another writer holds the |
|
| 67 | - | /// lock, whatever the busy timeout; an immediate one waits its turn. |
|
| 68 | - | pub async fn begin_write(pool: &Pool) -> sqlx::Result<sqlx::Transaction<'static, sqlx::Sqlite>> { |
|
| 69 | - | pool.begin_with("BEGIN IMMEDIATE").await |
|
| 70 | 166 | } |
modified crates/hub/src/db/notify.rs +16 −0
| … | 340 unchanged lines |
||
| 341 | 341 | ||
| 342 | 342 | /// Full-text hits, best first. The query is quoted so punctuation cannot |
|
| 343 | 343 | /// reach the FTS5 parser. |
|
| 344 | + | #[cfg(not(feature = "postgres"))] |
|
| 344 | 345 | pub async fn search(pool: &Pool, query: &str, repo_id: Option<i64>, limit: i64) -> sqlx::Result<Vec<Hit>> { |
|
| 345 | 346 | let terms: Vec<String> = query.split_whitespace().map(|t| format!("\"{}\"", t.replace('"', ""))).collect(); |
|
| 346 | 347 | if terms.is_empty() { |
|
| … | 3 unchanged lines |
||
| 350 | 351 | let sql = "SELECT kind, repo_id, target_id, number, title, snippet(search, 5, '<mark>', '</mark>', '…', 12) AS snippet FROM search WHERE search MATCH $1 AND ($2 IS NULL OR repo_id = $3) ORDER BY rank LIMIT $4"; |
|
| 351 | 352 | sqlx::query_as(sql).bind(q).bind(repo_id).bind(repo_id).bind(limit).fetch_all(pool).await |
|
| 352 | 353 | } |
|
| 354 | + | ||
| 355 | + | /// Full-text hits, best first, as SQLite's: every word must appear |
|
| 356 | + | /// (stemmed, as FTS5's porter tokenizer does), titles weigh more, and the |
|
| 357 | + | /// snippet is from the body with the words marked. |
|
| 358 | + | #[cfg(feature = "postgres")] |
|
| 359 | + | pub async fn search(pool: &Pool, query: &str, repo_id: Option<i64>, limit: i64) -> sqlx::Result<Vec<Hit>> { |
|
| 360 | + | if query.split_whitespace().next().is_none() { |
|
| 361 | + | return Ok(vec![]); |
|
| 362 | + | } |
|
| 363 | + | let sql = "SELECT kind, repo_id, target_id, number, title, \ |
|
| 364 | + | ts_headline('english', body, q, 'StartSel=\"<mark>\", StopSel=\"</mark>\", MaxWords=12, MinWords=4') AS snippet \ |
|
| 365 | + | FROM search, plainto_tsquery('english', $1) q WHERE doc @@ q AND ($2 IS NULL OR repo_id = $3) \ |
|
| 366 | + | ORDER BY ts_rank(doc, q) DESC LIMIT $4"; |
|
| 367 | + | sqlx::query_as(sql).bind(query).bind(repo_id).bind(repo_id).bind(limit).fetch_all(pool).await |
|
| 368 | + | } |
modified crates/hub/src/db/repos.rs +1 −1
| … | 123 unchanged lines |
||
| 124 | 124 | let now = now_ms(); |
|
| 125 | 125 | sqlx::query_as( |
|
| 126 | 126 | "INSERT INTO repositories (owner, name, description, visibility, created_ms, updated_ms, required_approvals) \ |
|
| 127 | - | VALUES ($1, $2, $3, $4, $5, $6, (SELECT COUNT(*) >= 2 FROM org_members m JOIN organisations o ON o.id = m.org_id WHERE o.name = $7)) RETURNING *", |
|
| 127 | + | VALUES ($1, $2, $3, $4, $5, $6, (SELECT CASE WHEN COUNT(*) >= 2 THEN 1 ELSE 0 END FROM org_members m JOIN organisations o ON o.id = m.org_id WHERE o.name = $7)) RETURNING *", |
|
| 128 | 128 | ) |
|
| 129 | 129 | .bind(owner) |
|
| 130 | 130 | .bind(name) |
|
| … | 232 unchanged lines |
modified crates/hub/src/db/runners.rs +1 −1
| … | 427 unchanged lines |
||
| 428 | 428 | /// (jobs run, passed, busy milliseconds) of runner `id`. |
|
| 429 | 429 | pub async fn totals(pool: &Pool, id: i64) -> sqlx::Result<(i64, i64, i64)> { |
|
| 430 | 430 | sqlx::query_as( |
|
| 431 | - | "SELECT COUNT(*), COALESCE(SUM(status = 'passed'), 0), COALESCE(SUM(finished_ms - started_ms), 0) \ |
|
| 431 | + | "SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'passed' THEN 1 ELSE 0 END), 0), CAST(COALESCE(SUM(finished_ms - started_ms), 0) AS BIGINT) \ |
|
| 432 | 432 | FROM flow_jobs WHERE runner_id = $1 AND finished_ms IS NOT NULL", |
|
| 433 | 433 | ) |
|
| 434 | 434 | .bind(id) |
|
| … | 15 unchanged lines |
modified crates/hub/src/images.rs +6 −5
| … | 243 unchanged lines |
||
| 244 | 244 | ||
| 245 | 245 | /// What each repository's jobs ran on since `since_ms`, newest first. |
|
| 246 | 246 | pub async fn uses(pool: &Pool, since_ms: i64) -> sqlx::Result<Vec<Use>> { |
|
| 247 | - | sqlx::query_as( |
|
| 248 | - | "SELECT lower(json_extract(j.sandbox, '$.rootfs')) AS image, p.owner, p.name, COUNT(*) AS jobs, |
|
| 247 | + | let rootfs = crate::db::json_text("j.sandbox", "rootfs"); |
|
| 248 | + | sqlx::query_as(&format!( |
|
| 249 | + | "SELECT lower({rootfs}) AS image, p.owner, p.name, COUNT(*) AS jobs, |
|
| 249 | 250 | MAX(COALESCE(j.started_ms, r.created_ms)) AS last_ms |
|
| 250 | 251 | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id |
|
| 251 | - | WHERE r.created_ms >= $1 AND COALESCE(json_extract(j.sandbox, '$.rootfs'), '') != '' |
|
| 252 | - | GROUP BY image, p.id ORDER BY last_ms DESC", |
|
| 253 | - | ) |
|
| 252 | + | WHERE r.created_ms >= $1 AND COALESCE({rootfs}, '') != '' |
|
| 253 | + | GROUP BY lower({rootfs}), p.id, p.owner, p.name ORDER BY last_ms DESC" |
|
| 254 | + | )) |
|
| 254 | 255 | .bind(since_ms) |
|
| 255 | 256 | .fetch_all(pool) |
|
| 256 | 257 | .await |
|
| … | 6 unchanged lines |
modified crates/hub/src/junit.rs +5 −3
| … | 516 unchanged lines |
||
| 517 | 517 | } |
|
| 518 | 518 | let ids = reports.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(","); |
|
| 519 | 519 | sqlx::query_as(&format!( |
|
| 520 | - | "SELECT classname, name, SUM(status IN ('failed', 'error')) AS failed, SUM(status = 'passed') AS passed \ |
|
| 521 | - | FROM test_cases WHERE report_id IN ({ids}) GROUP BY classname, name \ |
|
| 522 | - | HAVING failed > 0 AND passed > 0 ORDER BY failed DESC, classname, name LIMIT $1" |
|
| 520 | + | "SELECT classname, name, failed, passed FROM (\ |
|
| 521 | + | SELECT classname, name, SUM(CASE WHEN status IN ('failed', 'error') THEN 1 ELSE 0 END) AS failed, \ |
|
| 522 | + | SUM(CASE WHEN status = 'passed' THEN 1 ELSE 0 END) AS passed \ |
|
| 523 | + | FROM test_cases WHERE report_id IN ({ids}) GROUP BY classname, name) t \ |
|
| 524 | + | WHERE failed > 0 AND passed > 0 ORDER BY failed DESC, classname, name LIMIT $1" |
|
| 523 | 525 | )) |
|
| 524 | 526 | .bind(limit) |
|
| 525 | 527 | .fetch_all(pool) |
|
| … | 56 unchanged lines |
modified crates/hub/src/lib.rs +1 −1
| … | 95 unchanged lines |
||
| 96 | 96 | /// have run once; `serve` and the tests call this. |
|
| 97 | 97 | pub async fn build(config: Config) -> anyhow::Result<Shared> { |
|
| 98 | 98 | std::fs::create_dir_all(config.repos_dir())?; |
|
| 99 | - | let db = db::open(&config.db_path()).await?; |
|
| 99 | + | let db = db::open(&config).await?; |
|
| 100 | 100 | let repos = Arc::new(Repos::new(config.repos_dir())); |
|
| 101 | 101 | let wikis = Arc::new(Repos::new(config.data.join("wikis"))); |
|
| 102 | 102 | let hub_key = keys::ensure_identity(&config.keys_dir(), &config.site.name, config.base_url())?; |
|
| … | 208 unchanged lines |
modified crates/hub/src/storage.rs +3 −6
| … | 310 unchanged lines |
||
| 311 | 311 | } |
|
| 312 | 312 | ||
| 313 | 313 | pub async fn policy(pool: &Pool, org_id: i64) -> sqlx::Result<Policy> { |
|
| 314 | - | let row: Option<(i64, String)> = sqlx::query_as("SELECT require_bucket, allowed FROM org_storage_policy WHERE org_id = $1") |
|
| 314 | + | let row: Option<(bool, String)> = sqlx::query_as("SELECT require_bucket, allowed FROM org_storage_policy WHERE org_id = $1") |
|
| 315 | 315 | .bind(org_id) |
|
| 316 | 316 | .fetch_optional(pool) |
|
| 317 | 317 | .await?; |
|
| 318 | 318 | Ok(row |
|
| 319 | - | .map(|(r, a)| Policy { |
|
| 320 | - | require_bucket: r != 0, |
|
| 321 | - | allowed: a.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect(), |
|
| 322 | - | }) |
|
| 319 | + | .map(|(r, a)| Policy { require_bucket: r, allowed: a.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect() }) |
|
| 323 | 320 | .unwrap_or_default()) |
|
| 324 | 321 | } |
|
| 325 | 322 | ||
| … | 13 unchanged lines |
||
| 339 | 336 | ON CONFLICT (org_id) DO UPDATE SET require_bucket = excluded.require_bucket, allowed = excluded.allowed", |
|
| 340 | 337 | ) |
|
| 341 | 338 | .bind(org_id) |
|
| 342 | - | .bind(require_bucket as i64) |
|
| 339 | + | .bind(require_bucket) |
|
| 343 | 340 | .bind(prefixes.join("\n")) |
|
| 344 | 341 | .execute(pool) |
|
| 345 | 342 | .await |
|
| … | 62 unchanged lines |
modified crates/hub/tests/accounts.rs +5 −5
| … | 1064 unchanged lines |
||
| 1065 | 1065 | // With registration closed, an unknown GitHub account is turned away. |
|
| 1066 | 1066 | db::accounts::set_setting(&app.db, "registration_mode", "closed").await.unwrap(); |
|
| 1067 | 1067 | sqlx::query("DELETE FROM account_logins WHERE provider = 'github'").execute(&app.db).await.unwrap(); |
|
| 1068 | - | sqlx::query("UPDATE accounts SET email_verified = 0 WHERE username = 'octocat'").execute(&app.db).await.unwrap(); |
|
| 1068 | + | sqlx::query("UPDATE accounts SET email_verified = FALSE WHERE username = 'octocat'").execute(&app.db).await.unwrap(); |
|
| 1069 | 1069 | let r = get(app, "/login/github", None).await; |
|
| 1070 | 1070 | let (cookie, state) = state_of(&r); |
|
| 1071 | 1071 | let no = send_with(app, "GET", &format!("/login/github/callback?code=c5&state={state}"), Some(&cookie), None).await; |
|
| … | 92 unchanged lines |
||
| 1164 | 1164 | register(app, "andy", "").await; |
|
| 1165 | 1165 | let andy = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
|
| 1166 | 1166 | // The account is over a year old, but the span follows its first push. |
|
| 1167 | - | sqlx::query("UPDATE accounts SET created_ms = ? WHERE id = ?") |
|
| 1167 | + | sqlx::query("UPDATE accounts SET created_ms = $1 WHERE id = $2") |
|
| 1168 | 1168 | .bind(db::now_ms() - 400 * 86_400_000) |
|
| 1169 | 1169 | .bind(andy.id) |
|
| 1170 | 1170 | .execute(&app.db) |
|
| … | 9 unchanged lines |
||
| 1180 | 1180 | (game.id, 2, 9, "landed #1 (web)", ""), |
|
| 1181 | 1181 | (secret.id, 1, 50, "", ""), |
|
| 1182 | 1182 | ] { |
|
| 1183 | - | sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, changes, account_id, pusher, via) VALUES (?, ?, 'main', 1, ?, ?, ?, 'andy', ?)") |
|
| 1183 | + | sqlx::query("INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, changes, account_id, pusher, via) VALUES ($1, $2, 'main', TRUE, $3, $4, $5, 'andy', $6)") |
|
| 1184 | 1184 | .bind(repo) |
|
| 1185 | 1185 | .bind(now - ago_days * 86_400_000) |
|
| 1186 | 1186 | .bind(reason) |
|
| … | 4 unchanged lines |
||
| 1191 | 1191 | .await |
|
| 1192 | 1192 | .unwrap(); |
|
| 1193 | 1193 | } |
|
| 1194 | - | sqlx::query("INSERT INTO releases (repo_id, tag, title, author_id, author, created_ms) VALUES (?, 'v1.0', 'One', ?, 'andy', ?)") |
|
| 1194 | + | sqlx::query("INSERT INTO releases (repo_id, tag, title, author_id, author, created_ms) VALUES ($1, 'v1.0', 'One', $2, 'andy', $3)") |
|
| 1195 | 1195 | .bind(game.id) |
|
| 1196 | 1196 | .bind(andy.id) |
|
| 1197 | 1197 | .bind(now - 3 * 86_400_000) |
|
| … | 263 unchanged lines |
||
| 1461 | 1461 | let octo = db::accounts::account_by_name(&app.db, "octocat").await.unwrap().expect("signed up through the invitation"); |
|
| 1462 | 1462 | assert!(!db::accounts::invitation_unused(&app.db, &code).await.unwrap(), "the invitation is spent"); |
|
| 1463 | 1463 | let used_by: Option<i64> = |
|
| 1464 | - | sqlx::query_scalar("SELECT used_by FROM invitations WHERE code = ?").bind(&code).fetch_one(&app.db).await.unwrap(); |
|
| 1464 | + | sqlx::query_scalar("SELECT used_by FROM invitations WHERE code = $1").bind(&code).fetch_one(&app.db).await.unwrap(); |
|
| 1465 | 1465 | assert_eq!(used_by, Some(octo.id)); |
|
| 1466 | 1466 | ||
| 1467 | 1467 | // Spent: no buttons, and starting with it is refused plainly. |
|
| … | 6 unchanged lines |
modified crates/hub/tests/api.rs +3 −3
| … | 695 unchanged lines |
||
| 696 | 696 | { |
|
| 697 | 697 | use oxygenhub::db::hooks::{queue_delivery, MAX_WAITING}; |
|
| 698 | 698 | let before: i64 = |
|
| 699 | - | sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = ?").bind(hook.id).fetch_one(&app.db).await.unwrap(); |
|
| 699 | + | sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = $1").bind(hook.id).fetch_one(&app.db).await.unwrap(); |
|
| 700 | 700 | for i in 0..MAX_WAITING { |
|
| 701 | 701 | sqlx::query( |
|
| 702 | - | "INSERT INTO deliveries (hook_id, delivery, event, payload, created_ms, next_ms) VALUES (?, ?, 'push', '{}', 0, 0)", |
|
| 702 | + | "INSERT INTO deliveries (hook_id, delivery, event, payload, created_ms, next_ms) VALUES ($1, $2, 'push', '{}', 0, 0)", |
|
| 703 | 703 | ) |
|
| 704 | 704 | .bind(hook.id) |
|
| 705 | 705 | .bind(format!("flood-{i}")) |
|
| … | 6 unchanged lines |
||
| 712 | 712 | assert!(queue_delivery(&app.db, hook.id, "now-room", "push", "{}").await.unwrap().is_some()); |
|
| 713 | 713 | sqlx::query("DELETE FROM deliveries WHERE delivery = 'now-room'").execute(&app.db).await.unwrap(); |
|
| 714 | 714 | let after: i64 = |
|
| 715 | - | sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = ?").bind(hook.id).fetch_one(&app.db).await.unwrap(); |
|
| 715 | + | sqlx::query_scalar("SELECT COUNT(*) FROM deliveries WHERE hook_id = $1").bind(hook.id).fetch_one(&app.db).await.unwrap(); |
|
| 716 | 716 | assert_eq!(before, after); |
|
| 717 | 717 | } |
|
| 718 | 718 | assert_eq!(hook.format, "discord"); |
|
| … | 780 unchanged lines |
modified crates/hub/tests/backups.rs +6 −6
| … | 163 unchanged lines |
||
| 164 | 164 | s.every = "hourly".into(); |
|
| 165 | 165 | backups::save(&h.app.db, &s).await.unwrap(); |
|
| 166 | 166 | assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "never run: due"); |
|
| 167 | - | sqlx::query("INSERT INTO backup_runs (owner, started_ms, status) VALUES ('andy', ?, 'ok')") |
|
| 167 | + | sqlx::query("INSERT INTO backup_runs (owner, started_ms, status) VALUES ('andy', $1, 'ok')") |
|
| 168 | 168 | .bind(db::now_ms()) |
|
| 169 | 169 | .execute(&h.app.db) |
|
| 170 | 170 | .await |
|
| … | 283 unchanged lines |
||
| 454 | 454 | let r2 = db::repos::get(&fresh.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 455 | 455 | assert_eq!(r2.diffusion, "green", "settings come back"); |
|
| 456 | 456 | // The issue, its comment, label, milestone and the assignee this hub has. |
|
| 457 | - | let issues: Vec<(i64, i64, String, Option<i64>)> = sqlx::query_as("SELECT id, number, title, author_id FROM issues WHERE repo_id = ?") |
|
| 457 | + | let issues: Vec<(i64, i64, String, Option<i64>)> = sqlx::query_as("SELECT id, number, title, author_id FROM issues WHERE repo_id = $1") |
|
| 458 | 458 | .bind(r2.id) |
|
| 459 | 459 | .fetch_all(&fresh.app.db) |
|
| 460 | 460 | .await |
|
| … | 5 unchanged lines |
||
| 466 | 466 | assert_eq!(comments.len(), 1); |
|
| 467 | 467 | assert_eq!((comments[0].author.as_str(), comments[0].body.as_str()), ("bob", "seen it too")); |
|
| 468 | 468 | let labels: Vec<String> = |
|
| 469 | - | sqlx::query_scalar("SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?") |
|
| 469 | + | sqlx::query_scalar("SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = $1") |
|
| 470 | 470 | .bind(iid) |
|
| 471 | 471 | .fetch_all(&fresh.app.db) |
|
| 472 | 472 | .await |
|
| 473 | 473 | .unwrap(); |
|
| 474 | 474 | assert_eq!(labels, vec!["bug"], "the label, its id mapped"); |
|
| 475 | 475 | let milestone: Option<String> = |
|
| 476 | - | sqlx::query_scalar("SELECT m.title FROM issues i JOIN milestones m ON m.id = i.milestone_id WHERE i.id = ?") |
|
| 476 | + | sqlx::query_scalar("SELECT m.title FROM issues i JOIN milestones m ON m.id = i.milestone_id WHERE i.id = $1") |
|
| 477 | 477 | .bind(iid) |
|
| 478 | 478 | .fetch_one(&fresh.app.db) |
|
| 479 | 479 | .await |
|
| … | 3 unchanged lines |
||
| 483 | 483 | // The proposal, its review and its timeline. |
|
| 484 | 484 | let p2 = db::proposals::get(&fresh.app.db, r2.id, p.number).await.unwrap().expect("the proposal"); |
|
| 485 | 485 | assert_eq!((p2.title.as_str(), p2.author.as_str(), p2.author_id), ("Double-buffer the sprites", "bob", None)); |
|
| 486 | - | let reviews: Vec<(String, String)> = sqlx::query_as("SELECT author, verdict FROM proposal_reviews WHERE proposal_id = ?") |
|
| 486 | + | let reviews: Vec<(String, String)> = sqlx::query_as("SELECT author, verdict FROM proposal_reviews WHERE proposal_id = $1") |
|
| 487 | 487 | .bind(p2.id) |
|
| 488 | 488 | .fetch_all(&fresh.app.db) |
|
| 489 | 489 | .await |
|
| … | 374 unchanged lines |
||
| 864 | 864 | tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
|
| 865 | 865 | let again = backups::run(&h.app, "andy", "now").await.unwrap(); |
|
| 866 | 866 | assert_eq!(again.status, "ok"); |
|
| 867 | - | let told = |db: sqlx::SqlitePool| async move { db::notify::inbox(&db, andy.id, 20).await.unwrap() }; |
|
| 867 | + | let told = |db: oxygenhub::db::Pool| async move { db::notify::inbox(&db, andy.id, 20).await.unwrap() }; |
|
| 868 | 868 | assert!(told(h.app.db.clone()).await.iter().any(|n| n.kind == "backup.recovered" && n.title.contains("works again"))); |
|
| 869 | 869 | assert_eq!(backups::daily_summary(&h.app, db::now_ms(), true).await.unwrap(), 1); |
|
| 870 | 870 | let summary = told(h.app.db.clone()).await.into_iter().find(|n| n.kind == "backup.summary").expect("a summary"); |
|
| … | 20 unchanged lines |
modified crates/hub/tests/flow.rs +29 −29
| … | 647 unchanged lines |
||
| 648 | 648 | let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 649 | 649 | for r in [&first[0], &second] { |
|
| 650 | 650 | for j in db::flow::jobs(&h.app.db, r.id).await.unwrap() { |
|
| 651 | - | sqlx::query("UPDATE flow_jobs SET status = 'passed' WHERE id = ?").bind(j.id).execute(&h.app.db).await.unwrap(); |
|
| 651 | + | sqlx::query("UPDATE flow_jobs SET status = 'passed' WHERE id = $1").bind(j.id).execute(&h.app.db).await.unwrap(); |
|
| 652 | 652 | } |
|
| 653 | - | sqlx::query("UPDATE flow_runs SET status = 'passed', finished_ms = 1 WHERE id = ?").bind(r.id).execute(&h.app.db).await.unwrap(); |
|
| 653 | + | sqlx::query("UPDATE flow_runs SET status = 'passed', finished_ms = 1 WHERE id = $1").bind(r.id).execute(&h.app.db).await.unwrap(); |
|
| 654 | 654 | } |
|
| 655 | 655 | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs/1/rerun", Some("site"), None).await; |
|
| 656 | 656 | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| … | 370 unchanged lines |
||
| 1027 | 1027 | assert_eq!(st, StatusCode::OK, "{text}"); |
|
| 1028 | 1028 | ||
| 1029 | 1029 | let (kind, title, summary, files_n, bytes): (String, String, String, i64, i64) = |
|
| 1030 | - | sqlx::query_as("SELECT kind, title, summary, files, bytes FROM reports WHERE job_id = ? AND name = 'frame-timing'") |
|
| 1030 | + | sqlx::query_as("SELECT kind, title, summary, files, bytes FROM reports WHERE job_id = $1 AND name = 'frame-timing'") |
|
| 1031 | 1031 | .bind(id) |
|
| 1032 | 1032 | .fetch_one(&h.app.db) |
|
| 1033 | 1033 | .await |
|
| 1034 | 1034 | .unwrap(); |
|
| 1035 | 1035 | assert_eq!((kind.as_str(), title.as_str(), summary.as_str()), ("performance", "Frame timing", "Median 16.4 ms")); |
|
| 1036 | 1036 | assert_eq!((files_n, bytes), (3, (data.len() + page.len() + toml.len()) as i64)); |
|
| 1037 | - | let rid: i64 = sqlx::query_scalar("SELECT id FROM reports WHERE job_id = ?").bind(id).fetch_one(&h.app.db).await.unwrap(); |
|
| 1037 | + | let rid: i64 = sqlx::query_scalar("SELECT id FROM reports WHERE job_id = $1").bind(id).fetch_one(&h.app.db).await.unwrap(); |
|
| 1038 | 1038 | let m = oxygenhub::reports::metrics(&h.app.db, rid).await.unwrap(); |
|
| 1039 | 1039 | assert_eq!((m[0].key.as_str(), m[0].value, m[0].fail_above), ("median_ms", 16.4, Some(20.0))); |
|
| 1040 | 1040 | let log = db::flow::log(&h.app.db, id, 0, 1000).await.unwrap(); |
|
| … | 38 unchanged lines |
||
| 1079 | 1079 | let first: i64 = sqlx::query_scalar("SELECT id FROM reports WHERE name = 'frame-timing'").fetch_one(&h.app.db).await.unwrap(); |
|
| 1080 | 1080 | let second: i64 = sqlx::query_scalar( |
|
| 1081 | 1081 | "INSERT INTO reports (repo_id, name, kind, title, summary, context, run_id, job_id, run_number, job, bookmark, state, manifest, files, bytes, created_ms) \ |
|
| 1082 | - | SELECT repo_id, name, kind, title, 'Median 18.0 ms', context, run_id, NULL, 2, job, bookmark, state, manifest, files, bytes, created_ms + 1000 FROM reports WHERE id = ? RETURNING id", |
|
| 1082 | + | SELECT repo_id, name, kind, title, 'Median 18.0 ms', context, run_id, NULL, 2, job, bookmark, state, manifest, files, bytes, created_ms + 1000 FROM reports WHERE id = $1 RETURNING id", |
|
| 1083 | 1083 | ) |
|
| 1084 | 1084 | .bind(first) |
|
| 1085 | 1085 | .fetch_one(&h.app.db) |
|
| 1086 | 1086 | .await |
|
| 1087 | 1087 | .unwrap(); |
|
| 1088 | 1088 | sqlx::query( |
|
| 1089 | - | "INSERT INTO report_metrics (report_id, key, value, unit, better, fail_above) VALUES (?, 'median_ms', 18.0, 'ms', 'lower', 20)", |
|
| 1089 | + | "INSERT INTO report_metrics (report_id, key, value, unit, better, fail_above) VALUES ($1, 'median_ms', 18.0, 'ms', 'lower', 20)", |
|
| 1090 | 1090 | ) |
|
| 1091 | 1091 | .bind(second) |
|
| 1092 | 1092 | .execute(&h.app.db) |
|
| … | 534 unchanged lines |
||
| 1627 | 1627 | assert_eq!(oxygenhub::flow::reap_lost_jobs(&h.app).await.unwrap(), 0); |
|
| 1628 | 1628 | // Started long ago and never reported: ended. |
|
| 1629 | 1629 | let old = db::now_ms() - 24 * 3600 * 1000; |
|
| 1630 | - | sqlx::query("UPDATE flow_jobs SET started_ms = ? WHERE id = ?").bind(old).bind(id).execute(&h.app.db).await.unwrap(); |
|
| 1630 | + | sqlx::query("UPDATE flow_jobs SET started_ms = $1 WHERE id = $2").bind(old).bind(id).execute(&h.app.db).await.unwrap(); |
|
| 1631 | 1631 | assert_eq!(oxygenhub::flow::reap_lost_jobs(&h.app).await.unwrap(), 1); |
|
| 1632 | 1632 | let j = db::flow::job(&h.app.db, id).await.unwrap().unwrap(); |
|
| 1633 | 1633 | assert_eq!(j.status, "error"); |
|
| … | 100 unchanged lines |
||
| 1734 | 1734 | let mut j: Vec<String> = db::flow::jobs(&app.db, runs[0].id).await.unwrap().into_iter().map(|j| j.name).collect(); |
|
| 1735 | 1735 | j.sort(); |
|
| 1736 | 1736 | // Passed, so the next push is scoped against it, not superseding it. |
|
| 1737 | - | sqlx::query("UPDATE flow_runs SET status = 'passed' WHERE id = ?").bind(runs[0].id).execute(&app.db).await.unwrap(); |
|
| 1737 | + | sqlx::query("UPDATE flow_runs SET status = 'passed' WHERE id = $1").bind(runs[0].id).execute(&app.db).await.unwrap(); |
|
| 1738 | 1738 | j |
|
| 1739 | 1739 | }; |
|
| 1740 | 1740 | assert_eq!(names(h.app.clone(), 1).await, ["build", "dist", "lint", "test"], "the first run has every job"); |
|
| … | 35 unchanged lines |
||
| 1776 | 1776 | panic!("no run {n}"); |
|
| 1777 | 1777 | }; |
|
| 1778 | 1778 | let first = wait_runs(h.app.clone(), 1).await; |
|
| 1779 | - | sqlx::query("UPDATE flow_runs SET status = 'passed' WHERE id = ?").bind(first[0].id).execute(&h.app.db).await.unwrap(); |
|
| 1780 | - | sqlx::query("UPDATE flow_jobs SET status = 'passed' WHERE run_id = ?").bind(first[0].id).execute(&h.app.db).await.unwrap(); |
|
| 1779 | + | sqlx::query("UPDATE flow_runs SET status = 'passed' WHERE id = $1").bind(first[0].id).execute(&h.app.db).await.unwrap(); |
|
| 1780 | + | sqlx::query("UPDATE flow_jobs SET status = 'passed' WHERE run_id = $1").bind(first[0].id).execute(&h.app.db).await.unwrap(); |
|
| 1781 | 1781 | ||
| 1782 | 1782 | // Run 2 (a version bump) has a job running when run 3 is pushed. |
|
| 1783 | 1783 | write(work.path(), "Cargo.toml", "version = \"0.1.1\"\n"); |
|
| … | 80 unchanged lines |
||
| 1864 | 1864 | assert_eq!(limits["build"], 1800, "the site's default"); |
|
| 1865 | 1865 | // 40 minutes in: past the default and its grace, within slow's limit. |
|
| 1866 | 1866 | let started = db::now_ms() - 40 * 60 * 1000; |
|
| 1867 | - | sqlx::query("UPDATE flow_jobs SET started_ms = ?").bind(started).execute(&h.app.db).await.unwrap(); |
|
| 1867 | + | sqlx::query("UPDATE flow_jobs SET started_ms = $1").bind(started).execute(&h.app.db).await.unwrap(); |
|
| 1868 | 1868 | oxygenhub::flow::reap_lost_jobs(&h.app).await.unwrap(); |
|
| 1869 | 1869 | let status = |id: i64| { |
|
| 1870 | 1870 | let app = h.app.clone(); |
|
| … | 976 unchanged lines |
||
| 2847 | 2847 | let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("other-secret"), Some(holder.clone())).await; |
|
| 2848 | 2848 | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 2849 | 2849 | // The hub holds the image too, so test-1 could fetch it. |
|
| 2850 | - | sqlx::query("INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES (?, 1, 'x86_64', 1, 'andy', 0)") |
|
| 2850 | + | sqlx::query("INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES ($1, 1, 'x86_64', 1, 'andy', 0)") |
|
| 2851 | 2851 | .bind(ROOTFS) |
|
| 2852 | 2852 | .execute(&h.app.db) |
|
| 2853 | 2853 | .await |
|
| … | 117 unchanged lines |
||
| 2971 | 2971 | async move { |
|
| 2972 | 2972 | let id: i64 = sqlx::query_scalar( |
|
| 2973 | 2973 | "INSERT INTO reports (repo_id, name, kind, title, summary, context, job, bookmark, state, files, bytes, created_ms) \ |
|
| 2974 | - | VALUES (?, 'perf', 'performance', 'Perf', '', '{}', 'perf', ?, 's', 1, 1, ?) RETURNING id", |
|
| 2974 | + | VALUES ($1, 'perf', 'performance', 'Perf', '', '{}', 'perf', $2, 's', 1, 1, $3) RETURNING id", |
|
| 2975 | 2975 | ) |
|
| 2976 | 2976 | .bind(repo.id) |
|
| 2977 | 2977 | .bind(bookmark) |
|
| … | 1 unchanged lines |
||
| 2979 | 2979 | .fetch_one(&db) |
|
| 2980 | 2980 | .await |
|
| 2981 | 2981 | .unwrap(); |
|
| 2982 | - | sqlx::query("INSERT INTO report_files (report_id, path, blob, size) VALUES (?, 'report.toml', ?, 1)") |
|
| 2982 | + | sqlx::query("INSERT INTO report_files (report_id, path, blob, size) VALUES ($1, 'report.toml', $2, 1)") |
|
| 2983 | 2983 | .bind(id) |
|
| 2984 | 2984 | .bind(format!("{:064x}", id)) |
|
| 2985 | 2985 | .execute(&db) |
|
| 2986 | 2986 | .await |
|
| 2987 | 2987 | .unwrap(); |
|
| 2988 | - | sqlx::query("INSERT INTO report_metrics (report_id, key, value, unit, better) VALUES (?, 'ms', 1, '', '')") |
|
| 2988 | + | sqlx::query("INSERT INTO report_metrics (report_id, key, value, unit, better) VALUES ($1, 'ms', 1, '', '')") |
|
| 2989 | 2989 | .bind(id) |
|
| 2990 | 2990 | .execute(&db) |
|
| 2991 | 2991 | .await |
|
| … | 11 unchanged lines |
||
| 3003 | 3003 | let files = |id: i64| { |
|
| 3004 | 3004 | let db = h.app.db.clone(); |
|
| 3005 | 3005 | async move { |
|
| 3006 | - | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM report_files WHERE report_id = ?").bind(id).fetch_one(&db).await.unwrap() |
|
| 3006 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM report_files WHERE report_id = $1").bind(id).fetch_one(&db).await.unwrap() |
|
| 3007 | 3007 | } |
|
| 3008 | 3008 | }; |
|
| 3009 | 3009 | assert_eq!((files(feature[0]).await, files(feature[1]).await, files(feature[2]).await), (0, 0, 1)); |
|
| … | 147 unchanged lines |
||
| 3157 | 3157 | let body = serde_json::json!({ "runner": "earth-1", "runtimes": ["wasm"], "images": [held], "swarm": { "addr": "10.0.0.1:7401", "site": "home", "cert": "11".repeat(32) } }); |
|
| 3158 | 3158 | let (st, out) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(body.to_string())).await; |
|
| 3159 | 3159 | assert!(st == StatusCode::NO_CONTENT || st == StatusCode::OK, "{st} {out}"); |
|
| 3160 | - | sqlx::query("INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES (?, 1048576, 'x86_64', 9, 'andy', 1)") |
|
| 3160 | + | sqlx::query("INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES ($1, 1048576, 'x86_64', 9, 'andy', 1)") |
|
| 3161 | 3161 | .bind(&kept) |
|
| 3162 | 3162 | .execute(&h.app.db) |
|
| 3163 | 3163 | .await |
|
| … | 361 unchanged lines |
||
| 3525 | 3525 | // both writes failed) is mended from the bookmark itself, and a pass |
|
| 3526 | 3526 | // with nothing to mend changes nothing. |
|
| 3527 | 3527 | let pins_of = || async { |
|
| 3528 | - | sqlx::query_scalar::<_, String>("SELECT heads FROM diffusion_pins WHERE consumer_id = ?") |
|
| 3528 | + | sqlx::query_scalar::<_, String>("SELECT heads FROM diffusion_pins WHERE consumer_id = $1") |
|
| 3529 | 3529 | .bind(game.id) |
|
| 3530 | 3530 | .fetch_one(&h.app.db) |
|
| 3531 | 3531 | .await |
|
| 3532 | 3532 | .unwrap() |
|
| 3533 | 3533 | }; |
|
| 3534 | 3534 | let right = pins_of().await; |
|
| 3535 | - | sqlx::query("UPDATE diffusion_pins SET heads = 'stale' WHERE consumer_id = ?").bind(game.id).execute(&h.app.db).await.unwrap(); |
|
| 3536 | - | sqlx::query("DELETE FROM diffusion_indexed WHERE consumer_id = ?").bind(game.id).execute(&h.app.db).await.unwrap(); |
|
| 3535 | + | sqlx::query("UPDATE diffusion_pins SET heads = 'stale' WHERE consumer_id = $1").bind(game.id).execute(&h.app.db).await.unwrap(); |
|
| 3536 | + | sqlx::query("DELETE FROM diffusion_indexed WHERE consumer_id = $1").bind(game.id).execute(&h.app.db).await.unwrap(); |
|
| 3537 | 3537 | let r = oxygenhub::diffusion::reconcile(&h.app).await; |
|
| 3538 | 3538 | assert!(r.reindexed >= 1, "{r:?}"); |
|
| 3539 | 3539 | assert_eq!(pins_of().await, right, "the pin read again from the game's bookmark"); |
|
| … | 49 unchanged lines |
||
| 3589 | 3589 | pass_all().await; |
|
| 3590 | 3590 | let g2_row = db::repos::get(&h.app.db, "andy", "game2").await.unwrap().unwrap(); |
|
| 3591 | 3591 | for _ in 0..100 { |
|
| 3592 | - | if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ?") |
|
| 3592 | + | if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = $1") |
|
| 3593 | 3593 | .bind(g2_row.id) |
|
| 3594 | 3594 | .fetch_one(&h.app.db) |
|
| 3595 | 3595 | .await |
|
| … | 19 unchanged lines |
||
| 3615 | 3615 | let caught = caught.expect("bumped as soon as it was turned on"); |
|
| 3616 | 3616 | assert!(caught.body.contains("api v5") && caught.title.contains("1 change"), "{:?} {:?}", caught.title, caught.body); |
|
| 3617 | 3617 | // A bump someone closed is a "no": the reconciler does not open it again. |
|
| 3618 | - | sqlx::query("UPDATE proposals SET state = 'closed' WHERE id = ?").bind(caught.id).execute(&h.app.db).await.unwrap(); |
|
| 3618 | + | sqlx::query("UPDATE proposals SET state = 'closed' WHERE id = $1").bind(caught.id).execute(&h.app.db).await.unwrap(); |
|
| 3619 | 3619 | // (The bump's own runs finishing may still propose again, as Diffusion |
|
| 3620 | 3620 | // does on an upstream's moves; the reconciler itself opens nothing.) |
|
| 3621 | 3621 | let r = oxygenhub::diffusion::reconcile(&h.app).await; |
|
| … | 34 unchanged lines |
||
| 3656 | 3656 | assert!(app.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 3657 | 3657 | let app_row = db::repos::get(&h.app.db, "andy", "app").await.unwrap().unwrap(); |
|
| 3658 | 3658 | for _ in 0..100 { |
|
| 3659 | - | if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ?") |
|
| 3659 | + | if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = $1") |
|
| 3660 | 3660 | .bind(app_row.id) |
|
| 3661 | 3661 | .fetch_one(&h.app.db) |
|
| 3662 | 3662 | .await |
|
| … | 238 unchanged lines |
||
| 3901 | 3901 | pass_all().await; |
|
| 3902 | 3902 | let game_row = db::repos::get(&h.app.db, "andy", "game").await.unwrap().unwrap(); |
|
| 3903 | 3903 | for _ in 0..100 { |
|
| 3904 | - | let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ?") |
|
| 3904 | + | let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = $1") |
|
| 3905 | 3905 | .bind(game_row.id) |
|
| 3906 | 3906 | .fetch_one(&h.app.db) |
|
| 3907 | 3907 | .await |
|
| … | 69 unchanged lines |
||
| 3977 | 3977 | pass_all().await; |
|
| 3978 | 3978 | let app_row = db::repos::get(&h.app.db, "andy", "app").await.unwrap().unwrap(); |
|
| 3979 | 3979 | for _ in 0..100 { |
|
| 3980 | - | let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ?") |
|
| 3980 | + | let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = $1") |
|
| 3981 | 3981 | .bind(app_row.id) |
|
| 3982 | 3982 | .fetch_one(&h.app.db) |
|
| 3983 | 3983 | .await |
|
| … | 11 unchanged lines |
||
| 3995 | 3995 | for _ in 0..100 { |
|
| 3996 | 3996 | pass_all().await; |
|
| 3997 | 3997 | let s: Option<String> = sqlx::query_scalar( |
|
| 3998 | - | "SELECT status FROM flow_runs WHERE repo_id = ? AND bookmark = 'diffusion/deps/lib' ORDER BY id DESC LIMIT 1", |
|
| 3998 | + | "SELECT status FROM flow_runs WHERE repo_id = $1 AND bookmark = 'diffusion/deps/lib' ORDER BY id DESC LIMIT 1", |
|
| 3999 | 3999 | ) |
|
| 4000 | 4000 | .bind(app_row.id) |
|
| 4001 | 4001 | .fetch_optional(&h.app.db) |
|
| … | 109 unchanged lines |
||
| 4111 | 4111 | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 4112 | 4112 | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4113 | 4113 | let run = runs_of(&h.app, "andy", "alerts").await[0].clone(); |
|
| 4114 | - | sqlx::query("UPDATE flow_runs SET actor = 'andy' WHERE id = ?").bind(run.id).execute(&h.app.db).await.unwrap(); |
|
| 4114 | + | sqlx::query("UPDATE flow_runs SET actor = 'andy' WHERE id = $1").bind(run.id).execute(&h.app.db).await.unwrap(); |
|
| 4115 | 4115 | while let Some(j) = claim(&h.app).await { |
|
| 4116 | 4116 | let id = j["job_id"].as_i64().unwrap(); |
|
| 4117 | 4117 | let (name, ph) = (j["job"]["name"].as_str().unwrap().to_string(), j["pipeline_hash"].as_str().unwrap().to_string()); |
|
| … | 232 unchanged lines |
||
| 4350 | 4350 | tokio::time::sleep(std::time::Duration::from_millis(30)).await; |
|
| 4351 | 4351 | } |
|
| 4352 | 4352 | let run = run.expect("the run"); |
|
| 4353 | - | sqlx::query("UPDATE flow_runs SET actor = 'andy' WHERE id = ?").bind(run.id).execute(&app.db).await.unwrap(); |
|
| 4353 | + | sqlx::query("UPDATE flow_runs SET actor = 'andy' WHERE id = $1").bind(run.id).execute(&app.db).await.unwrap(); |
|
| 4354 | 4354 | let j = claim(&app).await.expect("plan is claimed"); |
|
| 4355 | 4355 | assert_eq!(j["job"]["name"], "plan"); |
|
| 4356 | 4356 | let (id, ph) = (j["job_id"].as_i64().unwrap(), j["pipeline_hash"].as_str().unwrap().to_string()); |
|
| … | 11 unchanged lines |
||
| 4368 | 4368 | oxygenhub::events::Replay::Reload => vec!["reload".into()], |
|
| 4369 | 4369 | }; |
|
| 4370 | 4370 | assert!(heard(oxygenhub::events::topic::repo(row.id)).contains(&"push".to_string()), "the push is on the repository's topic"); |
|
| 4371 | - | sqlx::query("UPDATE flow_runs SET actor = 'andy' WHERE id = ?").bind(run.id).execute(&h.app.db).await.unwrap(); |
|
| 4371 | + | sqlx::query("UPDATE flow_runs SET actor = 'andy' WHERE id = $1").bind(run.id).execute(&h.app.db).await.unwrap(); |
|
| 4372 | 4372 | let early = oxygenhub::flow::decide(&h.app, &row, &run, "apply", &carol, true).await.unwrap_err(); |
|
| 4373 | 4373 | assert!(early.contains("waits for plan first"), "{early}"); |
|
| 4374 | 4374 | let run = plan_passes(run.number).await; |
|
| … | 203 unchanged lines |
modified crates/hub/tests/issues.rs +3 −3
| … | 171 unchanged lines |
||
| 172 | 172 | // oxygenhub#82: the sidebar's menu sets the whole list at once. |
|
| 173 | 173 | assert!(r.body.contains("Assign people to this issue") && r.body.contains("data-menu-filter"), "the assignees menu"); |
|
| 174 | 174 | let r = post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&action=set&username=andy&username=bob")).await; |
|
| 175 | - | let assigned = |db: sqlx::SqlitePool| async move { |
|
| 175 | + | let assigned = |db: oxygenhub::db::Pool| async move { |
|
| 176 | 176 | let id: i64 = sqlx::query_scalar("SELECT id FROM issues WHERE number = 1").fetch_one(&db).await.unwrap(); |
|
| 177 | 177 | let mut a = db::issues::assignees(&db, id).await.unwrap(); |
|
| 178 | 178 | a.sort(); |
|
| … | 107 unchanged lines |
||
| 286 | 286 | // Opening a thread marks it read and goes there. |
|
| 287 | 287 | let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id; |
|
| 288 | 288 | let newest: i64 = sqlx::query_scalar( |
|
| 289 | - | "SELECT id FROM notifications WHERE account_id = ? AND thread = '/andy/demo/proposal/3' ORDER BY id DESC LIMIT 1", |
|
| 289 | + | "SELECT id FROM notifications WHERE account_id = $1 AND thread = '/andy/demo/proposal/3' ORDER BY id DESC LIMIT 1", |
|
| 290 | 290 | ) |
|
| 291 | 291 | .bind(bob_id) |
|
| 292 | 292 | .fetch_one(&app.db) |
|
| … | 659 unchanged lines |
||
| 952 | 952 | register(app, "andy").await; |
|
| 953 | 953 | app.repos.create(&RepoName::parse("andy/trunky").unwrap(), None).unwrap(); |
|
| 954 | 954 | let row = db::repos::create(&app.db, "andy", "trunky", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 955 | - | sqlx::query("UPDATE repositories SET default_bookmark = 'trunk' WHERE id = ?").bind(row.id).execute(&app.db).await.unwrap(); |
|
| 955 | + | sqlx::query("UPDATE repositories SET default_bookmark = 'trunk' WHERE id = $1").bind(row.id).execute(&app.db).await.unwrap(); |
|
| 956 | 956 | db::issues::create( |
|
| 957 | 957 | &app.db, |
|
| 958 | 958 | db::issues::NewIssue { |
|
| … | 233 unchanged lines |