Files
silo-server/migrations/sql/20260727010621_user_setting_values.sql
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

113 lines
5.7 KiB
SQL

-- Canonical storage for the cross-platform user settings contract.
--
-- user_setting_values replaces the string-valued preference surfaces with one
-- typed table: the manifest in contracts/settings/v1 remains the schema, and
-- this table stores validated JSON plus the scope identity the value hangs off.
-- See docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md
-- ("Canonical storage").
--
-- Delete behavior is application-enforced. The two cascades below are the only
-- ones this schema can inherit — user ownership, and composite profile
-- ownership, which user_device_settings already carries. Library, series and
-- device identity columns reference nothing (libraries and series live in the
-- shared catalog, devices in user_devices, and the per-user SQLite store
-- declares no foreign keys at all), so the owning delete paths remove those
-- rows and the userstore conformance suite holds both backends to it.
-- +goose Up
-- +goose StatementBegin
CREATE TABLE public.user_setting_values (
id bigserial PRIMARY KEY,
user_id integer NOT NULL,
key text NOT NULL,
scope text NOT NULL,
profile_id text,
device_id text,
library_id integer,
series_id text,
value jsonb NOT NULL,
revision bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT user_setting_values_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE,
-- MATCH SIMPLE: account-scope rows carry a NULL profile_id and are exempt.
CONSTRAINT user_setting_values_profile_fkey
FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE,
CONSTRAINT user_setting_values_scope_check
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
CONSTRAINT user_setting_values_identity_check 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)
)
);
-- One explicit value per identity. These exist for correctness, not for reads.
CREATE UNIQUE INDEX user_setting_values_account_uq
ON public.user_setting_values (user_id, key) WHERE scope = 'account';
CREATE UNIQUE INDEX user_setting_values_profile_uq
ON public.user_setting_values (user_id, profile_id, key) WHERE scope = 'profile';
CREATE UNIQUE INDEX user_setting_values_profile_device_uq
ON public.user_setting_values (user_id, profile_id, device_id, key) WHERE scope = 'profile_device';
CREATE UNIQUE INDEX user_setting_values_profile_library_uq
ON public.user_setting_values (user_id, profile_id, library_id, key) WHERE scope = 'profile_library';
CREATE UNIQUE INDEX user_setting_values_profile_series_uq
ON public.user_setting_values (user_id, profile_id, series_id, key) WHERE scope = 'profile_series';
-- The hot path: one query per resolution request collects every candidate row
-- for a key set at one identity, and the resolver ranks them in Go.
CREATE INDEX user_setting_values_resolution_idx
ON public.user_setting_values (user_id, profile_id, key, scope);
CREATE INDEX user_setting_values_series_idx
ON public.user_setting_values (user_id, profile_id, series_id);
CREATE INDEX user_setting_values_library_idx
ON public.user_setting_values (user_id, profile_id, library_id);
-- Mutation idempotency. Rows expire after 30 days; expires_at is not
-- self-enforcing, so a sweeper deletes them on the decisionlog_cleanup pattern.
CREATE TABLE public.user_setting_mutations (
user_id integer NOT NULL,
mutation_id text NOT NULL,
request_hash text NOT NULL,
result jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
CONSTRAINT user_setting_mutations_pkey PRIMARY KEY (user_id, mutation_id),
CONSTRAINT user_setting_mutations_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE
);
CREATE INDEX user_setting_mutations_expiry_idx
ON public.user_setting_mutations (expires_at);
-- Inert audit table for the one-time migration. It has no runtime read/write
-- API and is not an extension bag: it retains unrecognized or invalid historical
-- rows for operator inspection instead of silently deleting them. Bounded by the
-- migration rather than by traffic, so no sweeper applies.
CREATE TABLE public.user_setting_migration_rejects (
id bigserial PRIMARY KEY,
user_id integer NOT NULL,
source_table text NOT NULL,
source_key text NOT NULL,
identity jsonb NOT NULL,
value text,
reason text NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT user_setting_migration_rejects_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE
);
CREATE INDEX user_setting_migration_rejects_user_idx
ON public.user_setting_migration_rejects (user_id, source_table);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS public.user_setting_migration_rejects;
DROP TABLE IF EXISTS public.user_setting_mutations;
DROP TABLE IF EXISTS public.user_setting_values;
-- +goose StatementEnd