Laev.Sync (laev v0.1.40)

Copy Markdown View Source

Optional cross-device sync of user state to a self-hosted endpoint.

laev is local-first: with no endpoint configured, nothing ever leaves the machine (today's behaviour). Set LAEV_SYNC_URL (+ optional LAEV_SYNC_TOKEN) and laev mirrors its user-state bundle to that URL so a second computer or a phone sees the same watchlist, history, resume points and watched flags.

The contract with the server

The server is a dumb blob store — it holds one opaque JSON document per (token, doc-name) pair and nothing more. laev owns all merge logic, so any client that speaks the same GET/PUT is automatically consistent. The token lives in the path; the last segment is a free-form doc name (laev keeps everything in one doc):

GET  <base>/<token>/laev   -> 200 {bundle} (+ ETag), or 404 when empty
PUT  <base>/<token>/laev   -> 200 (+ new ETag)
           If-Match: "<etag>"   stale => 412 (no overwrite);
           unknown token => 401; body > 5 MB => 413

What is synced (and what is deliberately not)

Synced: watchlist, resume/history, per-episode positions, holds, watched flags, and per-series audio/subtitle track choices. Optionally (LAEV_SYNC_KEYS) also the API keys, encrypted — see "Secrets" below. Never synced: MAL OAuth tokens (mal.json), because the refresh token rotates on every refresh and two machines sharing one would log each other out.

Secrets

A laev key is laev_<token>.<secret>. The token half is the path segment the server files the document under, so the server necessarily knows it; the secret half never leaves the machine, and is what the API keys are encrypted with (AES-256-GCM, key derived by PBKDF2). That split is the whole point: the server — or a stolen backup of one — holds ciphertext it has no way to open, while the user still has a single string to paste on a new machine.

Which keys travel is Laev.Config.syncable_keys/0, i.e. everything laev knows about minus the sync settings themselves and the two machine-specific ones, so a provider added later is carried without touching this module.

Merge model

Everything is per-record last-write-wins by updated_at. Deletions are tombstones (deleted: true) so an unpin/unwatch on one device propagates instead of being resurrected on the next merge. A local sidecar (sync.json) remembers the last synced bundle so we can diff the flat data files into timestamped changes — including deletions — without every writer having to know about sync.

Every function returns values; sync never raises into the caller.

Summary

Functions

Whether laev syncs automatically (on launch and after each episode). Off by default — with sync configured but auto off, state only moves when the user runs laev sync or the "sync now" menu action. LAEV_SYNC_AUTO.

Total per-collection changes (max of pulled/pushed) from a summary.

Is the key this user already has still good?

Everything that travels between devices.

Connect this user to a laev key, and prove it works before saying so.

The maintainer-hosted endpoint, used when no other is given.

The key, as it would be pasted into another device.

Whether the laev key also carries the API keys (LAEV_SYNC_KEYS).

The whole laev key as the user holds it — both halves. This is what gets shown, copied and pasted; token/0 is the only part that goes to a server.

Whether laev pushes each change the moment it happens (a pin, a watched flag, a resume point) as a small delta, rather than waiting for the next full sync. Off by default. LAEV_SYNC_LIVE.

Push only what changed since the last sync as a delta — used for per-action "live" saving. Cheap: when nothing changed it makes no network call. Sends just the changed records to the server's merge endpoint; if the server has no delta endpoint yet, falls back to a full (safe) sync so nothing is lost.

The half that never leaves this machine — it encrypts the API keys, so the server must not be able to derive it. nil for a key issued without one.

Pull → merge → apply → push, once. Returns

Sync, but only log a one-line result to stderr (used at startup/exit).

The half of the laev key the server sees. A key is laev_<token>.<secret>; only the part before the dot ever goes on the wire, as the path segment that identifies the document. Keys issued before the secret existed have no dot and are returned whole.

The configured base URL (for display), e.g. https://host/laev.

Functions

auto?()

Whether laev syncs automatically (on launch and after each episode). Off by default — with sync configured but auto off, state only moves when the user runs laev sync or the "sync now" menu action. LAEV_SYNC_AUTO.

change_count(summary)

Total per-collection changes (max of pulled/pushed) from a summary.

check()

Is the key this user already has still good?

A read, never a write: it asks the server for the document and says what is in it, so a settings page can show "connected, 37 saved titles" rather than making somebody press sync to find out.

collections()

Everything that travels between devices.

connect(key, url \\ nil)

Connect this user to a laev key, and prove it works before saying so.

Everything a second device needs is in that one string, so this is the whole of setting one up: the key is saved, a sync is run, and what came back is reported. A key that cannot be reached or refuses the document leaves nothing configured, because a sync setting that silently does nothing is worse than an error — the first anyone would know of it is a watchlist that stopped following them.

{:ok, summary} with what arrived, or {:error, reason}.

default_url()

The maintainer-hosted endpoint, used when no other is given.

enabled?()

key_for_sharing()

The key, as it would be pasted into another device.

keys_enabled?()

Whether the laev key also carries the API keys (LAEV_SYNC_KEYS).

laev_key()

The whole laev key as the user holds it — both halves. This is what gets shown, copied and pasted; token/0 is the only part that goes to a server.

live?()

Whether laev pushes each change the moment it happens (a pin, a watched flag, a resume point) as a small delta, rather than waiting for the next full sync. Off by default. LAEV_SYNC_LIVE.

live_push()

Push only what changed since the last sync as a delta — used for per-action "live" saving. Cheap: when nothing changed it makes no network call. Sends just the changed records to the server's merge endpoint; if the server has no delta endpoint yet, falls back to a full (safe) sync so nothing is lost.

Returns :skip (disabled/off), :ok (nothing to send), {:ok, n} (n records pushed), a full-sync result, or {:error, reason}.

secret()

The half that never leaves this machine — it encrypts the API keys, so the server must not be able to derive it. nil for a key issued without one.

sync()

Pull → merge → apply → push, once. Returns:

  • :disabled — no endpoint configured
  • {:ok, summary} — a map of per-collection change counts
  • {:error, reason} — network / server problem (local state untouched)

sync_quiet(label)

Sync, but only log a one-line result to stderr (used at startup/exit).

token()

The half of the laev key the server sees. A key is laev_<token>.<secret>; only the part before the dot ever goes on the wire, as the path segment that identifies the document. Keys issued before the secret existed have no dot and are returned whole.

url()

The configured base URL (for display), e.g. https://host/laev.