DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two version numbers in our settings file, and no code reads either of them

Notifio is a desktop app that watches rental search pages. Its settings surface is one boolean: auto-reply on or off. Everything else that used to be configurable turned into a number in the source rather than a field in a form.

That one boolean is stored on disk, encrypted, in a file that carries two version numbers. I went looking for the code that reads them this week and there isn't any.

The file

const DATA_VERSION = 4;

export interface Envelope {
  v: number;
  enc: 'safeStorage' | 'none';
  data: string;
}

function defaults(): UserData {
  return { version: DATA_VERSION, autoReply: { enabled: false } };
}
Enter fullscreen mode Exit fullscreen mode

On disk that is an envelope whose data is either base64 ciphertext or a JSON string, and inside that is the payload with its own version: 4. Envelope format version 1, payload shape version 4. The envelope's has never moved. The payload's has moved three times, because this file used to hold a reply profile, then named message templates, then a single shared message, and now holds none of those.

Here is the reader:

export function readEnvelope<T>(filePath: string, fallback: T): T {
  if (!fs.existsSync(filePath)) return fallback;
  try {
    const envelope = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Envelope;
    let json: string;
    if (envelope.enc === 'safeStorage') {
      if (!canEncrypt()) {
        throw new Error('data is encrypted but safeStorage is unavailable');
      }
      json = safeStorage.decryptString(Buffer.from(envelope.data, 'base64'));
    } else {
      json = envelope.data;
    }
    return JSON.parse(json) as T;
  } catch (err) {
    console.error(`[store] Failed to read ${filePath}:`, err);
    return fallback;
  }
}
Enter fullscreen mode Exit fullscreen mode

It branches on enc. It never looks at v.

And here is the whole of the payload migration:

/**
 * 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 },
  };
}
Enter fullscreen mode Exit fullscreen mode

It never looks at version either. It reads one path out of whatever object it was handed, coerces it with === true, and rebuilds the file's entire contents from that.

So both numbers are written on every save and read by nothing. Which is either a smell or a design, and I think it is worth being clear about which.

Shape-driven beats version-driven, until it doesn't

A version-driven migration is a chain: if version 1, do this, then if version 2, do that. It is the right tool when a change cannot be detected from the data, and it has a cost, which is that you keep every step forever and each one has to still be correct years after the shape it operated on stopped existing.

A shape-driven read skips all of that. data.autoReply?.enabled === true is correct for a version 1 file, a version 4 file, an empty object, null, a file written by a future version with six new fields, and a file somebody hand-edited badly. It is idempotent, it needs no history, and it cannot be half-applied. For a migration whose job is "keep one boolean and throw the rest away", there is no chain worth writing.

Where it runs out is any change that the shape cannot distinguish. If I ever ship a release where enabled means the opposite of what it means today, or where it becomes a three-way enum and the old true has to map to the middle value, no amount of reading the shape will tell me which file I am holding. That is the day version earns its place, and that is the only honest reason to keep writing it: it costs one field now, and without it that future migration is impossible rather than awkward.

What I would change is the comment. A field that is written and never read looks like dead weight to the next person, who is usually me, and the fix is one line next to DATA_VERSION saying it exists for a migration that has not been needed yet.

The missing-file case is the real version check

Because nothing branches on a version, the way an unreadable file is handled is the whole of the compatibility story:

} catch (err) {
  console.error(`[store] Failed to read ${filePath}:`, err);
  return fallback;
}
Enter fullscreen mode Exit fullscreen mode

Any failure returns the fallback, and for this store the fallback is { autoReply: { enabled: false } }. Which means every bad outcome in this file, corrupt JSON, a truncated write, ciphertext that cannot be decrypted because the data moved to a machine whose keychain is unavailable, lands on auto-reply switched off.

That direction is the entire reason I am relaxed about it. The feature this boolean gates sends a message to a landlord on the user's behalf. "We could not read your settings so we did nothing" is recoverable in one click. The opposite default would be a product I would not ship. The encryption itself is allowed to be unavailable, and enc in the envelope is what records which way that went, which is the one piece of self-description in the file that is actually load-bearing.

The write side is the same tmp-then-rename as the rest of the local stores, so a crash mid-write cannot produce the corrupt file that the reader is nonetheless written to survive:

const tmp = `${filePath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(envelope), 'utf8');
fs.renameSync(tmp, filePath);
Enter fullscreen mode Exit fullscreen mode

The field the migration deletes, and its one remaining reader

migrate throws away the old shared message. That text was something the user typed, so throwing it away silently would be rude. There is a deliberate hole in the floor for it:

/**
 * 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 : '';
}
Enter fullscreen mode Exit fullscreen mode

Note what this is: a second reader of the same file, at a different type, bypassing the migration on purpose, because the migration is exactly what would destroy the thing it wants. readEnvelope being generic over the payload is what makes it possible, and the last three words of that comment are the only enforcement there is.

The one caller is the recordings module, which folds the salvaged text into any recording that still refers to the old setting, once, and then writes the recordings back:

const inherited = legacyMessage().trim();
// ... for each step still bound to the old shared message:
binding: inherited
  ? ({ kind: 'literal', value: inherited } as const)
  : ({ kind: 'skip' } as const),
Enter fullscreen mode Exit fullscreen mode

And if there is nothing to inherit, the recording is marked broken rather than left to send an empty message:

status: 'broken' as const,
brokenReason:
  'The message is now saved with each site rather than on its own. Redo this site so yours is stored with it.',
Enter fullscreen mode Exit fullscreen mode

That is the part I would defend hardest. A data migration that cannot complete has two options: guess, or tell the user. Guessing here means a landlord receives a blank enquiry from a customer who thought the app was working, which is worse than any amount of friction. So the row says it is broken, in a sentence that explains what changed, and asks for thirty seconds of the user's time.

One wart while I am in here. The top of that module still describes its file as "plain JSON", written when it was, while the function underneath it encrypts:

// 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.
Enter fullscreen mode Exit fullscreen mode

Two comments in one file disagreeing about whether the file is encrypted. The code is right and the header is three refactors stale, which is the sort of thing you only notice by reading your own source for a blog post.

The general version, which I keep relearning in this repo: a version number is a promise that something will branch on it, and if nothing ever does, what you actually built is a shape-driven reader. Both are fine. Writing one and believing you have the other is how a migration ends up quietly dropping a field that someone typed.

The switch all of this stores lives in the app you can install from the download page, what it does and what the upgrade includes is written out on the pricing page and the help page, and the per-site setup guides are at notifio.app/alerts, including Kamernet and WG-Gesucht.

Top comments (0)