Skip to main content

max / goingson

config: move settings off localStorage onto the shared config store Step 6 (final) of the portable-config migration. GoingsOn kept its ~9 settings in the frontend's localStorage, unsynced. They now live in a backend user_config table (migration 060) with a sync posture, using the real synckit-client config adapter — GO is on the SyncStore engine, so unlike audiofiles/BB it wires config_sync_table into goingson_schema and the engine generates the export/import triggers, gated on the config_key_policy allowlist. The engine applies the posture predicate symmetrically, so the import boundary is enforced for free. Backend: - config_key::CONFIG declares posture per key. Synced: theme, event_lead_minutes, plan/review nudges, work start/end hour. Local (per-device): ui_mode, welcomed, hint_shortcuts. Undeclared keys fail closed. - config_sync_table(&CONFIG) added to the manifest; seed_config_policy seeds config_key_policy from the spec every launch (before any migration_sql regen); run_config_migration regenerates triggers on an already-cutover device. - get_config/set_config/delete_config/get_all_config commands (sqlx over the pool; set validates keys against the spec). user_config excluded from local backup (synced keys recover via sync, device-local keys re-derive). Frontend: - New config.js: a superset drop-in for the old localStorage calls. Known keys (by clean or legacy name) read from a startup-preloaded cache and write through to the backend; other keys pass through to localStorage. ui_mode is mirrored back to localStorage so the pre-paint bootstrap-uimode.js still reads it synchronously (no flash). - One-time migration copies existing localStorage values into the backend on first load. All read/write sites (themes, settings, plan-review, events, app, viewport, components' setLocalStorage) routed through GoingsOn.config; init-time readers await config.ready. 582 backend tests pass; JS suite 56/56. The compose-page.js frontend-lint finding is pre-existing (unrelated).
Author: Max Johnson <me@maxj.phd> · 2026-07-24 23:21 UTC
Commit: b8e66fb992d46cae7788a8f3040e9f1166dd15aa
Parent: f7131f5
21 files changed, +483 insertions, -30 deletions
M Cargo.lock +10
@@ -2289,6 +2289,7 @@ dependencies = [
2289 2289 "sha2 0.11.0",
2290 2290 "sqlx",
2291 2291 "synckit-client",
2292 + "synckit-config",
2292 2293 "tauri",
2293 2294 "tauri-build",
2294 2295 "tauri-plugin-dialog",
@@ -6193,6 +6194,7 @@ dependencies = [
6193 6194 "serde",
6194 6195 "serde_json",
6195 6196 "sha2 0.11.0",
6197 + "synckit-config",
6196 6198 "thiserror 2.0.18",
6197 6199 "tokio",
6198 6200 "tokio-stream",
@@ -6205,6 +6207,14 @@ dependencies = [
6205 6207 ]
6206 6208
6207 6209 [[package]]
6210 + name = "synckit-config"
6211 + version = "0.1.2"
6212 + dependencies = [
6213 + "rusqlite",
6214 + "thiserror 2.0.18",
6215 + ]
6216 +
6217 + [[package]]
6208 6218 name = "synstructure"
6209 6219 version = "0.13.2"
6210 6220 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -63,6 +63,7 @@ pub const EXCLUDED_TABLES: &[&str] = &[
63 63 "sync_state", // sync cursors/flags, internal
64 64 "hlc_state", // hybrid logical clock, device-local sync internal
65 65 "sync_committed_hlc", // per-row committed HLC clock store, device-local sync internal
66 + "user_config", // app settings; synced keys recover via cloud sync, device-local keys re-derive per device
66 67 ];
67 68
68 69 /// Restore a backup into the database as a single transaction.
@@ -0,0 +1,11 @@
1 + -- App settings as key/value rows, moved off the frontend's localStorage onto the
2 + -- shared config store (step 6 of the portable-config migration). Which keys sync
3 + -- is governed by posture: the SyncStore engine generates this table's triggers
4 + -- from the config manifest (config_sync_table in syncstore/manifest.rs), and they
5 + -- enqueue a changelog row only for keys the config_key_policy allowlist marks
6 + -- replicated. The policy is seeded from the ConfigSpec at every launch
7 + -- (syncstore::seed_config_policy); an undeclared key has no row and never syncs.
8 + CREATE TABLE IF NOT EXISTS user_config (
9 + key TEXT PRIMARY KEY,
10 + value TEXT NOT NULL
11 + );
@@ -59,4 +59,5 @@
59 59 056 30e200908dd4f6d2ebdf5b8f1419207d9c9af430f99df471b61cfd3c9659851a0f8a9b9596aeb682c6567af76bd39ff6
60 60 057 f43f541166c9beb552e22bc5170fcb7bb95db580a6f82fc5dca88e74edeb433ff22e0bd72ddf3913800075a53d360978
61 61 058 8c800b52c85af7f107d7706f4d5c3dfcc3a44f5877a606da23bb3e1df0fa4812f34f2fc78999b16bcab9a6491ff78512
62 - 059 a493b5976c6e2af49213f9d2e2ec1fb052d88e150b98a4dcaea34539472bb92ebc06ed2d66fb69738e522f208efeedd9
62 + 059 32140c4ee5bb75b9d6530a0b7c60cef872acc5fa50bb969eb9368036131d4f84931cddbcf6f8b41038fb0d5fc175af7b
63 + 060 e5b833cff6c768710b0daf88fc9cf087938278ec158c99c0789242878832e802ae00e82bef2cc45efe21644de8d9fa57
@@ -20,6 +20,7 @@ makeover.workspace = true
20 20 goingson-core = { workspace = true }
21 21 goingson-db-sqlite = { workspace = true }
22 22 synckit-client = { path = "../../../synckit/synckit-client" }
23 + synckit-config = { path = "../../../synckit/synckit-config" }
23 24
24 25 # Tauri
25 26 tauri = { workspace = true, features = ["image-png"] }
@@ -614,6 +614,7 @@
614 614
615 615 <!-- Namespace (must load first) -->
616 616 <script src="js/goingson.js"></script>
617 + <script src="js/config.js"></script>
617 618 <script src="js/dispatch.js"></script>
618 619 <script src="js/viewport.js"></script>
619 620
@@ -71,10 +71,12 @@ document.addEventListener('DOMContentLoaded', async () => {
71 71 GoingsOn.tasks.load();
72 72 }
73 73
74 - // First-run welcome
75 - if (!localStorage.getItem('go-welcomed')) {
74 + // First-run welcome. These flags live in the config store now; wait for the
75 + // cache so a first run is not misread as a returning one.
76 + await GoingsOn.config.ready;
77 + if (!GoingsOn.config.get('go-welcomed')) {
76 78 showWelcome();
77 - } else if (!localStorage.getItem('go-hint-shortcuts')) {
79 + } else if (!GoingsOn.config.get('go-hint-shortcuts')) {
78 80 // One-time hint after first session
79 81 setTimeout(() => showHint('go-hint-shortcuts', 'Press ? anytime to see keyboard shortcuts'), 2000);
80 82 }
@@ -376,11 +378,11 @@ function showWelcome() {
376 378 }
377 379
378 380 /**
379 - * Show a one-time dismissible hint toast. Sets localStorage key so it only shows once.
381 + * Show a one-time dismissible hint toast. Sets a config flag so it only shows once.
380 382 */
381 383 function showHint(storageKey, message) {
382 - if (localStorage.getItem(storageKey)) return;
383 - localStorage.setItem(storageKey, '1');
384 + if (GoingsOn.config.get(storageKey)) return;
385 + GoingsOn.config.set(storageKey, '1');
384 386 GoingsOn.ui.showToast(message, 'info', { duration: 5000 });
385 387 }
386 388
@@ -56,9 +56,11 @@ function toggleExpand(el) {
56 56 if (on && off) el.textContent = el.classList.contains('expanded') ? on : off;
57 57 }
58 58
59 - /** Set a localStorage key (replaces inline `localStorage.setItem(...)`). */
59 + /** Set a config value. Routes through `GoingsOn.config`, so a known config key
60 + * (e.g. the nudge prefs and the welcome/hint flags) lands in the backend
61 + * `user_config` store while any other key passes through to localStorage. */
60 62 function setLocalStorage(key, value) {
61 - try { localStorage.setItem(key, value); } catch (e) { /* storage disabled */ }
63 + GoingsOn.config.set(key, value);
62 64 }
63 65
64 66 /** Run a `GoingsOn` action only when Enter was pressed (for keydown handlers). */
@@ -0,0 +1,138 @@
1 + /**
2 + * GoingsOn - App config cache.
3 + *
4 + * Settings used to live in localStorage. Step 6 of the portable-config migration
5 + * moves the known config keys into the backend `user_config` table (synced by
6 + * posture), while keeping a synchronous read API so the rest of the frontend does
7 + * not have to become async.
8 + *
9 + * `GoingsOn.config` is a superset drop-in for the old localStorage calls:
10 + * - a KNOWN config key (by its clean store name or its legacy localStorage
11 + * name) is read from an in-memory cache and written through to the backend;
12 + * - any other key passes through to localStorage unchanged.
13 + *
14 + * The cache is filled once at startup from `get_all_config`; consumers that read
15 + * during app init must `await GoingsOn.config.ready` first. The one paint-critical
16 + * key, `ui_mode`, is mirrored back to localStorage on every write so the
17 + * pre-stylesheet `bootstrap-uimode.js` can still read it synchronously before the
18 + * first paint.
19 + */
20 + (function () {
21 + 'use strict';
22 +
23 + const invoke = window.__TAURI__.core.invoke;
24 +
25 + // Legacy localStorage key -> clean store key. Callers may pass either.
26 + const LEGACY = {
27 + 'goingson-theme': 'theme',
28 + 'goingson.uiMode': 'ui_mode',
29 + 'go-welcomed': 'welcomed',
30 + 'go-hint-shortcuts': 'hint_shortcuts',
31 + 'goingson-event-lead-minutes': 'event_lead_minutes',
32 + 'goingson-plan-nudges': 'plan_nudges',
33 + 'goingson-review-nudges': 'review_nudges',
34 + 'goingson-work-start-hour': 'work_start_hour',
35 + 'goingson-work-end-hour': 'work_end_hour',
36 + };
37 + // Store key -> its localStorage mirror key, for paint-critical keys the
38 + // pre-paint bootstrap reads synchronously.
39 + const MIRROR = { ui_mode: 'goingson.uiMode' };
40 +
41 + const STORE_KEYS = new Set(Object.values(LEGACY));
42 + const cache = new Map();
43 + let resolveReady;
44 + const ready = new Promise((r) => { resolveReady = r; });
45 +
46 + /** The clean store key for a caller key (store name or legacy name), or null
47 + * when the key is not a known config key. */
48 + function norm(key) {
49 + if (STORE_KEYS.has(key)) return key;
50 + return LEGACY[key] || null;
51 + }
52 +
53 + function readLocal(key) {
54 + try { return localStorage.getItem(key); } catch (e) { return null; }
55 + }
56 + function writeLocal(key, value) {
57 + try { localStorage.setItem(key, value); } catch (e) { /* storage disabled */ }
58 + }
59 + function removeLocal(key) {
60 + try { localStorage.removeItem(key); } catch (e) { /* storage disabled */ }
61 + }
62 +
63 + async function load() {
64 + try {
65 + const all = await invoke('get_all_config');
66 + for (const k of Object.keys(all)) cache.set(k, all[k]);
67 + // One-time migration: for any known key the backend does not yet have,
68 + // copy the old localStorage value up. Idempotent (skipped once the
69 + // backend has the key); localStorage is left intact as the ui_mode
70 + // mirror and a harmless fallback.
71 + for (const legacyKey of Object.keys(LEGACY)) {
72 + const storeKey = LEGACY[legacyKey];
73 + if (cache.has(storeKey)) continue;
74 + const v = readLocal(legacyKey);
75 + if (v === null) continue;
76 + cache.set(storeKey, v);
77 + try {
78 + await invoke('set_config', { key: storeKey, value: v });
79 + } catch (e) {
80 + console.error('config migrate failed for', storeKey, e);
81 + }
82 + }
83 + } catch (e) {
84 + console.error('Failed to load config:', e);
85 + } finally {
86 + resolveReady();
87 + }
88 + }
89 +
90 + GoingsOn.config = {
91 + /** Resolves once the cache is populated. Await before reading at init. */
92 + ready,
93 +
94 + /** Synchronous read. Known keys come from the cache (falling back to the
95 + * localStorage mirror, then `fallback`); unknown keys pass through to
96 + * localStorage. Returns a string or `fallback` (default null). */
97 + get(key, fallback = null) {
98 + const storeKey = norm(key);
99 + if (storeKey) {
100 + if (cache.has(storeKey)) return cache.get(storeKey);
101 + const mirrored = MIRROR[storeKey] ? readLocal(MIRROR[storeKey]) : null;
102 + return mirrored !== null ? mirrored : fallback;
103 + }
104 + const v = readLocal(key);
105 + return v !== null ? v : fallback;
106 + },
107 +
108 + /** Write. Known keys go to the cache + backend (+ localStorage mirror for
109 + * paint-critical keys); unknown keys pass through to localStorage. */
110 + set(key, value) {
111 + value = String(value);
112 + const storeKey = norm(key);
113 + if (storeKey) {
114 + cache.set(storeKey, value);
115 + if (MIRROR[storeKey]) writeLocal(MIRROR[storeKey], value);
116 + invoke('set_config', { key: storeKey, value }).catch((e) =>
117 + console.error('set_config failed for', storeKey, e));
118 + return;
119 + }
120 + writeLocal(key, value);
121 + },
122 +
123 + /** Remove a key. */
124 + remove(key) {
125 + const storeKey = norm(key);
126 + if (storeKey) {
127 + cache.delete(storeKey);
128 + if (MIRROR[storeKey]) removeLocal(MIRROR[storeKey]);
129 + invoke('delete_config', { key: storeKey }).catch((e) =>
130 + console.error('delete_config failed for', storeKey, e));
131 + return;
132 + }
133 + removeLocal(key);
134 + },
135 + };
136 +
137 + load();
138 + })();
@@ -764,7 +764,7 @@
764 764 * and applies it to the UI status dots. All date math is done in Rust.
765 765 */
766 766 async function updateEventStatusDot() {
767 - const leadMinutes = parseInt(localStorage.getItem('goingson-event-lead-minutes') || '15', 10);
767 + const leadMinutes = parseInt(GoingsOn.config.get('goingson-event-lead-minutes', '15'), 10);
768 768 let status, label;
769 769
770 770 try {
@@ -11,10 +11,10 @@
11 11
12 12 function getSettings() {
13 13 return {
14 - workStartHour: parseInt(localStorage.getItem('goingson-work-start-hour') || '9', 10),
15 - workEndHour: parseInt(localStorage.getItem('goingson-work-end-hour') || '17', 10),
16 - planNudges: localStorage.getItem('goingson-plan-nudges') !== 'disabled',
17 - reviewNudges: localStorage.getItem('goingson-review-nudges') !== 'disabled',
14 + workStartHour: parseInt(GoingsOn.config.get('goingson-work-start-hour', '9'), 10),
15 + workEndHour: parseInt(GoingsOn.config.get('goingson-work-end-hour', '17'), 10),
16 + planNudges: GoingsOn.config.get('goingson-plan-nudges') !== 'disabled',
17 + reviewNudges: GoingsOn.config.get('goingson-review-nudges') !== 'disabled',
18 18 };
19 19 }
20 20
@@ -86,7 +86,7 @@
86 86 // Section Renderers
87 87
88 88 async function renderAppearance(container) {
89 - const savedTheme = localStorage.getItem('goingson-theme') || 'system';
89 + const savedTheme = GoingsOn.config.get('goingson-theme') || 'system';
90 90 const { light: lightThemes, dark: darkThemes, highContrast: highContrastThemes } = await GoingsOn.themes.getByType();
91 91
92 92 const highContrastGroup = highContrastThemes.length > 0
@@ -130,7 +130,7 @@
130 130 <label class="form-label">Event indicator lead time</label>
131 131 <select id="event-lead-time-selector" class="form-select" data-change="settings.onEventLeadTimeChange" data-a1="@value">
132 132 ${[5, 10, 15, 30, 60].map(m => {
133 - const currentLead = parseInt(localStorage.getItem('goingson-event-lead-minutes') || '15', 10);
133 + const currentLead = parseInt(GoingsOn.config.get('goingson-event-lead-minutes') || '15', 10);
134 134 const label = m === 60 ? '1 hour' : m + ' minutes';
135 135 const suffix = m === 15 ? ' (default)' : '';
136 136 return '<option value="' + m + '"' + (currentLead === m ? ' selected' : '') + '>' + label + suffix + '</option>';
@@ -150,11 +150,11 @@
150 150 <label class="form-label">Work hours</label>
151 151 <div class="work-hours-row">
152 152 <select class="form-select" data-change="settings.onWorkHoursChange" data-a1="start" data-a2="@value">
153 - ${buildHourOptions(parseInt(localStorage.getItem('goingson-work-start-hour') || '9', 10))}
153 + ${buildHourOptions(parseInt(GoingsOn.config.get('goingson-work-start-hour') || '9', 10))}
154 154 </select>
155 155 <span>to</span>
156 156 <select class="form-select" data-change="settings.onWorkHoursChange" data-a1="end" data-a2="@value">
157 - ${buildHourOptions(parseInt(localStorage.getItem('goingson-work-end-hour') || '17', 10))}
157 + ${buildHourOptions(parseInt(GoingsOn.config.get('goingson-work-end-hour') || '17', 10))}
158 158 </select>
159 159 </div>
160 160 <p class="form-hint" style="margin-top: 0.5rem;">Controls when plan/review nudge dots appear.</p>
@@ -162,15 +162,15 @@
162 162 <div class="form-group">
163 163 <label class="form-label">Plan nudges</label>
164 164 <select class="form-select" data-change="ui.setLocalStorage" data-a1="goingson-plan-nudges" data-a2="@value">
165 - <option value="enabled" ${localStorage.getItem('goingson-plan-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option>
166 - <option value="disabled" ${localStorage.getItem('goingson-plan-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option>
165 + <option value="enabled" ${GoingsOn.config.get('goingson-plan-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option>
166 + <option value="disabled" ${GoingsOn.config.get('goingson-plan-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option>
167 167 </select>
168 168 </div>
169 169 <div class="form-group">
170 170 <label class="form-label">Review nudges</label>
171 171 <select class="form-select" data-change="ui.setLocalStorage" data-a1="goingson-review-nudges" data-a2="@value">
172 - <option value="enabled" ${localStorage.getItem('goingson-review-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option>
173 - <option value="disabled" ${localStorage.getItem('goingson-review-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option>
172 + <option value="enabled" ${GoingsOn.config.get('goingson-review-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option>
173 + <option value="disabled" ${GoingsOn.config.get('goingson-review-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option>
174 174 </select>
175 175 </div>
176 176 </div>
@@ -369,7 +369,7 @@
369 369 // Helpers
370 370
371 371 function onEventLeadTimeChange(value) {
372 - localStorage.setItem('goingson-event-lead-minutes', value);
372 + GoingsOn.config.set('goingson-event-lead-minutes', value);
373 373 if (GoingsOn.events && GoingsOn.events.updateEventStatusDot) {
374 374 GoingsOn.events.updateEventStatusDot();
375 375 }
@@ -386,7 +386,7 @@
386 386
387 387 function onWorkHoursChange(which, value) {
388 388 const key = which === 'start' ? 'goingson-work-start-hour' : 'goingson-work-end-hour';
389 - localStorage.setItem(key, value);
389 + GoingsOn.config.set(key, value);
390 390 }
391 391
392 392 // Populate GoingsOn Namespace
@@ -87,7 +87,7 @@
87 87
88 88 applyColors(theme.intents);
89 89 GoingsOn.state.set('currentThemeId', themeId);
90 - localStorage.setItem('goingson-theme', themeId);
90 + GoingsOn.config.set('goingson-theme', themeId);
91 91
92 92 // Update the theme selector if it exists
93 93 const selector = document.getElementById('theme-selector');
@@ -111,10 +111,12 @@
111 111 * Load theme from localStorage or use system preference
112 112 */
113 113 async function loadThemeFromStorage() {
114 + // The chosen theme lives in the config store now; wait for the cache.
115 + await GoingsOn.config.ready;
114 116 // Pre-fetch the theme list so it's cached for the settings UI
115 117 await fetchThemeList();
116 118
117 - const savedTheme = localStorage.getItem('goingson-theme');
119 + const savedTheme = GoingsOn.config.get('goingson-theme');
118 120 if (savedTheme === 'system') {
119 121 await applySystemTheme();
120 122 } else if (savedTheme) {
@@ -134,7 +136,7 @@
134 136 } else {
135 137 await loadTheme('goingson');
136 138 }
137 - localStorage.setItem('goingson-theme', 'system');
139 + GoingsOn.config.set('goingson-theme', 'system');
138 140 }
139 141
140 142 /**
@@ -181,7 +183,7 @@
181 183 // Listen for system theme changes
182 184 if (window.matchMedia) {
183 185 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
184 - if (localStorage.getItem('goingson-theme') === 'system') {
186 + if (GoingsOn.config.get('goingson-theme') === 'system') {
185 187 applySystemTheme();
186 188 }
187 189 });
@@ -32,9 +32,9 @@
32 32 */
33 33 setOverride: function (m) {
34 34 if (m === null) {
35 - localStorage.removeItem('goingson.uiMode');
35 + GoingsOn.config.remove('goingson.uiMode');
36 36 } else if (m === 'mobile' || m === 'desktop') {
37 - localStorage.setItem('goingson.uiMode', m);
37 + GoingsOn.config.set('goingson.uiMode', m);
38 38 } else {
39 39 return;
40 40 }
@@ -0,0 +1,99 @@
1 + //! App config (`user_config`) key/value commands.
2 + //!
3 + //! The SQLite side of settings that used to live in the frontend's
4 + //! `localStorage` (step 6 of the portable-config migration). Writes land in the
5 + //! `user_config` table, whose SyncStore-generated triggers replicate the keys
6 + //! the config spec ([`crate::config_key::CONFIG`]) marks `Synced`. Keys are
7 + //! validated against the spec on write, so the table stays the declared closed
8 + //! set rather than accumulating whatever the frontend sends.
9 +
10 + use std::collections::HashMap;
11 + use std::sync::Arc;
12 +
13 + use tauri::State;
14 + use tracing::instrument;
15 +
16 + use super::ApiError;
17 + use crate::config_key::CONFIG;
18 + use crate::state::AppState;
19 +
20 + #[allow(
21 + clippy::needless_pass_by_value,
22 + reason = "passed to .map_err(db_err), which supplies the error by value"
23 + )]
24 + fn db_err(e: sqlx::Error) -> ApiError {
25 + ApiError::database(e.to_string())
26 + }
27 +
28 + /// Reject a key the spec does not declare, so `user_config` mirrors the spec's
29 + /// closed set and a typo cannot create a stray, never-synced row.
30 + fn ensure_known(key: &str) -> Result<(), ApiError> {
31 + if CONFIG.keys().any(|(known, _)| known == key) {
32 + Ok(())
33 + } else {
34 + Err(ApiError::validation("key", "unknown config key"))
35 + }
36 + }
37 +
38 + /// One config value, or `None` when unset.
39 + #[tauri::command]
40 + #[instrument(skip_all)]
41 + pub async fn get_config(
42 + state: State<'_, Arc<AppState>>,
43 + key: String,
44 + ) -> Result<Option<String>, ApiError> {
45 + let row: Option<(String,)> = sqlx::query_as("SELECT value FROM user_config WHERE key = ?1")
46 + .bind(&key)
47 + .fetch_optional(&state.pool)
48 + .await
49 + .map_err(db_err)?;
50 + Ok(row.map(|(v,)| v))
51 + }
52 +
53 + /// Every config key and value, for the frontend to preload into its cache so its
54 + /// synchronous reads stay synchronous.
55 + #[tauri::command]
56 + #[instrument(skip_all)]
57 + pub async fn get_all_config(
58 + state: State<'_, Arc<AppState>>,
59 + ) -> Result<HashMap<String, String>, ApiError> {
60 + let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM user_config")
61 + .fetch_all(&state.pool)
62 + .await
63 + .map_err(db_err)?;
64 + Ok(rows.into_iter().collect())
65 + }
66 +
67 + /// Set a config value (upsert). A synced key's write is captured by the engine's
68 + /// export trigger; a device-local key's is not.
69 + #[tauri::command]
70 + #[instrument(skip_all)]
71 + pub async fn set_config(
72 + state: State<'_, Arc<AppState>>,
73 + key: String,
74 + value: String,
75 + ) -> Result<(), ApiError> {
76 + ensure_known(&key)?;
77 + sqlx::query(
78 + "INSERT INTO user_config (key, value) VALUES (?1, ?2) \
79 + ON CONFLICT(key) DO UPDATE SET value = excluded.value",
80 + )
81 + .bind(&key)
82 + .bind(&value)
83 + .execute(&state.pool)
84 + .await
85 + .map_err(db_err)?;
86 + Ok(())
87 + }
88 +
89 + /// Remove a config key. Absent already is not an error.
90 + #[tauri::command]
91 + #[instrument(skip_all)]
92 + pub async fn delete_config(state: State<'_, Arc<AppState>>, key: String) -> Result<(), ApiError> {
93 + sqlx::query("DELETE FROM user_config WHERE key = ?1")
94 + .bind(&key)
95 + .execute(&state.pool)
96 + .await
97 + .map_err(db_err)?;
98 + Ok(())
99 + }
@@ -18,6 +18,7 @@
18 18
19 19 mod app_info;
20 20 pub(crate) mod attachment;
21 + mod config;
21 22 mod contact;
22 23 mod daily_note;
23 24 mod day_planning;
@@ -115,6 +116,7 @@ pub(crate) fn write_private_temp(path: &std::path::Path, bytes: &[u8]) -> std::i
115 116 // Re-export all commands for registration in main.rs
116 117 pub use app_info::*;
117 118 pub use attachment::*;
119 + pub use config::*;
118 120 pub use contact::*;
119 121 pub use daily_note::*;
120 122 pub use day_planning::*;
@@ -0,0 +1,104 @@
1 + //! GoingsOn's `user_config` keys and their sync posture.
2 + //!
3 + //! Config used to live in the frontend's `localStorage`, unsynced. Step 6 of the
4 + //! portable-config migration moves it onto the shared config store: a
5 + //! `user_config` key/value table (migration 060) whose sync is governed by
6 + //! posture. GoingsOn is on synckit's `SyncStore` engine, so unlike audiofiles and
7 + //! Balanced Breakfast it uses the real config adapter:
8 + //! [`config_sync_table`](synckit_client::config_sync_table) folds this spec's
9 + //! table into [`goingson_schema`](crate::syncstore::manifest::goingson_schema)
10 + //! and the engine generates the export/import triggers, gated on the
11 + //! `config_key_policy` allowlist seeded from [`CONFIG`]. The engine applies the
12 + //! posture predicate symmetrically, so the import boundary (a remote peer must
13 + //! not set a device-local key) is enforced by the engine, not by hand.
14 + //!
15 + //! Posture is [`synckit_config::Posture`]; `Synced` keys travel, `Local` keys
16 + //! stay on the device, and an undeclared key is `Local` (fail-closed).
17 + //!
18 + //! The store keys are the clean, unprefixed names (the family convention: theme
19 + //! is `theme`, not `goingson-theme`); the frontend maps its old `localStorage`
20 + //! keys to these once, on migration.
21 + //!
22 + //! <!-- wiki: go-config -->
23 +
24 + use synckit_config::{ConfigSpec, Posture};
25 +
26 + /// GoingsOn's `user_config` posture declaration.
27 + ///
28 + /// Preferences worth carrying across a user's devices are `Synced`; per-device
29 + /// state is `Local`. `ui_mode` (desktop/mobile form factor) is `Local` and also
30 + /// the one paint-critical key, kept mirrored in `localStorage` for the pre-paint
31 + /// bootstrap. `welcomed` (first-run) and `hint_shortcuts` (a keyboard-hint
32 + /// dismissal) are per-device.
33 + pub const CONFIG: ConfigSpec = ConfigSpec::new(
34 + "user_config",
35 + &[
36 + // Synced: preferences carried across the user's devices.
37 + ("theme", Posture::Synced),
38 + ("event_lead_minutes", Posture::Synced),
39 + ("plan_nudges", Posture::Synced),
40 + ("review_nudges", Posture::Synced),
41 + ("work_start_hour", Posture::Synced),
42 + ("work_end_hour", Posture::Synced),
43 + // Local: per-device state, never synced.
44 + ("ui_mode", Posture::Local),
45 + ("welcomed", Posture::Local),
46 + ("hint_shortcuts", Posture::Local),
47 + ],
48 + );
49 +
50 + #[cfg(test)]
51 + mod tests {
52 + use super::*;
53 +
54 + #[test]
55 + fn only_preferences_sync() {
56 + for key in [
57 + "theme",
58 + "event_lead_minutes",
59 + "plan_nudges",
60 + "review_nudges",
61 + "work_start_hour",
62 + "work_end_hour",
63 + ] {
64 + assert!(
65 + CONFIG.is_synced(key),
66 + "{key} is a preference and should sync"
67 + );
68 + }
69 + for key in ["ui_mode", "welcomed", "hint_shortcuts"] {
70 + assert!(
71 + !CONFIG.is_synced(key),
72 + "{key} is per-device and must not sync"
73 + );
74 + }
75 + }
76 +
77 + #[test]
78 + fn an_undeclared_key_is_local() {
79 + assert!(!CONFIG.is_synced("something_new"), "fail closed");
80 + }
81 +
82 + // The policy rows the engine seeds and joins: every declared key, the synced
83 + // ones marked replicated. Drift here would desync the export/import filter
84 + // from the declaration.
85 + #[test]
86 + fn policy_rows_mark_only_synced_keys_replicated() {
87 + let replicated: std::collections::BTreeSet<&str> = CONFIG
88 + .policy_rows()
89 + .filter(|r| r.replicated)
90 + .map(|r| r.key)
91 + .collect();
92 + let expected: std::collections::BTreeSet<&str> = [
93 + "theme",
94 + "event_lead_minutes",
95 + "plan_nudges",
96 + "review_nudges",
97 + "work_start_hour",
98 + "work_end_hour",
99 + ]
100 + .into_iter()
101 + .collect();
102 + assert_eq!(replicated, expected);
103 + }
104 + }
@@ -7,6 +7,7 @@
7 7 pub mod backup_scheduler;
8 8 pub mod blob_gc;
9 9 pub mod commands;
10 + pub mod config_key;
10 11 pub mod email;
11 12 pub mod email_sync_scheduler;
12 13 pub mod export;
@@ -52,6 +53,11 @@ macro_rules! all_commands {
52 53 $crate::commands::open_email_blob,
53 54 $crate::commands::save_email_blob,
54 55 $crate::commands::get_file_size,
56 + // Config (user_config key/value)
57 + $crate::commands::get_config,
58 + $crate::commands::get_all_config,
59 + $crate::commands::set_config,
60 + $crate::commands::delete_config,
55 61 // Projects
56 62 $crate::commands::list_projects,
57 63 $crate::commands::get_project,
@@ -149,6 +149,12 @@ impl AppState {
149 149 let backup_settings = Arc::new(SqliteBackupSettingsRepository::new(pool.clone()));
150 150 let sync_accounts = Arc::new(SqliteSyncAccountRepository::new(pool.clone()));
151 151
152 + // Config (step 6): seed config_key_policy from the config spec every
153 + // launch, before any engine trigger regeneration below joins it. Idempotent.
154 + if let Err(e) = crate::syncstore::seed_config_policy(&db_path) {
155 + warn!("config policy seed failed (will retry next launch): {e}");
156 + }
157 +
152 158 // Groups p4 / M2: migrate this device's sync bookkeeping to the SyncStore
153 159 // engine (idempotent, guarded by the `syncstore_migrated` flag). Non-fatal
154 160 // and transactional; a failure leaves the legacy state intact and retries
@@ -164,6 +170,13 @@ impl AppState {
164 170 warn!("SyncStore group migration failed (will retry next launch): {e}");
165 171 }
166 172
173 + // Config (step 6): regenerate triggers so an already-migrated device gains
174 + // the user_config config triggers. No-op on a fresh install (the cutover
175 + // already generated them) and once applied. Non-fatal.
176 + if let Err(e) = crate::syncstore::run_config_migration(&db_path) {
177 + warn!("SyncStore config migration failed (will retry next launch): {e}");
178 + }
179 +
167 180 // Build the SyncStore over goingson.db (its own WAL rusqlite connection,
168 181 // coexisting with the sqlx pool) when a sync client is configured.
169 182 let sync_store = load_sync_client(&app_data_dir)
@@ -25,6 +25,7 @@
25 25 //! config/credential table can never leak into a group scope. `group_id` is
26 26 //! provenance only, never a synced column.
27 27
28 + use synckit_client::store::config_sync_table;
28 29 use synckit_client::{ConflictStrategy, SyncSchema, SyncTable};
29 30
30 31 /// `email_accounts` synced columns: config only, never credentials. The secret
@@ -338,6 +339,11 @@ pub(crate) fn goingson_schema() -> SyncSchema {
338 339 "completed_at",
339 340 ],
340 341 ),
342 + // App settings (step 6): a key/value table filtered by posture. The
343 + // engine generates its export/import triggers gated on the
344 + // `config_key_policy` allowlist, seeded from the spec by
345 + // `seed_config_policy`. Personal, never group-scoped.
346 + config_sync_table(&crate::config_key::CONFIG),
341 347 ])
342 348 // GO's current model; also the engine default.
343 349 .conflict_strategy(ConflictStrategy::HybridLogicalClock)
@@ -371,6 +377,8 @@ mod tests {
371 377 "weekly_reviews",
372 378 "monthly_goals",
373 379 "monthly_reflections",
380 + // Step 6: app settings, key/value (not id-keyed). Always last.
381 + "user_config",
374 382 ];
375 383
376 384 /// The project subtree that can be shared into a group (M3). Everything else
@@ -417,6 +425,11 @@ mod tests {
417 425 #[test]
418 426 fn every_table_is_id_keyed_and_id_first() {
419 427 for table in goingson_schema().tables() {
428 + // user_config is the one key/value table (key-keyed by design); every
429 + // domain table is id-keyed and emits id first.
430 + if table.name() == "user_config" {
431 + continue;
432 + }
420 433 let cols = table.emitted_columns();
421 434 assert_eq!(
422 435 cols.first(),