The per-calendar JSON blob is gone. v1 kept every calendar's colour, every visibility toggle and a custom palette inside one calendar_colors TEXT column, so hiding one calendar rewrote the whole document -- which is where "Fix calendar visibility preservation during event updates" came from. Those are rows now, and set_visible touches visibility alone. Foreign keys are switched on. v1 declared external_calendars.user_id as INTEGER against a TEXT users.id; SQLite enforces neither the type nor the constraint unless asked, so it was decorative and could never match. Session tokens are stored as SHA-256, never in the clear, so a copy of the database cannot be used to impersonate anyone. The CalDAV password is the one secret that cannot be hashed -- it has to be replayed to the server -- so it gets an encrypted column with the algorithm recorded alongside, and one way in and one way out instead of v1's eight localStorage reads. Preferences are one column each with CHECK constraints, so a bad view or a nonsense time increment is refused whatever route it arrives by. A NULL display timezone means "follow the browser", which is a state v1 could not express -- as with a NULL calendar colour meaning "defer to the server", which is why it hashed paths to invent one. Feed caching stores a content hash beside the ETag, because a published Outlook feed sends neither ETag nor Last-Modified and staleness has to be detectable anyway. Thirty tests against real in-memory SQLite, not mocks. One caught that create() returned nanosecond timestamps while the column stores microseconds, so a session never compared equal to itself read back.
146 lines
6.0 KiB
SQL
146 lines
6.0 KiB
SQL
-- Runway's schema.
|
|
--
|
|
-- Two things went wrong last time and both are structural rather than
|
|
-- incidental, so they are addressed here rather than in the code above.
|
|
--
|
|
-- First, per-calendar settings lived in a single `calendar_colors TEXT` column
|
|
-- holding JSON: colours, visibility and a custom palette all in one blob. There
|
|
-- was no way to change one calendar's colour without rewriting every user's
|
|
-- whole preference document, no way to query it, and no way for the database to
|
|
-- reject nonsense. Those are three tables now.
|
|
--
|
|
-- Second, `external_calendars.user_id` was declared INTEGER while `users.id` is
|
|
-- a TEXT uuid. SQLite does not enforce foreign key types, so the mismatch was
|
|
-- silent and the constraint could never match. Every key here is TEXT, and
|
|
-- foreign keys are enforced (see `PRAGMA foreign_keys` at connection time).
|
|
--
|
|
-- Timestamps are RFC 3339 strings in UTC. SQLite has no date type; picking one
|
|
-- representation and using it everywhere beats v1's mix of TEXT and DATETIME.
|
|
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY,
|
|
-- The CalDAV identity. A person is defined by the account they log into,
|
|
-- so the same username on two servers is two users.
|
|
username TEXT NOT NULL,
|
|
server_url TEXT NOT NULL,
|
|
display_name TEXT,
|
|
created_at TEXT NOT NULL,
|
|
last_login_at TEXT,
|
|
UNIQUE (username, server_url)
|
|
);
|
|
|
|
-- The CalDAV password, encrypted at rest.
|
|
--
|
|
-- v1 kept this in the browser's localStorage in cleartext and re-sent it on
|
|
-- every request in an `X-CalDAV-Password` header, read from eight different
|
|
-- call sites. The server has to be able to replay it to the CalDAV server, so
|
|
-- it cannot be hashed -- but it can be encrypted, and it can live in exactly
|
|
-- one place with one way in and one way out.
|
|
CREATE TABLE credentials (
|
|
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
|
-- Ciphertext and nonce as bytes, not base64: the encoding is the storage
|
|
-- layer's business and nothing else needs to know about it.
|
|
ciphertext BLOB NOT NULL,
|
|
nonce BLOB NOT NULL,
|
|
-- Named so the scheme can be changed later without guessing what old rows
|
|
-- were encrypted with.
|
|
algorithm TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
-- Sessions, keyed on a hash of the token rather than the token.
|
|
--
|
|
-- The cookie holds a random secret; this table holds only its SHA-256. A copy
|
|
-- of the database therefore cannot be used to impersonate anybody, which was
|
|
-- not true of v1's `sessions.token`.
|
|
CREATE TABLE sessions (
|
|
token_hash TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
last_seen_at TEXT NOT NULL,
|
|
-- Enough to show someone a list of their signed-in devices later.
|
|
user_agent TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_sessions_user ON sessions (user_id);
|
|
CREATE INDEX idx_sessions_expires ON sessions (expires_at);
|
|
|
|
-- Preferences that are single values, one column each.
|
|
CREATE TABLE preferences (
|
|
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
|
-- The two token axes: colour and shape/density.
|
|
theme TEXT NOT NULL DEFAULT 'default',
|
|
style TEXT NOT NULL DEFAULT 'default',
|
|
-- 'month' | 'week' | 'day' | 'agenda' | 'year'
|
|
view TEXT NOT NULL DEFAULT 'week',
|
|
-- Grid granularity in minutes.
|
|
time_increment INTEGER NOT NULL DEFAULT 30,
|
|
-- 0 = Sunday, matching chrono's Weekday::num_days_from_sunday.
|
|
week_starts_on INTEGER NOT NULL DEFAULT 0,
|
|
-- The IANA zone to render in. Never an offset: an offset cannot tell
|
|
-- January from July, which is what made v1's recurring events drift.
|
|
-- NULL means "use whatever the browser reports", which is the right
|
|
-- default for someone who moves.
|
|
display_timezone TEXT,
|
|
-- Where a new event goes by default.
|
|
default_calendar TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
|
|
CHECK (time_increment IN (5, 10, 15, 20, 30, 60)),
|
|
CHECK (week_starts_on BETWEEN 0 AND 6),
|
|
CHECK (view IN ('month', 'week', 'day', 'agenda', 'year'))
|
|
);
|
|
|
|
-- Per-calendar settings: one row per calendar, not one JSON blob per user.
|
|
--
|
|
-- `color` is nullable on purpose. NULL means "whatever the server says", which
|
|
-- is a different statement from any particular colour -- v1 could not express
|
|
-- it, so it hashed the calendar's path to invent one and disagreed with every
|
|
-- other client about what colour a calendar was.
|
|
CREATE TABLE calendar_settings (
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
calendar_href TEXT NOT NULL,
|
|
color TEXT,
|
|
visible INTEGER NOT NULL DEFAULT 1,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL,
|
|
|
|
PRIMARY KEY (user_id, calendar_href),
|
|
CHECK (visible IN (0, 1))
|
|
);
|
|
|
|
-- A subscribed read-only ICS feed.
|
|
CREATE TABLE feeds (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
color TEXT,
|
|
visible INTEGER NOT NULL DEFAULT 1,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
|
|
UNIQUE (user_id, url),
|
|
CHECK (visible IN (0, 1))
|
|
);
|
|
|
|
CREATE INDEX idx_feeds_user ON feeds (user_id);
|
|
|
|
-- What a feed returned last time, and what to say when asking again.
|
|
--
|
|
-- `etag` and `last_modified` are both nullable because a real published Outlook
|
|
-- calendar sends neither. `content_hash` is the fallback that makes staleness
|
|
-- detectable anyway: fetch, hash, compare, and skip the reparse when it matches.
|
|
CREATE TABLE feed_cache (
|
|
feed_id TEXT PRIMARY KEY REFERENCES feeds (id) ON DELETE CASCADE,
|
|
body TEXT NOT NULL,
|
|
content_hash TEXT NOT NULL,
|
|
etag TEXT,
|
|
last_modified TEXT,
|
|
fetched_at TEXT NOT NULL,
|
|
-- From Cache-Control or Expires, when the server offers one.
|
|
expires_at TEXT
|
|
);
|