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>
This commit is contained in:
@@ -30,6 +30,14 @@ func TestSQLiteProgressSince(t *testing.T) {
|
||||
storetest.RunProgressSince(t, newConformanceStore)
|
||||
}
|
||||
|
||||
// TestSQLiteSettingValues runs the canonical settings-contract storage
|
||||
// conformance tests against the per-user SQLite backend. The Postgres backend
|
||||
// runs the same suite in internal/userstore/pgstore, which is what keeps the two
|
||||
// from drifting on scope identity, partial uniqueness and delete behavior.
|
||||
func TestSQLiteSettingValues(t *testing.T) {
|
||||
storetest.RunSettingValues(t, newConformanceStore)
|
||||
}
|
||||
|
||||
func TestSQLiteAddFavoriteAtReportsInsertion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newConformanceStore(t)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const schemaVersion = 13
|
||||
const schemaVersion = 14
|
||||
|
||||
func runMigrations(db *sql.DB) error {
|
||||
version, err := userVersion(db)
|
||||
@@ -133,9 +133,29 @@ func runMigrations(db *sql.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if version < 14 {
|
||||
if err := migrateToV14(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec("PRAGMA user_version = 14"); err != nil {
|
||||
return fmt.Errorf("setting sqlite user_version 14: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// migrateToV14 adds the canonical settings contract tables. InitSchema already
|
||||
// creates them with IF NOT EXISTS on every open, so this step is what records
|
||||
// that an existing database has them — the same shape migrateToV6 used for
|
||||
// series_playback_preferences.
|
||||
func migrateToV14(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(settingContractSchema); err != nil {
|
||||
return fmt.Errorf("creating settings contract tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateToV13 replaces the v1 watch_progress stamp triggers with the current
|
||||
// bodies (CREATE TRIGGER IF NOT EXISTS never replaces an existing trigger, and
|
||||
// InitSchema runs before migrations, so this must drop AND recreate). v2 makes
|
||||
|
||||
@@ -292,7 +292,9 @@ func DeleteProfile(db *sql.DB, id string) error {
|
||||
return fmt.Errorf("deleting collection visibility for profile %s: %w", id, err)
|
||||
}
|
||||
|
||||
// Cascade-delete related tables.
|
||||
// Cascade-delete related tables. This database declares no foreign keys, so
|
||||
// user_setting_values is listed here; account-scope rows carry a NULL
|
||||
// profile_id and are untouched, matching the Postgres backend.
|
||||
cascadeTables := []string{
|
||||
"favorites",
|
||||
"watchlist",
|
||||
@@ -301,6 +303,7 @@ func DeleteProfile(db *sql.DB, id string) error {
|
||||
"profile_allowed_libraries",
|
||||
"series_playback_preferences",
|
||||
"library_playback_preferences",
|
||||
"user_setting_values",
|
||||
}
|
||||
for _, table := range cascadeTables {
|
||||
column := "profile_id"
|
||||
|
||||
@@ -255,6 +255,83 @@ CREATE INDEX IF NOT EXISTS idx_home_item_dismissals_lookup
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_hidden_history_items_lookup
|
||||
ON hidden_history_items(profile_id, hidden_before);
|
||||
` + settingContractSchema
|
||||
|
||||
// settingContractSchema is the per-user half of the canonical settings contract
|
||||
// storage. It mirrors the PostgreSQL shape in
|
||||
// migrations/sql/20260727010621_user_setting_values.sql with user_id omitted:
|
||||
// this database is already scoped to one user. jsonb becomes TEXT plus a
|
||||
// json_valid CHECK, bigserial becomes INTEGER PRIMARY KEY AUTOINCREMENT, and
|
||||
// timestamptz becomes an RFC3339 TEXT column, matching the rest of this schema.
|
||||
//
|
||||
// This file declares no foreign keys, deliberately and consistently with every
|
||||
// other table here, so deleting a profile, library, series or device removes
|
||||
// these rows through the owning delete path rather than a cascade. The userstore
|
||||
// conformance suite holds both backends to identical behavior there.
|
||||
const settingContractSchema = `
|
||||
CREATE TABLE IF NOT EXISTS user_setting_values (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
profile_id TEXT,
|
||||
device_id TEXT,
|
||||
library_id INTEGER,
|
||||
series_id TEXT,
|
||||
value TEXT NOT NULL CHECK (json_valid(value)),
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
|
||||
CHECK (
|
||||
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_setting_values_account_uq
|
||||
ON user_setting_values (key) WHERE scope = 'account';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_setting_values_profile_uq
|
||||
ON user_setting_values (profile_id, key) WHERE scope = 'profile';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_setting_values_profile_device_uq
|
||||
ON user_setting_values (profile_id, device_id, key) WHERE scope = 'profile_device';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_setting_values_profile_library_uq
|
||||
ON user_setting_values (profile_id, library_id, key) WHERE scope = 'profile_library';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_setting_values_profile_series_uq
|
||||
ON user_setting_values (profile_id, series_id, key) WHERE scope = 'profile_series';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_setting_values_resolution_idx
|
||||
ON user_setting_values (profile_id, key, scope);
|
||||
CREATE INDEX IF NOT EXISTS user_setting_values_series_idx
|
||||
ON user_setting_values (profile_id, series_id);
|
||||
CREATE INDEX IF NOT EXISTS user_setting_values_library_idx
|
||||
ON user_setting_values (profile_id, library_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_setting_mutations (
|
||||
mutation_id TEXT PRIMARY KEY,
|
||||
request_hash TEXT NOT NULL,
|
||||
result TEXT NOT NULL CHECK (json_valid(result)),
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_setting_mutations_expiry_idx
|
||||
ON user_setting_mutations (expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_setting_migration_rejects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_table TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
identity TEXT NOT NULL CHECK (json_valid(identity)),
|
||||
value TEXT,
|
||||
reason TEXT NOT NULL,
|
||||
recorded_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_setting_migration_rejects_source_idx
|
||||
ON user_setting_migration_rejects (source_table);
|
||||
`
|
||||
|
||||
// InitSchema creates all tables in the given SQLite database.
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package userdb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// settingValueColumns is the projection every read shares, in the order
|
||||
// scanSettingValue expects.
|
||||
const settingValueColumns = `key, scope, profile_id, device_id, library_id, series_id,
|
||||
value, revision, created_at, updated_at`
|
||||
|
||||
// settingConflictTargets maps a scope to the partial unique index that enforces
|
||||
// one explicit value per identity. SQLite requires an upsert against a partial
|
||||
// index to repeat that index's WHERE clause, so the target carries it.
|
||||
var settingConflictTargets = map[settingscontract.Scope]string{
|
||||
settingscontract.ScopeAccount: "(key) WHERE scope = 'account'",
|
||||
settingscontract.ScopeProfile: "(profile_id, key) WHERE scope = 'profile'",
|
||||
settingscontract.ScopeProfileDevice: "(profile_id, device_id, key) WHERE scope = 'profile_device'",
|
||||
settingscontract.ScopeProfileLibrary: "(profile_id, library_id, key) WHERE scope = 'profile_library'",
|
||||
settingscontract.ScopeProfileSeries: "(profile_id, series_id, key) WHERE scope = 'profile_series'",
|
||||
}
|
||||
|
||||
// settingIdentityPredicate returns the WHERE fragment and bind arguments that
|
||||
// address exactly one row. Every scope compares only the columns it populates,
|
||||
// so no clause ever has to reason about NULL equality.
|
||||
func settingIdentityPredicate(id userstore.SettingIdentity) (string, []any) {
|
||||
args := []any{id.Key, string(id.Scope)}
|
||||
clause := "key = ? AND scope = ?"
|
||||
switch id.Scope {
|
||||
case settingscontract.ScopeProfile:
|
||||
args = append(args, id.ProfileID)
|
||||
clause += " AND profile_id = ?"
|
||||
case settingscontract.ScopeProfileDevice:
|
||||
args = append(args, id.ProfileID, id.DeviceID)
|
||||
clause += " AND profile_id = ? AND device_id = ?"
|
||||
case settingscontract.ScopeProfileLibrary:
|
||||
args = append(args, id.ProfileID, id.LibraryID)
|
||||
clause += " AND profile_id = ? AND library_id = ?"
|
||||
case settingscontract.ScopeProfileSeries:
|
||||
args = append(args, id.ProfileID, id.SeriesID)
|
||||
clause += " AND profile_id = ? AND series_id = ?"
|
||||
}
|
||||
return clause, args
|
||||
}
|
||||
|
||||
// GetSettingValue returns the explicit value at exactly one scope, or nil when
|
||||
// that identity is unset.
|
||||
func GetSettingValue(db *sql.DB, id userstore.SettingIdentity) (*userstore.SettingValue, error) {
|
||||
if err := id.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clause, args := settingIdentityPredicate(id)
|
||||
row := db.QueryRow("SELECT "+settingValueColumns+" FROM user_setting_values WHERE "+clause, args...)
|
||||
value, err := scanSettingValue(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting setting value %q at %s: %w", id.Key, id.Scope, err)
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
// ListSettingValuesForResolution collects every candidate row for a resolution
|
||||
// request in one query. The predicate covers all five scopes at once: ranking by
|
||||
// each definition's resolution order happens in Go, so a four-scope chain still
|
||||
// costs one round trip rather than four lookups per key.
|
||||
func ListSettingValuesForResolution(
|
||||
db *sql.DB,
|
||||
query userstore.SettingResolutionQuery,
|
||||
) ([]userstore.SettingValue, error) {
|
||||
q := query.Normalized()
|
||||
if len(q.Keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(q.Keys)+len(q.LibraryIDs)+len(q.SeriesIDs)+2)
|
||||
for _, key := range q.Keys {
|
||||
args = append(args, key)
|
||||
}
|
||||
args = append(args, q.ProfileID, q.DeviceID)
|
||||
for _, libraryID := range q.LibraryIDs {
|
||||
args = append(args, libraryID)
|
||||
}
|
||||
for _, seriesID := range q.SeriesIDs {
|
||||
args = append(args, seriesID)
|
||||
}
|
||||
|
||||
libraryClause := "0"
|
||||
if len(q.LibraryIDs) > 0 {
|
||||
libraryClause = "scope = 'profile_library' AND library_id IN (" + placeholders(len(q.LibraryIDs)) + ")"
|
||||
}
|
||||
seriesClause := "0"
|
||||
if len(q.SeriesIDs) > 0 {
|
||||
seriesClause = "scope = 'profile_series' AND series_id IN (" + placeholders(len(q.SeriesIDs)) + ")"
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT `+settingValueColumns+`
|
||||
FROM user_setting_values
|
||||
WHERE key IN (`+placeholders(len(q.Keys))+`)
|
||||
AND (
|
||||
scope = 'account'
|
||||
OR (
|
||||
profile_id = ?
|
||||
AND (
|
||||
scope = 'profile'
|
||||
OR (scope = 'profile_device' AND device_id = ?)
|
||||
OR (`+libraryClause+`)
|
||||
OR (`+seriesClause+`)
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(device_id, ''),
|
||||
COALESCE(library_id, 0), COALESCE(series_id, '')`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing setting values for resolution: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var values []userstore.SettingValue
|
||||
for rows.Next() {
|
||||
value, err := scanSettingValue(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning setting value: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertSettingValue writes the explicit value at one scope and increments that
|
||||
// row's revision.
|
||||
func UpsertSettingValue(
|
||||
db *sql.DB,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
) (*userstore.SettingValue, error) {
|
||||
if err := id.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := userstore.ValidateSettingValueJSON(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, ok := settingConflictTargets[id.Scope]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q has no storage identity", userstore.ErrInvalidSettingIdentity, id.Scope)
|
||||
}
|
||||
|
||||
now := nowRFC3339()
|
||||
row := db.QueryRow(fmt.Sprintf(`
|
||||
INSERT INTO user_setting_values
|
||||
(key, scope, profile_id, device_id, library_id, series_id, value, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT %s DO UPDATE SET
|
||||
value = excluded.value,
|
||||
revision = user_setting_values.revision + 1,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING %s`, target, settingValueColumns),
|
||||
id.Key, string(id.Scope),
|
||||
nullableText(id.ProfileID), nullableText(id.DeviceID),
|
||||
nullableInt(id.LibraryID), nullableText(id.SeriesID),
|
||||
string(value), now, now,
|
||||
)
|
||||
stored, err := scanSettingValue(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upserting setting value %q at %s: %w", id.Key, id.Scope, err)
|
||||
}
|
||||
return &stored, nil
|
||||
}
|
||||
|
||||
// DeleteSettingValue removes the explicit value at one scope — the `unset`
|
||||
// operation — and reports whether a row existed.
|
||||
func DeleteSettingValue(db *sql.DB, id userstore.SettingIdentity) (bool, error) {
|
||||
if err := id.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
clause, args := settingIdentityPredicate(id)
|
||||
result, err := db.Exec("DELETE FROM user_setting_values WHERE "+clause, args...)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("deleting setting value %q at %s: %w", id.Key, id.Scope, err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("counting deleted setting value %q: %w", id.Key, err)
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
// DeleteSettingValuesForProfile removes every profile-anchored value for one
|
||||
// profile. Account-scope rows carry a NULL profile_id and survive, which is what
|
||||
// deleting one household member out of an account has to mean.
|
||||
func DeleteSettingValuesForProfile(db *sql.DB, profileID string) (int64, error) {
|
||||
return execSettingValueDelete(db,
|
||||
"DELETE FROM user_setting_values WHERE profile_id = ?",
|
||||
fmt.Sprintf("profile %q", profileID), profileID)
|
||||
}
|
||||
|
||||
func DeleteSettingValuesForDevice(db *sql.DB, profileID, deviceID string) (int64, error) {
|
||||
return execSettingValueDelete(db,
|
||||
"DELETE FROM user_setting_values WHERE scope = 'profile_device' AND profile_id = ? AND device_id = ?",
|
||||
fmt.Sprintf("device %q", deviceID), profileID, deviceID)
|
||||
}
|
||||
|
||||
func DeleteSettingValuesForLibrary(db *sql.DB, libraryID int) (int64, error) {
|
||||
return execSettingValueDelete(db,
|
||||
"DELETE FROM user_setting_values WHERE scope = 'profile_library' AND library_id = ?",
|
||||
fmt.Sprintf("library %d", libraryID), libraryID)
|
||||
}
|
||||
|
||||
func DeleteSettingValuesForSeries(db *sql.DB, seriesID string) (int64, error) {
|
||||
return execSettingValueDelete(db,
|
||||
"DELETE FROM user_setting_values WHERE scope = 'profile_series' AND series_id = ?",
|
||||
fmt.Sprintf("series %q", seriesID), seriesID)
|
||||
}
|
||||
|
||||
func execSettingValueDelete(db *sql.DB, query, subject string, args ...any) (int64, error) {
|
||||
result, err := db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting setting values for %s: %w", subject, err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("counting setting values deleted for %s: %w", subject, err)
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// GetSettingMutation returns a recorded idempotency receipt, or nil.
|
||||
func GetSettingMutation(db *sql.DB, mutationID string) (*userstore.SettingMutationRecord, error) {
|
||||
row := db.QueryRow(`
|
||||
SELECT mutation_id, request_hash, result, created_at, expires_at
|
||||
FROM user_setting_mutations
|
||||
WHERE mutation_id = ?`, mutationID)
|
||||
record, err := scanSettingMutation(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting setting mutation %q: %w", mutationID, err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// PutSettingMutation never overwrites a receipt: DO NOTHING plus a second read
|
||||
// keeps a replayed mutation_id answering with the result the first attempt
|
||||
// produced, which is what makes a client's retry idempotent rather than a
|
||||
// silent re-run.
|
||||
func PutSettingMutation(
|
||||
db *sql.DB,
|
||||
record userstore.SettingMutationRecord,
|
||||
) (userstore.SettingMutationRecord, bool, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return userstore.SettingMutationRecord{}, false, err
|
||||
}
|
||||
|
||||
row := db.QueryRow(`
|
||||
INSERT INTO user_setting_mutations (mutation_id, request_hash, result, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (mutation_id) DO NOTHING
|
||||
RETURNING mutation_id, request_hash, result, created_at, expires_at`,
|
||||
record.MutationID, record.RequestHash, string(record.Result),
|
||||
nowRFC3339(), record.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
stored, err := scanSettingMutation(row)
|
||||
if err == nil {
|
||||
return stored, true, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return userstore.SettingMutationRecord{}, false, fmt.Errorf("recording setting mutation %q: %w", record.MutationID, err)
|
||||
}
|
||||
|
||||
existing, err := GetSettingMutation(db, record.MutationID)
|
||||
if err != nil {
|
||||
return userstore.SettingMutationRecord{}, false, err
|
||||
}
|
||||
if existing == nil {
|
||||
// The conflicting row was swept between the insert and this read; the
|
||||
// caller can safely retry rather than receive a phantom conflict.
|
||||
return userstore.SettingMutationRecord{}, false, fmt.Errorf(
|
||||
"recording setting mutation %q: conflicting receipt disappeared", record.MutationID)
|
||||
}
|
||||
return *existing, false, nil
|
||||
}
|
||||
|
||||
// DeleteExpiredSettingMutations removes receipts that expired before the given
|
||||
// instant and reports how many.
|
||||
func DeleteExpiredSettingMutations(db *sql.DB, before time.Time) (int64, error) {
|
||||
result, err := db.Exec(
|
||||
"DELETE FROM user_setting_mutations WHERE expires_at <= ?",
|
||||
before.UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sweeping expired setting mutations: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("counting swept setting mutations: %w", err)
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// sqlRow is the subset of *sql.Row and *sql.Rows the scan helpers need.
|
||||
type sqlRow interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanSettingValue(row sqlRow) (userstore.SettingValue, error) {
|
||||
var (
|
||||
value userstore.SettingValue
|
||||
scope string
|
||||
profileID sql.NullString
|
||||
deviceID sql.NullString
|
||||
libraryID sql.NullInt64
|
||||
seriesID sql.NullString
|
||||
raw string
|
||||
)
|
||||
if err := row.Scan(
|
||||
&value.Key, &scope, &profileID, &deviceID, &libraryID, &seriesID,
|
||||
&raw, &value.Revision, &value.CreatedAt, &value.UpdatedAt,
|
||||
); err != nil {
|
||||
return userstore.SettingValue{}, err
|
||||
}
|
||||
value.Scope = settingscontract.Scope(scope)
|
||||
value.ProfileID = profileID.String
|
||||
value.DeviceID = deviceID.String
|
||||
value.LibraryID = int(libraryID.Int64)
|
||||
value.SeriesID = seriesID.String
|
||||
value.Value = json.RawMessage(raw)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func scanSettingMutation(row sqlRow) (userstore.SettingMutationRecord, error) {
|
||||
var (
|
||||
record userstore.SettingMutationRecord
|
||||
raw string
|
||||
createdAt string
|
||||
expiresAt string
|
||||
)
|
||||
if err := row.Scan(&record.MutationID, &record.RequestHash, &raw, &createdAt, &expiresAt); err != nil {
|
||||
return userstore.SettingMutationRecord{}, err
|
||||
}
|
||||
record.Result = json.RawMessage(raw)
|
||||
var err error
|
||||
if record.CreatedAt, err = parseRFC3339(createdAt); err != nil {
|
||||
return userstore.SettingMutationRecord{}, fmt.Errorf("parsing created_at for mutation %q: %w", record.MutationID, err)
|
||||
}
|
||||
if record.ExpiresAt, err = parseRFC3339(expiresAt); err != nil {
|
||||
return userstore.SettingMutationRecord{}, fmt.Errorf("parsing expires_at for mutation %q: %w", record.MutationID, err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func parseRFC3339(value string) (time.Time, error) {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return parsed.UTC(), nil
|
||||
}
|
||||
|
||||
func nowRFC3339() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func nullableText(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func nullableInt(value int) any {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func placeholders(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
||||
}
|
||||
@@ -166,12 +166,30 @@ func DeleteDeviceSetting(db *sql.DB, profileID, deviceID, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllDeviceSettings clears everything one device holds for one profile.
|
||||
// It is the forget-device path, so it drops the canonical profile_device values
|
||||
// alongside the legacy string overrides: this database declares no foreign keys,
|
||||
// so nothing else would.
|
||||
func DeleteAllDeviceSettings(db *sql.DB, profileID, deviceID string) error {
|
||||
_, err := db.Exec("DELETE FROM user_device_settings WHERE profile_id = ? AND device_id = ?", profileID, deviceID)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning transaction to forget device %q: %w", deviceID, err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if _, err := tx.Exec(
|
||||
"DELETE FROM user_device_settings WHERE profile_id = ? AND device_id = ?",
|
||||
profileID, deviceID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("deleting all device settings for device %q: %w", deviceID, err)
|
||||
}
|
||||
return nil
|
||||
if _, err := tx.Exec(
|
||||
"DELETE FROM user_setting_values WHERE scope = 'profile_device' AND profile_id = ? AND device_id = ?",
|
||||
profileID, deviceID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("deleting setting values for device %q: %w", deviceID, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func DeleteDeviceSettingsByKey(db *sql.DB, key string) error {
|
||||
|
||||
@@ -3,6 +3,7 @@ package userdb
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -420,3 +421,49 @@ func (s *SQLiteUserStore) UpsertLibraryPlaybackPreference(_ context.Context, pre
|
||||
func (s *SQLiteUserStore) DeleteLibraryPlaybackPreference(_ context.Context, profileID string, libraryID int) error {
|
||||
return DeleteLibraryPlaybackPreference(s.db, profileID, libraryID)
|
||||
}
|
||||
|
||||
// --- Canonical typed setting values ---
|
||||
|
||||
func (s *SQLiteUserStore) GetSettingValue(_ context.Context, id userstore.SettingIdentity) (*userstore.SettingValue, error) {
|
||||
return GetSettingValue(s.db, id)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) ListSettingValuesForResolution(_ context.Context, query userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
|
||||
return ListSettingValuesForResolution(s.db, query)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) UpsertSettingValue(_ context.Context, id userstore.SettingIdentity, value json.RawMessage) (*userstore.SettingValue, error) {
|
||||
return UpsertSettingValue(s.db, id, value)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) DeleteSettingValue(_ context.Context, id userstore.SettingIdentity) (bool, error) {
|
||||
return DeleteSettingValue(s.db, id)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) DeleteSettingValuesForProfile(_ context.Context, profileID string) (int64, error) {
|
||||
return DeleteSettingValuesForProfile(s.db, profileID)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) DeleteSettingValuesForDevice(_ context.Context, profileID, deviceID string) (int64, error) {
|
||||
return DeleteSettingValuesForDevice(s.db, profileID, deviceID)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) DeleteSettingValuesForLibrary(_ context.Context, libraryID int) (int64, error) {
|
||||
return DeleteSettingValuesForLibrary(s.db, libraryID)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) DeleteSettingValuesForSeries(_ context.Context, seriesID string) (int64, error) {
|
||||
return DeleteSettingValuesForSeries(s.db, seriesID)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) GetSettingMutation(_ context.Context, mutationID string) (*userstore.SettingMutationRecord, error) {
|
||||
return GetSettingMutation(s.db, mutationID)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) PutSettingMutation(_ context.Context, record userstore.SettingMutationRecord) (userstore.SettingMutationRecord, bool, error) {
|
||||
return PutSettingMutation(s.db, record)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) DeleteExpiredSettingMutations(_ context.Context, before time.Time) (int64, error) {
|
||||
return DeleteExpiredSettingMutations(s.db, before)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user