Cloud sync (InstantDB)
Cloud sync (InstantDB)
Candela introduces cloud sync via InstantDB (shipped in v0.5.12).
The goal: a reinstall (or accidental adb uninstall) doesn't wipe your
library, follows, reading positions, bookmarks, pronunciation dictionary,
or your AI keys. Sign in on a new device → everything comes back.
This doc covers the architecture, what syncs vs. doesn't, conflict rules,
and the recovery story. The implementation lives in :core-sync.
Quick story
First device InstantDB Second device
============ ========= ============
sign in (magic code) ───────► refresh token ──────────► sign in (magic code)
push local state ───────────► row store
│
└───────────────────────► pull on launch
local state restored
mutate (add fiction) ───────► transact
│
└───── (eventual pull)──► local state catches up
Architecture
:core-sync (Kotlin / Hilt)
├── client/
│ ├── InstantClient — auth-plane HTTP (send/verify magic code,
│ │ verify refresh token, sign out)
│ ├── InstantSession — refresh token persistence (EncryptedSharedPreferences)
│ ├── InstantBackend — data-plane seam (fetch + upsert per (entity, id))
│ ├── DisabledBackend — used when INSTANTDB_APP_ID is the placeholder
│ ├── FakeInstantBackend — in-memory; tests + offline-only mode
│ └── HttpInstantBackend — production; HTTP /admin/query + /admin/transact
│ (as-token impersonation, no admin secret shipped)
├── crypto/
│ └── UserDerivedKey — PBKDF2 + AES-GCM for secrets-before-push
├── coordinator/
│ ├── SyncCoordinator — scheduler over the multibound Set<Syncer>
│ ├── Syncer — interface every per-domain syncer implements
│ ├── ConflictPolicies — LWW, union-with-tombstones, max-scalar
│ └── TombstoneStore — per-domain deletion log (DataStore-backed)
├── domain/
│ ├── SetSyncer — reusable union-with-tombstones syncer
│ ├── LwwBlobSyncer — reusable last-write-wins blob syncer
│ ├── LibrarySyncer — Fiction rows the user added
│ ├── FollowsSyncer — Fictions followed remotely
│ ├── PlaybackPositionSyncer — per-fiction position (max-stamped)
│ ├── BookmarksSyncer — per-chapter bookmarkCharOffset (LWW)
│ ├── PronunciationDictSyncer — user's pronunciation overrides (LWW)
│ └── SecretsSyncer — encrypted secrets bundle (LWW, AES-GCM)
└── di/
└── SyncModule — Hilt multibinds for Set<Syncer>
The auth UI lives in :feature/sync/ (SyncAuthScreen + SyncAuthViewModel),
keeping the Library Nocturne aesthetic. :app/StoryvoxApp.kt calls
SyncCoordinator.initialize() on cold start.
What syncs
| Domain | Storage | Conflict strategy | Encrypted? |
|---|---|---|---|
| Library | Fiction.inLibrary | union + tombstones | no |
| Follows | Fiction.followedRemotely | union + tombstones | no |
| Reading positions | playback_position | max(updatedAt) per fiction | no |
| Chapter bookmarks (#121) | Chapter.bookmarkCharOffset | LWW per chapter (blob) | no |
| Pronunciation dictionary (#135) | settings.PronunciationDict | LWW (single blob) | no |
| Secrets (LLM keys, cookies, tokens) | EncryptedSharedPreferences | LWW (single blob) | yes — AES-GCM, user passphrase |
What does NOT sync
- Fiction metadata bodies (title, author, cover URL, tag list). Each device re-fetches metadata from the originating source — Royal Road, GitHub, Outline, etc. Pushing this row content would balloon the wire format and create cross-device "who wins on a stale metadata fetch" races. The synced set membership ("this user added fictionId=X") triggers a metadata fetch on the second device.
- Chapter bodies (
Chapter.htmlBody,plainBody). Re-fetchable from the source; multi-MB per chapter. We sync the reading position inside a chapter, not the chapter text. - Real-time playback position. The playback layer updates 4-60 Hz — we'd burn bandwidth and battery pushing every frame. Position is synced on chapter change, sleep-timer fire, and periodic checkpoints (the existing PlaybackPosition row, which already debounces).
- Voice model
.onnxblobs. 14 MB – 330 MB each, slow to push, always re-downloadable from thevoices-v2GitHub release. We could sync the list of installed voices in a follow-up so a new device pre-downloads the user's favorites; not in v1. - PCM render cache. Device-local performance optimization.
- LLM chat sessions & messages. Listed as out-of-scope for v1 in
the PR — the entity set is large and the value-density is lower
than library/positions/secrets. Mechanical follow-up: a new
LlmSessionsSyncerslotting into the existing multibind. - App settings (Proto DataStore preferences). ~80 keys with enum and float and Boolean values. v1 ships the syncer seam but not the per-preference wiring — adding "sync theme override" alone is a 5-LOC repository touch + a syncer line. Follow-up PR lands the rest.
- Auth state row (
auth_cookie). The actual cookie/token is in EncryptedSharedPreferences and rides the SecretsSyncer; theauth_cookieRoom row is a derived projection rebuilt from the decrypted secrets on first launch.
Conflict rules
The brief's spec is implemented in coordinator/ConflictPolicies.kt:
- LWW for scalars: higher
updatedAtwins; ties go to local. - Union with tombstones for sets:
(local ∪ remote) − tombstones. Deletion is sticky until the server-side membership reflects the tombstone, at which point the local tombstone can be forgotten. - Max for reading position: the listener "generally only moves forward," so the server keeps the maximum seen across devices. Implemented as max-stamped — newer updatedAt wins; tie-break by higher charOffset so two devices simultaneously updating with clock skew don't lose the listener's actual position.
A corollary trade-off: a deliberate rewind across chapters loses to a later forward listen on another device. Mitigation: the chapter-change action carries its own LWW updatedAt, so an intentional chapter pick beats a max. Rewinding within a chapter (charOffset down) is handled by the next position write, which restamps and wins on the device's next sync.
Encryption story for secrets
All secrets (LLM API keys, Royal Road cookies, GitHub PAT, Outline
tokens) live locally in EncryptedSharedPreferences and are
encrypted client-side again before push.
- KDF: PBKDF2-HMAC-SHA256, 100k iterations, 16-byte salt.
- Cipher: AES-256-GCM.
- Envelope:
v1:<salt-b64>:<iv-b64>:<ct-b64>. - Salt: deterministically derived from the user id (so a new device with the same passphrase + userId derives the same key).
The user must set a sync passphrase in Settings → Account → Sync
before secrets sync. If they don't, the SecretsSyncer is a no-op and
the user is warned that their secrets won't ride the sync layer.
Recovery caveat: forget the passphrase, lose the secrets. The rest of the sync layer (library, positions, bookmarks) still recovers fine — they're not encrypted. This is by design: we'd rather force a re-paste of an API key than ship a key-escrow service or store the master key plaintext anywhere.
Recovery story
The four-line user journey:
- Get a new phone, install Candela.
- Open Settings → Account → Sync.
- Enter the same email, paste the magic code from your inbox.
- (Optional, for secrets): enter the sync passphrase.
What happens:
SyncCoordinator.initialize()validates the persisted refresh token; if it's fresh and valid, every syncer runspull.- Library entries reappear (set membership). Metadata fetches lazily on first browse.
- Reading positions land in the playback DAO — the Library "Resume" tile shows where you were.
- Bookmarks restore.
- Pronunciation dictionary restores.
- With the passphrase entered, LLM keys / RR cookies / GitHub PAT restore — chat works, RR sign-in is honored, GitHub OAuth state is back.
Setup (for the developer / JP)
One-time, in the InstantDB dashboard:
- Create an account (magic-link, no password to remember).
- Create a new app. Note the App ID (UUID-shaped).
- In the Candela repo's
local.properties(gitignored), add:INSTANTDB_APP_ID=00000000-0000-0000-0000-000000000000 - Build. The
:core-syncGradle script readslocal.propertiesat configure time and bakes the value intoBuildConfig.INSTANTDB_APP_ID. - Without that property the build still succeeds —
INSTANTDB_APP_IDdefaults to the literal"PLACEHOLDER"sentinel and the sync DI graph resolves toDisabledBackend. The app builds, the sign-in screen shows a "Sync isn't configured for this build" message, nothing crashes. - Permissions / schema: pre-create the attributes, then apply the
permissions. The
$defaultdeny in the permissions also stops clients from creating attributes, so on a fresh app the firsttransactwould fail with 400 permission-denied (hint["attrs","attr"]). An admin must create every field listed incore-sync/instant.attrs.json(blobs,positionsandsets×payload,updatedAt,userId) before devices sync.InstantAttrsTestfails if a write starts carrying a field missing from that file. When it does, add the field there and pre-create it on every Instant app before shipping. Verified on the self-hosted staging app, 2026-10-04. Permissions are required. Applycore-sync/instant.perms.jsonto the app (Instant dashboard → Permissions, or the self-hosted instance's permission API). Every synced entity (blobs,positions,sets) is owner-only for view, create, update and delete:auth.id == data.userId. Every row Candela writes carries auserIdstamp for this. Without the rules, row ids are derivable fromdomain:userId, so any signed-in client could read or overwrite another user's rows. Apply them to a FRESH app: rows written before theuserIdstamp existed have no owner and become unreadable under these rules. A$defaultrule denies every entity the file doesn't name, so the rules never fail open. Known limits: (a) old rows without auserIdcan't be read or deleted by their owner, which is why the rules are for a fresh app; (b) row ids are derived from the user's Instant id, so someone who learned a victim's internal user id could create that row first and block the victim's sync. User ids aren't shown to other users, and owner-onlyviewstops enumeration.
Wire format
InstantDB schema is dynamic. We use three entities:
sets— rows for the union-with-tombstone set domains (library, follows). Row id is<domain>:<userId>. Payload is a JSON map{ "members": [...], "tombstones": [...], "updatedAt": ... }.blobs— rows for LWW blob domains (pronunciation dict, bookmarks, secrets envelope, future settings). Row id is<domain>:<userId>. Payload is the domain's encoded blob string. updatedAt is a separate column.positions— single row per user, containing every per-fiction position. Row id ispositions:<userId>. Payload is a JSON map offictionId → { chapterId, charOffset, ... }.blobs/inbox— the push-to-Candela inbox (#1469): desktop pushers append URL / text items, the phone only reads (per-device seen-set) and imports them into the Library. Pulled on cold start and on each app foreground (throttled). Full design: push-to-candela.md.
The actual transact step format mirrors the InstantDB JS SDK:
["update", "blobs", "<domain>:<userId>", { "payload": "...", "updatedAt": 12345 }]
Known limitations (v1)
These are documented gaps, not bugs:
- Push-on-mutate is not wired yet. Today the coordinator's
requestPush(domain)exists but the per-repository writes don't call it. Follow-up: wirerequestPush("library")intoFictionRepositoryImpl.addToLibrary()etc. (5 LOC per domain). Mitigation today: a push happens on next sign-in and on cold start — so a reinstall recovery still works; you just may lose the last write made offline if the device dies before the next cold start. - No real-time pull. v1 talks to
/admin/queryand/admin/transactas one-shot requests on each sync round. v2 will hoist this onto a long-lived WebSocket against/runtime/sessionand subscribe toadd-query, surfacing changes from other devices in real time. The first attempt at the WS path (v0.5.41) shipped broken — see issue #691 — because it parsed thedatalog-result/EAV envelope as the materialised entity-keyed shape; the admin HTTP API returns the shape we actually want, at the cost of one TCP handshake per round. - Settings sync is stubbed. The seam exists; per-preference wiring lands in a follow-up.
- LLM session sync is deferred. Same story as settings.
- Permission rules live in
core-sync/instant.perms.jsonand must be applied to the Instant app (setup step 6). - Voice-blob inventory not synced. Future work.
Testing
./gradlew :core-sync:testDebugUnitTest runs:
ConflictPoliciesTest— LWW, union-with-tombstones, max-stamped.UserDerivedKeyTest— KDF determinism, AES-GCM roundtrip, envelope parsing, IV freshness.InstantClientTest— magic-code auth happy path against a fake transport (asserts the wire format matches the InstantDB runtime).SetSyncerTest— first-time push, tombstone propagation, union of concurrent adds.LwwBlobSyncerTest— newer-side-wins on both directions.
Self-hosted Instant
Instant Cloud shuts down on 2027-08-31, and Instant is open source (Apache-2.0), so Candela can sync with a self-hosted Instant instead. The server is build configuration, not code:
INSTANTDB_APP_ID: the app id on whichever Instant server the build talks to.INSTANTDB_API_URI(optional): the server's base URL, for examplehttps://instant.example.org. It must be https. Leave it unset to use Instant Cloud.
Both come from local.properties locally, or the Actions secrets of the same names in CI. Moving to a new server is a new build with new values. The data on the old server doesn't move with it: each device pushes its local library to the new server the next time the user signs in.