Files
verdande/internal/store/migrations/0001_init.sql
T

306 lines
12 KiB
SQL
Raw Normal View History

-- verdande — initial schema.
--
-- Conventions used throughout:
-- * ids are UUIDv7 stored as TEXT: time-sortable, so they index well and give a
-- stable creation order without a second column.
-- * timestamps are INTEGER unix seconds, UTC. The one exception is tasks.due_date,
-- which is a calendar date ('YYYY-MM-DD') and deliberately has no timezone —
-- "due Tuesday" means Tuesday wherever you happen to be standing.
-- * sort_order is REAL, not INTEGER, so drag-and-drop can insert between two
-- neighbours by averaging them instead of renumbering the whole list.
-- * soft delete is `deleted_at`; the 30-day trash sweeper is what makes it final.
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
avatar_color TEXT NOT NULL DEFAULT '#8a8f98',
timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen',
locale TEXT NOT NULL DEFAULT 'da',
is_admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Email identifies a person for login and invites, so it is unique case-insensitively.
-- Addresses are also stored already lowercased; this index is the backstop.
CREATE UNIQUE INDEX idx_users_email ON users (lower(email));
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
ua TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_sessions_user ON sessions (user_id);
CREATE INDEX idx_sessions_expires ON sessions (expires_at);
-- Invites carry a hashed token, never the token itself: the plaintext lives only in
-- the emailed link. A leaked database therefore cannot be used to accept invites.
CREATE TABLE invites (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'editor',
token_hash TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
accepted_at INTEGER,
CHECK (role IN ('owner', 'editor', 'viewer'))
);
CREATE INDEX idx_invites_email ON invites (lower(email));
-- Single-use tokens for "forgot my password". Same hashed-token rule as invites.
CREATE TABLE password_resets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER
);
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'graphite',
icon TEXT,
view_mode TEXT NOT NULL DEFAULT 'list',
owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
is_inbox INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
CHECK (view_mode IN ('list', 'board', 'calendar'))
);
CREATE INDEX idx_projects_owner ON projects (owner_id) WHERE deleted_at IS NULL;
-- Exactly one Inbox per user, enforced here rather than in application code.
CREATE UNIQUE INDEX idx_projects_one_inbox ON projects (owner_id) WHERE is_inbox = 1;
CREATE TABLE project_members (
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (project_id, user_id),
CHECK (role IN ('owner', 'editor', 'viewer'))
);
CREATE INDEX idx_project_members_user ON project_members (user_id);
CREATE TABLE sections (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE INDEX idx_sections_project ON sections (project_id) WHERE deleted_at IS NULL;
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
section_id TEXT REFERENCES sections (id) ON DELETE SET NULL,
parent_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
content TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 4,
-- due_date is the calendar day ('YYYY-MM-DD'); due_datetime pins it to a moment
-- when the task has a clock time. A timed task sets both, so day-based queries
-- never have to do timezone maths.
due_date TEXT,
due_datetime INTEGER,
due_timezone TEXT,
duration_min INTEGER,
recurrence_rule TEXT,
assignee_id TEXT REFERENCES users (id) ON DELETE SET NULL,
created_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
completed_at INTEGER,
completed_by TEXT REFERENCES users (id) ON DELETE SET NULL,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
-- A Danish transliteration of the searchable text, kept beside the original so
-- full-text search can match either spelling. See tasks_fts below for why.
fold TEXT GENERATED ALWAYS AS (
replace(replace(replace(replace(replace(replace(
content || ' ' || description,
'ø', 'o'), 'Ø', 'o'),
'æ', 'ae'), 'Æ', 'ae'),
'å', 'aa'), 'Å', 'aa')
) STORED,
CHECK (priority BETWEEN 1 AND 4)
);
-- The three access patterns that carry the app: a project's open tasks, the Today
-- and Upcoming views, and "what is assigned to me".
CREATE INDEX idx_tasks_project ON tasks (project_id, sort_order)
WHERE deleted_at IS NULL AND completed_at IS NULL;
CREATE INDEX idx_tasks_due ON tasks (due_date)
WHERE deleted_at IS NULL AND completed_at IS NULL AND due_date IS NOT NULL;
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id)
WHERE deleted_at IS NULL AND completed_at IS NULL AND assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
CREATE TABLE labels (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'graphite',
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX idx_labels_user_name ON labels (user_id, lower(name));
CREATE TABLE task_labels (
task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
label_id TEXT NOT NULL REFERENCES labels (id) ON DELETE CASCADE,
PRIMARY KEY (task_id, label_id)
);
CREATE INDEX idx_task_labels_label ON task_labels (label_id);
-- A reminder is either absolute (remind_at) or relative to the task's due time
-- (offset_min, negative = before). Exactly one of the two.
CREATE TABLE reminders (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
remind_at INTEGER,
offset_min INTEGER,
sent_at INTEGER,
created_at INTEGER NOT NULL,
CHECK ((remind_at IS NULL) <> (offset_min IS NULL))
);
CREATE INDEX idx_reminders_pending ON reminders (remind_at) WHERE sent_at IS NULL;
CREATE TABLE comments (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE INDEX idx_comments_task ON comments (task_id) WHERE deleted_at IS NULL;
-- An attachment hangs on a task or on a comment, never both and never neither.
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL,
path TEXT NOT NULL,
uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
CHECK ((task_id IS NULL) <> (comment_id IS NULL))
);
CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL;
CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL;
CREATE TABLE activity (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
task_id TEXT REFERENCES tasks (id) ON DELETE SET NULL,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
event TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL
);
CREATE INDEX idx_activity_project ON activity (project_id, created_at DESC);
CREATE INDEX idx_activity_task ON activity (task_id) WHERE task_id IS NOT NULL;
CREATE TABLE filters (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
query TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'graphite',
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_filters_user ON filters (user_id);
-- Personal API tokens for scripts and integrations. Hashed, like every other token.
CREATE TABLE api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
expires_at INTEGER
);
CREATE INDEX idx_api_tokens_user ON api_tokens (user_id);
-- Full-text search over tasks. External-content FTS5: the index stores no copy of
-- the text, it points back into `tasks` by rowid, and the triggers below keep the
-- two in step. `UNINDEXED` columns are carried for filtering without being tokenized.
--
-- Three indexed columns, not two. FTS5's `remove_diacritics` strips combining marks,
-- and in Unicode "ø" and "æ" are not marked letters — they are letters in their own
-- right. Diacritic folding alone therefore leaves a Danish user unable to find
-- "grøn" by typing "gron". The generated `fold` column on `tasks` carries a
-- transliterated copy, and searching all three columns covers every spelling:
--
-- "grøn" → content hit "gron" → fold hit
-- "århus" → content hit "aarhus" → fold hit
-- "arhus" → content hit, because remove_diacritics folds the indexed å to a
--
CREATE VIRTUAL TABLE tasks_fts USING fts5 (
content,
description,
fold,
id UNINDEXED,
project_id UNINDEXED,
content = 'tasks',
content_rowid = 'rowid',
tokenize = 'unicode61 remove_diacritics 2'
);
CREATE TRIGGER tasks_fts_insert AFTER INSERT ON tasks BEGIN
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
END;
CREATE TRIGGER tasks_fts_delete AFTER DELETE ON tasks BEGIN
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
END;
CREATE TRIGGER tasks_fts_update AFTER UPDATE ON tasks BEGIN
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
END;