diff --git a/backend/internal/store/config.go b/backend/internal/store/config.go new file mode 100644 index 0000000..86b80f6 --- /dev/null +++ b/backend/internal/store/config.go @@ -0,0 +1,39 @@ +package store + +import ( + "context" + "fmt" +) + +// ConfigValues returns every application-configuration override row as +// key -> value. An absent key means its default (from internal/config's +// app-key registry) is in effect -- the table stores overrides only. +func (db *DB) ConfigValues(ctx context.Context) (map[string]string, error) { + rows, err := db.QueryContext(ctx, `SELECT key, value FROM config`) + if err != nil { + return nil, fmt.Errorf("config values: %w", err) + } + defer rows.Close() + + values := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("scan config row: %w", err) + } + values[k] = v + } + return values, rows.Err() +} + +// SetConfigValue upserts one override row. Key validation is the caller's +// job (config.ValidateAppValue) -- the store stays a dumb K/V layer. +func (db *DB) SetConfigValue(ctx context.Context, key, value string) error { + if _, err := db.ExecContext(ctx, ` + INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + key, value); err != nil { + return fmt.Errorf("set config %q: %w", key, err) + } + return nil +} diff --git a/backend/internal/store/config_test.go b/backend/internal/store/config_test.go new file mode 100644 index 0000000..d356ff6 --- /dev/null +++ b/backend/internal/store/config_test.go @@ -0,0 +1,35 @@ +package store + +import ( + "context" + "testing" +) + +func TestConfigValues_EmptyThenUpsertOverwrites(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + values, err := db.ConfigValues(ctx) + if err != nil { + t.Fatalf("ConfigValues (empty): %v", err) + } + if len(values) != 0 { + t.Fatalf("expected no overrides in a fresh DB, got %v", values) + } + + if err := db.SetConfigValue(ctx, "session.duration", "168"); err != nil { + t.Fatalf("SetConfigValue: %v", err) + } + // Same key again: upsert must overwrite, not error or duplicate. + if err := db.SetConfigValue(ctx, "session.duration", "24"); err != nil { + t.Fatalf("SetConfigValue (overwrite): %v", err) + } + + values, err = db.ConfigValues(ctx) + if err != nil { + t.Fatalf("ConfigValues: %v", err) + } + if len(values) != 1 || values["session.duration"] != "24" { + t.Fatalf("expected {session.duration: 24}, got %v", values) + } +} diff --git a/backend/internal/store/schema.sql b/backend/internal/store/schema.sql index 1a9272c..fc4b42c 100644 --- a/backend/internal/store/schema.sql +++ b/backend/internal/store/schema.sql @@ -251,3 +251,14 @@ CREATE TABLE sync_runs ( status TEXT NOT NULL CHECK(status IN ('running','success','error')), error_message TEXT ); + +-- Application configuration: instance-global key/value overrides, shared +-- by every user -- deliberately the one table with no user_id, because +-- application configuration is common to all users by definition. Stores +-- overrides only; defaults live in internal/config's app-key registry. +-- Cold: read once at startup, a change applies on the next backend restart. +CREATE TABLE config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 9e41b1a..050d82c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -14,6 +14,7 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit - [`kind_assignments`](#kind_assignments) - [`sync_state`](#sync_state) - [`sync_runs`](#sync_runs) +- [`config`](#config) ## `users` @@ -263,6 +264,16 @@ CREATE TABLE sync_runs ( ); ``` +## `config` + +```sql +CREATE TABLE config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + ## Views ### `current_kind_assignment`