Files
kvitt/contracts/schema/server-v1.sql
T
jacobh2 c6df2ab8f5 M1: contracts and vectors (SPEC v0.1, schema 1)
contracts/openapi.yaml (OpenAPI 3.1, E-1..E-33, error shape and the
exhaustive error code enum, device bearer scheme), contracts/schema/
server-v1.sql (SPEC §14.1 verbatim), seven test-vector files per §14.5.5,
80 examples (every JSON body of §14.3.4, every error code, sync pull/push,
every FCM push type), and tools/contracts-check.py (stdlib-only reference
of §9.1, §9.3, §9.4 and §14.5.4 plus shape checks; the M1 gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:57:59 +02:00

231 lines
12 KiB
SQL

-- Kvitt server schema v1. Do not edit; add a new migration and bump the schema version (SPEC §14.8).
CREATE TABLE meta (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
) STRICT;
-- One global, strictly increasing counter for server_version. Exactly one row.
CREATE TABLE sync_sequence (
id INTEGER PRIMARY KEY CHECK (id = 1),
value INTEGER NOT NULL
) STRICT;
CREATE TABLE users (
id TEXT PRIMARY KEY NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 40),
picture_version INTEGER CHECK (picture_version IS NULL OR picture_version >= 1),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
server_version INTEGER NOT NULL UNIQUE
) STRICT;
CREATE UNIQUE INDEX users_single_admin ON users (role) WHERE role = 'admin';
CREATE TABLE devices (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users (id),
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
token_hash TEXT NOT NULL UNIQUE,
fcm_token TEXT,
app_version_code INTEGER,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
revoked_at TEXT,
revoked_by_user_id TEXT REFERENCES users (id)
) STRICT;
CREATE INDEX devices_user ON devices (user_id);
CREATE TABLE invites (
id TEXT PRIMARY KEY NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('admin_setup', 'partner', 'device')),
code_hash TEXT NOT NULL UNIQUE,
target_user_id TEXT REFERENCES users (id),
created_by_user_id TEXT REFERENCES users (id),
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
accepted_at TEXT,
accepted_device_id TEXT REFERENCES devices (id),
cancelled_at TEXT,
CHECK ((kind = 'device') = (target_user_id IS NOT NULL)),
CHECK ((kind = 'admin_setup') = (created_by_user_id IS NULL) OR kind = 'device')
) STRICT;
CREATE TABLE accounts (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
name_key TEXT NOT NULL,
created_by_user_id TEXT NOT NULL REFERENCES users (id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT,
server_version INTEGER NOT NULL UNIQUE
) STRICT;
-- name_key = Unicode-lowercased, trimmed name, computed in Rust.
CREATE UNIQUE INDEX accounts_name_live ON accounts (name_key) WHERE deleted_at IS NULL;
CREATE TABLE account_default_shares (
account_id TEXT NOT NULL REFERENCES accounts (id),
user_id TEXT NOT NULL REFERENCES users (id),
share_bp INTEGER NOT NULL CHECK (share_bp BETWEEN 0 AND 10000),
PRIMARY KEY (account_id, user_id)
) STRICT, WITHOUT ROWID;
CREATE TABLE categories (
id TEXT PRIMARY KEY NOT NULL,
default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN
('groceries', 'restaurants', 'home', 'utilities', 'transport',
'travel', 'entertainment', 'health', 'shopping', 'other')),
name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40),
icon TEXT NOT NULL,
color TEXT NOT NULL,
sort_order INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT,
server_version INTEGER NOT NULL UNIQUE,
CHECK (name IS NOT NULL OR default_key IS NOT NULL)
) STRICT;
CREATE TABLE recurring_templates (
id TEXT PRIMARY KEY NOT NULL,
account_id TEXT NOT NULL REFERENCES accounts (id),
category_id TEXT NOT NULL REFERENCES categories (id),
amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000),
description TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100),
notes TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000),
payer_user_id TEXT NOT NULL REFERENCES users (id),
split_mode TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')),
day_of_month INTEGER NOT NULL CHECK (day_of_month BETWEEN 1 AND 31),
start_period TEXT NOT NULL,
end_period TEXT,
paused INTEGER NOT NULL DEFAULT 0 CHECK (paused IN (0, 1)),
last_generated_period TEXT,
created_by_user_id TEXT NOT NULL REFERENCES users (id),
updated_by_user_id TEXT NOT NULL REFERENCES users (id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
server_version INTEGER NOT NULL UNIQUE
) STRICT;
CREATE INDEX recurring_templates_account ON recurring_templates (account_id);
-- Modes ratio and full: share_bp set, share_ore NULL. Mode exact: share_ore set, share_bp NULL. Mode default: no rows.
CREATE TABLE recurring_template_splits (
template_id TEXT NOT NULL REFERENCES recurring_templates (id),
user_id TEXT NOT NULL REFERENCES users (id),
share_bp INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000),
share_ore INTEGER CHECK (share_ore IS NULL OR share_ore >= 0),
PRIMARY KEY (template_id, user_id),
CHECK ((share_bp IS NULL) <> (share_ore IS NULL))
) STRICT, WITHOUT ROWID;
CREATE TABLE expenses (
id TEXT PRIMARY KEY NOT NULL,
account_id TEXT NOT NULL REFERENCES accounts (id),
category_id TEXT NOT NULL REFERENCES categories (id),
amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000),
description TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100),
notes TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000),
expense_date TEXT NOT NULL,
payer_user_id TEXT NOT NULL REFERENCES users (id),
split_mode TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')),
recurring_template_id TEXT REFERENCES recurring_templates (id),
recurring_period TEXT,
import_key TEXT UNIQUE,
created_by_user_id TEXT NOT NULL REFERENCES users (id),
updated_by_user_id TEXT NOT NULL REFERENCES users (id),
updated_by_device_id TEXT REFERENCES devices (id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
server_received_at TEXT NOT NULL,
server_version INTEGER NOT NULL UNIQUE,
CHECK ((recurring_template_id IS NULL) = (recurring_period IS NULL))
) STRICT;
CREATE UNIQUE INDEX expenses_recurring_once ON expenses (recurring_template_id, recurring_period)
WHERE recurring_template_id IS NOT NULL;
CREATE INDEX expenses_account ON expenses (account_id);
CREATE INDEX expenses_category ON expenses (category_id);
-- Exactly two rows per expense, one per household user. share_bp NULL for mode exact.
CREATE TABLE expense_splits (
expense_id TEXT NOT NULL REFERENCES expenses (id),
user_id TEXT NOT NULL REFERENCES users (id),
share_ore INTEGER NOT NULL CHECK (share_ore >= 0),
share_bp INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000),
PRIMARY KEY (expense_id, user_id)
) STRICT, WITHOUT ROWID;
CREATE TABLE settlements (
id TEXT PRIMARY KEY NOT NULL,
from_user_id TEXT NOT NULL REFERENCES users (id),
to_user_id TEXT NOT NULL REFERENCES users (id),
amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000),
settlement_date TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 200),
import_key TEXT UNIQUE,
created_by_user_id TEXT NOT NULL REFERENCES users (id),
updated_by_user_id TEXT NOT NULL REFERENCES users (id),
updated_by_device_id TEXT REFERENCES devices (id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
server_received_at TEXT NOT NULL,
server_version INTEGER NOT NULL UNIQUE,
CHECK (from_user_id <> to_user_id)
) STRICT;
CREATE TABLE settlement_allocations (
settlement_id TEXT NOT NULL REFERENCES settlements (id),
account_id TEXT NOT NULL REFERENCES accounts (id),
amount_ore INTEGER NOT NULL CHECK (amount_ore >= 1),
PRIMARY KEY (settlement_id, account_id)
) STRICT, WITHOUT ROWID;
CREATE INDEX settlement_allocations_account ON settlement_allocations (account_id);
-- Append-only. changes is a JSON array of {"field","old","new"} (SPEC §14.4.7).
CREATE TABLE audit_log (
id TEXT PRIMARY KEY NOT NULL,
entity_type TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')),
entity_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')),
source TEXT NOT NULL CHECK (source IN ('app', 'recurring', 'import')),
actor_user_id TEXT NOT NULL REFERENCES users (id),
actor_device_id TEXT REFERENCES devices (id),
client_at TEXT NOT NULL,
server_at TEXT NOT NULL,
changes TEXT NOT NULL,
server_version INTEGER NOT NULL UNIQUE
) STRICT;
CREATE INDEX audit_log_entity ON audit_log (entity_type, entity_id);
-- Idempotency of POST /sync. Rows older than 90 days are pruned daily.
CREATE TABLE sync_mutations (
mutation_id TEXT PRIMARY KEY NOT NULL,
device_id TEXT NOT NULL REFERENCES devices (id),
entity_type TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')),
entity_id TEXT NOT NULL,
result TEXT NOT NULL,
received_at TEXT NOT NULL
) STRICT;
CREATE INDEX sync_mutations_received ON sync_mutations (received_at);
INSERT INTO sync_sequence (id, value) VALUES (1, 10);
INSERT INTO meta (key, value) VALUES ('schema_version', '1');
INSERT INTO meta (key, value) VALUES ('sync_epoch', lower(hex(randomblob(16))));
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) VALUES
('01920000-0000-7000-8000-000000000001', 'groceries', NULL, 'shopping_cart', 'green', 10, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 1),
('01920000-0000-7000-8000-000000000002', 'restaurants', NULL, 'restaurant', 'orange', 20, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 2),
('01920000-0000-7000-8000-000000000003', 'home', NULL, 'home', 'brown', 30, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 3),
('01920000-0000-7000-8000-000000000004', 'utilities', NULL, 'bolt', 'yellow', 40, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 4),
('01920000-0000-7000-8000-000000000005', 'transport', NULL, 'directions_car', 'blue', 50, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 5),
('01920000-0000-7000-8000-000000000006', 'travel', NULL, 'flight', 'teal', 60, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 6),
('01920000-0000-7000-8000-000000000007', 'entertainment', NULL, 'movie', 'purple', 70, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 7),
('01920000-0000-7000-8000-000000000008', 'health', NULL, 'medical_services', 'red', 80, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 8),
('01920000-0000-7000-8000-000000000009', 'shopping', NULL, 'shopping_bag', 'pink', 90, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 9),
('01920000-0000-7000-8000-00000000000a', 'other', NULL, 'category', 'grey', 100, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 10);