Ship Data Apps with AI

👥 Users & money·Lesson 18 of 26·~20 min·Advanced

🔄 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.

1

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.

KeyHoldsUploaded?
myapp.v1What people log: entries, profiles, a wishlistYes, and only this
myapp.prefsThis device's dark mode, quiet mode, dismissed banners, accepted termsNever
myapp.authLogin token, user id, emailNever
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

Claude Code
YouStore synced data, device preferences and the login token under three separate localStorage keys: myapp.v1, myapp.prefs and myapp.auth, moving any old setting or token out on first load. Only myapp.v1 is ever uploaded.
ClaudeDone; old settings and the token moved out once. Check DevTools (F12) → Application → Local storage: myapp.v1 should hold no dark or token.↳ Edit · index.html↳ Bash · node engine.js

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.

2

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:

  1. Every record gets an id (a random name no other record shares) and an edited-at time, u, from Date.now() (milliseconds since 1970), on every change.
  2. Download first, then merge by id: keep every record either side has; if both edited the same one, the newer edit wins.
  3. Upload the result and save it on the device.
  4. 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

Claude Code
YouGive every record a random id and set u on every edit. In engine.js add fresh, migrate, mergeStates(local, server) and payload: merge lists by id, newer edit wins, with self-check cases. In index.html add syncSave and push: download my household record, merge, upload, save, 1.2 seconds after the last change and whenever the app opens or returns to the screen.
Claudenode engine.js prints engine ok with three new cases: records only here or only on the server both survive, and the newer edit wins. push() works once step 4's households exist.↳ Edit · engine.js↳ Edit · index.html↳ Bash · node engine.js

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.

3

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), write tomb[id] = -now. Negative means "added back"; being later, it wins.
  • Parents: deleting a baby drops its entries.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouWrite mergeStates(local, server) that merges by id, keeps the newer edit and honors tombstones, with self-check cases for a delete on one device. Add remove and readd helpers that take now as an input.
Claudenode engine.js prints engine ok. The new case runs a fake server: phone A deletes e1, phone B syncs a stale copy, and e1 stays gone on both.↳ Edit · engine.js↳ Bash · node engine.js

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:

AssertionError [ERR_ASSERTION]: deleted on A, gone on B

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.

4

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

Claude Code
YouIn myapp-admin, write setup-households.mjs: create households and household_members with exactly the fields, index and rules pasted below, reading .env via node --env-file without opening it. Create only what's missing, touch nothing else, print each rule.
ClaudeCreated households, then household_members (its relation needs households first), then the households rules, which mention it. The printout matches your block; a second run prints nothing to do.↳ Write · setup-households.mjs↳ Bash · node --env-file=.env setup-households.mjs

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.

5

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

Claude Code
YouIn myapp-admin, write test-households.mjs, reading .env the same way: sign up three throwaway users and check every case as owner A, member B, stranger C and a logged-out visitor, one line each, then PASS or FAIL. Afterwards, even after a failure, delete the test users and their household and count what's left.
ClaudeEvery check passes; cleanup counted 0 left.↳ Write · test-households.mjs↳ Bash · node --env-file=.env test-households.mjs

Run it yourself (PowerShell, Git Bash or Mac Terminal); you should see something like:

cd ../myapp-admin
node --env-file=.env test-households.mjs
ok owner A creates a household, writes ok visitor sees 0 households; A's: 404 ok stranger C reads or changes A's: 404 ok member B joins with the code, reads, writes ok owner A sees B's write ok member B delete, take ownership, add C: all refused ok cleanup test users and households left: 0 PASS

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

Claude Code
YouAdd sharing to the account sheet: at sign-in, use my existing household (or create one) unless I'm joining one, Copy code and Copy invite link buttons, a member list (id and email) in the household's data, join from ?join= after sign-in (adopting the household's data, with a warning if that replaces this phone's entries), and Leave household: pauseSync(), scrub my email (newer u, tombstone for my member entry), hand ownership on if it's mine, delete my membership, delete the household if I was last. Then upload and run check.sh.
ClaudeUploaded index.html and engine.js; check.sh is all green.↳ Edit · index.html↳ Bash · scp · bash check.sh

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).

  1. Normal window: at https://myapp.yourdomain.com, sign up as A, log three items, copy the invite link.
  2. Private window: open the link, sign up as B and join: A's three items appear. Log one more.
  3. Close every private window (that wipes them), open a new one and log in as A: four items, both members listed.
  4. Delete an item in the normal window. A few seconds later, reload the private one: it's gone, even after another reload.
  5. 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:

  1. pauseSync(), so no queued upload puts anything back.
  2. Last member out? Delete the household; cascade delete takes the memberships.
  3. 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.
  4. 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:

  1. "Store synced data, device preferences and the login token under three separate localStorage keys."
  2. "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."
  3. "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.prefs and myapp.auth, with no setting or token inside myapp.v1.
  • node engine.js prints engine ok, merge and delete cases included, and you've seen it fail with the tombstone filter removed.
  • node --env-file=.env test-households.mjs ends with PASS and 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.