store: add users table and user_id scoping to migrations

Schema-only step toward per-user profile isolation: profile/workout_kinds/
sync_state are rebuilt to drop their singleton constraints, activities/
sync_runs gain a nullable user_id column. Store methods are scoped in
later tasks.
This commit is contained in:
2026-07-25 10:20:59 +02:00
parent 033e412cb7
commit 245d4bf821
7 changed files with 201 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
oidc_sub TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -0,0 +1,15 @@
-- user_id is nullable here even though every row will eventually need one:
-- migrations can't take runtime parameters, so the actual owner isn't known
-- yet. A one-time Go bootstrap step (store.ClaimLegacyOwner, run at
-- geniusrund startup) backfills every existing row to one user once given
-- that user's OIDC subject; from then on every store method requires a
-- non-nil userID and this column is never NULL again in practice.
ALTER TABLE activities ADD COLUMN user_id INTEGER REFERENCES users(id);
ALTER TABLE sync_runs ADD COLUMN user_id INTEGER REFERENCES users(id);
-- Used by UpsertActivity's ON CONFLICT target going forward. The original
-- UNIQUE(garmin_activity_id) constraint (migration 0001) stays in place too
-- -- Garmin's own activity ids are already globally unique in practice, so
-- the stricter constraint is harmless, and SQLite can't drop a column-level
-- constraint without a full table rebuild, which isn't worth the risk here.
CREATE UNIQUE INDEX ux_activities_user_garmin_id ON activities(user_id, garmin_activity_id);

View File

@@ -0,0 +1,76 @@
-- profile.id's CHECK(id = 1) singleton constraint (migration 0003) can't be
-- dropped via ALTER TABLE -- SQLite has no DROP CONSTRAINT -- so this
-- rebuilds the table via SQLite's documented rename/recreate/copy/drop
-- pattern instead. Always create the replacement under a different name and
-- RENAME it into place at the end (never rename the live table away first)
-- -- verified directly against SQLite that this ordering is what keeps
-- other tables' foreign keys intact when they exist (not the case for
-- profile, but kept consistent with migrations 0024/0025 for the same
-- pattern). user_id is nullable for the same not-yet-known-owner reason as
-- migration 0022 -- see its comment.
CREATE TABLE profile_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
name TEXT NOT NULL DEFAULT 'Default',
garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '',
rolling_window_days INTEGER NOT NULL DEFAULT 90,
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095,
max_heart_rate REAL,
resting_heart_rate REAL,
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
hr_zone1_max_pct REAL NOT NULL DEFAULT 60,
hr_zone2_min_pct REAL NOT NULL DEFAULT 60,
hr_zone2_max_pct REAL NOT NULL DEFAULT 70,
hr_zone3_min_pct REAL NOT NULL DEFAULT 70,
hr_zone3_max_pct REAL NOT NULL DEFAULT 80,
hr_zone4_min_pct REAL NOT NULL DEFAULT 80,
hr_zone4_max_pct REAL NOT NULL DEFAULT 90,
hr_zone5_min_pct REAL NOT NULL DEFAULT 90,
hr_zone5_max_pct REAL NOT NULL DEFAULT 100,
warmup_minutes REAL NOT NULL DEFAULT 10,
cooldown_minutes REAL NOT NULL DEFAULT 5,
min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720,
min_representative_time_seconds REAL NOT NULL DEFAULT 3,
pace_color TEXT NOT NULL DEFAULT '#3b82f6',
heart_rate_color TEXT NOT NULL DEFAULT '#ef4444',
warmup_color TEXT NOT NULL DEFAULT '#c2410c',
effort_color TEXT NOT NULL DEFAULT '#7c3aed',
recovery_color TEXT NOT NULL DEFAULT '#15803d',
cooldown_color TEXT NOT NULL DEFAULT '#fb923c',
main_line_tint_pct REAL NOT NULL DEFAULT 20,
background_darken_pct REAL NOT NULL DEFAULT 35,
target_brighten_pct REAL NOT NULL DEFAULT 20,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id)
);
INSERT INTO profile_new (
id, user_id, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days,
max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct,
warmup_minutes, cooldown_minutes,
min_representative_pace_sec_per_km, min_representative_time_seconds,
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
main_line_tint_pct, background_darken_pct, target_brighten_pct,
created_at, updated_at
)
SELECT
id, NULL, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days,
max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct,
warmup_minutes, cooldown_minutes,
min_representative_pace_sec_per_km, min_representative_time_seconds,
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
main_line_tint_pct, background_darken_pct, target_brighten_pct,
created_at, updated_at
FROM profile;
DROP TABLE profile;
ALTER TABLE profile_new RENAME TO profile;

View File

@@ -0,0 +1,31 @@
-- workout_kinds.name's UNIQUE(name) constraint (migration 0001) must become
-- per-user (UNIQUE(user_id, name)) now that every user gets their own copy
-- of the same 8-kind taxonomy -- otherwise a second user could never be
-- provisioned (inserting the same seeded name would collide). kind_assignments
-- and workout_type_paces hold foreign keys into this table
-- (workout_kind_id REFERENCES workout_kinds(id)) -- the create-new-name/
-- copy/drop-old/rename-into-place order below (verified against a real
-- SQLite database) leaves those foreign keys' schema text untouched
-- throughout, so they resolve correctly again the instant the final
-- `ALTER TABLE workout_kinds_new RENAME TO workout_kinds` completes.
CREATE TABLE workout_kinds_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
rule_json TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, name)
);
INSERT INTO workout_kinds_new (id, user_id, name, description, color, rule_json, priority, is_active, created_at, updated_at)
SELECT id, NULL, name, description, color, rule_json, priority, is_active, created_at, updated_at
FROM workout_kinds;
DROP TABLE workout_kinds;
ALTER TABLE workout_kinds_new RENAME TO workout_kinds;

View File

@@ -0,0 +1,16 @@
-- Same CHECK(id = 1) rebuild reasoning as migration 0023's profile rebuild.
CREATE TABLE sync_state_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id)
);
INSERT INTO sync_state_new (id, user_id, earliest_synced_date, backfill_complete)
SELECT id, NULL, earliest_synced_date, backfill_complete
FROM sync_state;
DROP TABLE sync_state;
ALTER TABLE sync_state_new RENAME TO sync_state;