# `Laev.Sync`
[🔗](https://github.com/alexdont/laev/blob/main/lib/laev/sync.ex#L1)

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.

# `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`

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`

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`

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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
