- 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).
114 lines
5.3 KiB
SQL
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);
|