Skip to main content
getbased sync is local-first CRDT storage plus app-level delta surfaces. The relay carries encrypted Evolu data, but the browser still needs deterministic merge rules for profile arrays, maps, tombstones, import snapshots, and Agent Access state. This page is the contributor contract for adding or changing a synced profile surface.

Core modules

Delta surface types

importSnapshots are user-visible because Settings → Data can reopen a saved import review. They must keep stable IDs and tombstones so deleting one source file does not delete unrelated same-day marker values. markerPlacements uses immutable gb:marker:* or custom:* IDs as map keys. A row carries only the visual destination metadata; lab entries and dot-keyed companion maps are never re-keyed. Unknown marker IDs or destination categories remain in the profile for forward compatibility while the current runtime falls back to native display. New supplement and medication records have sm_... IDs. The supplements delta config uses that stable ID when present and retains the legacy name|startDate|type hash only for unmigrated compatibility records. Period changes, structured product facts, and quality evidence must stay attached to the same item identity.

Configured, active, and paused

Sync has three distinct browser states: Turning the user-facing switch off pauses; it must not run disconnect cleanup. Local saves still call the normal dirty hook while paused, including saves to inactive profiles. Resume rebinds subscriptions and flushes the durable pending work before any inbound merge. If that flush fails or is skipped, the pull fails closed. Disconnect/reset is the explicit destructive operation for local Sync state. It forgets the Evolu owner and local planner/history keys, then reloads. It does not delete profile data or ciphertext already held by the relay.

Device-local profile fields

importBenchmarks and deletedImportBenchmarkIds live in the imported-data blob for local persistence and full backups, but they must never enter cross-device sync. Their timing, loaded-model state, context, and hardware metadata describe the browser that ran the test. The boundary has three parts:
  1. benchmark saves use saveImportedData({ immediate: true, skipSync: true });
  2. stripLocalOnlyProfileData() removes both keys from legacy blob and v4 delta serialization; and
  3. preserveLocalOnlyProfileData() restores the receiving browser’s local values after an inbound merge.
Do not add these keys to a delta map. A remote payload must not erase or replace local benchmark history. Confirmed lab entries and importSnapshots remain normal synced surfaces even when their related model-test diagnostics are local-only.

Snapshot advancement rule

Do not advance the local “last pushed” snapshot until the remote operation applies successfully. This protects local edits from being marked synced when the relay write failed or was rejected. When adding a surface:
  1. Add it to sync-delta-surfaces.js.
  2. Add identity/tombstone behavior in sync-delta-surface-config.js.
  3. Verify export/import behavior if the surface belongs in JSON backups.
  4. Add apply/merge tests for local-vs-remote conflicts.
  5. Add observability if the surface is user-visible in Settings diagnostics.

No-op and dirty-generation rules

Equivalent maintenance saves must not create relay history. data.js skips an imported-data write when the stored value is unchanged, and sync-push.js skips an outbound profile payload when its deterministic content matches the last committed payload. Forced rebuilds are the explicit exception. Every real local edit marks its profile dirty before the debounced push. The marker is a generation token, not a boolean:
  1. a push captures the generation it is sending;
  2. successful remote completion clears only that captured generation; and
  3. an edit that arrives during the push leaves a newer generation dirty.
Before an inbound pull can merge remote scalar state, it flushes pending local work. If the flush cannot commit, the pull must fail closed rather than let stale remote state overwrite the local edit. This rule applies to inactive profiles too: durable profile storage, not only the active in-memory profile, is the source for their pending push. readProfileImportedData() always uses encryptedGetItem() for named profiles. That wrapper owns the IndexedDB-backed *-imported route whether or not the blob contents are encrypted. A missing/unreadable named profile returns null, not a newly created default object. sync-push.js, scheduled pushes, pull preflight, and compaction treat that result as a hard skip/failure so a storage read problem cannot become an authoritative empty relay update. sync-origin-state.js records exact committed syncedAt values for the current tab. An inbound subscription with one of those timestamps still refreshes local state but does not show the misleading “updated from another device” notice. Other tabs and devices do not share this in-memory marker and still show the notice.

Existing-mnemonic restore ordering

