The header comment on Notifio's settings module is the shortest specification I have written:
/**
* The auto-reply on/off switch. That is the entire settings surface.
*
* Replaces the old profile store, and then the shared message: neither is needed,
* because a recording already contains everything that site's form asks for,
* message included. Nothing about a reply is configured centrally any more.
*/
The app has an optional auto-reply feature. It used to keep a profile (your name, phone, email), then named message templates, then one shared message. Today it keeps a boolean. Three rounds of deletion, and the interesting engineering is all in how you delete stored user data without losing anyone's work.
Why the settings went away
Auto-reply works by replaying a demonstration you performed once on a site's own enquiry form. I wrote about that approach before: We automate form filling with zero LLM calls, and that is the feature.
Once a recording exists, every central setting is a duplicate of something the recording already knows. The site asked for a phone number, so the recording has a phone number in it. The site had a message box, so the recording has your message in it.
And duplicated configuration does not sit there harmlessly, it disagrees. A global default message and a per-recording message are two answers to "what did you actually send", and the user cannot see which one won. Every support conversation about that feature starts with reconstructing which of the two applied.
Configuration that restates data you already hold is not a convenience, it is a second source of truth. The fix was not to reconcile them. It was to have one.
The migration is a constructor
The usual shape for evolving a stored format is a version number and a chain of upgrade functions. Ours is this:
/**
* Earlier versions stored a profile, then named templates, then a shared message.
* All three are dropped on read: the switch is the only thing left worth keeping,
* and a recording holds everything else.
*/
function migrate(raw: unknown): UserData {
const data = (raw ?? {}) as { autoReply?: Partial<AutoReplySettings> };
return {
version: DATA_VERSION,
autoReply: { enabled: data.autoReply?.enabled === true },
};
}
It does not branch on the version. It does not know what version 1, 2 or 3 looked like. It reads the one field that still exists and builds a valid current object, so every historical shape, every partially written file, null, and a JSON array are all valid input.
Two details:
enabled: data.autoReply?.enabled === true fails closed. A missing field, a string "true", a 1, or a corrupt object all produce false. For a feature that sends messages to strangers on your behalf, the only safe default is off, and the strict comparison is what makes "we could not read your settings" and "you had it switched off" land in the same safe place.
DATA_VERSION is 4, and nothing reads it. It is there for support: if someone sends me their file, the number tells me which era it came from. The moment a version number is load bearing, you own a chain of upgrade functions forever. When a migration can be written as "rebuild from what still matters", it costs nothing to keep it total.
The one exception, named and fenced
A migration that only deletes has a problem: it deletes data that something else still needs, once. When the shared message moved into recordings, existing recordings pointed at the setting rather than holding text. Drop the setting and their message is gone.
So there is exactly one escape hatch, and it says what it is:
/**
* The shared message as it was before it moved into each recording.
*
* Read straight off disk, because the current shape has no message field for it
* to survive a migrate() through. recipes.ts calls this once to fold the text
* into recordings that still point at the old setting; nothing else should.
*/
export function legacyMessage(): string {
const raw = readEnvelope<{ message?: unknown }>(PROFILE_PATH, {});
return typeof raw.message === 'string' ? raw.message : '';
}
It bypasses migrate deliberately, because migrate is the thing throwing the field away. The recordings store calls it on first read, folds the text into each affected recording, writes them back, and never needs it again.
The good part is what happens when there is nothing to inherit, for instance because the user never set a message:
* A recipe with nothing to inherit is marked broken rather than left to submit an
* empty message: the site row then asks for a redo, which is the honest outcome.
with a reason string written for the person reading it, not for the log:
'The message is now saved with each site rather than on its own. Redo this site so yours is stored with it.'
A migration that cannot complete has three options: guess, fail silently, or tell the user what to do. Sending a blank enquiry to a landlord on someone's behalf is the worst possible guess, so the record is marked broken and the UI asks for two minutes of the user's time. Data migrations are allowed to have a user interface.
One envelope for every local file
Both stores, the switch and the recordings, go through the same two functions:
export interface Envelope {
v: number;
enc: 'safeStorage' | 'none';
data: string;
}
export function writeEnvelope(filePath: string, value: unknown): void {
const json = JSON.stringify(value);
const envelope: Envelope = canEncrypt()
? { v: 1, enc: 'safeStorage', data: safeStorage.encryptString(json).toString('base64') }
: { v: 1, enc: 'none', data: json };
// Atomic write so a crash mid-write can't corrupt the file.
const tmp = `${filePath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(envelope), 'utf8');
fs.renameSync(tmp, filePath);
}
There are two version numbers in this system and they have different jobs. v: 1 in the envelope is the format of the container: what enc means, what data holds. DATA_VERSION: 4 is the shape of the payload inside. Keeping them separate means changing how the file is encrypted does not look like a change to what it contains.
Encrypting a single boolean with the OS keychain is, on its own, theatre. It is not theatre for the file next to it:
// Encrypted, because recipe steps now hold the values captured from the
// user's own recording: their name, phone, email and whatever else the site
// asked for. Writing that in clear next to an encrypted store would defeat
// the point of encrypting anything.
One code path for all local user data means the file that genuinely holds personal information cannot be the one someone forgot to encrypt. The cost of applying it to the boolean too is zero.
What happens when the keychain is not there
safeStorage needs a working OS keychain. Sometimes there is not one: the module runs headless in development outside Electron, and a Linux session can come up without a secret service.
export function canEncrypt(): boolean {
try {
return safeStorage.isEncryptionAvailable();
} catch {
// Not running under Electron, or the OS keychain is unavailable. Fall back to
// plaintext rather than losing the data.
return false;
}
}
The write path degrades to plaintext, which is the right call for a local file on a machine whose owner can read the process memory anyway. The read path is where the honest trade lives:
if (envelope.enc === 'safeStorage') {
if (!canEncrypt()) {
throw new Error('data is encrypted but safeStorage is unavailable');
}
json = safeStorage.decryptString(Buffer.from(envelope.data, 'base64'));
}
That throw is caught by the caller, which logs and returns the fallback. So a machine that loses access to its keychain reads an encrypted file, cannot decrypt it, and gets defaults: auto-reply off, no recordings. The user sees a feature that appears to have forgotten everything, with a line in the log explaining why.
I am comfortable with that because of the direction it fails in. The alternative, refusing to start, is worse for an app whose main job is watching rental pages and has nothing to do with auto-reply. The alternative of silently continuing with an empty store and overwriting the encrypted file would destroy the recordings, so nothing in the read path writes.
The rule I would keep: decide whether each store is load bearing for the app's main job, and let the ones that are not fail into their defaults. The monitor must never stop because a secondary feature's file is unreadable. That is also why these live in separate files in the first place:
// Auto-reply data. Kept in separate files from config.json on purpose: a
// corrupt profile or ledger must never stop the monitor from finding listings.
The general lesson
Every settings field you ship is a thing you will migrate, document, support and eventually contradict. Three rounds of deletion here each came from the same realisation, which is that we were storing an answer the user had already given us somewhere more specific.
If you take one habit from this: when you add a settings field, ask where else that fact is already recorded. If the answer is "in the thing this setting configures", you are about to build two sources of truth and a support queue.
Have a look
The switch this whole post is about is the one toggle in the app's settings, and what it costs (a separate one-time upgrade, no subscription) is on notifio.app/pricing. What the feature does, in plain words, is at notifio.app/help, and the app itself is at notifio.app.
Top comments (0)