DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Five files in one folder, four different durability strategies, and the state we refuse to write down

Notifio is a desktop app that watches rental searches and tells you when something new appears. It has no local database. No sqlite, no better-sqlite3, no level, nothing with a .node binary in it.

That was a packaging decision before it was a storage decision. The app already ships a Chromium inside an Electron app, which makes the installer large and the build fiddly, and adding a native module that has to be rebuilt per platform and per Electron version was one more thing to break on a release I was not going to be around to debug. So everything is files.

What I did not expect is that "everything is files" would turn into five separate files with four different durability stories. Here is the folder, and why each thing in it is the shape it is.

The folder

// src/paths.ts
export const USER_DATA_DIR    = app.getPath('userData');
export const DATA_DIR         = path.join(USER_DATA_DIR, 'data');
export const BROWSER_DATA_DIR = path.join(USER_DATA_DIR, 'browser-data');
export const CONFIG_PATH      = path.join(USER_DATA_DIR, 'config.json');
export const PROFILE_PATH     = path.join(USER_DATA_DIR, 'profile.json');
export const ENTITLEMENT_PATH = path.join(USER_DATA_DIR, 'entitlement.json');
export const RECIPES_PATH     = path.join(USER_DATA_DIR, 'recipes.json');
export const LEDGER_DIR       = path.join(USER_DATA_DIR, 'ledger');
export const FINDS_PATH       = path.join(USER_DATA_DIR, 'finds.json');
Enter fullscreen mode Exit fullscreen mode

app.getPath('userData') resolves to %APPDATA%\Notifio on Windows and ~/Library/Application Support/Notifio on macOS. Everything writable lives there, because inside a packaged asar __dirname is read only, and the first version of this app learned that the way everyone does.

One module owns all of it. Nothing else in the codebase calls path.join against the user data directory, and one function creates the directories:

export function initDataDirs(): void {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.mkdirSync(BROWSER_DATA_DIR, { recursive: true });
  fs.mkdirSync(LEDGER_DIR, { recursive: true });
  if (!fs.existsSync(CONFIG_PATH)) { /* seed from the bundled copy */ }
}
Enter fullscreen mode Exit fullscreen mode

It has to run before any other module loads, because modules read paths at import time. That ordering is enforced by a require rather than an import in the main process, which was its own small adventure and its own post.

Four strategies

config.json is plain and seeded. It holds the email the alerts go to and the list of searches. On first run it is copied from a bundled default if one exists, otherwise written from a literal. Plain JSON, no encryption, because its contents are things the user typed into a form and can see on screen anyway.

profile.json and recipes.json share an encrypted envelope. These two go through Electron's safeStorage, which hands the key to the OS keychain:

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 };

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

Two things in there are load bearing. The enc field means a file written on a machine with no working keychain is still readable, and a file written with encryption announces that fact rather than being guessed at on read. And the write is a temp file plus a rename, so a crash halfway through leaves the previous version intact instead of a truncated JSON file. fs.rename is atomic within a filesystem, which is all the guarantee needed here.

The fallback to plaintext is a real tradeoff. The alternative is to refuse to save, and I decided that losing a user's data because their keychain is unavailable is the worse failure.

entitlement.json is a cache that is deliberately not security. It records whether a licence has the auto reply upgrade, which the server decides. The comment at the top says what it is:

Note this is a speed bump, not DRM. A local Electron app cannot keep a secret from its owner, and this file is plain JSON. Anything that must not be forged has to be enforced server-side.

I would rather write that down than pretend. The file exists so a flaky connection does not switch a paid feature off mid search, and it reads with one defensive line that matters: autoReply: parsed.autoReply === true. Anything that is not an explicit true, including a missing field or a string or a number, fails closed.

ledger/replies.ndjson is append only, with compaction. The reply engine needs to answer two questions across restarts: has this listing already been messaged, and how many messages went out recently. So every attempt is one JSON object on one line, appended:

fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8');
Enter fullscreen mode Exit fullscreen mode

Updates are appended as another row with the same id, and readers fold last wins. That is the whole trick, and it buys atomicity for free: a single appendFileSync of one line either lands or does not, and there is no read, modify, write window for a crash to land inside. Compaction is a threshold, rewriting the file once it passes 8000 rows and keeping the most recent 2000.

Nothing about found listings goes in there. The alert is the record of those.

The ring buffer, and why it is not in config.json

finds.json is the history the app shows you. It is the newest 200 finds and nothing else:

const MAX_FINDS = 200;

export function add(items: Omit<Find, 'foundAt'>[]): Find[] {
  const existing = read();
  const seen = new Set(existing.map((f) => f.id));
  const fresh = items.filter((item) => !seen.has(item.id)).map((item) => ({ ...item, foundAt: Date.now() }));
  if (fresh.length === 0) return [];
  write([...fresh, ...existing].slice(0, MAX_FINDS));
  return fresh;
}
Enter fullscreen mode Exit fullscreen mode

Two deliberate choices. It returns only the entries it actually added, so the caller can notify about exactly those and nothing twice, which is what makes the dedupe and the notification one decision instead of two.

And it is a separate file from config.json on purpose. config.json is read on every poll cycle; a history that grows forever inside it would make that read grow forever too. It is also separate from the per search diff baselines, which get deleted whenever a search is re-pointed at a new URL. That moment, when you change your search, is exactly when you least want your history of what turned up to vanish.

Reading is forgiving in the same way everywhere:

try {
  const parsed = JSON.parse(fs.readFileSync(FINDS_PATH, 'utf8'));
  _cache = Array.isArray(parsed) ? (parsed as Find[]) : [];
} catch {
  _cache = [];
}
Enter fullscreen mode Exit fullscreen mode

A corrupt or missing history is an empty history. The thing that must never happen is a monitor that will not start because a file it only needed for display would not parse. The same reasoning is why these are separate files at all: a broken recipe store or ledger cannot stop the app finding listings for you, which is the one job it has. You can see what that job looks like per site on the alert pages, for instance the Kamernet one.

The state we refuse to persist

The one thing in this app with no file behind it is the live per search state: which search is being checked right now, which one succeeded, which one is waiting on a login. It lives in a module level map in the main process and dies with the process, and the header comment is the argument:

Deliberately in memory only: it describes the current run, and a stale "checking..." restored from disk after a restart would be a lie.

That one took a bug to learn. The window reloads itself every time it is reopened, so the renderer cannot be the owner of anything it wants to survive. Moving ownership into the main process fixed that. Writing it to disk as well would have fixed nothing and created a new category of wrong, which is a UI confidently showing you the state of a poll that stopped happening when the app quit.

If you want to see the shape of the thing all this is storing for, the download page has the installers, and the help page is where the questions about it land.

The summary I would have wanted up front

Pick the durability strategy per file, from the question "what is the worst thing that happens if this file is garbage".

  • Garbage means the app cannot do its job: own file, forgiving read, atomic write.
  • Garbage means a user lost something they typed: atomic write, encrypted if it is personal, plus a version field so you can migrate it later.
  • Garbage means a landlord gets messaged twice: append only, so there is no window to be interrupted in.
  • Garbage means a stale number on screen: do not write it down at all.

Top comments (0)