super-productivity/docs/sync-and-op-log/contributor-sync-model.md
Johannes Millan 42e6626b76 docs(sync): consolidate sync docs + enforce the contributor model
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.
2026-05-15 16:51:50 +02:00

106 lines
5.4 KiB
Markdown

# 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_ACTIONS` and handles `isRemote`
itself: `operation-log.effects.ts` (captures/persists every action). You are
almost certainly not adding a second.
- Remote **archive** side effects are _not_ an `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`.
## 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, 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.
---
## 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`](./operation-rules.md)
- **Architecture:** [`operation-log-architecture.md`](./operation-log-architecture.md)
- **Diagrams:** [`diagrams/05-meta-reducers.md`](./diagrams/05-meta-reducers.md),
[`diagrams/08-sync-flow-explained.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`