const MIGRATION_024: &str = r#"
CREATE TABLE IF NOT EXISTS user_pins (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
pin_hash TEXT NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_item_visibility (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id TEXT NOT NULL,
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, item_id)
);
CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
CREATE TABLE IF NOT EXISTS user_libraries (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id TEXT NOT NULL,
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, library_id)
);
CREATE TABLE IF NOT EXISTS download_grants (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, download_id)
);
CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
-- Backfill: the active user has seen everything already cached on this device.
INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
SELECT u.id, i.id
FROM users u CROSS JOIN items i
WHERE u.is_active = 1
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
INSERT OR IGNORE INTO user_libraries (user_id, library_id)
SELECT u.id, l.id
FROM users u CROSS JOIN libraries l
WHERE u.is_active = 1
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
-- Downloads already record who asked for them, so every existing row becomes
-- exactly one grant held by its original requester.
INSERT OR IGNORE INTO download_grants (user_id, download_id)
SELECT d.user_id, d.id FROM downloads d;
"#;Expand description
Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
Three tables, one purpose each:
user_pinsholds the switching gate. The PIN hash lives here rather than wrapping the access token, because a wrapped token would leave a locked profile unable to resume its own downloads or drain its own sync queue until someone typed the code. See DR-268 for why that trade was taken.user_item_visibilityrecords what the server has actually shown to each user. It is written as a byproduct of the cache write path, never rebuilt, so it cannot disagree with what the server returned.download_grantsseparates the bytes from the claim on them, so one file can serve several profiles and is unlinked only when the last claim goes.
The backfill is not optional. Every existing cache row and download predates
the concept of a user; without it an upgrading install’s library goes blank.
It grants the active user only — other pre-existing rows re-populate from
the server on next browse, which is strictly safer than handing every
profile the whole cache. The OR (SELECT COUNT(*) ...) = 0 arm covers an
install whose single user somehow has is_active = 0.
TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272