Some internal tools do not deserve a schema. This is the story of one that got a page in the admin area, a data file in the repository and a working log in localStorage, and of the two lines that decide whether that is a reasonable design or a leak.
The tool is a call list. Roughly a thousand rows, each one an organisation with a location, a phone number and the person to ask for, researched by hand for an outreach campaign. It started life as a standalone HTML file sitting in a docs folder, opened from the filesystem, with the progress kept in that one browser. That worked right up until it needed to be used from a second machine and not be a 159 line file full of inline script.
The data file stays a data file
The rows live in lib/admin/call-list.json, 947 of them, one per line, with single letter keys:
{"n": "...", "g": "...", "l": "...", "c": "...", "s": 1489, "p": "...", "a": "...", "e": "..."}
Those keys are inherited from the HTML page it came from, and they were kept rather than expanded, for a reason worth stating: with short keys each row fits on one line, so the file diffs row by row when a number is corrected. Expand the keys to name, location, postcode and the same data becomes a ten line object per row and a correction becomes an unreviewable diff. The expansion happens in code instead, in a mapper the rows pass through on the way out.
The two lines that matter
A contact list is exactly the kind of data that leaks by accident, and the mechanism is boring: a client component imports a type from a module, that module imports the rows for convenience, and the bundler cheerfully ships a thousand rows of names, direct lines and email addresses into a static chunk that anybody can fetch. No endpoint, no auth bug, nothing in a log. It is in the JavaScript.
So the module split is the design:
// lib/admin/call-list.ts
/**
* Free of the data file on purpose. The rows are loaded by `call-list-data.ts`,
* which is server only, and handed to the screen as props from behind the admin
* gate. Importing them here would put them in a static JS chunk that anyone can
* fetch.
*/
export interface CallListEntry { ... }
// lib/admin/call-list-data.ts
import 'server-only';
import raw from '@/lib/admin/call-list.json';
The contract module holds the types, the statuses, the labels and every pure function. The data module holds the import of the rows and nothing else, and it carries import 'server-only', which turns "a client component reached this" from a runtime surprise into a build error. The screen is a client component that receives the rows as props from a server component behind the admin check. One direction only.
Two small things the mapper does on the way out, which are also the sort of thing you only notice once the data is real:
id: `${kind}:${row.n}${row.c ? `|${row.c}` : ''}`,
dial: phone.replace(/\s*ext.*$/i, '').replace(/\s/g, ''),
The id is not the name, because names collide: seven names in the list are used by more than one organisation, and one of those seven is used by three. The key that the progress log is written against has to survive that, so the postcode is part of it. And the tel: link gets the digits with any extension stripped off, because dialling a string with "ext 204" on the end does not work, while a human reading "ext 204" next to the button does.
The progress log is not product data
Here is the part I expected to be told off for. The outcome of each call, a status and a note, is kept in localStorage under one key, with no table behind it:
export const CALL_LOG_STORAGE_KEY = 'cogniprep:admin-call-list';
const FORMAT_VERSION = 1;
The justification is that this is one operator's working notes, not product data. Nothing else in the system reads it, no feature depends on it, and the cost of a table is a migration, a repository, two routes and a set of tests for data whose entire lifetime is one campaign. What it does need is a way to move between a phone and a laptop, so instead of a server it has an export and an import, and those are where the care went.
The envelope is versioned, and a wrong file says so.
export function parseCallLog(value: unknown): CallLog | null {
if (typeof value !== 'object' || value === null) return null;
const { version, records } = value as { version?: unknown; records?: unknown };
if (version !== FORMAT_VERSION || typeof records !== 'object' || records === null) {
return null;
}
// ... then each record is validated and bad ones are skipped
}
null means "this is not a call list backup at all", which lets the screen say so. If the function returned an empty log for both cases, importing the wrong file would look exactly like importing an empty one, which is the kind of silent no-op that costs somebody an afternoon.
Writes can fail, and the function admits it. writeCallLog returns a boolean rather than throwing or swallowing, because a browser can refuse storage, and a tool whose notes are silently not being saved is worse than one that says so once.
Clearing a row keeps the row. Setting an outcome back to nothing stores a blank record with a fresh timestamp instead of deleting the key. That sounds like clutter until you look at the merge:
export function mergeCallLogs(current: CallLog, incoming: CallLog) {
const log = { ...current };
let updated = 0;
for (const [id, record] of Object.entries(incoming)) {
const mine = log[id];
if (!mine || record.updatedAt > mine.updatedAt) {
log[id] = record;
updated++;
}
}
return { log, updated };
}
Last write wins, per row, by timestamp, and the import reports how many rows it changed. If a cleared row were deleted instead of blanked, importing yesterday's backup would resurrect the outcome that was deliberately cleared this morning, because there would be nothing newer to beat it. A tombstone with a timestamp is what makes an import idempotent and order-independent.
That is the entire sync design: a JSON file, a merge function and 107 lines of tests over the parse, update and merge rules. No conflict UI, because per-row last-write-wins has no conflicts to show.
See it
There is nothing public to click on this one, since the tool lives behind the admin gate. What the outreach is for is the product itself: cogniprep.app and the plain English version of who builds it on the about page, which also explains the stance that the rest of the engineering has to live up to.
If you want to check the general claim rather than this tool, it is checkable on any site, including ours: open a page, take the list of script chunks from the Network tab, and search them for a string that should only exist on the server. That search returning nothing is the only real evidence that a server-side module stayed server-side.
The general version
Two questions settle most "where does this live" arguments. Does anything other than this one screen read it, and what breaks if it is lost? If the answers are "no" and "one person retypes some notes", the browser is a perfectly good database, and a versioned export with a per-row last-write-wins merge is a perfectly good sync protocol. The part that is not negotiable is the module boundary around the data, because that one is not about convenience.
Top comments (0)