Munchable keeps a fair amount on the device on purpose. The health profile, the scan history and food log, a small cache of resolved products and the ingredient knowledge overlay all live in AsyncStorage rather than on our server, which is most of why the privacy story at munchable.app/privacy is as short as it is. You can see the app itself, which runs the same code in a browser, at app.munchable.app.
Local storage of that kind has a property that is easy to forget: the writer is not you. It is a build of your app from any point in the past, and its idea of your data shapes is frozen at whatever was true on the day the user last updated. Reading it is reading untrusted input, and the failure mode is not a parse error. It is a plausible wrong answer.
Here is the one that actually bit us, and then the four mechanisms we now use.
Absent read as an answer
The product cache stores resolved products by barcode so a product you scanned survives an app restart. A later release added a field to that shape, named, recording whether Munchable already holds a name for the barcode.
Entries written by the previous build did not have it. They rehydrated with the field simply absent, and absent is falsy, and falsy meant "no name".
The consequence was not a blank screen. It was the capture screen telling people "nobody has added this barcode yet" for products we had perfectly good names for, and then taking whatever name they typed. A missing field became a confident false statement, and then a data quality problem in the catalogue.
The fix is embarrassingly small and the comment is longer than the code, deliberately:
/**
* Bump on every change to the shape of a stored `ResolvedProduct`.
*
* Without it an entry written by an older build rehydrates with the new fields
* simply absent, and absent reads as a real answer rather than as "we do not
* know". `named` is the case that bit: undefined counts as unnamed, so after an
* app update the capture screen told people "nobody has added this barcode yet"
* for products Munchable had names for, and then took the name they typed.
*/
const STORE_KEY = 'munchable-products-v2';
For a cache, a version in the key is the whole migration strategy. There is nothing to preserve: the worst case of throwing every entry away is one extra network lookup per product. Compare that to the cost of one wrong sentence on screen and the choice makes itself.
The rule I would write on the wall: a cache key carries a version, and you bump it on any shape change, not just on an incompatible one. The instinct that additive changes are safe is wrong here, because the new field's default is a claim.
Mechanism two: drop ids this build no longer knows
Not everything can be thrown away. The health profile is the user's own answers, and losing it means asking them to do onboarding again, which is a much worse outcome than a re-lookup.
So the profile store keeps its persisted state and filters it. zustand's default merge handles the additive case correctly on its own, a profile stored before allergies existed just keeps the empty default. What it does not handle is ids that have since been retired:
// zustand's default merge is otherwise exactly right (a profile stored
// before allergies existed simply keeps the empty default above). What it
// does not do is drop ids this build no longer knows, and it has to: an
// allergen renamed or retired in a later release would be ignored by the
// engine, so a profile that lists an allergy would render no allergy row
// at all, and the pickers look every id up by name and would crash on it.
merge: (persisted, current) => {
const p = (persisted ?? {}) as Partial<ProfileState>;
return {
...current,
...p,
conditions: Array.isArray(p.conditions) ? p.conditions.filter(isConditionId) : [],
allergens: Array.isArray(p.allergens) ? p.allergens.filter(isAllergenId) : [],
healthy: Array.isArray(p.healthy) ? p.healthy.filter(isHealthPreferenceId) : [],
};
},
The two consequences named in that comment are worth separating, because they have different severities.
The crash is the obvious one: a picker that looks an id up by name gets undefined and dereferences it.
The silent one is much worse, and it is specific to the allergen layer. If the engine does not recognise a stored allergen id, it produces no finding for it, and the result screen renders no allergen row at all. The user still believes they declared that allergy. An app that quietly stops checking something safety critical, while continuing to look exactly the same, is the single failure mode we design hardest against. Filtering on the way in turns it into a visible one: the allergy disappears from their profile screen, where they can see it and set it again.
The Array.isArray guards in there are not paranoia about our own writer. They are about a partially written or hand-edited storage file, which on a rooted device or a browser devtools console is entirely reachable.
Mechanism three: validate each row, not just the container
The scan history is a list, and lists need per-item validation rather than a single shape check, because one bad entry should cost you one entry:
const entries = Array.isArray(p.entries)
? p.entries
.filter(
(e): e is HistoryEntry =>
!!e && typeof e.id === 'string' && typeof e.barcode === 'string' && typeof e.at === 'number',
)
.map((e) => ({ ...e, feeling: isFeeling(e.feeling) ? e.feeling : undefined }))
: [];
The map is the interesting half. feeling is a small closed vocabulary (the log lets you record that a food left you fine, a bit off, or rough) and a value outside it is coerced back to undefined rather than dropping the whole entry. Losing a note is annoying. Losing the record that you ate the thing at all is worse, because that is the part you cannot reconstruct.
Mechanism four: a one-time handover between two stores
Before the food log existed, the profile store kept the last twelve scans so the hub could show a recents list. When the log arrived, those scans should become its first entries, once.
Two things make that harder than it looks. Both stores hydrate from AsyncStorage on their own schedule, so neither can assume the other is ready, and the import must never run twice.
function migrateLegacyScans(): void {
const run = (): boolean => {
const profile = useProfile.getState();
const history = useHistory.getState();
if (!profile.hydrated || !history.hydrated) return false;
const legacy: ScanRecord[] = profile.recentScans;
if (legacy.length > 0) {
if (history.entries.length === 0) {
const entries = [...legacy].sort((a, b) => b.at - a.at).map((r) => ({ ...r, id: entryId(r) }));
useHistory.setState({ entries });
}
profile.clearLegacyScans();
}
return true;
};
if (run()) return;
const unsubscribe = useProfile.subscribe((s) => {
if (s.hydrated && run()) unsubscribe();
});
}
Try immediately, and if the other store is not ready, subscribe until it is. Idempotence comes from emptying the source: clearLegacyScans() runs whether or not the entries were actually imported, so a second run finds nothing to do. And the guard on history.entries.length === 0 means an install that already has a log never has twelve old scans appear underneath it.
The legacy field itself stays declared and persisted, with a comment saying it is never written any more. Deleting the type would have been tidier and would have broken the handover for anyone updating from an old enough build.
The fifth thing, which is not a migration
Account deletion wipes local data, and that list has to include keys the current build no longer writes:
await AsyncStorage.multiRemove([
'munchable-profile', // useProfile persist (health profile)
'munchable-history', // useHistory persist (scan history + food log)
'munchable-products-v1', // productCache (pre-v2 key, still cleared)
'munchable-products-v2', // productCache
'hints:sent', // legacy: ingredient-hint memory, no longer written
'taxonomy:overlay', // taxonomy overlay
'taxonomy:meta',
]);
The v1 line is the direct consequence of the fix at the top of this post. Bumping a cache key orphans the old one, and an orphaned key still contains data. If your delete list is written from what your code currently writes, it will miss exactly the keys your migrations abandoned. Ours is written from every key the app has ever used, with a comment per line saying which is which, and the two legacy entries stay there permanently.
Every one of these is three lines of code. The expensive part was learning that "absent" is not a neutral value, it is whatever your defaults say it is, and your defaults were written for a world where the field exists.
Top comments (0)