docs/sync-and-op-log/contributor-sync-model.md
The one thing to understand before writing any effect, reducer, or bulk dispatch that touches synced state.
Super Productivity syncs by replaying an operation log. Almost every sync correctness rule you will hit is a facet of a single invariant:
One user intent = exactly one operation. Replayed and remote operations must never re-trigger effects.
Reducers must run for remote/replayed operations (that is how state is rebuilt). Effects must not — the UI side effect (snack, sound, navigation) already happened on the originating client, and any cascading change is already its own entry in the operation log. Re-running effects on replay duplicates side effects and emits phantom operations that conflict with sync.
Everything below is that invariant applied at three points.
Effects inject LOCAL_ACTIONS, never inject(Actions).
LOCAL_ACTIONS is the standard actions stream with meta.isRemote filtered
out (src/app/util/local-actions.token.ts). Remote/replayed operations are
applied as one bulkApplyOperations action; LOCAL_ACTIONS ensures your effect
only sees genuine local user intent.
private _actions$ = inject(LOCAL_ACTIONS);ALL_ACTIONS and handles isRemote
itself: operation-log.effects.ts (captures/persists every action). You are
almost certainly not adding a second.ALL_ACTIONS case:
archive-operation-handler.effects.ts itself uses LOCAL_ACTIONS; the
remote-client archive writes/deletes are driven separately by
OperationApplierService → ArchiveOperationHandler.✅ Enforced by local-rules/no-actions-in-effects — you cannot get this
wrong; the linter rejects inject(Actions) / Actions imports in
*.effects.ts.
Selector-driven effects must guard with skipDuringSyncWindow().
An effect that reacts to a selector (store state) instead of a specific action bypasses Boundary 1 entirely — it fires on every store change, including hydration and sync replay. Two timing gaps (initial startup before first sync; the post-sync re-evaluation window) make such effects emit operations with stale vector clocks that immediately conflict.
skipDuringSyncWindow() for selector-based effects that modify
frequently-synced entities or perform "repair"/"consistency" work.skipWhileApplyingRemoteOps() /
HydrationStateService.isApplyingRemoteOps() exist for finer control.✅ Enforced by local-rules/require-hydration-guard (existing rule).
Multi-entity changes are meta-reducers, not effects. Bulk dispatch loops yield.
src/app/root-store/meta/task-shared-meta-reducers/, not in an effect that
dispatches a fan-out of follow-up actions. An effect-based fan-out emits N
operations for one intent and re-runs on replay (a restatement of Boundary 1).store.dispatch() is non-blocking. After a loop of 50+ dispatches, add
await new Promise((r) => setTimeout(r, 0)) so captured operations don't
lose intermediate state.⚠️ local-rules/no-multi-entity-effect (warn) flags this heuristically — it
catches the array-literal fan-out shape (map(() => [a(), b()])), not every
multi-entity dispatch (e.g. a of(a(), b()) varargs fan-out slips past). The
blessed pattern is a task-shared-meta-reducers/ reducer.
| Question | Answer | Linter |
|---|---|---|
| Does it inject the actions stream? | Use LOCAL_ACTIONS (not Actions) | ✅ no-actions-in-effects (error) |
| Does it react to a selector instead of an action? | Add skipDuringSyncWindow() | ✅ require-hydration-guard (error) |
| Does one user intent change >1 entity? | Make it a meta-reducer, not an effect | ⚠️ no-multi-entity-effect (warn) |
| Does it dispatch in a loop of 50+? | await new Promise(r => setTimeout(r, 0)) after the loop | — (convention) |
Two of the three are mechanically enforced — you do not need to memorize them, only understand why (the invariant at the top).
A sync cycle spans many awaits; a destructive config change (provider/account
switch, folder move, encryption enable/disable/password change) can land in any
of those gaps. A stale cycle must not apply, upload, acknowledge, or advance the
cursor against the new target/epoch afterwards.
SyncProviderManager.syncEpoch is a monotonic counter, bumped after each
such change completes (and at runWithSyncBlocked entry, which additionally
blocks new cycles first and then drains running ones, bounded). First-time
setup (no previous config / first provider activation) does NOT bump — there
is no old target to fence, and the bump would race the fresh config's first
sync into a spurious abort.fenceEpoch. Capturing earlier — e.g. at the cycle claim — lets a switch
complete in the awaits between and hands the cycle the new provider with a
stale epoch: a spurious abort of the first post-switch sync.getOperationSyncCapable(provider, { fenceEpoch }) returns a per-cycle delegate that re-asserts the epoch before
every provider call. Local writes (apply inside the lock closures, ack
persists, hydration, migration appends, rejected-ops handling, rebuild resume)
re-assert via assertSyncEpochUnchanged at the call site.SyncEpochChangedError, handled at every entry point
as a benign abort (no error snack, UNKNOWN_OR_CHANGED) — each abort
point is crash-equivalent by design (deferred acks re-upload, a behind cursor
re-downloads with dedup).An unthreaded flow is an UNFENCED flow: fenceEpoch: undefined disables the
assert. When adding a new sync entry point, capture and thread the epoch; when
adding a new local write inside a cycle, add an assert before it. Deliberately
unthreaded today: forceUploadLocalState / the USE_LOCAL/USE_REMOTE
conflict-resolution flows (covered by the encryption flag + cycle guard), and
key-recovery config writes (content-only, must NOT bump).
operation-rules.mdoperation-log-architecture.mddiagrams/05-meta-reducers.md,
diagrams/08-sync-flow-explained.mdsrc/app/util/local-actions.token.ts,
src/app/util/skip-during-sync-window.operator.ts,
src/app/op-log/apply/hydration-state.service.ts