Joining a Sync identity must finish application state before navigation. Evolu’s default restoreAppOwner() behavior can reload from inside its worker before the returned promise resolves. A first-time join previously reloaded while Sync still had provisional persist: false state, so the restored owner could not pull relay data. For a user-entered existing mnemonic, sync-identity.js calls:
It then enables/persists Sync and completes getbased’s join-finalization path before issuing the single controlled reload. The local seeding/recovery path remains separate. Tests for this contract must assert call ordering, not only that restoreAppOwner() was invoked.

Backup-restore preflight

A full backup can restore data that the current relay history previously deleted. Before reload, prepareRestoredProfilesForSync():
  1. records the safe restored profile IDs under labcharts-sync-backup-restore-pending;
  2. clears their per-surface delta snapshots;
  3. clears delta cutover and the previous Sync timestamp; and
  4. gives each restored profile a new durable dirty generation.
On the next active start—or after joining an existing identity—the inbound callback first force-pushes every pending restored profile, then flushes all remaining dirty profiles. Only after both phases succeed may remote tombstones or scalar state apply. The pending restore list is cleared only after a successful preflight, so a reload or failed relay write can retry safely.

Relay storage and compaction

Relay quota measures append-only encrypted message history. The client gets authoritative storedBytes and quotaBytes from the owner-signed /self/owner-storage endpoint when the relay supports it; a local estimate is only a temporary fallback. Self-service compaction is a guarded rebuild, not a blind delete:
  1. flush every dirty local profile, including inactive profiles;
  2. pull and merge the latest relay state;
  3. call owner-signed /self/compact-owner;
  4. reset local Evolu history under the same owner;
  5. force-push a complete snapshot for every profile; and
  6. verify that the relay contains the rebuilt history.
If any preflight push fails—or any required profile blob cannot be read—compaction must not begin. If deletion succeeds but rebuilding is interrupted, the UI keeps a Retry rebuild path so the same canonical device can finish without deleting local profile data.

Tombstones and deletes

Deletes are explicit tombstones, not absence. This matters for:
  • marker/date values that should stay deleted after another device still has the old value;
  • per-file import snapshots where Settings → Data delete removes only markers owned by that snapshot;
  • profile deletes, where single deletes propagate automatically but batched deletes are quarantined.
For batched profile deletes, receiving browsers show an apply/restore UI in Settings → Data. This prevents a stolen mnemonic from silently wiping every profile on every paired device.

Agent Access sync

Agent Access state is part of the encrypted profile. The enabled state, read token, Agent Context key, relay metadata, and wearable-series window can travel to another paired browser. Wearable OAuth refresh tokens do not sync; each device must reconnect wearable vendors independently. The encrypted Agent Access summary is produced by the same buildLabContext() formatter used by in-app AI, with context toggles bypassed because the Agent Access switch is the external-sharing permission. Context-card serialization must preserve explicit values such as digestive none/normal answers and Light & Circadian skinType; adding a stored card field requires assertions for both the in-app context and encrypted gateway payload.

Verification checklist

Before shipping sync changes:
  • run targeted unit tests for the touched surface;
  • run browser sync specs if the surface is visible in Settings or the dashboard;
  • verify tombstone behavior for delete/recreate conflicts;
  • verify snapshot advancement after simulated failed pushes;
  • verify equivalent saves and unchanged payloads do not create new pushes;
  • verify pulls and compaction flush dirty active and inactive profiles;
  • verify paused saves remain dirty, no subscription merge runs while paused, and resume pushes them before pulling;
  • verify disconnect/reset clears local Sync identity/history while pause preserves them;
  • verify an unreadable active or inactive profile cannot produce an empty push and stops compaction preflight;
  • verify existing-mnemonic restore persists the joined owner before the controlled reload;
  • verify restored profiles clear stale planner state and are force-pushed before inbound tombstones;
  • verify source-tab notifications are suppressed without suppressing receiving devices;
  • check Settings → Data diagnostics for a useful label and no plaintext secrets;
  • verify owner storage refresh and compaction/rebuild failure recovery when relay maintenance changes;
  • test export/import if the surface should survive backups.