uttrflow / uttrflow/uttrflow-swift
One entry an older build cannot decode sets aside the whole dictionary or history, silently: going back a version loses every learned word
- Dominant language
- Swift
- Stars
- 4
- Forks
- 17
- Avg merge
- 3h 32m
- Merged PRs (30d)
- 277
Description
## What happens
The dictionary, history, snippet and clipboard stores each decode their whole file as one array (`LocalStore.read`, `Sources/UttrflowCore/Support/StoredList.swift:41-56`). If any single element fails to decode, the whole file is renamed to `.unreadable-` and the store answers empty (`PersonalDictionaryStore.load`, `Sources/UttrflowDictionary/PersonalDictionaryStore.swift:221-222`; `DictationHistoryStore.load`, `Sources/UttrflowHistory/DictationHistoryStore.swift:120-122`). The next write starts a fresh file beside it.
Entries carry enums with synthesized `Codable`, so a value added by a newer build is a decode failure for an older one. The concrete case today: 66e9fab added `WordOrigin.shipped` (`Sources/UttrflowDictionary/DictionaryEntry.swift:14-15`), and every dictionary gains a `shipped` entry for the product's own name. A user who installs 2026.9.14 and then goes back to an earlier build (a bad update, a rollback to a known-good version) gets:
- an empty Dictionary page, with every learned and added word moved into the set-aside file;
- no notice: only `ClipboardStore` ever checks `LocalStore.hasSetAside` (`Sources/UttrflowClipboard/ClipboardStore.swift:437`), and only to protect its picture sweep;
- nothing that brings the set-aside file back when they upgrade again, because by then a new `dictionary.v1.json` exists.
`Docs/history-store-file.md` explains the choice not to salvage record by record ("the realistic corruption is a whole file somebody mangled"). A file written by a newer version of the same app is a second realistic case the doc does not consider, and its damage is every entry, not one.
## Why it matters
The personal dictionary is the thing a user builds up over weeks so their names and terms come out right. Losing it silently makes dictation visibly worse with no explanation, and the history case removes what the Corrections and Insights pages are built from.
## How to reproduce (headless)
Write a `dictionary.v1.json` with three entries, one of which has `"origin": "someFutureOrigin"`, into a temporary directory and read it through `PersonalDictionaryStore(file:).allEntries()`. On main (26d7bc1) the answer is `[]` and the folder holds only `dictionary.v1.json.unreadable-`.
## Acceptance criteria (needs a product decision first)
Options, not mutually exclusive:
- decode element by element and keep the entries that decode, setting aside only the file's original bytes as today;
- decode unknown enum raw values to a fallback case rather than failing the entry;
- tell the user when a store has a set-aside file, and offer to bring it back when this build can read it.
Whichever is chosen, a test pins that one undecodable entry does not empty the dictionary or the history.
Contributor guide
Research direction
Start with LocalStore.read and the load methods in Sources/UttrflowDictionary/PersonalDictionaryStore.swift and Sources/UttrflowHistory/DictationHistoryStore.swift, then run the headless reproduction described in the issue. After the product decision, implement the chosen behavior across the affected stores and add a regression test showing that one undecodable entry does not empty the dictionary or history.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- swift
- Domain
- data, desktop-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100