DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We write four version numbers to disk and read none of them, and every migration we shipped is a shape check

Notifio is a desktop app that watches rental search pages from your own machine and emails you when a new listing appears. It has no database and no account system, so everything it knows lives in files under app.getPath('userData'). I have written about the five files and their four durability strategies before.

This week I went looking for something narrower: how the app handles data written by an older build of itself. Every shipped desktop app has this problem, every version bump makes it worse, and the standard answer is a version number in the file.

We have four of those. Here is every one, and every read of one.

The four version numbers

The settings file writes one:

const DATA_VERSION = 4;

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

function save(data: UserData): void {
  writeEnvelope(PROFILE_PATH, { ...data, version: DATA_VERSION });
  _cache = migrate(data);
}
Enter fullscreen mode Exit fullscreen mode

The recordings file writes one:

const FILE_VERSION = 2;

const parsed = readEnvelope<RecipeFile>(RECIPES_PATH, { version: FILE_VERSION, recipes: [] });
Enter fullscreen mode Exit fullscreen mode

And the envelope those two files share writes one of its own, on every write:

const envelope: Envelope = canEncrypt()
  ? { v: 1, enc: 'safeStorage', data: safeStorage.encryptString(json).toString('base64') }
  : { v: 1, enc: 'none', data: json };
Enter fullscreen mode Exit fullscreen mode

Four numbers on disk: version: 4, version: 2, and v: 1 in two files. Now grep the repository for a line that compares any of them to anything.

There isn't one. readEnvelope branches on enc and never looks at v. The recordings reader takes parsed.recipes and ignores parsed.version. And the settings migration, which is the one place in the app explicitly named after the job a version field exists for, does not read the version either:

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 takes one field it recognises, defaults it to false unless it is exactly true, stamps the current version on the result and throws everything else away. That function is correct, it has survived three product changes, and the number next to it has never once been consulted.

How the migrations actually happened

Four real schema changes have shipped in this app's local storage. Not one of them used a version number.

The settings file lost two features. It used to hold a reply profile, then named templates, then a shared message. All three are gone, because a recorded reply flow now contains everything the site's form asks for. The migration is the migrate() above: anything that is not the switch is dropped on read. The "version" of the data is irrelevant, because there is exactly one field worth keeping and the test is whether that field is present and true.

Recordings inherited a message that used to live elsewhere. This one is a genuine data migration and it is driven entirely by shape:

const isOldMessageBinding = (step: RecipeStep): boolean =>
  (step.binding as { kind?: string } | undefined)?.kind === 'message';

if (!stored.some((r) => Array.isArray(r.steps) && r.steps.some(isOldMessageBinding))) {
  return { recipes: stored, changed: false };
}
Enter fullscreen mode Exit fullscreen mode

A step whose binding is of kind message is a step written by an older build, because new builds never produce one. The presence of that kind is the version number. The file is rewritten only if at least one is found, so a user who upgrades and has nothing to migrate pays one array scan and no write.

The config file grew three optional fields. This is the file the monitor reads on every poll, it holds the licence and the saved searches, and it has no version field at all. Its type documents the migration strategy in comments:

export interface Site {
  name: string;
  url: string;
  enabled: boolean;
  skipLogin?: boolean;
  /** Defaults to 'email' when absent, so existing configs keep working. */
  replyMode?: ReplyMode;
}

export interface Config {
  email: { to: string };
  sites: Site[];
  license?: { email: string; token: string };
  /** Local alert channels. Absent means on, so existing configs get them. */
  notifications?: {
    desktop?: boolean;
  };
}
Enter fullscreen mode Exit fullscreen mode

Three optional fields, three comments saying what absence means, and the read side spells it out as the thing it is:

if (fresh.length > 0 && config.notifications?.desktop !== false) {
  notifyFinds(site.name, fresh, log);
}
Enter fullscreen mode Exit fullscreen mode

!== false rather than === true. A config written before desktop notifications existed has no notifications key, so the feature is on for everybody who upgrades, which is what we wanted. If that check had been === true, every existing user would have silently lost a feature they never turned off, and no version number would have saved them, because the version number would have had to be read by something and then mapped to a default, which is the same decision written in two more places.

The entitlement cache grew a price. Same pattern, with a comment that says the quiet part:

export interface ValidateResult {
  valid: boolean;
  plan?: string;
  autoReply: boolean;
  /** Absent from servers and caches that predate local prices. */
  upgradePrice?: UpgradePrice;
}
Enter fullscreen mode Exit fullscreen mode

And the read is a shape check rather than a version check:

const parsed = JSON.parse(fs.readFileSync(ENTITLEMENT_PATH, 'utf8')) as CachedEntitlement;
if (!parsed || typeof parsed.fetchedAt !== 'string') return null;
// Anything other than an explicit `true` fails closed.
_memory = { ...parsed, autoReply: parsed.autoReply === true };
Enter fullscreen mode Exit fullscreen mode

typeof parsed.fetchedAt !== 'string' is doing the job a version field is usually hired for: it answers "is this file the shape I expect", and it does it without needing to know which build wrote it.

The one place a version would have helped, and the reason it did not

There is a fifth store: an append-only log of reply attempts, one JSON object per line. Rows in it are years of product decisions deep, and two of its 21 statuses can no longer be produced:

/**
 * Legacy. Produced by the old dry-run rehearsal, which no longer exists.
 * Kept because the ledger is append-only and older rows still carry it.
 */
| 'awaiting_approval'
Enter fullscreen mode Exit fullscreen mode

An append-only file is the one case where a file-level version is useless by construction, because a single file holds rows written by every build the user has ever run. A version per row would work. What we have instead is a union type that never deletes a member, and a fold that merges rows by id so the newest row wins. The old status names are the version history, and they are load-bearing: awaiting_approval is in the set that means "never touch this listing again", so forgetting it would mean messaging a landlord twice about the same room.

So which is the mistake

Two defensible readings.

The first: the version fields are dead weight. Four numbers written on every save, none read, and each one is an invitation for a future contributor to believe there is a migration framework here when there is not. Delete them, keep the shape checks, and the code says exactly what it does.

The second, which is where I land: keep them and leave them unread, because they cost one integer per write and they are the only cheap way out of a corner I might paint myself into. The shape-check approach works beautifully for adding optional fields and badly for one specific case, which is a field whose meaning changes while its name and type stay the same. If enabled: true ever has to mean something different from what it means today, no shape check can tell the two apart, and the only thing that can is a number somebody wrote down before the change. That is cheap insurance against the one failure mode the current design cannot handle.

What I will actually change is smaller than either: a comment above each version field saying nothing reads it and what would have to be true for something to start, because a number with no reader and no explanation is the kind of thing that gets "fixed" by someone adding a branch that nobody tested.

I wrote a while ago that storage written by an older build of your app is untrusted input, and 'absent' is the trap. The audit above is the same lesson from the other side: absence is also the tool. Every migration this app has shipped was a question about what a missing field means, and every one of those questions had an answer that was obvious the moment it was asked, which is why they are three one-line comments in a type definition rather than a framework.

If you want to see the files this is about, they are in the directory the help page tells you how to delete, the app is a one-time licence with installers here, and what is stored where is spelled out in the privacy notice. The searches it watches are set up per site, with a page each at notifio.app/alerts such as Pararius and Funda.

Top comments (0)