Our app had no offline mode for its first two months: every screen was a REST call, and with the radios off every screen was empty. The fix that shipped is deliberately small. The server stays the only source of truth, and the phone keeps a read-only mirror of what it last saw, filled as a side effect of every successful fetch and read only when a fetch fails. No sync engine, no delta endpoint, no conflict resolution.
That design took one evening to write and eight minutes to break. This is why its convergence rule became "replace by scope, but merge the survivors", and how a later bug turned a read into a wipe. React Native 0.81, @op-engineering/op-sqlite. Every block below is copied from the repository at the commit named above it; 9bed3a1 is the current head.
One table, one opaque column
The cache does not model the domain. neurons_cache has an id primary key, four indexed text columns (status, sphere, type, due_at) and a payload column holding the JSON the server returned. No migration when the API grows a field; SQLite only discriminates on the four columns. A deliberately dumb store.
Network first, write through, fall back
Every read service follows the same shape. Try the network. On success, hand the rows to the cache without awaiting it and return them. On failure, probe the socket so the offline banner shows up fast, then serve the mirror if it has ever been primed.
frontend/src/services/MemoryService.ts at 9bed3a1, inside listNeurons:
// Write-through : an unfiltered "active" fetch is a full snapshot of the
// active set → replace-by-scope (deletions converge) ; anything narrower
// only upserts.
const isFullActiveList =
filters.status === 'active' && !filters.sphere && !filters.type && !filters.limit;
void (isFullActiveList
? neuronCache.replaceActiveSet(rows as unknown as Array<Record<string, unknown>>)
: neuronCache.upsertMany(rows as unknown as Array<Record<string, unknown>>)
).catch(() => {});
And the start of the catch (err) branch of the same function:
webSocketService.ensureAlive();
// Offline fallback : serve the mirror if it has ever been primed.
try {
if (await neuronCache.isPrimed()) {
const cached = await neuronCache.list({
status: filters.status,
sphere: filters.sphere,
type: filters.type
});
return cached as unknown as MemoryNeuron[];
}
The lines that follow swallow any cache failure and rethrow the original network error. Two decisions hide here. The primed flag separates "offline with a genuinely empty memory" from "never synced": only the second should surface an error, and an empty table cannot tell you which one you are in. And the void ...catch(() => {}) means a cache failure can never fail a read; the cache is a side effect, not a dependency.
Replace by scope: convergence without tombstones
The server can delete rows or move them out of a status, and a cache that only ever upserts keeps ghosts forever. Instead of tombstones or a delta endpoint, a fetch that is a complete snapshot of some scope replaces that scope: the unfiltered active list replaces the active set, a calendar window replaces that window. Anything narrower only upserts, because it cannot know what it did not receive.
frontend/src/services/NeuronCacheDb.ts at d31f159, the commit that introduced the mirror:
/** Full sync of a calendar window [fromIso, toIso]. */
async replaceWindow(
fromIso: string,
toIso: string,
rows: Array<Record<string, unknown>>
): Promise<void> {
const db = await dbForCurrentOwner();
await db.execute(
'delete from neurons_cache where due_at is not null and due_at >= ? and due_at <= ?',
[fromIso, toIso]
);
await upsertInto(db, rows);
await setMeta(db, 'primed', '1');
},
Correct if one endpoint feeds the table. Two do.
Two REST shapes, one row
The list endpoint returns each neuron with graph fields, mention_count and last_mentioned_at among them, which size the nodes of the home constellation. The calendar endpoint returns the same neuron with agenda fields and without the graph fields. Same primary key, two projections.
The cache was designed for this: its upsert reads the existing payload, spreads the incoming row over it, and writes the union back.
frontend/src/services/neuronCacheGuards.ts at 9bed3a1:
export function mergePayload(
existingJson: string | null,
incoming: Record<string, unknown>
): string {
const existing = existingJson ? parsePayload(existingJson) : null;
return JSON.stringify(existing ? { ...existing, ...incoming } : incoming);
}
frontend/src/services/NeuronCacheDb.ts at 9bed3a1:
async function upsertInto(db: DB, rows: Array<Record<string, unknown>>): Promise<void> {
for (const row of rows) {
const id = idOf(row);
if (!id) continue;
const existing = await db.execute('select payload from neurons_cache where id = ?', [id]);
const existingPayload = existing.rows[0] ? String(existing.rows[0].payload) : null;
const payload = mergePayload(existingPayload, row);
const merged = parsePayload(payload) ?? row;
await db.execute(
`insert or replace into neurons_cache (id, status, sphere, type, due_at, payload)
values (?, ?, ?, ?, ?, ?)`,
[
id,
typeof merged.status === 'string' ? merged.status : null,
typeof merged.sphere === 'string' ? merged.sphere : null,
typeof merged.type === 'string' ? merged.type : null,
typeof merged.due_at === 'string' ? merged.due_at : null,
payload
]
);
}
}
The merge is not done by SQLite. insert or replace is delete-and-insert under a friendlier name; the union exists only because the function reads the old payload first. So the invariant "a row is the union of every shape that ever fed it" is upheld by upsertInto and nothing else, and replaceWindow broke it from the outside: the delete ran before upsertInto could read, and every dated neuron in the window came back with agenda fields only.
Nothing noticed online, because the next full list refetch restored the graph fields. Offline, the constellation read the mirror, computed Math.log(1 + mention_count) on undefined, got NaN, and put it in an SVG path. On Android a NaN coordinate in a path is a native crash, not a JavaScript exception. The app died on relaunch with the radios off, the one scenario the feature existed for.
Merge the survivors, delete only the missing
The fix, eight minutes after the mirror landed, reverses the order and narrows the delete.
frontend/src/services/NeuronCacheDb.ts at 9bed3a1, unchanged since 8c4e6dc:
async replaceWindow(
fromIso: string,
toIso: string,
rows: Array<Record<string, unknown>>
): Promise<void> {
const db = await dbForCurrentOwner();
await upsertInto(db, rows);
const existing = await db.execute(
'select id from neurons_cache where due_at is not null and due_at >= ? and due_at <= ?',
[fromIso, toIso]
);
const existingIds = existing.rows.map((r) => String(r.id));
for (const id of staleWindowIds(existingIds, rows as Array<{ id?: unknown }>)) {
await db.execute('delete from neurons_cache where id = ?', [id]);
}
await setMeta(db, 'primed', '1');
},
Upsert first, so every survivor is merged. Then take the set difference between what the window holds and what the server just returned, and delete only that. staleWindowIds is a pure function with its own tests. Same scope semantics, but survivors keep the fields the other endpoint gave them.
The same commit added withGraphDefaults at read time, filling the two graph fields with safe values on any row that lacks them. That layer heals phones that already had a poisoned cache on disk, which the root fix cannot repair.
The read that wiped the cache
The mirror belongs to exactly one identity, user:<id> or device:<uuid>. Every operation first resolves the current owner and, if it differs from the stored one, wipes the database, so no row is ever shown across identities. That rule is right. Where the identity came from was not.
frontend/src/services/NeuronCacheDb.ts at 9bed3a1:
async function dbForCurrentOwner(): Promise<DB> {
const db = await openDb();
const owner = ownerKeyOf(await getKnownUserId(), await getDeviceId());
const stored = await getMeta(db, 'owner');
if (stored !== owner) {
await wipe(db);
await setMeta(db, 'owner', owner);
}
return db;
}
For a month, the owner in that function came from getSession(). The Supabase auth client refreshes the access token when you read the session past its lifetime, and if that refresh fails with a network error it returns a null session while leaving the real one on disk. So one hour after the last online use, the cache asked who owned it, was told "nobody", concluded the owner had changed from user: to device:, and deleted every row and the primed flag before serving. Coming back online flipped the key a second time, wiped again, refetched everything, and hid the whole sequence.
It had been there since the mirror's first commit, needed an expired token no test session ever reached, and was triggered by a read: opening the app offline destroyed the data it was opening to show.
The replacement reads the session blob the auth client persisted in AsyncStorage and never touches the network. It is fail-closed: an absent blob means anonymous, and a home-grown "last known user" key is dropped too, because a key outliving a sign-out would keep the previous owner's rows readable.
Cache-first paint, network overwrite
The constellation is the one screen that reads the cache before trying the network, because a blank first screen for two seconds reads as data loss.
frontend/src/hooks/useNeuronGraph.ts at 9bed3a1:
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const [cachedNodes, cachedEdges] = await Promise.all([
neuronCache.list({ status: 'active' }),
neuronCache.listEdges()
]);
if (cancelled || cachedNodes.length === 0) return;
setNodes((prev) => (prev.length > 0 ? prev : (cachedNodes as unknown as MemoryNeuron[])));
setEdges((prev) => (prev.length > 0 ? prev : (cachedEdges as unknown as MemoryEdge[])));
setReady(true);
The effect ends with an empty catch and a cleanup setting cancelled. The functional updater is the interesting line. Two reads race, SQLite and the network. If the network wins, prev.length > 0 is already true and the stale cache result is discarded. If the cache wins, it paints, and the network result replaces it through the unconditional setNodes(rows) in refresh. "Non-empty already" is the whole arbitration.
What the code does not prove
replaceActiveSet still does delete-then-insert on status = 'active'. The code supports one reason it has not bitten: the list row is the richer shape, so overwriting an agenda row with it loses nothing the constellation needs. But the invariant is now asymmetric, protected on one path by ordering and on the other by an accident of which endpoint is fatter. The day the calendar endpoint grows a field the list endpoint lacks, that path strips it. Nobody has written that test.
mergePayload is a shallow spread: a nested metadata object from the incoming row replaces the stored one wholesale. upsertInto runs a select and an insert per row, sequentially, with no transaction; nothing measures where that hurts.
The tests for the owner bug drive the real data path: a session blob in AsyncStorage as the auth client writes it, a getSession mock returning null on top, and an assertion that no delete from neurons_cache is ever executed. The tests for the merge bug cover staleWindowIds and withGraphDefaults in isolation. Nothing exercises replaceWindow against a real SQLite with two shapes of the same id. The invariant that crashed the app is documented in three comments and enforced by the order of two lines.
The lesson I kept is that "replace by scope" is a statement about ids, not about rows. The scope says which ids should exist afterwards, nothing about what each row should contain when more than one endpoint contributes to it. Once two projections of the same entity share a primary key, delete-then-insert stops being a cache refresh and becomes a lossy projection, and the loss shows up wherever the cache is read without the network to hide it.
The mirror described here ships in the Android app at xneuronal.com.











