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).
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