Files
native/src/device-sync/engine/poison.ts
T
Linus Rath 96a6b38321 feat: sync the device's contacts and calendars with the server in the background #34
The sync adapters from ed2525e start a headless task for every sync, but
the task only reported "disabled". This adds the engine it runs
(`src/device-sync/engine`, `src/device-sync/jmap`): for one Android
account and authority it downloads the server's changes, uploads what
the device changed, and hands a report back to SyncManager.

- Downloads follow `/changes` from the state kept in the provider's
  SyncState. Rows and the state they reach are written in one batch, so
  a killed run resumes where it stopped. Without a state, after
  `cannotCalculateChanges` or after the user discarded deletions, a full
  reconcile compares ids instead.
- Uploads go in three phases across accounts (creates, updates,
  deletions), so moving an item to another account never destroys it
  before the copy exists. A create carries a uid written to the device
  first, and the server is asked for that uid before creating, so a lost
  response or a killed run never makes a duplicate. Updates are patches
  guarded by `ifInState`; nothing is replaced whole.
- More than 50 deletions that are also more than a fifth of the synced
  items hold every deletion back and report `tooManyDeletions`, so
  Android asks the user; "undo" restores the items from the server.
- An item the server refuses is marked and not sent again until it
  changes or its back-off (1 hour, doubling to a day) ends. Such
  refusals and planner errors stay with their item; the rest of the run
  goes on.
- Runs of one account and authority take a lock, stop at checkpoints
  when cancelled or close to the deadline, and never throw: every
  failure becomes an outcome, and a rejected sign-in posts one "Sign in
  again" notification.
- The JMAP side uses a client of its own per app account, never the
  UI's, since a run can share the JS runtime with the app.

Turning sync off or signing out runs the same upload first
(`teardownAuthority`) and deletes the rows only once nothing is waiting,
or when the user agreed to lose it.

The planners that map items to rows land in the next commits; until
then a run fails before it touches a row.

Checked: 94 engine tests against the fake provider and JMAP server
(sync flows, failures, crashes and retries, teardown), and `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit.
2026-09-27 04:19:21 +02:00

35 lines
1.5 KiB
TypeScript

/**
* Poison markers (docs/device-sync.md, "Uploads", SetErrors): an item the
* server refused (`invalidProperties`, `invalidPatch`, `tooLarge`,
* `overQuota`, …) is not retried until its back-off ends (1 h, doubling to
* a day) or it changes. The engine writes the marker (SYNC4 / SYNC_DATA5)
* and checks it before planning an upload; the fingerprint covers what the
* item would upload from: its mapped rows, its flags and its shadow.
*/
import { jsonHash } from '../common/json';
import type { PoisonMarker } from '../planner';
export function poisonFingerprint(content: unknown): string {
return jsonHash(content);
}
/** True while the marker holds: same fingerprint and the back-off not over. */
export function isBackedOff(marker: PoisonMarker | null, fingerprint: string, now: number): boolean {
return !!marker && marker.fp === fingerprint && marker.until > now;
}
/** The next marker after a refusal: attempts count up while the item stays the same. */
export function nextMarker(
previous: PoisonMarker | null,
fingerprint: string,
error: { type: string; description?: string },
now: number,
backoff: { firstMs: number; maxMs: number },
): PoisonMarker {
const n = previous && previous.fp === fingerprint ? previous.n + 1 : 1;
const delay = Math.min(backoff.firstMs * 2 ** (n - 1), backoff.maxMs);
const marker: PoisonMarker = { fp: fingerprint, type: error.type, n, until: now + delay };
if (error.description) marker.description = error.description.slice(0, 500);
return marker;
}