IsotopeAI / docs

Guides

Getting started Configuration Supabase setup Sync & backup Backup console Community

Reference

Architecture API reference Database CLI Android APK Admin console

Help

Troubleshooting FAQ Contributing Changelog

Legal

Security Privacy Terms License

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.

IndexedDB isotope_main
Primary local store. Tasks, sessions, subjects, habits, logs, tests, exams and mock tests. Survives a page reload and a browser restart.
localStorage isotope_*_v2
Smaller records and UI state, namespaced per user so two accounts on one device cannot read each other.
localStorage auth keys
isotope-auth-token, sb-<ref>-auth-token, isotope-last-jwt, isotope-last-rt. Your session.
Supabase Postgres
Cloud copy. Per-row for community and stats tables; whole-snapshot for the study data bundle.
Supabase Storage
Backup JSON objects and avatars, recorded in 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.

Decision ladder comparing a local snapshot with 143 tasks against a newer but empty cloud snapshot. Step two, rich versus empty, resolves in favour of the local snapshot, so the timestamp comparison is never reached.
Richness outranks recency. The newer empty snapshot loses at step two.

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:

#TestRationale
1Present and validA parseable snapshot beats a missing or malformed one.
2Rich beats emptyThe rule that protects real work. Decides the fresh-install case.
3Rich beats not-richHandles partial snapshots that are not fully empty.
4Newer timestampOnly consulted once both sides are comparably substantial.
5Higher richness scoreWeighted count across collections.
6Larger payloadFinal 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:

version
Monotonic counter. Higher wins when content hashes differ.
content_hash
Stable hash of the payload. Equal hashes mean no write is required, which avoids pointless round-trips.
last_synced_at
When the row last reconciled.
deleted_at
Tombstone. A hard delete cannot replicate — the other device would simply re-create the row.
device_id
Origin of the write, used to break ties deterministically.

When sync runs #

TriggerDelayNotes
Startup8sAfter session hydration, so the JWT is valid.
Local data changed15s debounceCoalesces a burst of edits into one upload.
Tab became visible2sOnly if the tab was hidden for a meaningful interval.
Network came back3sRe-validates the session first rather than blindly retrying.
Periodicevery 5 minVisible 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:

  1. Schema dump

    Via the Management API into sql/isotope-schema-restore.sql — tables, functions, policies, triggers and indexes.

  2. Data, auth users and storage

    Every table to JSONL, plus auth users and every discovered storage bucket.

  3. Pack and checksum

    tar -czf, then gzip -t for integrity, then a .sha256 sidecar.

  4. 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:

  1. Extract and validate

    Refuses to proceed without a manifest.json.

  2. Apply schema, data and storage

    Tables are inserted in foreign-key order from fk_order in the manifest.

  3. Verify against the target

    Counts must match. On mismatch the run aborts and keeps the extracted directory for inspection.

  4. Scaffold .env

    Only after verification passes. The previous .env is copied aside first, and non-project keys are carried over.

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

BucketVisibilityHolds
user-contentPrivateBackups, imports, exports, cloud snapshots, user files, note attachments.
avatarsPublic readProfile images. Write paths are owner-scoped.
group-iconsPublic readGroup icons — rendered in Discover for people who are not members. Owner-scoped writes.
study-materialPrivateA 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:

  1. Rich beats empty, even when older. Data outranks recency.

  2. If both are rich, the newest meaningful data timestamp wins — not the file mtime, which changes on any rewrite.

  3. 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:

  1. Check auth.

  2. Build the local backup.

  3. Count local data.

  4. Call /__auth/backup/best to find the richest cloud candidate.

  5. If local is empty and cloud is rich, call /__auth/restore-best-backup.

  6. Apply the backup to browser local data.

  7. Verify the restored counts are non-empty.

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

EndpointEffect
/__auth/storage/cleanup-previewReports what would be deleted. Changes nothing.
/__auth/storage/cleanup-applyDeletes — and only with confirm: true in the body.
/__admin/storageAdmin 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.