docs/sync-and-op-log/diagrams/05-meta-reducers.md
Last Updated: January 2026 Status: Implemented
This document illustrates how meta-reducers ensure atomic state changes across multiple entities, preventing inconsistency during sync.
flowchart TD
subgraph UserAction["User Action (e.g., Delete Tag)"]
Action[deleteTag action]
end
subgraph MetaReducers["Meta-Reducer Chain (Atomic)"]
Capture["stateCaptureMetaReducer
━━━━━━━━━━━━━━━
Captures before-state"]
TagMeta["tagSharedMetaReducer
━━━━━━━━━━━━━━━
• Remove tag from tasks
• Delete orphaned tasks
• Clean TaskRepeatCfgs
• Clean TimeTracking"]
OtherMeta["Other meta-reducers
━━━━━━━━━━━━━━━
Pass through"]
end
subgraph FeatureReducers["Feature Reducers"]
TagReducer["tag.reducer
━━━━━━━━━━━━━━━
Delete tag entity"]
end
subgraph Effects["Effects Layer"]
OpEffect["OperationLogEffects
━━━━━━━━━━━━━━━
• Compute state diff
• Create single Operation
• with entityChanges[]"]
end
subgraph Result["Single Atomic Operation"]
Op["Operation {
opType: 'DEL',
entityType: 'TAG',
entityChanges: [
{TAG, delete},
{TASK, update}x3,
{TASK_REPEAT_CFG, delete}
]
}"]
end
Action --> Capture
Capture --> TagMeta
TagMeta --> OtherMeta
OtherMeta --> FeatureReducers
FeatureReducers --> OpEffect
OpEffect --> Result
style UserAction fill:#fff,stroke:#333,stroke-width:2px
style MetaReducers fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
style FeatureReducers fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style Effects fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
style Result fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
flowchart LR
subgraph Problem["❌ Effects Pattern (Non-Atomic)"]
direction TB
A1[deleteTag action] --> E1[tag.reducer]
E1 --> A2[effect: removeTagFromTasks]
A2 --> E2[task.reducer]
E2 --> A3[effect: cleanTaskRepeatCfgs]
A3 --> E3[taskRepeatCfg.reducer]
Note1["Each action = separate operation
Sync may deliver partially
→ Inconsistent state"]
end
subgraph Solution["✅ Meta-Reducer Pattern (Atomic)"]
direction TB
B1[deleteTag action] --> M1[tagSharedMetaReducer]
M1 --> M2["All changes in one pass:
• tasks updated
• repeatCfgs cleaned
• tag deleted"]
M2 --> R1[Single reduced state]
Note2["One action = one operation
All changes sync together
→ Consistent state"]
end
style Problem fill:#ffebee,stroke:#c62828,stroke-width:2px
style Solution fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
The StateChangeCaptureService computes entity changes by comparing before and after states:
flowchart TD
subgraph Before["Before State (captured by meta-reducer)"]
B1["tasks: {t1, t2, t3}"]
B2["tags: {tag1, tag2}"]
B3["taskRepeatCfgs: {cfg1}"]
end
subgraph After["After State (post-reducer)"]
A1["tasks: {t1', t2', t3}"]
A2["tags: {tag2}"]
A3["taskRepeatCfgs: {}"]
end
subgraph Diff["State Diff Computation"]
D1["Compare entity collections"]
D2["Identify: created, updated, deleted"]
end
subgraph Changes["Entity Changes"]
C1["TAG tag1: DELETED"]
C2["TASK t1: UPDATED (tagId removed)"]
C3["TASK t2: UPDATED (tagId removed)"]
C4["TASK_REPEAT_CFG cfg1: DELETED"]
end
Before --> Diff
After --> Diff
Diff --> Changes
style Before fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
style After fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
style Diff fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style Changes fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
| Action | Entities Affected | Meta-Reducer |
|---|---|---|
deleteTag | Tag, Tasks (remove tagId), TaskRepeatCfgs, TimeTracking | tagSharedMetaReducer |
deleteTags | Tags, Tasks, TaskRepeatCfgs, TimeTracking | tagSharedMetaReducer |
deleteProject | Project, Tasks (cascade delete), TaskRepeatCfgs, TimeTracking | projectSharedMetaReducer |
convertToMainTask | Parent task, Child task, Sub-tasks | taskSharedMetaReducer |
moveTaskUp/Down | Multiple tasks (reorder) | taskSharedMetaReducer |
classDiagram
class Operation {
+string id
+string clientId
+OpType opType
+EntityType entityType
+string entityId
+VectorClock vectorClock
+number timestamp
+EntityChange[] entityChanges
}
class EntityChange {
+EntityType entityType
+string entityId
+ChangeType changeType
+unknown beforeState
+unknown afterState
}
class ChangeType {
<<enumeration>>
CREATED
UPDATED
DELETED
}
Operation --> EntityChange : contains 0..*
EntityChange --> ChangeType : has
When remote operations are applied, all entity changes are replayed atomically:
sequenceDiagram
participant Remote as Remote Op
participant Applier as OperationApplierService
participant Store as NgRx Store
participant State as Final State
Remote->>Applier: Operation with entityChanges[]
loop For each entityChange
Applier->>Applier: Convert to action
Applier->>Store: dispatch(action)
end
Note over Store: All changes applied
in single reducer pass
Store->>State: Consistent state
Note over State: Either ALL changes applied
or NONE (transaction semantics)
The lwwUpdateMetaReducer handles LWW Update actions (created when the local side wins a conflict). It distinguishes between three entity storage patterns:
flowchart TD
subgraph Input["LWW Update Action"]
Action["[TASK] LWW Update
entityType + entityId + winningData"]
end
subgraph Lookup["Entity Registry Lookup"]
Registry["Look up entity storage pattern
in entity registry"]
end
subgraph Patterns["Storage Pattern Handling"]
Adapter["ADAPTER ENTITIES
━━━━━━━━━━━━━━━
TASK, PROJECT, TAG, NOTE,
TASK_REPEAT_CFG, ISSUE_PROVIDER,
SIMPLE_COUNTER, BOARD, METRIC,
REMINDER, PLUGIN_USER_DATA,
PLUGIN_METADATA
━━━━━━━━━━━━━━━
adapter.updateOne() or addOne()
+ relationship syncing"]
Singleton["SINGLETON ENTITIES
━━━━━━━━━━━━━━━
GLOBAL_CONFIG,
TIME_TRACKING,
MENU_TREE,
WORK_CONTEXT
━━━━━━━━━━━━━━━
Entire feature state replaced
with winning data"]
Unsupported["UNSUPPORTED
━━━━━━━━━━━━━━━
Map, array, virtual
━━━━━━━━━━━━━━━
Warning logged,
no action taken"]
end
Input --> Lookup
Lookup --> Patterns
style Adapter fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
style Singleton fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style Unsupported fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
For adapter-backed entities, the meta-reducer handles two sub-cases:
| Condition | Behavior | Why |
|---|---|---|
| Entity exists in store | adapter.updateOne() — replaces entity with winning data | Normal conflict resolution |
| Entity NOT in store | adapter.addOne() — recreates entity | Handles DELETE vs UPDATE race (entity was deleted locally but update won remotely) |
After updating a task via LWW, the meta-reducer syncs related entity references:
| Field Changed | Relationship Synced |
|---|---|
projectId | project.taskIds updated to reflect new/old project membership |
tagIds | tag.taskIds updated for each added/removed tag |
dueDay | TODAY_TAG.taskIds updated (virtual tag, membership via dueDay) |
parentId | parent.subTaskIds updated for new/old parent task |
Key file: src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts
| File | Purpose |
|---|---|
src/app/root-store/meta/task-shared-meta-reducers/ | Task-related multi-entity changes |
src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.ts | Tag deletion with cleanup |
src/app/root-store/meta/task-shared-meta-reducers/project-shared.reducer.ts | Project deletion with cleanup |
src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts | LWW Update handling (adapter/singleton/relationship sync) |