-- 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);