Sync & backup
Your study data lives on your device first and replicates to your own Supabase project. This page covers how sync decides what wins, and how to take a full backup you can restore into any project.
The guarantee A snapshot containing real study data will never be replaced by an empty one, even when the empty snapshot is newer. This is asserted by a test that runs on every CI build, because it is the one failure mode that destroys work irreversibly.
Storage model #
Data exists in three places, and it is worth knowing which is which — they have different lifetimes and are cleared by different actions.
isotope_mainisotope_*_v2isotope-auth-token, sb-<ref>-auth-token, isotope-last-jwt, isotope-last-rt. Your session.backup_manifests.Clearing the cache does not sign you out
caches.delete() only removes HTTP responses — the cached copies of
/assets/*.js. It cannot reach localStorage or IndexedDB, so your session
and your study data are untouched. This is why the app's automatic recovery from a
stale bundle is safe.
Conflict resolution #
The hard case is not two edits to the same task. It is a fresh install producing an empty snapshot that is, by timestamp, newer than years of real work. Last-write-wins would destroy the data. So richness is evaluated before recency.
The comparison ladder #
compareBackupCandidates() in
public/sync/backup-normalizer.js returns 1,
-1 or 0. The first rule that separates the two candidates
decides the outcome:
| # | Test | Rationale |
|---|---|---|
| 1 | Present and valid | A parseable snapshot beats a missing or malformed one. |
| 2 | Rich beats empty | The rule that protects real work. Decides the fresh-install case. |
| 3 | Rich beats not-rich | Handles partial snapshots that are not fully empty. |
| 4 | Newer timestamp | Only consulted once both sides are comparably substantial. |
| 5 | Higher richness score | Weighted count across collections. |
| 6 | Larger payload | Final tie-break. |
What counts as rich #
Two definitions, and both are deliberately conservative:
// Rich: any meaningful collection has at least one row …
function isCountsRich(counts, sizeBytes = 0) {
if (RICH_COLLECTION_KEYS.some((key) => Number(counts[key] || 0) > 0)) return true;
// … or the payload is substantial and not entirely empty
const realCollectionCount = ARRAY_COLLECTION_KEYS
.reduce((sum, key) => sum + Number(counts[key] || 0), 0);
return sizeBytes > 100 * 1024 && realCollectionCount > 0;
}
// Empty: every array collection is zero AND there is no timer state
function isCountsEmpty(counts) {
return ARRAY_COLLECTION_KEYS.every((key) => Number(counts[key] || 0) === 0)
&& Number(counts.timerState || 0) === 0;
}
The tracked collections are tasks, sessions,
subjects, habits, dailyLogs,
tests, exams and mockTests, plus
profile and timerState.
Which timestamp is used #
Snapshots come from several sources with different field names, so
candidateTime() takes the maximum of every plausible field rather than
trusting one:
Math.max( meaningfulTimestamp(candidate.meaningful_data_at), meaningfulTimestamp(candidate.exported_at), meaningfulTimestamp(candidate.updated_at), meaningfulTimestamp(candidate.created_at), )
meaningfulTimestamp discards zero and epoch-adjacent values, so a
missing date cannot masquerade as 1970 and win a comparison by accident.
Merging #
When both sides are rich, mergeBackupData() keeps the union rather than
picking a winner. Rows are matched by id, and the higher version wins per
row. Nothing present on either side is dropped.
Per-row sync #
Community and stats tables sync per row rather than as a bundle, using four columns present on six tables:
When sync runs #
| Trigger | Delay | Notes |
|---|---|---|
| Startup | 8s | After session hydration, so the JWT is valid. |
| Local data changed | 15s debounce | Coalesces a burst of edits into one upload. |
| Tab became visible | 2s | Only if the tab was hidden for a meaningful interval. |
| Network came back | 3s | Re-validates the session first rather than blindly retrying. |
| Periodic | every 5 min | Visible tabs only. |
A minimum 60-second interval is enforced between runs, so no combination of triggers can produce a request storm.
Auth-blocked state If the JWT cannot be refreshed, sync enters an auth-blocked state and stops retrying. Retrying an upload with a dead token cannot succeed and would only burn requests. Recovery happens when a valid session is obtained, not on a timer.
Full backup #
backup.sh captures the schema, every table, auth users and storage
objects into a single verified tarball.
# full backup, auto-verified against the source project ./backup.sh backup # database only, skip storage ./backup.sh backup --no-storage # keep only the 5 most recent tarballs ./backup.sh backup --keep 5
Four stages run in order:
Schema dump
Via the Management API into
sql/isotope-schema-restore.sql— tables, functions, policies, triggers and indexes.Data, auth users and storage
Every table to JSONL, plus auth users and every discovered storage bucket.
Pack and checksum
tar -czf, thengzip -tfor integrity, then a.sha256sidecar.Verify against source
Row counts, auth user count and storage object counts are compared back to the live project. A mismatch fails loudly.
Auditioning a backup before you trust it
verify cross-checks any tarball against any project, so a backup can be
validated without restoring it:
./backup.sh verify backups/isotope-backup-20260827-233852.tar.gz # or against a specific project ./backup.sh verify <file> --supabase-url=... --service-key=... --pat=...
Exit code 0 means every check passed. Worth running after a restore to prove the target matches the tarball, and worth running on an old backup before you rely on it — a backup you have never verified is a hypothesis, not a backup.
Scheduling
# daily at 03:00, keeping the newest 14 0 3 * * * cd ~/isotope-code && ./backup.sh backup --keep 14 >> backups/backup.log 2>&1
Redirecting to a log matters more than it looks: a cron job that fails silently produces the same visible result as one that succeeds — nothing — until the day you need the backup.
Inspecting a backup #
./backup.sh info backups/isotope-backup-20260827-233852.tar.gz
Reports checksum match, gzip integrity, the file list, and a manifest summary with table count, total rows, auth users and storage size.
Restore #
Restore targets any project, including a brand new one — this is how you migrate.
./backup.sh restore backups/isotope-backup-20260827-233852.tar.gz \ --supabase-url=https://new-project.supabase.co \ --anon-key=<anon> \ --service-key=<service_role> \ --pat=<management-api-token>
The ordering here is deliberate. Verification runs before anything local is touched:
Extract and validate
Refuses to proceed without a
manifest.json.Apply schema, data and storage
Tables are inserted in foreign-key order from
fk_orderin the manifest.Verify against the target
Counts must match. On mismatch the run aborts and keeps the extracted directory for inspection.
Scaffold
.envOnly after verification passes. The previous
.envis copied aside first, and non-project keys are carried over.Restart the server
The app comes back pointed at the restored project.
Restore overwrites the target Rows in the target project are replaced by rows from the tarball. Take a backup of the target first if it contains anything you need. Verification protects you from a partial restore, not from an intentional one.
Storage bucket layout #
Four buckets, with different visibility. Getting these wrong is how a private backup ends up publicly readable, so the distinction is worth stating explicitly.
| Bucket | Visibility | Holds |
|---|---|---|
user-content | Private | Backups, imports, exports, cloud snapshots, user files, note attachments. |
avatars | Public read | Profile images. Write paths are owner-scoped. |
group-icons | Public read | Group icons — rendered in Discover for people who are not members. Owner-scoped writes. |
study-material | Private | A student's own PDFs and scans. Private in every direction. |
Public read never means public write — writes are owner-scoped in all four, on the
{auth.uid()}/… path convention.
Backups can only carry buckets that exist
group-icons and study-material were absent from the project while
the app uploaded to both, and the backup tool passed regardless: it dumped three buckets and
compared against three. A bucket the code needs and the database lacks is invisible to a tool
whose reference point is the database. The required set is now declared in
REQUIRED_BUCKETS, restore creates the union of the manifest and that set, and
verify fails when one is missing — so a restored project can accept an upload the source
project could not. A fifth bucket, notes, was removed: zero objects, zero
references.
Within user-content, canonical writes go to three paths:
{userId}/backups/latest.json
{userId}/backups/history/{timestamp}-{hash}.json
{userId}/cloud-snapshot/latest.json
Four older path shapes are still read for compatibility —
imports/latest.json, exports/latest.json, and timestamped
files under imports/, exports/ and
cloud-snapshot/history/. They are never written to any more. Dropping the
reads would orphan every backup made before the paths were unified.
Which backup wins #
With several candidate files, choosing the newest is the obvious rule and the wrong one. A fresh empty backup is newer than a rich one from yesterday, and picking it loses everything. The selection rule is therefore:
Rich beats empty, even when older. Data outranks recency.
If both are rich, the newest meaningful data timestamp wins — not the file mtime, which changes on any rewrite.
If both are empty, newest wins. Nothing is at stake.
An empty local workspace cannot overwrite a rich cloud backup This is the single most important guarantee in the sync system. A fresh browser, a cleared cache or a failed restore all present as “no local data”, and without this rule the next upload would erase the cloud copy. The server checks candidates before accepting an upload and refuses:
{
"ok": false,
"code": "BLOCKED_EMPTY_OVERWRITE",
"message": "Cloud has richer backup. Restore first."
}
If you see that code, the correct response is to restore first, not to force the upload. It is telling you the cloud knows something this device does not.
The manual sync sequence #
Eight steps, and the order is the point — the verification happens before the upload, not after:
Check auth.
Build the local backup.
Count local data.
Call
/__auth/backup/bestto find the richest cloud candidate.If local is empty and cloud is rich, call
/__auth/restore-best-backup.Apply the backup to browser local data.
Verify the restored counts are non-empty.
Only then upload the canonical backup.
On the browser side the adapter is public/sync/local-data-adapter.js. It
writes the IndexedDB database isotope_main, mirrors to
localStorage keys such as isotope_tasks_v2, records restore
metadata in isotope_restore_metadata, and dispatches
isotope:sync_refresh when finished so open views reload rather than showing
stale counts.
Storage cleanup #
Cleanup is preview-first, and that is not a convenience — it is the only thing standing between a bug in the selection logic and a deleted backup.
| Endpoint | Effect |
|---|---|
/__auth/storage/cleanup-preview | Reports what would be deleted. Changes nothing. |
/__auth/storage/cleanup-apply | Deletes — and only with confirm: true in the body. |
/__admin/storage | Admin view across all users. |
The preview reports action, reason, size, hash, path and bytes freed for every candidate, so a wrong decision is visible before it is irreversible. Three paths are protected and never deleted regardless of age:
{userId}/backups/latest.json
{userId}/cloud-snapshot/latest.json
the currently selected best backup
Two known gaps
Avatar duplicates are not cleaned — that needs an avatar-aware pass, since the
backup heuristics do not apply to images. And sync_items exists in the
schema but is not yet the runtime queue for every local change, so per-row sync is
narrower than the table implies.
Key precedence #
Backup and restore resolve credentials in this order:
CLI arguments → .backup_env → .env
Inherited shell environment is deliberately discarded. A shell that has sourced
.env for another project would otherwise silently redirect a backup to the
wrong database — a mistake that is invisible until you try to restore.
Verifying sync works #
# end-to-end sync against a running server npm run test:supabase-sync # prove a fresh browser can restore an existing account npm run backup:prove-restore # validate local backup files npm run backup:validate
Admin mode also exposes /__admin/sync, a console showing the best
available backup per user with a dry-run repair.