mitbringsl/backend/migrations/000001_init_schema.up.sql
Tronax 2899eb205b
Backend Phase A: foundation, migrations, Docker setup
- Go backend skeleton: config (caarlos0/env), slog JSON logging,
  pgxpool store, HTTP server with graceful shutdown.
- httpapi: render helpers, Problem errors, middleware chain
  (requestID / logging / recover / CORS), /healthz and /readyz.
- Migrations: full initial schema (users, sessions, lists,
  list_members, items, op_log SOURCE OF TRUTH, item_names) +
  golang-migrate runner binary using source/iofs (embedded).
- Docker: multi-stage Dockerfile (Go 1.26 -> distroless nonroot),
  builds both server and migrate binaries.
- deploy: docker-compose (caddy + backend + migrate + postgres:16),
  Caddyfile (auto-HTTPS), .env.example, pg extensions init script.
- AGENTS.md: project context + roadmap for AI agents.

Verified: image builds, both binaries run in container (smoke test).
2026-08-05 15:14:37 +02:00

114 lines
5.3 KiB
SQL

-- Initial schema for mitbringsl.
-- Design notes:
-- * op_log is the append-only SOURCE OF TRUTH for sync.
-- * users/lists/items are projections of op_log (kept up to date as ops arrive).
-- * Tombstones (deleted_at) prevent resurrection of removed items by late offline ops.
-- * (client_id, client_seq) unique key on op_log makes pushes idempotent.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ---------------------------------------------------------------------------
-- users
-- ---------------------------------------------------------------------------
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT, -- NULL for OIDC-only accounts
oidc_subject TEXT, -- IdP subject id
oidc_issuer TEXT, -- which IdP the subject belongs to
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (oidc_issuer, oidc_subject)
);
-- ---------------------------------------------------------------------------
-- sessions (opaque tokens issued by this backend after login)
-- ---------------------------------------------------------------------------
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, -- store SHA-256 hash, never the raw token
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
user_agent TEXT,
ip TEXT
);
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
-- ---------------------------------------------------------------------------
-- lists (projection)
-- ---------------------------------------------------------------------------
CREATE TABLE lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ, -- tombstone
hlc_ts BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX lists_owner_idx ON lists(owner_id) WHERE deleted_at IS NULL;
-- list_members (prepared for shared lists; MVP uses owner only)
CREATE TABLE list_members (
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member', -- 'owner' | 'member'
PRIMARY KEY (list_id, user_id)
);
-- ---------------------------------------------------------------------------
-- items (projection)
-- ---------------------------------------------------------------------------
CREATE TABLE items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT,
checked BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ, -- tombstone
hlc_ts BIGINT NOT NULL DEFAULT 0,
client_id UUID,
checked_at TIMESTAMPTZ
);
CREATE INDEX items_list_idx ON items(list_id) WHERE deleted_at IS NULL;
CREATE INDEX items_checked_idx ON items(list_id, checked) WHERE deleted_at IS NULL;
-- ---------------------------------------------------------------------------
-- op_log (SOURCE OF TRUTH)
-- ---------------------------------------------------------------------------
CREATE TABLE op_log (
seq BIGSERIAL PRIMARY KEY, -- monotonic server cursor
list_id UUID NOT NULL, -- list the op belongs to (FK added after lists exist; keep loose for tombstone cleanup)
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id UUID NOT NULL,
op_type TEXT NOT NULL, -- list_create|list_rename|list_delete
-- item_add|item_update|item_remove
target_id UUID NOT NULL, -- affected list/item id
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
client_seq BIGINT NOT NULL, -- idempotency key part
hlc_ts BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (client_id, client_seq)
);
CREATE INDEX op_log_list_seq_idx ON op_log(list_id, seq);
CREATE INDEX op_log_target_idx ON op_log(target_id);
-- ---------------------------------------------------------------------------
-- item_names (autocomplete aggregation, all users)
-- ---------------------------------------------------------------------------
CREATE TABLE item_names (
name TEXT PRIMARY KEY, -- lowercased
usage_count INTEGER NOT NULL DEFAULT 0,
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX item_names_trgm_idx ON item_names USING gin (name gin_trgm_ops);
CREATE INDEX item_names_count_idx ON item_names(usage_count DESC);