feat(datastore): add UpdateCheckState persistence

First piece of #591 (opt-in periodic update check). A small persisted
state (last_checked_at, last_seen_version) under update-check.json,
mirroring Settings' Get/Save shape — separate from Settings itself since
this is runtime state, not operator-editable config.

Not wired to anything yet.

Refs #591
This commit is contained in:
Tobias Gesellchen
2026-08-09 01:22:03 +02:00
parent 29463fac98
commit 500f7850be
3 changed files with 108 additions and 0 deletions
+1
View File
@@ -8,3 +8,4 @@ parity_mismatches/
stats/
patterns.json
settings.json
update-check.json
+56
View File
@@ -2720,6 +2720,62 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
return ds.atomicWriteFile(path, data)
}
// UpdateCheckState is the small persisted state for the opt-in periodic
// update check (#591, _/i591/design-update-check.md): when it last ran and
// what it last saw, so a restart doesn't lose the "already logged this
// version" and "don't hammer GitHub on every startup" context. Separate
// from Settings, which is operator-editable config, not runtime state.
type UpdateCheckState struct {
LastCheckedAt string `json:"last_checked_at,omitempty"`
LastSeenVersion string `json:"last_seen_version,omitempty"`
}
// GetUpdateCheckState retrieves the persisted update-check state. Same
// missing-file-is-not-an-error shape as GetSettings — a fresh install (or
// one that has never had the check enabled) has no file yet.
func (ds *DataStore) GetUpdateCheckState() (UpdateCheckState, error) {
if ds == nil || ds.DataDir == "" {
return UpdateCheckState{}, nil
}
path := filepath.Join(ds.DataDir, "update-check.json")
if !ds.rootExists(path) {
return UpdateCheckState{}, nil
}
data, err := ds.rootReadFile(path)
if err != nil {
return UpdateCheckState{}, err
}
var state UpdateCheckState
if err := json.Unmarshal(data, &state); err != nil {
return UpdateCheckState{}, err
}
return state, nil
}
// SaveUpdateCheckState persists the update-check state.
func (ds *DataStore) SaveUpdateCheckState(state UpdateCheckState) error {
if ds == nil || ds.DataDir == "" {
return nil
}
if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(ds.DataDir, "update-check.json")
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return ds.atomicWriteFile(path, data)
}
// SaveUsageStats saves usage statistics to the datastore.
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
dir := filepath.Join(ds.DataDir, "stats", "usage")
+51
View File
@@ -458,6 +458,57 @@ func TestSettingsPersistence(t *testing.T) {
}
}
// TestUpdateCheckState_MissingFileReturnsZeroValue verifies a fresh install
// (or one where the update check has never run) gets a zero-value state,
// not an error — same shape as GetSettings on a missing settings.json.
func TestUpdateCheckState_MissingFileReturnsZeroValue(t *testing.T) {
tempDir, err := os.MkdirTemp("", "update-check-missing-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
state, err := ds.GetUpdateCheckState()
if err != nil {
t.Fatalf("GetUpdateCheckState on a fresh install should not error, got: %v", err)
}
if state != (UpdateCheckState{}) {
t.Errorf("Expected zero-value state, got %+v", state)
}
}
// TestUpdateCheckState_Persistence is the roundtrip test, mirroring
// TestSettingsPersistence.
func TestUpdateCheckState_Persistence(t *testing.T) {
tempDir, err := os.MkdirTemp("", "update-check-persist-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
ds := NewDataStore(tempDir)
state := UpdateCheckState{
LastCheckedAt: "2026-08-09T12:00:00Z",
LastSeenVersion: "v0.122.0",
}
if err := ds.SaveUpdateCheckState(state); err != nil {
t.Fatalf("SaveUpdateCheckState failed: %v", err)
}
loaded, err := ds.GetUpdateCheckState()
if err != nil {
t.Fatalf("GetUpdateCheckState failed: %v", err)
}
if loaded != state {
t.Errorf("Expected %+v, got %+v", state, loaded)
}
}
// TestRecordActivity_EmptyKindReturnsNilNotError verifies GetActivityRecords
// for a kind that was never recorded returns an empty, non-error result —
// the "nothing recorded yet" case, not a failure.