Files
silo-server/internal/userstore/store.go
T
QuickandClaude Opus 5 05af63ea2b feat(settings): add canonical typed storage for the settings contract
The cross-platform settings contract needs one typed store behind it before a
resolver, routes or a migration can exist. This adds that storage to both
user-store backends and holds them to identical behavior.

PostgreSQL gets user_setting_values with the scope CHECK constraints, the five
partial unique indexes that enforce one explicit value per identity, and the
covering indexes the one-query read path needs, plus user_setting_mutations for
mutation_id idempotency and the inert user_setting_migration_rejects audit
table. The per-user SQLite store gets the same shape minus user_id, since that
database is already user-scoped.

The UserStore interface grows the typed operations: read one explicit value at
one scope, collect every candidate row for a resolution request in a single
query, upsert with a revision increment, unset, and the idempotency receipt
operations. The resolution read deliberately returns unranked candidates so the
resolver can rank in Go — one query per request, never one per scope, which the
pgx query-count test pins.

Delete behavior is application-enforced. Neither backend can inherit it from
constraints: the SQLite store declares no foreign keys, and library, series and
device columns are not FK targets in Postgres either. Profile deletion cascades
to profile-anchored values while account scope survives, forgetting a device
clears its profile_device values alongside the legacy overrides, and the
library/series purges remove only what is scoped to that entity.

The shared conformance suite covers all of it, including the set-versus-unset
distinction for false, 0, "" and null, so a divergence between the two backends
fails a test rather than reaching a client.

Part of #376

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 01:22:23 +00:00

185 lines
13 KiB
Go

