docs(sync): update planning doc

This commit is contained in:
Johannes Millan 2025-12-03 18:39:46 +01:00
parent 386c297ade
commit 0b5f2c7002
2 changed files with 204 additions and 74 deletions

View file

@ -152,80 +152,157 @@ graph TD
API <--> ServerDB
```
## 3. Conflict-Aware Migration Strategy
## 3. Conflict-Aware Migration Strategy (The Migration Shield)
This mindmap outlines the strategy for handling version conflicts during sync by migrating operations before conflict detection.
```mermaid
mindmap
root((Conflict-Aware<br/>Migration))
Strategies
Operation-Level Migration
Transform V1 Op to V2 Op
Extend SchemaMigration Interface
Inbound Path Receive
Intercept Remote Ops
Check Op Schema Version
Migrate Old Ops
Detect Conflicts on Migrated Ops
Outbound Path Send
Get Unsynced Ops
Migrate Pending Ops if Old
Ensure Upload matches Current Schema
Conflict Resolution
Unified Comparison
Local Current vs Remote Migrated
Prevent False Conflicts
```
## 4. Hybrid Manifest & Snapshot Architecture (WebDAV / Dropbox Fallback)
This diagram illustrates the efficient "Hybrid Manifest" approach for file-based sync, showing how small operations are buffered in the manifest to reduce request counts, how overflow creates new files, and how snapshotting consolidates history.
This diagram visualizes the "Receiver-Side Migration" strategy. The Migration Layer acts as a shield, ensuring that _only_ operations matching the current schema version ever reach the core conflict detection and application logic.
```mermaid
graph TD
%% Nodes
subgraph "Sources of Operations (Mixed Versions)"
Remote[Remote Client Sync]:::src
Disk[Local Disk Tail Ops]:::src
end
subgraph "Migration Layer (The Shield)"
Check{"Is Op Old?<br/>(vOp < vCurrent)"}:::logic
Migrate["Run migrateOperation()<br/>Pipeline"]:::action
CheckDrop{"Result is<br/>Null?"}:::logic
Pass["Pass Through"]:::pass
end
subgraph "Core System (Current Version Only)"
Conflict["Conflict Detection<br/>(Apples-to-Apples)"]:::core
Apply["Apply to State"]:::core
end
%% Flow
Remote --> Check
Disk --> Check
Check -- Yes --> Migrate
Check -- No --> Pass
Migrate --> CheckDrop
CheckDrop -- Yes --> Drop[("🗑️ Drop Op<br/>(Destructive Change)")]:::drop
CheckDrop -- No --> Conflict
Pass --> Conflict
Conflict --> Apply
%% Styles
classDef local fill:#fff,stroke:#333,stroke-width:2px,color:black;
classDef remote fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:black;
classDef decision fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,color:black;
classDef storage fill:#f9f,stroke:#333,stroke-width:2px,color:black;
subgraph "Client: Write Path (Hybrid)"
StartWrite((Sync Trigger)) --> LoadMan[Download manifest.json]
LoadMan --> CheckBuff{Buffer Full?<br/>> 50 Ops}:::decision
%% Path A: Buffer Open
CheckBuff -- No --> AppendBuff[Append Ops to<br/>manifest.embeddedOperations]
AppendBuff --> WriteMan[Upload manifest.json]:::remote
%% Path B: Buffer Full (Overflow)
CheckBuff -- Yes --> CreateFile[Flush embeddedOperations<br/>to ops/overflow_TIMESTAMP.json]:::storage
CreateFile --> UploadFile[Upload Op File]:::remote
UploadFile --> ClearBuff[Clear Buffer &<br/>Add Filename to<br/>manifest.operationFiles]
ClearBuff --> AppendBuff
end
subgraph "Client: Read Path"
StartRead((Sync Start)) --> DownMan[Download manifest.json]:::remote
DownMan --> CheckSnap{Newer<br/>Snapshot?}:::decision
CheckSnap -- Yes --> DownSnap[Download & Apply<br/>Snapshot File]:::remote
CheckSnap -- No --> CheckFiles
DownSnap --> CheckFiles
CheckFiles[Download & Apply<br/>New Op Files]:::remote
CheckFiles --> ApplyEmb[Apply<br/>embeddedOperations]
ApplyEmb --> Done((Sync Done))
end
subgraph "Client: Snapshotting (Compaction)"
Trigger{Trigger?<br/>> 50 Files}:::decision
Trigger -- Yes --> GenSnap[Generate Full Snapshot]:::storage
GenSnap --> UpSnap[Upload Snapshot File]:::remote
UpSnap --> UpManSnap[Update manifest.json:<br/>1. Set lastSnapshot<br/>2. Clear operationFiles]:::remote
UpManSnap --> Cleanup[Delete Old Files<br/>(Async)]
end
class LoadMan,WriteMan,CreateFile,UploadFile,DownMan,DownSnap,CheckFiles,UpSnap,UpManSnap remote;
class CheckBuff,CheckSnap,Trigger decision;
classDef src fill:#fff3e0,stroke:#ef6c00,stroke-width:2px;
classDef logic fill:#fff,stroke:#333,stroke-width:2px;
classDef action fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
classDef pass fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
classDef drop fill:#ffebee,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5;
classDef core fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px;
```
## 4. Migration Scenarios
### 4.1 Tail Ops Migration (Local Startup Consistency)
Ensures that operations occurring after a snapshot ("Tail Ops") are migrated to the current version before being applied to the migrated state.
```mermaid
sequenceDiagram
participant IDB as IndexedDB (SUP_OPS)
participant Hydrator as OpLogHydrator
participant Migrator as SchemaMigrationService
participant Applier as OperationApplier
participant Store as NgRx Store
Note over IDB, Store: App Updated from V1 -> V2
Hydrator->>IDB: Load Snapshot (Version 1)
IDB-->>Hydrator: Returns Snapshot V1
Hydrator->>Migrator: migrateIfNeeded(Snapshot V1)
Migrator-->>Hydrator: Returns Migrated Snapshot (Version 2)
Hydrator->>Store: Load Initial State (V2)
Hydrator->>IDB: Load Tail Ops (Version 1)
Note right of IDB: Ops created after snapshot<br/>but before update
IDB-->>Hydrator: Returns Ops [OpA(v1), OpB(v1)]
loop For Each Op
Hydrator->>Migrator: migrateOperation(Op V1)
Migrator-->>Hydrator: Returns Op V2 (or null)
alt Op was Dropped (null)
Hydrator->>Hydrator: Ignore
else Op Migrated
Hydrator->>Applier: Apply(Op V2)
Applier->>Store: Dispatch Action (V2 Payload)
end
end
Note over Store: State matches V2 Schema<br/>Consistency Preserved
```
### 4.2 Receiver-Side Sync Migration
Demonstrates how a client on V2 handles incoming data from a client still on V1.
```mermaid
sequenceDiagram
participant Remote as Remote Client (V1)
participant Server as Sync Server
participant Local as Local Client (V2)
participant Conflict as Conflict Detector
Remote->>Server: Upload Operation (Version 1)<br/>{ payload: { oldField: 'X' } }
Server-->>Local: Download Operation (Version 1)
Note over Local: Client V2 receives V1 data
Local->>Local: Check Op Schema Version (v1 < v2)
Local->>Local: Call SchemaMigrationService.migrateOperation()
Note over Local: Transforms payload:<br/>{ oldField: 'X' } -> { newField: 'X' }
Local->>Conflict: detectConflicts(Remote Op V2)
alt Conflict Detected
Conflict->>Local: Show Dialog (V2 vs V2 comparison)
else No Conflict
Local->>Local: Apply Operation (V2)
end
```
## 5. Hybrid Manifest (File-Based Sync)
This diagram illustrates the "Hybrid Manifest" optimization (`hybrid-manifest-architecture.md`) which reduces HTTP request overhead for WebDAV/Dropbox sync by buffering small operations directly inside the manifest file.
```mermaid
graph TD
%% Nodes
subgraph "Hybrid Manifest File (JSON)"
ManVer[Version: 2]:::file
SnapRef[Last Snapshot: 'snap_123.json']:::file
Buffer[Embedded Ops (Buffer)<br/>[Op1, Op2, ...]]:::buffer
ExtFiles[External Files List<br/>['ops_A.json', ...]]:::file
end
subgraph "Sync Logic (Upload Path)"
Start((Start Sync)) --> ReadMan[Download Manifest]
ReadMan --> CheckSize{Buffer Full?<br/>(> 50 ops)}
CheckSize -- No --> AppendBuffer[Append to<br/>Embedded Ops]:::action
AppendBuffer --> WriteMan[Upload Manifest]:::io
CheckSize -- Yes --> Flush[Flush Buffer]:::action
Flush --> CreateFile[Create 'ops_NEW.json'<br/>with old buffer content]:::io
CreateFile --> UpdateRef[Add 'ops_NEW.json'<br/>to External Files]:::action
UpdateRef --> ClearBuffer[Clear Buffer &<br/>Add Pending Ops]:::action
ClearBuffer --> WriteMan
end
%% Styles
classDef file fill:#fff3e0,stroke:#ef6c00,stroke-width:2px;
classDef buffer fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
classDef action fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
classDef io fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px;
```

View file

@ -753,10 +753,63 @@ To handle synchronization between clients on different schema versions, the syst
3. **Outbound Migration (Send Path)**
- **Location:** `OperationLogStore.getUnsynced()`
- **Logic:** Ensure all pending operations sent to the server match `CURRENT_SCHEMA_VERSION`. If an op was created before a local migration (e.g., pending from last session), migrate it on-the-fly before upload.
- **Strategy:** **Send As-Is (Receiver Migrates)**.
- **Logic:** The client sends operations exactly as they are stored in `SUP_OPS`, preserving their original `schemaVersion`. We do _not_ migrate operations before uploading.
- **Reasoning:** This follows the "Robustness Principle" (be conservative in what you do, liberal in what you accept). It avoids the performance cost of batch-migrating thousands of pending operations after a long offline period and eliminates the need to rewrite `SUP_OPS`. The receiving client (which knows its own version best) is responsible for upgrading incoming data.
4. **Conflict Resolution**
- The `ConflictResolutionService` will display the _migrated_ remote operation against the current local state, ensuring the user sees a consistent view of the data (e.g., "Time Estimate" on both sides, rather than "Estimate" vs "Time Estimate").
4. **Destructive Migrations**
- **Scenario:** A feature is removed in V2 (e.g., "Pomodoro" settings deleted), but we receive a V1 `UPDATE` op for it.
- **Logic:** The `migrateOperation` function can return `null`.
- **Handling:** The sync and replay systems must handle `null` by dropping the operation entirely.
5. **Conflict Resolution**
- The `ConflictResolutionService` will display the _migrated_ remote operation against the current local state.
- **UI Decision:** We display the migrated values directly without special "migrated" annotations. Ideally, the conflict dialog uses the same formatters/components as the main UI, so the data looks familiar (e.g., "1 hour" instead of "3600").
### A.7.12 Migration Safety
**Status:** Requirement (Not Implemented)
Migrations are critical and risky. To prevent data loss if a migration crashes mid-process:
1. **Backup Before Migrate:** Before `SchemaMigrationService.migrateIfNeeded()` begins modifying the state, it must create a backup of the current `state_cache`.
- Implementation: Copy `SUP_OPS` object store entry to a backup key (e.g., `state_cache_backup`).
2. **Rollback on Failure:** If migration throws an error, catch it, restore the backup, and prevent the app from loading potentially corrupted "half-migrated" state. (Likely show a fatal error screen asking user to export backup or contact support).
### A.7.13 Tail Ops Consistency
**Status:** Requirement (Not Implemented)
**What are Tail Ops?**
When the app starts, it loads the most recent snapshot (e.g., from yesterday). It then loads all operations that occurred _after_ that snapshot (the "tail") to reconstruct the exact state at the moment the app was closed.
**The Consistency Gap**
1. Snapshot is loaded (Version 1).
2. App is updated (Version 2).
3. Snapshot is migrated (V1 → V2).
4. Tail ops are loaded (Version 1).
5. **Problem:** If we apply V1 tail ops to the V2 state, they might write to fields that no longer exist or have changed format.
**Solution**
The **Tail Ops MUST be migrated** during hydration.
- The `OperationLogHydrator` must pass tail ops through the same `SchemaMigrationService.migrateOperation` pipeline used for sync.
- This ensures that the `OperationApplier` always receives operations matching the current runtime schema.
### A.7.14 Other Design Decisions
**Operation Envelope vs. Payload**
- **Decision:** `schemaVersion` applies **only to the `payload`** of the operation.
- **Reasoning:** Changes to the Operation structure itself (the "envelope", e.g., `id`, `vectorClock`, `opType`) are considered "System Level" breaking changes. They cannot be handled by the standard schema migration system. If the envelope changes, we would likely need a "Genesis V2" event or a specialized one-time database upgrade script.
**Conflict UI for Synthetic Conflicts**
- **Scenario:** Migration transforms logic (e.g., "1h" string → 3600 seconds).
- **Decision:** The Conflict Resolution UI will simply show the migrated value (3600). We will **not** implement special annotations (e.g., "Values differ due to migration").
- **KISS Principle:** Users generally recognize their data even if the format shifts slightly. The complexity of tracking "why" a value changed is not worth the implementation cost.
---