OXYGENHUB

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