Replace Postgres with SQLite

Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.

The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:

- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
  declared type is what makes the driver return time.Time, and the
  fixed-width UTC string is what makes ordering and comparison against
  datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
  edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
  formula out, guarded with max(0.0, ...) because cancellation returns a
  tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
  pragma is set on every connection.

Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
This commit is contained in:
Esa Kataja
2026-08-02 20:47:41 +03:00
parent a9776c6dde
commit 1fe5211ae6
28 changed files with 440 additions and 389 deletions
+48 -44
View File
@@ -1,52 +1,56 @@
-- Timestamps are declared `timestamp` and hold UTC 'YYYY-MM-DD HH:MM:SS': the declared type is what
-- makes the driver hand them back as time.Time, and a fixed-width UTC string is what makes
-- `order by created_at` and `expires_at > datetime('now')` mean what they say.
create table users (
id bigserial primary key,
name text not null,
email text not null unique,
password_hash text not null,
id integer primary key autoincrement,
name text not null,
email text not null unique,
password_hash text not null,
avatar text,
banned boolean not null default false,
created_at timestamptz not null default now()
banned integer not null default 0,
created_at timestamp not null default (datetime('now'))
);
create table sessions (
token text primary key,
user_id bigint not null references users (id) on delete cascade,
idle_ttl interval not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
token text primary key,
user_id integer not null references users (id) on delete cascade,
idle_ttl integer not null, -- seconds; SQLite has no interval type
expires_at timestamp not null,
created_at timestamp not null default (datetime('now'))
);
create index on sessions (user_id);
create index sessions_user on sessions (user_id);
create table invites (
id bigserial primary key,
code text not null unique,
is_valid boolean not null default true,
created_at timestamptz not null default now()
id integer primary key autoincrement,
code text not null unique,
is_valid integer not null default 1,
created_at timestamp not null default (datetime('now'))
);
create table songs (
id bigserial primary key,
title text not null,
artist text not null,
genre text not null,
id integer primary key autoincrement,
title text not null,
artist text not null,
genre text not null,
description text,
-- LRC or plain text, told apart by whether the first line starts with '['. Not covered by the
-- lock: nobody reviewed the lyrics.
lyrics text,
audio_file text not null,
duration_seconds integer not null,
audio_file text not null,
duration_seconds integer not null,
source_url text,
submitted_by bigint not null references users (id),
created_at timestamptz not null default now()
submitted_by integer not null references users (id),
created_at timestamp not null default (datetime('now'))
);
create index on songs (created_at desc);
create index songs_created_at on songs (created_at desc);
create table submissions (
id bigserial primary key,
user_id bigint not null references users (id) on delete cascade,
status text not null default 'queued',
id integer primary key autoincrement,
user_id integer not null references users (id) on delete cascade,
status text not null default 'queued',
status_msg text,
source_url text,
tmp_path text,
@@ -55,37 +59,37 @@ create table submissions (
genre text,
description text,
lyrics text,
created_at timestamptz not null default now(),
created_at timestamp not null default (datetime('now')),
constraint submissions_status check (
status in ('queued', 'downloading', 'converting', 'ready', 'failed')
)
);
-- The submission quota (5 per rolling 24h, failures excluded) reads this.
create index on submissions (user_id, created_at desc);
create index submissions_user_created on submissions (user_id, created_at desc);
create table reviews (
id bigserial primary key,
song_id bigint not null references songs (id) on delete cascade,
reviewer_id bigint not null references users (id),
score integer not null check (score between 1 and 100),
text text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
id integer primary key autoincrement,
song_id integer not null references songs (id) on delete cascade,
reviewer_id integer not null references users (id),
score integer not null check (score between 1 and 100),
text text not null,
created_at timestamp not null default (datetime('now')),
updated_at timestamp not null default (datetime('now')),
unique (song_id, reviewer_id)
);
create index on reviews (song_id);
create index reviews_song on reviews (song_id);
-- The queue asks "songs this member has not reviewed" — that lookup is by reviewer.
create index on reviews (reviewer_id, song_id);
create index reviews_reviewer_song on reviews (reviewer_id, song_id);
create table reports (
id bigserial primary key,
user_id bigint not null references users (id) on delete cascade,
body text not null,
id integer primary key autoincrement,
user_id integer not null references users (id) on delete cascade,
body text not null,
page text,
user_agent text,
resolved_at timestamptz,
created_at timestamptz not null default now()
resolved_at timestamp,
created_at timestamp not null default (datetime('now'))
);