Collapse the sprawling, partly-stale docs/sync-and-op-log/ tree into a
small authoritative set and make the sync-correctness invariant
partly lint-enforced instead of convention-only.
Docs:
- Delete superseded/duplicate/provably-stale design, plan, and
background-research docs (quick-reference, the architecture-diagrams
monolith, the "Hybrid Manifest" docs describing code that does not
exist, completed long-term plans, LLM-synthesis analyses).
- Salvage load-bearing decision history into the surviving docs before
deletion: rejected-alternatives rationale -> operation-log-architecture
("Why this architecture"); vector-clock pruning incident history ->
vector-clocks.md; archive-payload optimization -> architecture E.7.
- Add contributor-sync-model.md as the single-invariant entry point
(one user intent = one op; replayed/remote ops must not re-trigger
effects), with a decision table mapping to the enforcing linters.
- Repoint external/internal cross-refs; add CONTRIBUTING.md + CLAUDE.md
pointers; record the migration in a dated docs/plans/ design doc.
Enforcement (new eslint-local-rules):
- no-actions-in-effects (error): effects must inject LOCAL_ACTIONS /
ALL_ACTIONS, never the raw @ngrx/effects Actions stream.
- no-multi-entity-effect (warn, heuristic): flags a literal returned
array of >=2 action-creator calls; docstring + valid-case specs pin
exactly which shapes are and are not detected.
- run-specs.js runner wired into `npm run lint` via test:lint-rules;
refuses to run under test-framework globals and counts RuleTester.run
invocations so a spec that asserts nothing fails instead of passing.
- Correct the ALL_ACTIONS JSDoc in local-actions.token.ts to match
reality (archive-operation-handler uses LOCAL_ACTIONS).
Reviewed via parallel multi-agent review; findings W1/W2/W4 and a
dangling doc anchor addressed.
5.4 KiB
The Contributor Sync Model
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.
Boundary 1 — The action boundary
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.
- Default for all effects:
private _actions$ = inject(LOCAL_ACTIONS); - The only legitimate exception uses
ALL_ACTIONSand handlesisRemoteitself:operation-log.effects.ts(captures/persists every action). You are almost certainly not adding a second. - Remote archive side effects are not an
ALL_ACTIONScase:archive-operation-handler.effects.tsitself usesLOCAL_ACTIONS; the remote-client archive writes/deletes are driven separately byOperationApplierService→ArchiveOperationHandler.
✅ Enforced by local-rules/no-actions-in-effects — you cannot get this
wrong; the linter rejects inject(Actions) / Actions imports in
*.effects.ts.
Boundary 2 — The selector boundary
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.
- Use
skipDuringSyncWindow()for selector-based effects that modify frequently-synced entities or perform "repair"/"consistency" work. - The narrower
skipWhileApplyingRemoteOps()/HydrationStateService.isApplyingRemoteOps()exist for finer control. - Prefer action-based effects. A selector-based effect is the intuitive-but-usually-wrong choice; reach for it only when there is no action to key off.
✅ Enforced by local-rules/require-hydration-guard (existing rule).
The atomicity rule — one intent, one op
Multi-entity changes are meta-reducers, not effects. Bulk dispatch loops yield.
- A change that touches more than one entity for a single user intent (e.g.
deleting a tag also removing it from every task) must be one reducer pass
so it becomes one operation. Put it in
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, addawait 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.
Decision table — "I'm writing an effect"
| 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).
Why (deeper)
- Mechanism & rules:
operation-rules.md - Architecture:
operation-log-architecture.md - Diagrams:
diagrams/05-meta-reducers.md,diagrams/08-sync-flow-explained.md - Source of truth:
src/app/util/local-actions.token.ts,src/app/util/skip-during-sync-window.operator.ts,src/app/op-log/apply/hydration-state.service.ts