package userstore
import (
"context"
"encoding/json"
"errors"
"time"
)
var ErrCollectionGroupNotFound = errors.New("collection group not found")
// UserStore defines the interface for per-user data storage.
// Both SQLite and Postgres backends implement this interface.
type UserStore interface {
// Profiles
CreateProfile(ctx context.Context, p Profile) error
GetProfile(ctx context.Context, id string) (*Profile, error)
ListProfiles(ctx context.Context) ([]Profile, error)
UpdateProfile(ctx context.Context, id string, u UpdateProfileInput) error
DeleteProfile(ctx context.Context, id string) error
VerifyPIN(ctx context.Context, profileID, pin string) (bool, error)
// Progress
UpdateProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds ProgressThresholds) error
SetProgress(ctx context.Context, profileID, mediaItemID string, position, duration float64, thresholds ProgressThresholds) error
SetProgressAt(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) error
SetProgressIfNewer(ctx context.Context, profileID, mediaItemID string, position, duration float64, completed bool, updatedAt time.Time) (bool, error)
UpdateProgressHints(ctx context.Context, profileID, mediaItemID string, hints VersionHints) error
MarkWatched(ctx context.Context, profileID, mediaItemID string, duration float64) error
MarkProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error
ClearProgressBatch(ctx context.Context, profileID string, mediaItemIDs []string, updatedAt time.Time) error
ClearProgress(ctx context.Context, profileID, mediaItemID string) error
GetProgress(ctx context.Context, profileID, mediaItemID string) (*WatchProgress, error)
ListProgress(ctx context.Context, profileID, status string, limit, offset int) ([]WatchProgress, error)
// ListProgressFiltered is ListProgress with an additional SQL pre-filter on
// the backing catalog item's type and/or library, so the watched-items path
// no longer scans the whole status set before discarding non-matching rows.
// types is matched case-insensitively against media_items.type ("episode"
// resolves through the separate episodes table); a nil libraryID drops the
// library predicate, and an empty types + nil libraryID degrades to the
// plain status listing. It is a coarse pre-filter: callers still apply
// access/parental exclusions over the returned rows.
ListProgressFiltered(ctx context.Context, profileID, status string, types []string, libraryID *int, limit, offset int) ([]WatchProgress, error)
ListProgressByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]WatchProgress, error)
// ListProgressSince returns rows whose server cursor exceeds the opaque
// cursor token (empty = full delta), in cursor order, with the next cursor.
// Cross-device delta delivery depends only on the server-assigned synced_seq.
ListProgressSince(ctx context.Context, profileID, cursor string) ([]WatchProgress, string, error)
AddHistory(ctx context.Context, entry WatchHistoryEntry) error
AddHistoryIfMissing(ctx context.Context, entry WatchHistoryEntry) (bool, error)
ListHistory(ctx context.Context, profileID string, limit, offset int) ([]WatchHistoryEntry, error)
ListCompletedHistory(ctx context.Context, query CompletedHistoryQuery) ([]WatchHistoryEntry, error)
ListCompletedHistoryItems(ctx context.Context, query CompletedHistoryItemQuery) ([]CompletedHistoryItem, error)
RemoveHistoryItems(ctx context.Context, profileID string, mediaItemIDs []string, removedAt time.Time) error
DeleteHistoryBySource(ctx context.Context, profileID string, mediaItemIDs []string, source WatchHistorySource) error
ListHomeDismissals(ctx context.Context, profileID, surface string) ([]HomeItemDismissal, error)
UpsertHomeDismissal(ctx context.Context, dismissal HomeItemDismissal) error
DeleteHomeDismissal(ctx context.Context, profileID, surface, mediaItemID string) error
// Favorites & Watchlist
AddFavorite(ctx context.Context, profileID, mediaItemID string) error
AddFavoriteAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) (bool, error)
RemoveFavorite(ctx context.Context, profileID, mediaItemID string) error
ListFavorites(ctx context.Context, profileID string, limit, offset int) ([]Favorite, error)
ListFavoritesByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]bool, error)
IsFavorite(ctx context.Context, profileID, mediaItemID string) (bool, error)
AddToWatchlist(ctx context.Context, profileID, mediaItemID string) error
AddToWatchlistAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) (bool, error)
RemoveFromWatchlist(ctx context.Context, profileID, mediaItemID string) error
// ReplaceWatchlistOrder mirrors a provider's watchlist order: the given ids
// get sort_index 0..N-1 in order; all other rows reset to added_at ordering.
ReplaceWatchlistOrder(ctx context.Context, profileID string, orderedMediaItemIDs []string) error
ListWatchlist(ctx context.Context, profileID string, limit, offset int) ([]WatchlistEntry, error)
ListWatchlistByMediaItems(ctx context.Context, profileID string, mediaItemIDs []string) (map[string]bool, error)
InWatchlist(ctx context.Context, profileID, mediaItemID string) (bool, error)
// RemoveWatchedFromWatchlist reports the profile's preference for pruning
// fully-watched entries from the watchlist (defaults true): movies are
// removed outright on completion, while fully-watched series are only
// hidden from display so they reappear when new episodes are added.
RemoveWatchedFromWatchlist(ctx context.Context, profileID string) (bool, error)
// Collections
CreateCollection(ctx context.Context, input CreateCollectionInput) (*Collection, error)
GetCollection(ctx context.Context, id string) (*Collection, error)
ListCollections(ctx context.Context, profileID string) ([]Collection, error)
UpdateCollection(ctx context.Context, input UpdateCollectionInput) error
DeleteCollection(ctx context.Context, id string) error
AddCollectionItem(ctx context.Context, collectionID, mediaItemID string, position int) error
RemoveCollectionItem(ctx context.Context, collectionID, mediaItemID string) error
ListCollectionItems(ctx context.Context, collectionID string) ([]CollectionItem, error)
ReplaceCollectionItems(ctx context.Context, collectionID string, items []CollectionItemReplacement) error
ReorderCollectionItems(ctx context.Context, collectionID string, orderedMediaItemIDs []string) error
// ReorderCollections scopes to the supplied group_id. A nil groupID means
// the implicit Ungrouped bucket.
ReorderCollections(ctx context.Context, profileID string, groupID *string, orderedIDs []string) error
UpdateCollectionSyncState(ctx context.Context, input UpdateCollectionSyncStateInput) error
ListCollectionGroups(ctx context.Context) ([]CollectionGroup, error)
EnsureCollectionGroup(ctx context.Context, id string) error
CreateCollectionGroup(ctx context.Context, name, slug string, defaultSortMode GroupSortMode) (*CollectionGroup, error)
UpdateCollectionGroup(ctx context.Context, id string, name *string, slug *string, defaultSortMode *GroupSortMode) (*CollectionGroup, error)
DeleteCollectionGroup(ctx context.Context, id string) error
ReorderCollectionGroups(ctx context.Context, orderedIDs []string) error
// Section Overrides
ListSectionOverrides(ctx context.Context, profileID, scope, libraryID string) ([]SectionOverride, error)
SaveSectionOverrides(ctx context.Context, profileID, scope, libraryID string, overrides []SectionOverride) error
ResetSectionOverrides(ctx context.Context, profileID, scope, libraryID string) error
// Settings & Preferences
GetSetting(ctx context.Context, key string) (string, error)
SetSetting(ctx context.Context, key, value string) error
DeleteSetting(ctx context.Context, key string) error
ListSettings(ctx context.Context) ([]SettingEntry, error)
GetDeviceSetting(ctx context.Context, profileID, deviceID, key string) (*DeviceSettingEntry, error)
SetDeviceSetting(ctx context.Context, entry DeviceSettingEntry) error
DeleteDeviceSetting(ctx context.Context, profileID, deviceID, key string) error
DeleteAllDeviceSettings(ctx context.Context, profileID, deviceID string) error
DeleteDeviceSettingsByKey(ctx context.Context, key string) error
ListDeviceSettings(ctx context.Context, key string) ([]DeviceSettingEntry, error)
ListAllDeviceSettings(ctx context.Context) ([]DeviceSettingEntry, error)
SetSubtitlePreference(ctx context.Context, pref SubtitlePreference) error
GetSubtitlePreference(ctx context.Context, profileID, seriesID string) (*SubtitlePreference, error)
DeleteSubtitlePreference(ctx context.Context, profileID, seriesID string) error
SetAudioPreference(ctx context.Context, pref AudioPreference) error
GetAudioPreference(ctx context.Context, profileID, seriesID string) (*AudioPreference, error)
DeleteAudioPreference(ctx context.Context, profileID, seriesID string) error
SetSeriesPlaybackPreference(ctx context.Context, pref SeriesPlaybackPreference) error
GetSeriesPlaybackPreference(ctx context.Context, profileID, seriesID string) (*SeriesPlaybackPreference, error)
DeleteSeriesPlaybackPreference(ctx context.Context, profileID, seriesID string) error
GetLibraryPlaybackPreference(ctx context.Context, profileID string, libraryID int) (*LibraryPlaybackPreference, error)
ListLibraryPlaybackPreferences(ctx context.Context, profileID string) ([]LibraryPlaybackPreference, error)
UpsertLibraryPlaybackPreference(ctx context.Context, pref LibraryPlaybackPreference) error
DeleteLibraryPlaybackPreference(ctx context.Context, profileID string, libraryID int) error
// Canonical typed setting values (contracts/settings/v1).
//
// These back the settings contract's storage layer. The manifest remains
// the schema; the store holds validated JSON keyed by scope identity, and
// knows nothing about definitions, defaults or resolution order.
// GetSettingValue returns the explicit value at exactly one scope, or nil
// when that identity is unset. It does not resolve fallbacks.
GetSettingValue(ctx context.Context, id SettingIdentity) (*SettingValue, error)
// ListSettingValuesForResolution returns every candidate row for one
// resolution request in a single query, unranked. The resolver applies each
// definition's resolution order in Go; issuing one lookup per scope is a
// rejected implementation.
ListSettingValuesForResolution(ctx context.Context, query SettingResolutionQuery) ([]SettingValue, error)
// UpsertSettingValue writes the explicit value at one scope and increments
// that row's revision. Concurrent writes to one identity are
// last-write-wins in server receipt order; there is no compare-and-set
// precondition in v1.
UpsertSettingValue(ctx context.Context, id SettingIdentity, value json.RawMessage) (*SettingValue, error)
// DeleteSettingValue removes the explicit value at one scope — the `unset`
// operation — and reports whether a row existed.
DeleteSettingValue(ctx context.Context, id SettingIdentity) (bool, error)
// The scoped deletes below are application-enforced cleanup for identities
// this table cannot reference: the per-user SQLite store declares no foreign
// keys, and libraries, series and devices are not FK targets in Postgres
// either. Each removes only the rows scoped to the named entity.
DeleteSettingValuesForProfile(ctx context.Context, profileID string) (int64, error)
DeleteSettingValuesForDevice(ctx context.Context, profileID, deviceID string) (int64, error)
DeleteSettingValuesForLibrary(ctx context.Context, libraryID int) (int64, error)
DeleteSettingValuesForSeries(ctx context.Context, seriesID string) (int64, error)
// GetSettingMutation returns a recorded idempotency receipt, or nil.
GetSettingMutation(ctx context.Context, mutationID string) (*SettingMutationRecord, error)
// PutSettingMutation records a receipt without ever overwriting one. When
// the id is already recorded it returns the stored record with
// inserted=false, so the caller compares request hashes and answers
// already_applied or mutation_id_conflict.
PutSettingMutation(ctx context.Context, record SettingMutationRecord) (SettingMutationRecord, bool, error)
// DeleteExpiredSettingMutations removes receipts that expired before the
// given instant and reports how many. expires_at is not self-enforcing.
DeleteExpiredSettingMutations(ctx context.Context, before time.Time) (int64, error)
}
// DeviceRegistry is implemented by stores that track observed devices even
// when they do not currently have any device-scoped overrides.
type DeviceRegistry interface {
RegisterDevice(ctx context.Context, entry DeviceEntry) error
ListDevices(ctx context.Context) ([]DeviceEntry, error)
}