🔄 Sync and sharing that never lose work
Two phones, two parents, one log: merge by id, tombstones for deletes, household sharing from rules alone, and a two-user test before any screens.
You'll leave with
- A merge function that keeps every record from both sides
- Deletes that stay deleted across devices
- Household sharing with an invite code, proven by a script
Sync sounds like one line of code: upload my copy. It works with one phone. The day two parents log feedings on two phones, it quietly deletes one parent's entries, and nobody sees an error. First Bites and Baby Signs are logs a whole family writes to; this lesson is what that takes.
What does sync mean?
Keeping copies of the same data in agreement. Each phone keeps one, so the app works offline, the server keeps another, and syncing swaps changes until they match.
If each person only saves their own things, store each item as its own record (one saved item) on the server, as in Accounts without building auth: there's nothing to merge. This lesson is for a browser copy kept in sync across devices or people. It builds on that lesson's PocketBase server and the engine.js self-check (your rules in one file that tests itself) from Check the AI's work.
Three kinds of data, three keys
Before anything syncs, split what your app saves in localStorage under three keys:
What are localStorage and a key?
localStorage is a small store the browser keeps for each website, on one device: text saved under a name, the key. Nothing in it leaves the device unless your code sends it.
| Key | Holds | Uploaded? |
|---|---|---|
myapp.v1 | What people log: entries, profiles, a wishlist | Yes, and only this |
myapp.prefs | This device's dark mode, quiet mode, dismissed banners, accepted terms | Never |
myapp.auth | Login token, user id, email | Never |
What are a login token and a user id?
The token is a long random string the server gives your app at login; anyone holding it can act as you until it expires. The user id is your account's unique name.
Why: a synced dark mode lets your partner's phone win the next merge (the step that combines two copies) and flip your screen back; a synced token would land in a record every household member can read. First Bites keeps both out of its synced data.
With npx serve running as usual, open Claude Code in your myapp folder and ask:
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: sync code reads only myapp.v1, so nothing under the other keys can travel. Check it yourself at http://localhost:3000: F12 (Mac: Cmd+Option+I) opens DevTools (Application hides behind » if the panel is narrow), and Local storage → your address lists myapp.v1, myapp.prefs once you've changed a setting, and myapp.auth (it replaces Accounts' myapp.token) once you're logged in. Use Chrome or Edge on a computer; Safari needs its Develop menu on, and phones can't.
Merge by id, never overwrite
The obvious sync is "last write wins": each phone uploads its whole copy over the server's. One parent logs the 2 a.m. feeding; the other's phone, not yet updated, saves the 6 a.m. one, and its upload wipes the 2 a.m. feeding without an error. First Bites expects two parents on two phones, so it syncs like this:
- Every record gets an id (a random name no other record shares) and an edited-at time,
u, fromDate.now()(milliseconds since 1970), on every change. - Download first, then merge by id: keep every record either side has; if both edited the same one, the newer edit wins.
- Upload the result and save it on the device.
- Wait for a pause: push about 1.2 seconds after the last change, so ten quick taps make one upload.
this phone: e1 e2 (edited 9:05) e3
server copy: e1 e2 (edited 9:01) e4
merged: e1 e2 (edited 9:05) e3 e4
Two moments adopt instead of merging: a device with nothing logged takes the server copy on its first sync, and someone joining a household takes its data rather than mixing in their solo practice entries, warning first if that replaces entries on their phone. Every later sync merges.
The merge lives in engine.js, under test (final code in step 3). The page syncs one record in households, a collection (table) from step 4; someone alone has a household of one.
EXAMPLE — YOU ASK CLAUDE CODE
The page side:
let state = engine.migrate(JSON.parse(localStorage.getItem('myapp.v1') || 'null'));
let timer = null, inFlight = null, paused = false;
function syncSave() { // call after every change
localStorage.setItem('myapp.v1', JSON.stringify(state)); // this device first
clearTimeout(timer);
timer = setTimeout(() => { // about 1.2 s after the LAST change
inFlight = push().catch(err => console.error('sync failed', err)).finally(() => { inFlight = null; });
}, 1200);
}
async function push() { // also run it when the app opens or returns to the screen
if (paused || !auth || !householdId) return; // auth comes from myapp.auth
const url = '/api/collections/households/records/' + householdId;
const headers = { 'Content-Type': 'application/json', Authorization: auth.token };
const got = await fetch(url, { headers }); // 1. download
if (!got.ok) throw new Error('download failed: ' + got.status);
const merged = engine.mergeStates(state, (await got.json()).data); // 2. merge
const put = await fetch(url, { method: 'PATCH', headers, body: JSON.stringify({ data: engine.payload(merged) }) });
if (!put.ok) throw new Error('upload failed: ' + put.status); // 3. upload
state = engine.mergeStates(state, merged); // merge again: you may have logged something meanwhile
localStorage.setItem('myapp.v1', JSON.stringify(state));
render();
}
async function pauseSync() { // before leaving a household or deleting an account
paused = true;
clearTimeout(timer);
await inFlight;
}
What's happening underneath: fetch(url) alone is a GET, a request to read; a PATCH changes some fields of an existing record. push() downloads before it uploads, and its last merge keeps anything logged meanwhile. pauseSync() cancels the timer and waits for any upload on its way, so a queued push can't recreate what you're deleting.
⚠️ Every synced field must appear in four places: fresh() (a new empty state), migrate() (fills gaps in an older saved copy), mergeStates() and payload() (exactly what's uploaded). Miss one and a new field, say a wishlist, silently never reaches the other phone.
Deletes need tombstones
Merge by id has one hole: delete an entry, and about 1.2 seconds later the sync downloads the server copy, which still has it, and the merge, built to keep everything, puts it back.
What's a tombstone?
A small note saying "record X was deleted at time T". It syncs along with the data, so every copy learns about the delete and every merge drops X.
📍 First Bites shipped its merge with a written note: no tombstones yet, so a delete can come back; add them if it bites. It bit during Google Play launch prep, when deleted entries reappeared about 1.2 seconds later, on the next sync. That launch blocker forced a sync rewrite, which I tested with a fake-server harness (a test that plays the server in memory) across 23 scenarios.
What's happening underneath, in the code below:
- Delete: write
tomb[id] = now, then remove the record. - Merge: per id, keep the later time from either side, then drop records marked deleted.
- Re-add: if the id is the thing itself (wishlist item
avocado), writetomb[id] = -now. Negative means "added back"; being later, it wins. - Parents: deleting a baby drops its entries.
EXAMPLE — YOU ASK CLAUDE CODE
The final sync part of engine.js (inside the engine; also return the new functions):
const LISTS = ['babies', 'entries', 'wishlist']; // every synced list, named once
function fresh() { // 1. a brand-new state
return { v: 1, babies: [], entries: [], wishlist: [], tomb: {} };
}
function migrate(s) { // 2. an older saved copy gains what it lacks
const out = { ...fresh(), ...(s || {}) };
for (const k of LISTS) if (!Array.isArray(out[k])) out[k] = [];
if (!out.tomb || typeof out.tomb !== 'object') out.tomb = {};
return out;
}
function mergeList(mine, theirs) { // keep both sides; same id: the newer edit wins
const byId = new Map(mine.map(r => [r.id, r]));
for (const r of theirs) {
const have = byId.get(r.id);
if (!have || (r.u || 0) > (have.u || 0)) byId.set(r.id, r);
}
return [...byId.values()];
}
function mergeStates(local, server) { // 3. two copies in, one out
const a = migrate(local), b = migrate(server);
const tomb = { ...a.tomb };
for (const [id, t] of Object.entries(b.tomb)) { // per id, the later event wins: delete (+) or re-add (-)
if (!(id in tomb) || Math.abs(t) > Math.abs(tomb[id])) tomb[id] = t;
}
const live = id => !(tomb[id] > 0);
const out = { v: 1, tomb };
for (const k of LISTS) out[k] = mergeList(a[k], b[k]).filter(r => live(r.id));
out.entries = out.entries.filter(e => live(e.babyId)); // a deleted baby takes its entries along
return out;
}
function payload(s) { // 4. exactly what gets uploaded
const out = { v: s.v, tomb: s.tomb };
for (const k of LISTS) out[k] = s[k];
return out;
}
function remove(s, list, id, now) { // a delete leaves a tombstone
return { ...s, tomb: { ...s.tomb, [id]: now }, [list]: s[list].filter(r => r.id !== id) };
}
function readd(s, list, item, now) { // re-adding a keyed item, like wishlist 'avocado'
return { ...s, tomb: { ...s.tomb, [item.id]: -now },
[list]: [...s[list].filter(r => r.id !== item.id), { ...item, u: now }] };
}
The proof goes inside the self-check, with a one-line fake server: syncing a phone merges it with the server copy and keeps the result.
const { fresh, mergeStates, payload, remove } = engine;
const round = payload(mergeStates(fresh(), fresh())); // the four-places rule, enforced
for (const k of Object.keys(fresh())) assert.notEqual(round[k], undefined, k + ' is in fresh() but never syncs');
let server = fresh();
const sync = phone => (server = mergeStates(phone, server)); // a fake server
let a = sync({ ...fresh(), entries: [{ id: 'e1', u: 1 }] }); // phone A logs e1
let b = sync({ ...fresh(), entries: [{ id: 'e2', u: 1 }] }); // phone B logs e2
assert.equal(b.entries.length, 2, 'records from both phones survive');
a = sync(remove(a, 'entries', 'e1', 2)); // A deletes e1, then syncs
b = sync(b); // B syncs its stale copy, which still has e1
a = sync(a);
assert.equal(b.entries.length, 1, 'deleted on A, gone on B');
assert.equal(a.entries.length, 1, 'deleted on A, still gone on A');
Run node engine.js in a terminal in your myapp folder: engine ok. Then remove .filter(r => live(r.id)) (or the line in yours that drops deleted records), save and rerun:
Undo (Ctrl+Z, Mac: Cmd+Z), save, rerun: engine ok.
🦥 Tombstones can wait in a prototype: Baby Signs skipped them as YAGNI ("you aren't gonna need it"). Write the shortcut down in the code and your NOTES.md, because it will bite.
Household sharing with rules only
Two collections and their rules let a spouse, grandparent or nanny log into the same data from their own account. No server code, and no restart of a server other apps depend on.
What are PocketBase's rules?
Each collection has list, view, create, update and delete rules, checked on every request; @request.auth.id is whoever is logged in. Empty means anyone; locked (null) means only superusers, your admin accounts.
households
fields owner (text, required) · name (text) · data (json)
list, view @request.auth.id != '' && (owner = @request.auth.id || household_members_via_household.user ?= @request.auth.id)
update @request.auth.id != '' && (owner = @request.auth.id || household_members_via_household.user ?= @request.auth.id) && (@request.body.owner:isset = false || owner = @request.auth.id)
create, delete @request.auth.id != '' && owner = @request.auth.id
household_members
fields household (relation to households, single, cascade delete on) · user (text, required) · role (text)
index unique on (household, user)
list, view, create, delete @request.auth.id != '' && user = @request.auth.id
update locked (null)
What's a back-relation?
A relation field points at a record in another collection, like each membership row's household. household_members_via_household looks back along it: "the membership rows pointing here"; ?= means "at least one matches".
What's happening underneath: owner and members can read a household and write its data, but only the owner can hand it over (the update rule's last part). You create or delete only your own membership row, so nobody adds anyone else. Cascade delete clears a deleted household's memberships; the unique index (no two rows with the same pair) blocks joining twice.
Creating collections needs your superuser login, so admin scripts get their own folder, out of reach of npx serve and your deploys: in a terminal in myapp, run mkdir ../myapp-admin, and in Claude Code type /add-dir ../myapp-admin. Save a file named .env there (VS Code: Ctrl+N, Mac: Cmd+N, then File → Save As) with these lines, replacing each placeholder:
PB_URL=https://accounts.yourdomain.com
PB_EMAIL=you@yourdomain.com
PB_PASSWORD=YOUR_SUPERUSER_PASSWORD
🛑 That password can change every account on your server. Never paste it into the chat or commit it to git: node --env-file=.env hands the values straight to the script, so Claude never needs to see them.
EXAMPLE — YOU ASK CLAUDE CODE
Paste the rules block above under your message before sending. Then look yourself in your dashboard (https://accounts.yourdomain.com/_/): click a collection in the left sidebar, and the gear beside its name opens API Rules (labels vary by version).
The invite code is a house key
You don't need an invite system. A household's record id is 15 random characters nobody can guess, so it can be the code, shared as text or a link: https://myapp.yourdomain.com/?join=CODE. A friendlier code would need server code. On opening, the app reads the code (the part after ?join=), remembers it, and joins after sign-in:
let pendingJoin = null;
const code = new URLSearchParams(location.search).get('join');
if (code) {
pendingJoin = code; // used after sign-in
history.replaceState(null, '', location.pathname); // take the code out of the address bar
}
After sign-in, it creates your membership row (household = the code, user = your id, role = member) and adopts the household's data.
🛑 Anyone holding the code can join, then read and change everything: share it person to person, never publicly. If you add a service worker (the background script behind offline use, in Make it an app on their phone), file pages by path only, so ?join= addresses never become cache keys (the names saved pages are stored under), and purge old ones.
Reuse, don't rebuild
Another app needs households? Reuse the collection: a setup script adds one optional field, like app2_data (json), touching nothing else, and the first app's checks prove it still works. Only ever add shared fields; renaming or removing one breaks the apps already using it.
📍 My first sharing design, one private record per user, was replaced the same day, painless only because nobody else used it yet. With real users, write a migration (a one-time script that moves the data) first, then delete the old design: two designs drift.
Prove it with two users before building screens
A script plays four people against your real rules: owner A, member B, stranger C and a logged-out visitor. Members read and write; outsiders get 404 (not found); nobody can forge a membership; non-owners can't delete. First Bites' scripts proved these before it had any sharing screen.
EXAMPLE — YOU ASK CLAUDE CODE
Run it yourself (PowerShell, Git Bash or Mac Terminal); you should see something like:
cd ../myapp-admin
node --env-file=.env test-households.mjs
What's happening underneath: each fake person is a real account with its own token, so PocketBase treats them like a real family. A check you've never seen fail may test nothing: in the dashboard, loosen household_members' create rule to @request.auth.id != '', rerun, watch "add C" fail, and restore the rule.
Only now build the screens, and ship them as in Deploy behind one front door:
EXAMPLE — YOU ASK CLAUDE CODE
The live test: two people, fresh windows
Then by hand on your live site, with two throwaway accounts (plus-addresses like you+a@yourdomain.com reach your inbox on most mail hosts). A private window shares nothing with your normal one, so it acts as another device (Chrome or Edge: Ctrl+Shift+N; Mac: Cmd+Shift+N).
- Normal window: at
https://myapp.yourdomain.com, sign up as A, log three items, copy the invite link. - Private window: open the link, sign up as B and join: A's three items appear. Log one more.
- Close every private window (that wipes them), open a new one and log in as A: four items, both members listed.
- Delete an item in the normal window. A few seconds later, reload the private one: it's gone, even after another reload.
- Close all private windows, open a new one, log in as B and choose Leave household. Reload the normal window: B's email is gone from the member list.
📍 Baby Signs' household sync passed steps 1 to 3 live, baby's name and both members included. Then the test users were deleted.
Delete yours too: ask Claude to remove the test users, household and memberships and show zero left; then search the dashboard's users collection for you+: nothing.
Leaving and deleting without a trace
The shared data lists members and often who logged each entry, so it holds everyone's email. Leaving, or deleting an account, goes in this order:
pauseSync(), so no queued upload puts anything back.- Last member out? Delete the household; cascade delete takes the memberships.
- Otherwise scrub your email from the shared data (as a newer edit, or another phone's merge restores it), hand ownership on if it's yours, and delete your membership row.
- Deleting the account too? Delete your user record (the self-delete rule from Accounts without building auth), then sign out, keeping this device's copy.
Throw on any failed request and re-check with a fresh query, or "deleted" can hide data still on the server. Before launch, First Bites ran a 55-check data-removal test against production, the live site.
Do it with Claude: the prompts
The core of this lesson is three prompts:
- "Store synced data, device preferences and the login token under three separate localStorage keys."
- "Write mergeStates(local, server) that merges by id, keeps the newer edit and honors tombstones, with self-check cases for a delete on one device."
- "Create households and household_members with member-only rules, then prove them with a script acting as four different users, and delete the test users."
What's happening underneath: the server never decides who wins; your merge does, so it lives in engine.js with tests. When Claude says sync works, run both checks yourself, then add the rules to your NOTES.md:
## Sync rules
Three keys: myapp.v1 is synced; myapp.prefs and myapp.auth never leave the device.
Every synced field goes in fresh(), migrate(), mergeStates() and payload().
Deletes write tombstones; re-adding a keyed item writes a negative time.
Before leaving a household or deleting an account: pauseSync().
Before every ship: node engine.js, then in myapp-admin: node --env-file=.env test-households.mjs (PASS, 0 left).
Your turn
- Logged in, with a setting changed, DevTools → Application → Local storage shows
myapp.v1,myapp.prefsandmyapp.auth, with no setting or token insidemyapp.v1. node engine.jsprintsengine ok, merge and delete cases included, and you've seen it fail with the tombstone filter removed.node --env-file=.env test-households.mjsends withPASSand 0 left, and failed once while a rule was loosened.- Live: B joined by invite link and saw A's three items; A, in a fresh private window, saw four with both members; a deleted item stayed gone.
- After B left, A's member list no longer showed B's email, and the dashboard finds none of your test users.
Next, Get paid without leaking money, where the server, never the browser, decides who has paid.