From 3de60ca2e99f20b465778794231a3ea2a3ae8ddd Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:13:36 +0500 Subject: [PATCH 1/8] add fix-stale-sync-overwrites OpenSpec --- .../fix-stale-sync-overwrites/.openspec.yaml | 2 + .../fix-stale-sync-overwrites/design.md | 91 ++++++++++++++ .../fix-stale-sync-overwrites/proposal.md | 114 +++++++++++++++++ .../specs/manual-task-hiding/spec.md | 51 ++++++++ .../specs/repeating-tasks/spec.md | 118 ++++++++++++++++++ .../specs/sync-protocol/spec.md | 52 ++++++++ .../specs/tasks/spec.md | 43 +++++++ .../fix-stale-sync-overwrites/tasks.md | 41 ++++++ 8 files changed, 512 insertions(+) create mode 100644 openspec/changes/fix-stale-sync-overwrites/.openspec.yaml create mode 100644 openspec/changes/fix-stale-sync-overwrites/design.md create mode 100644 openspec/changes/fix-stale-sync-overwrites/proposal.md create mode 100644 openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md create mode 100644 openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md create mode 100644 openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md create mode 100644 openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md create mode 100644 openspec/changes/fix-stale-sync-overwrites/tasks.md diff --git a/openspec/changes/fix-stale-sync-overwrites/.openspec.yaml b/openspec/changes/fix-stale-sync-overwrites/.openspec.yaml new file mode 100644 index 00000000..0bd76e61 --- /dev/null +++ b/openspec/changes/fix-stale-sync-overwrites/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-18 diff --git a/openspec/changes/fix-stale-sync-overwrites/design.md b/openspec/changes/fix-stale-sync-overwrites/design.md new file mode 100644 index 00000000..8ce88a93 --- /dev/null +++ b/openspec/changes/fix-stale-sync-overwrites/design.md @@ -0,0 +1,91 @@ +# Design: fix-stale-sync-overwrites + +## Context + +Driven by FR1-FR6 from proposal. A confirmed multi-device bug: completing + editing a recurring task on device A, then opening a stale device B, resurrects the completed occurrence with the old description everywhere. The causal chain (verified in code): + +1. `HiddenTaskService.revealHiddenTasks()` refreshes `updated_at` + sets `pending` on device B's stale copy — a system mutation masquerading as a user edit. +2. Binary pull protection (`TaskRepository.applyServerRecords`: skip any `pending` local) preserves the stale copy against the newer server state. +3. Push LWW (`>=` client wins) lets the freshened stale copy overwrite the completed, newer record on the server. +4. `RecurringTaskDeduplicator` keeps the earliest-`next_date` copy verbatim and soft-deletes the copy carrying the user's newest edits; the deletion propagates. + +`TaskService.rebalanceBox()` is a second instance of the same class: it freshens `updated_at` + `pending` for every task in a box (hidden recurring clones included — `getByBox` filters only `is_deleted`). + +Constraint: manual hide/unhide (`TaskQuickActions`, `TaskDetailsTab` → `TaskService.update`) are genuine user edits and must keep syncing exactly as today. Recurrence math (`calculateNextDate`) reads only `repeat_rule`, `completed_at`, previous `next_date`, and the clock — it never reads `updated_at`, so timestamp changes cannot affect either recurring model. + +## Goals / Non-Goals + +**Goals** +- One enforced invariant: *system-initiated mutations never refresh `updated_at`; only user actions do.* They may still set `syncStatus: "pending"` to propagate. +- Make pull protection LWW-consistent with push, so a future violation of the invariant degrades gracefully instead of corrupting data. +- Dedup merges instead of discarding the newest content. + +**Non-Goals** (see proposal NG1-NG5): no server changes, no CRDT/field-level merge, no clock-skew handling, no UI changes. + +## Decisions + +### D1. Auto-reveal: `pending` without `updated_at` refresh (FR1) + +`revealHiddenTasks` writes `is_hidden: false, syncStatus: "pending"` and preserves the record's existing `updated_at`. + +Convergence: on push, if the server has no newer state, timestamps tie and the client wins (`>=`) — the reveal reaches the server. If the server is newer (real edit elsewhere), the server wins, returns `conflict`, and the client adopts the newest state. Either way LWW stays honest. + +*Alternatives rejected:* +- **Derive `is_hidden` at read time, never sync it** — cannot express manual unhide before `appear_date` (a synced user intent) nor indefinite hides (`appear_date = ""`), both of which exist today. +- **Don't sync reveal at all (local flip, stay `synced`)** — server keeps `is_hidden=true` forever; every pull of that record re-hides it, causing flicker and repeated writes. + +### D2. Rebalance: same invariant (FR4) + +`rebalanceBox` assigns keys + `pending` with timestamps untouched; `reorderTasks` keeps refreshing `updated_at` for the dragged task only. + +Consequence accepted: when a rebalanced record loses push LWW to a newer server edit, the box ends up with keys from two generations. Order stays deterministic; the next drag re-triggers rebalancing. This is cosmetic and rare (keys > 10 chars). + +*Alternatives rejected:* +- **Local-only rebalance (no sync)** — the next real edit of any task would push its local key into a foreign key-grid on other devices → wandering order with no upside. +- **Lazy per-task rebalance on next real edit** — rebalancing stops doing its job (shortening keys now); complexity without benefit. + +### D3. Dedup merge (FR3) + +Winner selection is unchanged (earliest `next_date`, tiebreak by `id`). The winner record is then merged: + +| Fields | Source copy | +|----------------------------------------------------------------------|--------------------------------------------------| +| `next_date`, `appear_date` (always as a pair) | earliest `next_date` | +| `name`, `description`, `goal_id`, `context_id`, `category_id`, `box` | freshest `updated_at` | +| `updated_at` | freshest copy's value — **not** refreshed to now | +| `repeat_rule` differs between copies | freshest copy wins **wholesale**, dates included | + +Rationale for the wholesale clause: a rule change recomputes `next_date`/`appear_date` under the new rule (`useRepeatRuleChangeDialog`); pairing old-rule dates with a new rule is incoherent, especially across a `fixed` ↔ `after_completion` switch. + +If the merge changed the stored winner, it is written with `syncStatus: "pending"` (its `updated_at` is the freshest copy's — honest under LWW). Loser soft-deletion behaves as today. Checklist items: keep today's behavior (winner keeps its own items; losers' items are cascade-soft-deleted). + +### D4. Pull protection becomes LWW (FR5) + +In every repository's `applyServerRecords`: a local `pending` record is overwritten iff `server.updated_at > local.updated_at` (strict), then marked `synced`. Equal-or-newer local wins locally and pushes later — the mirror of the server's `>=` client-wins rule, so both sides agree on every ordering. Overwrites of pending records are logged (`console.warn` with entity type, id, both timestamps) per the existing "log conflicts for debugging" sync rule. + +No new data loss: a pending record older than the server copy already loses at push (`conflict` → server record applied); this only resolves the same outcome earlier and keeps UI from showing stale data between pull and push. + +Timestamp comparison uses `Temporal.Instant.compare`, not string comparison. + +### D5. Test architecture + +- **Unit BDD (vitest-cucumber)** per feature file listed in the proposal; steps drive real services over `fake-indexeddb` with `fakeClock`. +- **Integration (`packages/integration`)**: two-device simulation = two isolated browser contexts (separate IndexedDB origins/storage states) against one real backend (Testcontainers). Scenarios: U1 (the reported bug end-to-end, both recurring models), U2 (manual unhide propagation), U3 (rebalance on stale device), plus NFR-REL1 convergence assertion (dump both devices' stores + server tables, compare). +- **Mutation testing** scoped to changed files (max 5 per run, sequential). + +## Risks / Trade-offs + +- [Reveal push with old timestamp hits `>=` tie → client wins with an *unchanged* timestamp; server revision bumps but `updated_at` doesn't] → acceptable: content is identical except `is_hidden`; other devices reveal locally anyway. +- [Pending record protected by equal timestamp could ping-pong between devices] → both devices produce identical records for tie cases (reveal/rebalance of the same base revision); pushes converge because content is equal. +- [Two-generation sort keys after partial rebalance loss] → cosmetic, self-heals on next rebalance (D2). +- [Clock skew between devices affects strict `>` pull comparison] → same exposure as existing push LWW; explicitly out of scope (NG3). +- [Dedup merge writes a record whose field combination existed on no device] → limited to content-vs-schedule split with the pair rule and wholesale rule (D3); integration tests assert coherence. +- [`applyServerRecords` changes touch every entity repository] → shared helper + contract-style test suite run against each repository to avoid divergence. + +## Migration Plan + +Client-only, no schema or server changes. Ships as one release; no data migration. Rollback = revert. Records already corrupted by the old behavior are not retroactively repaired (out of scope); the fix prevents new corruption. + +## Open Questions + +(none) diff --git a/openspec/changes/fix-stale-sync-overwrites/proposal.md b/openspec/changes/fix-stale-sync-overwrites/proposal.md new file mode 100644 index 00000000..c78aa4b0 --- /dev/null +++ b/openspec/changes/fix-stale-sync-overwrites/proposal.md @@ -0,0 +1,114 @@ +# fix-stale-sync-overwrites + +## Why + +A multi-device user loses data on recurring tasks: a device that was offline for a while resurrects a completed recurring task with a stale description and deletes the fresh copy on all other devices. Root cause: system-initiated mutations (auto-reveal of hidden tasks, sort-order rebalancing, post-pull deduplication) refresh `updated_at` and set `syncStatus: "pending"` as if the user had edited the record. Under whole-record last-write-wins this makes stale records "newer" than real user edits, and the binary pull protection (`pending` → never overwrite) lets them survive until push, where they win the conflict. + +## What Changes + +- **MODIFIED**: Auto-reveal of hidden tasks (`HiddenTaskService`) marks records `pending` but no longer refreshes `updated_at` — the timestamp stays at the last real user edit. +- **MODIFIED**: Sort-order rebalancing (`TaskService.rebalanceBox`) marks rebalanced records `pending` but no longer refreshes their `updated_at`; only the task the user actually dragged gets a fresh timestamp. +- **MODIFIED**: Recurring-copy deduplication after pull merges instead of blindly keeping the earliest copy: schedule fields (`next_date` + `appear_date`, always as a pair) come from the copy with the earliest `next_date`, content fields from the copy with the freshest `updated_at`; if `repeat_rule` differs between copies, the freshest copy wins wholesale. +- **MODIFIED**: Pull protection becomes LWW-consistent: a server record overwrites a local `pending` record when the server's `updated_at` is strictly newer; ties and older server records preserve the local record. Conflicts are logged. +- **UNCHANGED (guarded by tests)**: Manual hide and manual unhide-before-appear-date remain real user edits — they refresh `updated_at`, sync to other devices, and must keep working for both recurring and non-recurring tasks. +- Specs gain mermaid sequence diagrams for every multi-device sync scenario covered by this change. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `sync-protocol`: "Pull protects local dirty records" changes from status-based (never overwrite `pending`) to LWW-based (overwrite `pending` only when server `updated_at` is strictly newer). +- `repeating-tasks`: "System reveals hidden tasks when appear date arrives" no longer refreshes `updated_at`; "System deduplicates recurring copies after pull" changes from keep-earliest-copy to merge (earliest schedule pair + the freshest content, freshest-wholesale on `repeat_rule` mismatch). +- `manual-task-hiding`: "Hidden tasks auto-reveal on appear date" no longer refreshes `updated_at`; new requirement that manual unhide before the appear date is a synced user edit visible on other devices. +- `tasks`: "Lazy rebalancing when key exceeds threshold" — rebalanced tasks are marked `pending` without an `updated_at` refresh. + +## Goals + +- G1: The reported scenario (stale device with an auto-revealed recurring copy) converges to the newest user content on all devices — no resurrected completions, no stale descriptions, no deleted fresh copies. +- G2: One invariant across the codebase: system-initiated mutations never refresh `updated_at`; only user actions do. +- G3: Both recurring models (`fixed` schedule and `after_completion`) behave identically before and after the change in single-device flows. + +## Non-Goals + +- NG1: No server-side changes — conflict resolution on push (`>=` client wins) stays as is. +- NG2: No field-level merge / CRDT — conflict granularity remains whole-record LWW. +- NG3: No clock-skew mitigation between devices. +- NG4: No change to the dedup winner-by-earliest-`next_date` schedule semantics or to skip logic. +- NG5: No UI changes — screens, controls, and UX of hiding/revealing tasks stay untouched. + +## Users & Scenarios + +- U1: Multi-device user with recurring tasks. Completes and edits a recurring task on device A; device B was offline since before the edits. After device B comes online, both devices show the task with the newest description and correct completion state. +- U2: User who manually hides a task with a future appear date, then manually unhides it early on one device. The task becomes visible on all devices after sync. +- U3: User who drags a task in a large box triggering rebalancing on a stale device. Other tasks in the box do not lose newer content from other devices. + +## Requirements + +### Functional + +- FR1: `HiddenTaskService.revealHiddenTasks()` SHALL set `syncStatus: "pending"` and `is_hidden: false` without modifying `updated_at`. If the record is already `pending`, its state is not degraded. +- FR2: Manual hide and manual unhide SHALL remain regular user updates: refresh `updated_at`, set `pending`, clear `appear_date` on unhide — and SHALL propagate to other devices via ordinary push/pull. +- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: winner record keeps `next_date` and `appear_date` (as a pair) from the copy with the earliest `next_date` (tiebreak by `id`), and content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`, `box`) from the copy with the freshest `updated_at`. When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now). +- FR4: `TaskService.rebalanceBox()` SHALL assign new `sort_order` keys and `syncStatus: "pending"` without modifying `updated_at`. `reorderTasks()` SHALL keep refreshing `updated_at` only for the dragged task. +- FR5: `applyServerRecords()` in every entity repository SHALL overwrite a local `pending` record when the server record's `updated_at` is strictly newer than the local one, setting `syncStatus: "synced"`. When local `updated_at` is equal or newer, the local record SHALL be preserved. Each overwrite of a `pending` record SHALL be logged with entity type, id, and both timestamps. +- FR6: Behavior of both recurring models SHALL be preserved: `after_completion` next date derives only from the new `completed_at`; `fixed` keeps early-completion preservation and skip logic. (Regression guard — no recurrence math changes.) + +### Non-Functional + +#### Reliability + +- NFR-REL1: For every scenario in this change's feature files, after both devices complete a push+pull cycle, IndexedDB state on both devices and the server state SHALL be identical (convergence), verified by integration tests. + +#### Performance + +- Not applicable — no new queries or loops beyond the existing dedup pass. + +#### Accessibility + +- Not applicable — no UI changes. + +#### Responsive + +- Not applicable — no UI changes. + +## UX Acceptance Criteria + +- UX1: The user never observes a completed recurring task returning to active state after a sync. +- UX2: The user never observes a task description reverting to an older version after a sync. +- UX3: A manually unhidden task appears on other devices after their next sync. + +## UI States Matrix + +No UI changes — existing loading/error/empty/offline states are unaffected. + +## Behavior + +Scenarios live in feature files tagged `@fix-stale-sync-overwrites @FR-X`: + +- `packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature` — FR1, FR2 (unit BDD, vitest-cucumber) +- `packages/client/src/test/features/repeating_tasks/dedup_merge.feature` — FR3, FR6 (unit BDD) +- `packages/client/src/test/features/sync_protocol/rebalance_sync.feature` — FR4 (unit BDD) +- `packages/client/src/test/features/sync_protocol/pull_lww_protection.feature` — FR5 (unit BDD) +- Integration tests (`packages/integration`) simulate two devices against a real backend for the U1/U2/U3 scenarios and the NFR-REL1 convergence check. + +## Visual Reference + +Not applicable — no UI changes. + +## Affected IA + +No changes. + +## Success Metrics + +- M1: The reported two-device scenario, reproduced as an integration test, passes: final description on both devices equals the latest user edit, and the completed occurrence stays completed. +- M2: Zero `pending` records whose `updated_at` was written by a code path other than a user action (verified by unit tests on reveal, rebalance, dedup). +- M3: Mutation score on changed files >= 95% (minimum acceptable 90%). + +## Open Questions + +(none — tie semantics, clock skew, and server behavior were explicitly scoped out in NG1-NG3) diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md new file mode 100644 index 00000000..7f86db88 --- /dev/null +++ b/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md @@ -0,0 +1,51 @@ +# Delta: manual-task-hiding + +## MODIFIED Requirements + +### Requirement: Hidden tasks auto-reveal on appear date +# implements FR1 of fix-stale-sync-overwrites (was: FR7, FR8 of hide-tasks) + +The system SHALL automatically set `is_hidden = false` when `appear_date <= logicalDate`. The task SHALL remain in its original box. Auto-reveal SHALL set `syncStatus = "pending"` but SHALL NOT modify `updated_at` — it is a system-derived transition, not a user edit; the timestamp stays at the last real user edit so a stale device cannot win last-write-wins against newer edits from another device. + +#### Scenario: Task revealed when appear date arrives +- **WHEN** the logical date reaches or passes the task's `appear_date` +- **THEN** `HiddenTaskService.revealHiddenTasks()` sets `is_hidden = false` +- **AND** the task remains in its original box +- **AND** the task's `updated_at` is unchanged and `syncStatus` is `"pending"` + +## ADDED Requirements + +### Requirement: Manual unhide before appear date is a synced user edit +# implements FR2 of fix-stale-sync-overwrites + +When the user manually unhides a task before its `appear_date`, the system SHALL treat it as a regular user edit: set `is_hidden = false`, clear `appear_date`, refresh `updated_at`, and set `syncStatus = "pending"`. The manual unhide SHALL propagate to other devices via ordinary push/pull and SHALL win last-write-wins against any older state of the record. Manual hide (setting `is_hidden = true` with an `appear_date`) SHALL behave symmetrically as a user edit. + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B + + Note over A: task hidden until 07-20 (both devices in sync) + Note over A: user manually unhides on 07-15
is_hidden=false, appear_date="",
updated_at=now, pending + A->>S: push (accepted, new revision) + B->>S: pull + S-->>B: task: is_hidden=false, appear_date="" + Note over B: local copy synced & older → overwritten
task visible on device B before 07-20 + Note over B: reveal check ignores it
(appear_date empty) +``` + +#### Scenario: Manual unhide propagates to another device +- **GIVEN** a task hidden until "2026-07-20" synced on devices A and B +- **WHEN** the user manually unhides it on device A on "2026-07-15" and both devices sync +- **THEN** the task is visible on device B with `appear_date = ""` before "2026-07-20" + +#### Scenario: Manual unhide refreshes the timestamp +- **GIVEN** a hidden task with `updated_at = t1` +- **WHEN** the user manually unhides it +- **THEN** `is_hidden = false`, `appear_date = ""`, `updated_at > t1`, `syncStatus = "pending"` + +#### Scenario: Manual hide propagates to another device +- **GIVEN** a visible task synced on devices A and B +- **WHEN** the user hides it until "2026-08-01" on device A and both devices sync +- **THEN** the task is hidden on device B with `appear_date = "2026-08-01"` diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md new file mode 100644 index 00000000..488a1c43 --- /dev/null +++ b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md @@ -0,0 +1,118 @@ +# Delta: repeating-tasks + +## MODIFIED Requirements + +### Requirement: System reveals hidden tasks when appear date arrives +# implements FR1 of fix-stale-sync-overwrites (was: FR11 of repeating-tasks-specs, FR4 of day-boundary) + +System MUST reveal hidden tasks (set is_hidden=false, syncStatus="pending") when appear_date <= logical date. Reveal MUST NOT modify `updated_at` — auto-reveal is a system-derived transition, not a user edit, and the record's timestamp SHALL remain that of the last real user edit so that last-write-wins conflict resolution cannot prefer a stale record over newer edits from another device. Reveal MUST be triggered on: app mount, day boundary transition (instead of midnight), sync_complete event, return from background (visibility change), and day boundary setting change. + +#### Scenario: Reveal tasks whose appear_date has arrived +- **GIVEN** hidden task with appear_date "2026-01-15" and logical date is "2026-01-15" +- **WHEN** system runs reveal check +- **THEN** task has is_hidden false, syncStatus "pending" +- **AND** the task's `updated_at` is unchanged + +#### Scenario: Do not reveal tasks whose appear_date is future +- **GIVEN** hidden task with appear_date "2026-01-20" and logical date is "2026-01-15" +- **WHEN** system runs reveal check +- **THEN** task remains hidden + +#### Scenario: Reveal triggered on app mount +- **WHEN** app mounts +- **THEN** system runs reveal check with current logical date + +#### Scenario: Reveal triggered at day boundary +- **WHEN** clock passes the configured day boundary time +- **THEN** system runs reveal check with new logical date + +#### Scenario: Reveal of a stale copy loses push conflict to newer server state +- **GIVEN** device B holds a recurring copy last edited at t2 that was completed on device A at t5 (t5 > t2) +- **WHEN** device B auto-reveals the copy and pushes it with `updated_at = t2` +- **THEN** the server responds `conflict` and device B applies the server record (completed, newest content) + +### Requirement: System deduplicates recurring copies after pull +# implements FR3, FR6 of fix-stale-sync-overwrites (was: FR1, FR2 of dedup-recurring-after-pull) + +After applying a pull batch, the system SHALL detect duplicate recurring copies — multiple non-completed, non-deleted tasks sharing the same `original_task_id`. Among duplicates, the system SHALL keep the winner by earliest `next_date`, tiebreak by lexicographically smallest `id`. Losers SHALL be soft-deleted with `syncStatus: "pending"`. + +The winner SHALL be merged, not kept verbatim: + +- Schedule fields `next_date` and `appear_date` SHALL be taken **as a pair** from the copy with the earliest `next_date` (they are coupled via `advance_days`; mixing copies would corrupt reveal timing). +- Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`, `box`) SHALL be taken from the copy with the freshest `updated_at`. +- The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time. +- When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule. + +Merge semantics apply identically to both recurring models (`fixed` and `after_completion`); the earliest-`next_date` winner rule for schedules is unchanged, and for `after_completion` any suboptimal winner self-corrects at the next completion because its next date derives only from the new `completed_at`. + +Two-device double completion of the same occurrence (fixed schedule): + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B + + Note over A: completes occurrence,
edits description ("v2")
clone A1: next_date=07-10, updated_at=t5 + Note over B: completes same occurrence offline
clone B1: next_date=07-09, updated_at=t3 + A->>S: push A1 + B->>S: push B1 + B->>S: pull + S-->>B: A1 + Note over B: dedup group {A1, B1}
winner dates: B1 (next_date 07-09)
winner content: A1 ("v2", updated_at t5)
loser A1 soft-deleted + B->>S: push merged winner + deletion + A->>S: pull + S-->>A: merged copy: earliest date, newest content +``` + +Two-device double completion (`after_completion`, delay 3 days): + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B + + Note over A: completes at 07-01
clone A1: next_date=07-04 + Note over B: completes at 07-02 offline
clone B1: next_date=07-05 + A->>S: push A1 + B->>S: push B1, then pull + S-->>B: A1 + Note over B: dedup: winner dates from A1 (07-04),
content from freshest updated_at + Note over B: at next completion next_date is recomputed
from new completed_at — self-corrects +``` + +#### Scenario: Two duplicates with same next_date — tiebreak by id +- **GIVEN** task Copy-A (id="aaa...", original_task_id="root", next_date="2026-07-01") and Copy-B (id="bbb...", original_task_id="root", next_date="2026-07-01"), both non-completed, non-deleted +- **WHEN** deduplication runs after pull +- **THEN** Copy-A is kept (smaller id) and Copy-B is soft-deleted + +#### Scenario: Two duplicates with different next_date — earlier wins +- **GIVEN** task Copy-A (original_task_id="root", next_date="2026-07-05") and Copy-B (original_task_id="root", next_date="2026-07-01"), both non-completed, non-deleted +- **WHEN** deduplication runs after pull +- **THEN** Copy-B is kept (earlier next_date) and Copy-A is soft-deleted + +#### Scenario: Winner takes content from the freshest copy +- **GIVEN** Copy-A (next_date="2026-07-05", description="v2", updated_at="2026-07-02T10:00:00.000Z") and Copy-B (next_date="2026-07-01", description="v1", updated_at="2026-07-01T10:00:00.000Z"), same original_task_id +- **WHEN** deduplication runs after pull +- **THEN** the kept record has next_date "2026-07-01", appear_date from Copy-B, description "v2", and updated_at "2026-07-02T10:00:00.000Z" + +#### Scenario: Dates always move as a pair +- **GIVEN** duplicates where the earliest-next_date copy has appear_date derived from its own next_date +- **WHEN** deduplication merges the winner +- **THEN** next_date and appear_date both come from the earliest-next_date copy (never mixed across copies) + +#### Scenario: Differing repeat_rule — freshest copy wins wholesale +- **GIVEN** Copy-A (repeat_rule=daily-1, next_date="2026-07-01", updated_at older) and Copy-B (repeat_rule=weekly-Mon, next_date="2026-07-06", updated_at newer), same original_task_id +- **WHEN** deduplication runs after pull +- **THEN** the kept record equals Copy-B in all fields including next_date and appear_date + +#### Scenario: No duplicates — no action +- **GIVEN** only one non-completed, non-deleted task per original_task_id +- **WHEN** deduplication runs after pull +- **THEN** no tasks are modified + +#### Scenario: Completed copies are excluded from dedup +- **GIVEN** Copy-A (original_task_id="root", is_completed=true) and Copy-B (original_task_id="root", is_completed=false) +- **WHEN** deduplication runs after pull +- **THEN** no deduplication occurs (only one non-completed copy exists) diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md new file mode 100644 index 00000000..1cf45331 --- /dev/null +++ b/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md @@ -0,0 +1,52 @@ +# Delta: sync-protocol + +## MODIFIED Requirements + +### Requirement: Pull protects local dirty records +# implements FR5 of fix-stale-sync-overwrites + +When applying server records from pull, the client SHALL compare timestamps for records that have `syncStatus = "pending"`: the server record SHALL overwrite the local pending record only when the server record's `updated_at` is strictly newer than the local `updated_at` (last-write-wins, mirroring push conflict resolution). When the local `updated_at` is equal or newer, the local pending record SHALL be preserved and pushed in the next sync. Records with `syncStatus = "synced"` or `"rejected"` SHALL be overwritten by the server version as before. Every overwrite of a pending record SHALL be logged with entity type, record id, and both timestamps. + +Multi-device convergence for the stale-device scenario this rule fixes (device B held a stale pending copy freshened by a system mutation): + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B (stale) + + A->>S: push: task edited + completed (updated_at = t5) + Note over B: auto-reveal marks task pending
WITHOUT refreshing updated_at (stays t2) + B->>S: push: pending task (updated_at = t2) + S-->>B: conflict (server updated_at t5 > t2) + B->>B: apply server_record, syncStatus = synced + B->>S: pull + S-->>B: records with revision > last_known + Note over B: pull LWW: server t5 > local t2
pending record would also be overwritten here + A->>S: pull + S-->>A: no stale resurrection — state converged +``` + +#### Scenario: Clean local record is overwritten by server +- **WHEN** pull returns a record that exists locally with `syncStatus = "synced"` +- **THEN** local record is overwritten with server version + +#### Scenario: Pending local record loses to strictly newer server record +- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-01T10:00:00.000Z"` +- **WHEN** pull returns the same record with `updated_at = "2026-07-02T10:00:00.000Z"` +- **THEN** local record is overwritten with the server version and `syncStatus` becomes `"synced"` +- **AND** the overwrite is logged with entity type, id, and both timestamps + +#### Scenario: Pending local record with equal timestamp is preserved +- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-01T10:00:00.000Z"` +- **WHEN** pull returns the same record with `updated_at = "2026-07-01T10:00:00.000Z"` +- **THEN** local record is NOT overwritten (will be pushed in next sync) + +#### Scenario: Pending local record with newer timestamp is preserved +- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-03T10:00:00.000Z"` +- **WHEN** pull returns the same record with `updated_at = "2026-07-02T10:00:00.000Z"` +- **THEN** local record is NOT overwritten (will be pushed in next sync) + +#### Scenario: New server record is inserted +- **WHEN** pull returns a record that does not exist locally +- **THEN** record is inserted into IndexedDB diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md new file mode 100644 index 00000000..f0b74808 --- /dev/null +++ b/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md @@ -0,0 +1,43 @@ +# Delta: tasks + +## MODIFIED Requirements + +### Requirement: Lazy rebalancing when key exceeds threshold +# implements FR4 of fix-stale-sync-overwrites (was: FR9 of fractional-sort-order) + +When a newly generated sort_order key exceeds 10 characters, the system MUST rebalance all tasks in the same box with evenly distributed keys preserving current display order. Rebalanced tasks SHALL be marked `syncStatus = "pending"` but their `updated_at` SHALL NOT be modified — rebalancing is a system-derived mutation, and refreshing the timestamps would let a stale device overwrite newer content of every task in the box via whole-record last-write-wins. Only the task the user actually dragged SHALL get a refreshed `updated_at` (in `reorderTasks`). + +Rebalance on a stale device no longer clobbers newer content of untouched tasks: + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B (stale) + + A->>S: push: task X edited (updated_at = t5) + Note over B: user drags task Y →
rebalance touches whole box
Y: updated_at=now,
others (incl. X): timestamps kept + B->>S: push: Y (fresh) + rebalanced records (old timestamps) + S-->>B: Y accepted, X → conflict (server t5 newer) + B->>B: apply server X (newest content, server sort key) + B->>S: pull + Note over A,B: X keeps its newest content everywhere,
new sort keys sync where no newer edit exists +``` + +#### Scenario: Rebalancing triggered by long key +- **GIVEN** today box has tasks with deeply nested sort_order keys +- **WHEN** a drag-drop produces a key longer than 10 characters +- **THEN** all tasks in today box get fresh evenly distributed keys +- **AND** display order is preserved +- **AND** all rebalanced tasks are marked syncStatus="pending" +- **AND** `updated_at` of every task except the dragged one is unchanged + +#### Scenario: Rebalancing not triggered for short keys +- **GIVEN** today box has tasks with short sort_order keys +- **WHEN** a drag-drop produces a key of 4 characters +- **THEN** only the dragged task is updated + +#### Scenario: Rebalance on a stale device does not clobber newer content +- **GIVEN** device B holds task X last synced at t2, while the server has X edited at t5 (t5 > t2) +- **WHEN** device B rebalances the box (X gets a new sort key, `updated_at` stays t2) and pushes +- **THEN** the server responds `conflict` for X and device B applies the server version with the newest content diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/fix-stale-sync-overwrites/tasks.md new file mode 100644 index 00000000..e97cb1c6 --- /dev/null +++ b/openspec/changes/fix-stale-sync-overwrites/tasks.md @@ -0,0 +1,41 @@ +# Tasks: fix-stale-sync-overwrites + +## 1. Auto-reveal preserves updated_at (FR1, FR2) + +- [ ] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"`; already-`pending` record is not degraded (FR1) +- [ ] 1.2 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal (FR1) +- [ ] 1.3 Characterization tests: manual hide and manual unhide via `TaskService.update` refresh `updated_at`, clear `appear_date` on unhide, set `pending` (FR2 regression guard) +- [ ] 1.4 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios and manual hide/unhide sync scenarios, tags `@fix-stale-sync-overwrites @FR1` / `@FR2` + +## 2. Rebalance preserves updated_at (FR4) + +- [ ] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending`; dragged task in `reorderTasks` still gets fresh `updated_at` (FR4) +- [ ] 2.2 GREEN: change `rebalanceBox` to stop refreshing `updated_at` (FR4) +- [ ] 2.3 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4` + +## 3. Dedup merge (FR3, FR6) + +- [ ] 3.1 RED: unit tests for `RecurringTaskDeduplicator` merge — content from freshest `updated_at`, `next_date`+`appear_date` as a pair from earliest, winner `updated_at` = freshest copy's value (not now), wholesale-freshest on `repeat_rule` mismatch, existing winner/tiebreak/exclusion behavior unchanged (FR3) +- [ ] 3.2 GREEN: implement merge in `RecurringTaskDeduplicator` (FR3); keep file under 200 lines — extract merge logic into a separate module if needed +- [ ] 3.3 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization) +- [ ] 3.4 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6` + +## 4. LWW pull protection (FR5) + +- [ ] 4.1 RED: contract-style test suite for `applyServerRecords` (shared cases: synced overwritten; pending vs strictly-newer server → overwritten + logged; pending vs equal → preserved; pending vs older → preserved; new record inserted) run against every entity repository (FR5) +- [ ] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log; wire into all entity repositories (FR5) +- [ ] 4.3 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5` + +## 5. Integration tests (packages/integration) (NFR-REL1, M1) + +- [ ] 5.1 Two-device harness: two isolated browser contexts (separate storage) against one backend; helper to dump and diff device stores + server tables for convergence assertions (NFR-REL1) +- [ ] 5.2 Integration: reported bug end-to-end for `fixed` model — complete/edit/complete on A, stale B reopens, both converge to the newest description and completed state (U1, M1, FR1+FR3+FR5) +- [ ] 5.3 Integration: same scenario for `after_completion` model (U1, FR6) +- [ ] 5.4 Integration: manual unhide before appear_date propagates to device B (U2, FR2) +- [ ] 5.5 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4) + +## 6. Verification + +- [ ] 6.1 Mutation testing scoped to changed files (max 5 per run, sequential): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3) +- [ ] 6.2 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build` +- [ ] 6.3 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference From c65e5d44c41d24ea25962e6c0a18b96844ca5853 Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:03:56 +0500 Subject: [PATCH 2/8] add fix-stale-sync-overwrites OpenSpec --- .../fix-stale-sync-overwrites/design.md | 16 +++-- .../fix-stale-sync-overwrites/proposal.md | 6 +- .../specs/manual-task-hiding/spec.md | 6 +- .../specs/repeating-tasks/spec.md | 36 +++++++++--- .../specs/sync-protocol/spec.md | 2 +- .../specs/tasks/spec.md | 2 +- .../fix-stale-sync-overwrites/tasks.md | 58 ++++++++++++------- 7 files changed, 82 insertions(+), 44 deletions(-) diff --git a/openspec/changes/fix-stale-sync-overwrites/design.md b/openspec/changes/fix-stale-sync-overwrites/design.md index 8ce88a93..2fd80b9f 100644 --- a/openspec/changes/fix-stale-sync-overwrites/design.md +++ b/openspec/changes/fix-stale-sync-overwrites/design.md @@ -48,12 +48,16 @@ Consequence accepted: when a rebalanced record loses push LWW to a newer server Winner selection is unchanged (earliest `next_date`, tiebreak by `id`). The winner record is then merged: -| Fields | Source copy | -|----------------------------------------------------------------------|--------------------------------------------------| -| `next_date`, `appear_date` (always as a pair) | earliest `next_date` | -| `name`, `description`, `goal_id`, `context_id`, `category_id`, `box` | freshest `updated_at` | -| `updated_at` | freshest copy's value — **not** refreshed to now | -| `repeat_rule` differs between copies | freshest copy wins **wholesale**, dates included | +| Fields | Source copy | +|-------------------------------------------------------------------|--------------------------------------------------| +| `next_date`, `appear_date`, `is_hidden` (always as a triple) | earliest `next_date` | +| `name`, `description`, `goal_id`, `context_id`, `category_id` | freshest `updated_at` | +| `box`, `sort_order` (as a pair — sort keys are per-box grids) | freshest `updated_at` | +| `id`, `created_at`, `revision` (identity/bookkeeping) | winner's own | +| `updated_at` | freshest copy's value — **not** refreshed to now | +| `repeat_rule` differs between copies | freshest copy wins **wholesale**, dates included | + +`is_hidden` travels with the schedule triple because it is derived from that copy's `appear_date` (hidden iff not yet due); pairing it with another copy's dates would break reveal timing. `sort_order` travels with `box` because sort keys only order within a box. Completion fields need no rule: every copy in a duplicate group is non-completed by the group filter. Rationale for the wholesale clause: a rule change recomputes `next_date`/`appear_date` under the new rule (`useRepeatRuleChangeDialog`); pairing old-rule dates with a new rule is incoherent, especially across a `fixed` ↔ `after_completion` switch. diff --git a/openspec/changes/fix-stale-sync-overwrites/proposal.md b/openspec/changes/fix-stale-sync-overwrites/proposal.md index c78aa4b0..4a2429b4 100644 --- a/openspec/changes/fix-stale-sync-overwrites/proposal.md +++ b/openspec/changes/fix-stale-sync-overwrites/proposal.md @@ -10,7 +10,7 @@ A multi-device user loses data on recurring tasks: a device that was offline for - **MODIFIED**: Sort-order rebalancing (`TaskService.rebalanceBox`) marks rebalanced records `pending` but no longer refreshes their `updated_at`; only the task the user actually dragged gets a fresh timestamp. - **MODIFIED**: Recurring-copy deduplication after pull merges instead of blindly keeping the earliest copy: schedule fields (`next_date` + `appear_date`, always as a pair) come from the copy with the earliest `next_date`, content fields from the copy with the freshest `updated_at`; if `repeat_rule` differs between copies, the freshest copy wins wholesale. - **MODIFIED**: Pull protection becomes LWW-consistent: a server record overwrites a local `pending` record when the server's `updated_at` is strictly newer; ties and older server records preserve the local record. Conflicts are logged. -- **UNCHANGED (guarded by tests)**: Manual hide and manual unhide-before-appear-date remain real user edits — they refresh `updated_at`, sync to other devices, and must keep working for both recurring and non-recurring tasks. +- **UNCHANGED (guarded by tests)**: Manual hide and manual unhide-before-appear-date remain real user edits — they refresh `updated_at` and sync to other devices. They apply to non-recurring tasks only (recurring tasks expose no manual hide/unhide controls per `manual-task-hiding`); hidden recurring copies are governed solely by auto-reveal (FR1). - Specs gain mermaid sequence diagrams for every multi-device sync scenario covered by this change. ## Capabilities @@ -51,8 +51,8 @@ A multi-device user loses data on recurring tasks: a device that was offline for ### Functional - FR1: `HiddenTaskService.revealHiddenTasks()` SHALL set `syncStatus: "pending"` and `is_hidden: false` without modifying `updated_at`. If the record is already `pending`, its state is not degraded. -- FR2: Manual hide and manual unhide SHALL remain regular user updates: refresh `updated_at`, set `pending`, clear `appear_date` on unhide — and SHALL propagate to other devices via ordinary push/pull. -- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: winner record keeps `next_date` and `appear_date` (as a pair) from the copy with the earliest `next_date` (tiebreak by `id`), and content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`, `box`) from the copy with the freshest `updated_at`. When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now). +- FR2: Manual hide and manual unhide (non-recurring tasks — the only kind with manual hide/unhide controls) SHALL remain regular user updates: refresh `updated_at`, set `pending`, clear `appear_date` on unhide — and SHALL propagate to other devices via ordinary push/pull. +- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: the winner record keeps `next_date`, `appear_date`, and `is_hidden` (always as a schedule triple — `is_hidden` is derived from `appear_date`) from the copy with the earliest `next_date` (tiebreak by `id`); content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys are per-box) from the copy with the freshest `updated_at`; identity fields (`id`, `created_at`, `revision`) stay the winner's own. When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now), and the winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it. - FR4: `TaskService.rebalanceBox()` SHALL assign new `sort_order` keys and `syncStatus: "pending"` without modifying `updated_at`. `reorderTasks()` SHALL keep refreshing `updated_at` only for the dragged task. - FR5: `applyServerRecords()` in every entity repository SHALL overwrite a local `pending` record when the server record's `updated_at` is strictly newer than the local one, setting `syncStatus: "synced"`. When local `updated_at` is equal or newer, the local record SHALL be preserved. Each overwrite of a `pending` record SHALL be logged with entity type, id, and both timestamps. - FR6: Behavior of both recurring models SHALL be preserved: `after_completion` next date derives only from the new `completed_at`; `fixed` keeps early-completion preservation and skip logic. (Regression guard — no recurrence math changes.) diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md index 7f86db88..e2f54a41 100644 --- a/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md +++ b/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md @@ -3,7 +3,7 @@ ## MODIFIED Requirements ### Requirement: Hidden tasks auto-reveal on appear date -# implements FR1 of fix-stale-sync-overwrites (was: FR7, FR8 of hide-tasks) +Implements FR1 of fix-stale-sync-overwrites (was: FR7, FR8 of hide-tasks). The system SHALL automatically set `is_hidden = false` when `appear_date <= logicalDate`. The task SHALL remain in its original box. Auto-reveal SHALL set `syncStatus = "pending"` but SHALL NOT modify `updated_at` — it is a system-derived transition, not a user edit; the timestamp stays at the last real user edit so a stale device cannot win last-write-wins against newer edits from another device. @@ -16,9 +16,9 @@ The system SHALL automatically set `is_hidden = false` when `appear_date <= logi ## ADDED Requirements ### Requirement: Manual unhide before appear date is a synced user edit -# implements FR2 of fix-stale-sync-overwrites +Implements FR2 of fix-stale-sync-overwrites. -When the user manually unhides a task before its `appear_date`, the system SHALL treat it as a regular user edit: set `is_hidden = false`, clear `appear_date`, refresh `updated_at`, and set `syncStatus = "pending"`. The manual unhide SHALL propagate to other devices via ordinary push/pull and SHALL win last-write-wins against any older state of the record. Manual hide (setting `is_hidden = true` with an `appear_date`) SHALL behave symmetrically as a user edit. +When the user manually unhides a manually hidden task before its `appear_date`, the system SHALL treat it as a regular user edit: set `is_hidden = false`, clear `appear_date`, refresh `updated_at`, and set `syncStatus = "pending"`. The manual unhide SHALL propagate to other devices via ordinary push/pull and SHALL win last-write-wins against any older state of the record. Manual hide (setting `is_hidden = true` with an `appear_date`) SHALL behave symmetrically as a user edit. This applies to non-recurring tasks only — recurring tasks expose no manual hide/unhide controls (see "Hide action excluded for recurring tasks"); hidden recurring copies are governed solely by system auto-reveal. ```mermaid sequenceDiagram diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md index 488a1c43..1e9433d7 100644 --- a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md +++ b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md @@ -3,7 +3,7 @@ ## MODIFIED Requirements ### Requirement: System reveals hidden tasks when appear date arrives -# implements FR1 of fix-stale-sync-overwrites (was: FR11 of repeating-tasks-specs, FR4 of day-boundary) +Implements FR1 of fix-stale-sync-overwrites (was: FR11 of repeating-tasks-specs, FR4 of day-boundary). System MUST reveal hidden tasks (set is_hidden=false, syncStatus="pending") when appear_date <= logical date. Reveal MUST NOT modify `updated_at` — auto-reveal is a system-derived transition, not a user edit, and the record's timestamp SHALL remain that of the last real user edit so that last-write-wins conflict resolution cannot prefer a stale record over newer edits from another device. Reveal MUST be triggered on: app mount, day boundary transition (instead of midnight), sync_complete event, return from background (visibility change), and day boundary setting change. @@ -26,21 +26,26 @@ System MUST reveal hidden tasks (set is_hidden=false, syncStatus="pending") when - **WHEN** clock passes the configured day boundary time - **THEN** system runs reveal check with new logical date +#### Scenario: Reveal triggered on day boundary change +- **WHEN** user changes the day boundary setting +- **THEN** system immediately runs reveal check with recalculated logical date + #### Scenario: Reveal of a stale copy loses push conflict to newer server state - **GIVEN** device B holds a recurring copy last edited at t2 that was completed on device A at t5 (t5 > t2) - **WHEN** device B auto-reveals the copy and pushes it with `updated_at = t2` - **THEN** the server responds `conflict` and device B applies the server record (completed, newest content) ### Requirement: System deduplicates recurring copies after pull -# implements FR3, FR6 of fix-stale-sync-overwrites (was: FR1, FR2 of dedup-recurring-after-pull) +Implements FR3, FR6 of fix-stale-sync-overwrites (was: FR1, FR2 of dedup-recurring-after-pull). After applying a pull batch, the system SHALL detect duplicate recurring copies — multiple non-completed, non-deleted tasks sharing the same `original_task_id`. Among duplicates, the system SHALL keep the winner by earliest `next_date`, tiebreak by lexicographically smallest `id`. Losers SHALL be soft-deleted with `syncStatus: "pending"`. The winner SHALL be merged, not kept verbatim: -- Schedule fields `next_date` and `appear_date` SHALL be taken **as a pair** from the copy with the earliest `next_date` (they are coupled via `advance_days`; mixing copies would corrupt reveal timing). -- Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`, `box`) SHALL be taken from the copy with the freshest `updated_at`. -- The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time. +- Schedule fields `next_date`, `appear_date`, and `is_hidden` SHALL be taken **as a triple** from the copy with the earliest `next_date` (`appear_date` is coupled to `next_date` via `advance_days`, and `is_hidden` is derived from `appear_date`; mixing copies would corrupt reveal timing). +- Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys only order within a box) SHALL be taken from the copy with the freshest `updated_at`. +- Identity fields (`id`, `created_at`, `revision`) SHALL stay the winner's own. Completion fields need no merge rule: every copy in a duplicate group is non-completed by the group filter. +- The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time. The winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it; an already-optimal winner is not rewritten. - When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule. Merge semantics apply identically to both recurring models (`fixed` and `after_completion`); the earliest-`next_date` winner rule for schedules is unchanged, and for `after_completion` any suboptimal winner self-corrects at the next completion because its next date derives only from the new `completed_at`. @@ -97,10 +102,25 @@ sequenceDiagram - **WHEN** deduplication runs after pull - **THEN** the kept record has next_date "2026-07-01", appear_date from Copy-B, description "v2", and updated_at "2026-07-02T10:00:00.000Z" -#### Scenario: Dates always move as a pair -- **GIVEN** duplicates where the earliest-next_date copy has appear_date derived from its own next_date +#### Scenario: Schedule fields always move as a triple +- **GIVEN** duplicates where the earliest-next_date copy has appear_date derived from its own next_date and is_hidden derived from that appear_date - **WHEN** deduplication merges the winner -- **THEN** next_date and appear_date both come from the earliest-next_date copy (never mixed across copies) +- **THEN** next_date, appear_date, and is_hidden all come from the earliest-next_date copy (never mixed across copies) + +#### Scenario: Hidden and revealed copies merge without breaking reveal timing +- **GIVEN** Copy-A revealed (is_hidden=false, next_date="2026-07-09", updated_at older) and Copy-B still hidden (is_hidden=true, next_date="2026-07-12", appear_date="2026-07-10", updated_at newer), same original_task_id and same repeat_rule +- **WHEN** deduplication runs after pull +- **THEN** the kept record has next_date "2026-07-09" and is_hidden false (from Copy-A) with content from Copy-B + +#### Scenario: Box and sort_order come from the freshest copy as a pair +- **GIVEN** duplicates in different boxes with box-specific sort_order keys +- **WHEN** deduplication merges the winner +- **THEN** box and sort_order both come from the freshest-updated_at copy (never mixed across copies) + +#### Scenario: Already-optimal winner is not rewritten +- **GIVEN** duplicates where the winner already carries both the earliest schedule triple and the freshest content, `syncStatus = "synced"` +- **WHEN** deduplication runs after pull +- **THEN** the winner record is not modified and keeps `syncStatus = "synced"`, while losers are soft-deleted #### Scenario: Differing repeat_rule — freshest copy wins wholesale - **GIVEN** Copy-A (repeat_rule=daily-1, next_date="2026-07-01", updated_at older) and Copy-B (repeat_rule=weekly-Mon, next_date="2026-07-06", updated_at newer), same original_task_id diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md index 1cf45331..22e0504d 100644 --- a/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md +++ b/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md @@ -3,7 +3,7 @@ ## MODIFIED Requirements ### Requirement: Pull protects local dirty records -# implements FR5 of fix-stale-sync-overwrites +Implements FR5 of fix-stale-sync-overwrites. When applying server records from pull, the client SHALL compare timestamps for records that have `syncStatus = "pending"`: the server record SHALL overwrite the local pending record only when the server record's `updated_at` is strictly newer than the local `updated_at` (last-write-wins, mirroring push conflict resolution). When the local `updated_at` is equal or newer, the local pending record SHALL be preserved and pushed in the next sync. Records with `syncStatus = "synced"` or `"rejected"` SHALL be overwritten by the server version as before. Every overwrite of a pending record SHALL be logged with entity type, record id, and both timestamps. diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md index f0b74808..d7e3743e 100644 --- a/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md +++ b/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md @@ -3,7 +3,7 @@ ## MODIFIED Requirements ### Requirement: Lazy rebalancing when key exceeds threshold -# implements FR4 of fix-stale-sync-overwrites (was: FR9 of fractional-sort-order) +Implements FR4 of fix-stale-sync-overwrites (was: FR9 of fractional-sort-order). When a newly generated sort_order key exceeds 10 characters, the system MUST rebalance all tasks in the same box with evenly distributed keys preserving current display order. Rebalanced tasks SHALL be marked `syncStatus = "pending"` but their `updated_at` SHALL NOT be modified — rebalancing is a system-derived mutation, and refreshing the timestamps would let a stale device overwrite newer content of every task in the box via whole-record last-write-wins. Only the task the user actually dragged SHALL get a refreshed `updated_at` (in `reorderTasks`). diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/fix-stale-sync-overwrites/tasks.md index e97cb1c6..1099f943 100644 --- a/openspec/changes/fix-stale-sync-overwrites/tasks.md +++ b/openspec/changes/fix-stale-sync-overwrites/tasks.md @@ -2,40 +2,54 @@ ## 1. Auto-reveal preserves updated_at (FR1, FR2) -- [ ] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"`; already-`pending` record is not degraded (FR1) -- [ ] 1.2 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal (FR1) -- [ ] 1.3 Characterization tests: manual hide and manual unhide via `TaskService.update` refresh `updated_at`, clear `appear_date` on unhide, set `pending` (FR2 regression guard) -- [ ] 1.4 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios and manual hide/unhide sync scenarios, tags `@fix-stale-sync-overwrites @FR1` / `@FR2` +- [ ] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"` (FR1) +- [ ] 1.2 RED: unit test — reveal of an already-`pending` record keeps `pending` and does not degrade its state (FR1) +- [ ] 1.3 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal; update JSDoc traceability to `Implements FR1 of fix-stale-sync-overwrites` (FR1) +- [ ] 1.4 Characterization tests: manual hide via `TaskService.update` refreshes `updated_at`, sets `appear_date`, sets `pending` (FR2 regression guard) +- [ ] 1.5 Characterization tests: manual unhide via `TaskService.update` refreshes `updated_at`, clears `appear_date`, sets `pending` (FR2 regression guard) +- [ ] 1.6 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios, tags `@fix-stale-sync-overwrites @FR1` +- [ ] 1.7 BDD unit: manual hide/unhide sync scenarios (non-recurring tasks) in the same feature, tags `@fix-stale-sync-overwrites @FR2` ## 2. Rebalance preserves updated_at (FR4) -- [ ] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending`; dragged task in `reorderTasks` still gets fresh `updated_at` (FR4) -- [ ] 2.2 GREEN: change `rebalanceBox` to stop refreshing `updated_at` (FR4) -- [ ] 2.3 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4` +- [ ] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending` (FR4) +- [ ] 2.2 RED: unit test — dragged task in `reorderTasks` still gets a fresh `updated_at` (FR4 regression guard) +- [ ] 2.3 GREEN: change `rebalanceBox` to stop refreshing `updated_at`; add traceability comment `Implements FR4 of fix-stale-sync-overwrites` (FR4) +- [ ] 2.4 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4` ## 3. Dedup merge (FR3, FR6) -- [ ] 3.1 RED: unit tests for `RecurringTaskDeduplicator` merge — content from freshest `updated_at`, `next_date`+`appear_date` as a pair from earliest, winner `updated_at` = freshest copy's value (not now), wholesale-freshest on `repeat_rule` mismatch, existing winner/tiebreak/exclusion behavior unchanged (FR3) -- [ ] 3.2 GREEN: implement merge in `RecurringTaskDeduplicator` (FR3); keep file under 200 lines — extract merge logic into a separate module if needed -- [ ] 3.3 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization) -- [ ] 3.4 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6` +- [ ] 3.1 RED: merge tests — content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) come from the freshest-`updated_at` copy (FR3) +- [ ] 3.2 RED: merge tests — `next_date` + `appear_date` + `is_hidden` come as a schedule triple from the earliest-`next_date` copy, never mixed across copies (FR3) +- [ ] 3.3 RED: merge tests — `box` + `sort_order` come as a pair from the freshest copy; `id`, `created_at`, `revision` stay the winner's own (FR3) +- [ ] 3.4 RED: merge tests — winner `updated_at` equals the freshest copy's value (not refreshed to now); on `repeat_rule` mismatch the freshest copy wins wholesale, dates included (FR3) +- [ ] 3.5 RED: merge tests — winner is written with `pending` only when the merge changed it; an already-optimal winner is not rewritten and keeps `synced` (FR3) +- [ ] 3.6 Characterization tests: existing behavior unchanged — winner selection (earliest `next_date`, id tiebreak), completed/deleted copies excluded, loser soft-delete + checklist cascade (FR3) +- [ ] 3.7 GREEN: implement merge in `RecurringTaskDeduplicator` — extract merge logic into a separate module to keep files under 200 lines; update traceability comments to reference FR3 of fix-stale-sync-overwrites (FR3) +- [ ] 3.8 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization) +- [ ] 3.9 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6` ## 4. LWW pull protection (FR5) -- [ ] 4.1 RED: contract-style test suite for `applyServerRecords` (shared cases: synced overwritten; pending vs strictly-newer server → overwritten + logged; pending vs equal → preserved; pending vs older → preserved; new record inserted) run against every entity repository (FR5) -- [ ] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log; wire into all entity repositories (FR5) -- [ ] 4.3 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5` +- [ ] 4.1 RED: contract-style shared test suite for `applyServerRecords` (cases: `synced` overwritten; `rejected` overwritten; `pending` vs strictly newer server → overwritten, logged, marked `synced`; `pending` vs equal → preserved; `pending` vs older → preserved; new record inserted) (FR5) +- [ ] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log with entity type, id, and both timestamps (FR5) +- [ ] 4.3 GREEN: wire the helper into `TaskRepository`, `GoalRepository`, `ContextRepository`, `CategoryRepository`; run the contract suite against each (FR5) +- [ ] 4.4 GREEN: wire the helper into `IdeaRepository`, `ChecklistRepository`, `AttachmentRepository`; run the contract suite against each (`SettingsRepository` excluded — settings sync uses timestamp filtering and has no `applyServerRecords`) (FR5) +- [ ] 4.5 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5` ## 5. Integration tests (packages/integration) (NFR-REL1, M1) -- [ ] 5.1 Two-device harness: two isolated browser contexts (separate storage) against one backend; helper to dump and diff device stores + server tables for convergence assertions (NFR-REL1) -- [ ] 5.2 Integration: reported bug end-to-end for `fixed` model — complete/edit/complete on A, stale B reopens, both converge to the newest description and completed state (U1, M1, FR1+FR3+FR5) -- [ ] 5.3 Integration: same scenario for `after_completion` model (U1, FR6) -- [ ] 5.4 Integration: manual unhide before appear_date propagates to device B (U2, FR2) -- [ ] 5.5 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4) +- [ ] 5.1 Two-device harness: two isolated browser contexts with separate storage states for the same user against one backend — reuse existing `page-lifecycle.ts` / `server-api.ts` infrastructure (NFR-REL1) +- [ ] 5.2 Convergence helper: dump both devices' IndexedDB stores + server tables and diff them for equality assertions (NFR-REL1) +- [ ] 5.3 Integration: reported bug end-to-end for the `fixed` model — complete/edit on A, stale B reopens and auto-reveals, both devices converge to the newest description and completed occurrence stays completed; convergence assertion (U1, M1, FR1+FR3+FR5, NFR-REL1) +- [ ] 5.4 Integration: same scenario for the `after_completion` model; convergence assertion (U1, FR6, NFR-REL1) +- [ ] 5.5 Integration: manual unhide before `appear_date` on device A propagates to device B; convergence assertion (U2, FR2, NFR-REL1) +- [ ] 5.6 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4, NFR-REL1) ## 6. Verification -- [ ] 6.1 Mutation testing scoped to changed files (max 5 per run, sequential): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3) -- [ ] 6.2 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build` -- [ ] 6.3 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference +- [ ] 6.1 Mutation run 1 (scoped, max 5 files, wait for completion): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, extracted merge module, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3) +- [ ] 6.2 Mutation run 2 (only after run 1 finishes): `TaskRepository.ts`, `GoalRepository.ts`, `ContextRepository.ts`, `CategoryRepository.ts` — same threshold (M3) +- [ ] 6.3 Mutation run 3 (only after run 2 finishes): `IdeaRepository.ts`, `ChecklistRepository.ts`, `AttachmentRepository.ts` — same threshold (M3) +- [ ] 6.4 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build` +- [ ] 6.5 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference; BDD scenarios carry `@fix-stale-sync-overwrites @FR-X` tags From 51f238c72737d97c87e3d094e6958f509ff93bb0 Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:25:13 +0500 Subject: [PATCH 3/8] implement fix-stale-sync-overwrites OpenSpec --- .../fix-stale-sync-overwrites/tasks.md | 98 ++++-- ...ntRepository.applyServerRecordsLww.test.ts | 34 ++ .../AttachmentRepository.test-setup.ts | 18 + .../db/repositories/AttachmentRepository.ts | 20 +- ...ryRepository.applyServerRecordsLww.test.ts | 33 ++ .../CategoryRepository.test-setup.ts | 18 + .../src/db/repositories/CategoryRepository.ts | 20 +- ...stRepository.applyServerRecordsLww.test.ts | 34 ++ .../db/repositories/ChecklistRepository.ts | 20 +- ...xtRepository.applyServerRecordsLww.test.ts | 33 ++ .../ContextRepository.test-setup.ts | 18 + ...alRepository.applyServerRecordsLww.test.ts | 33 ++ .../repositories/GoalRepository.test-setup.ts | 18 + ...eaRepository.applyServerRecordsLww.test.ts | 33 ++ .../repositories/IdeaRepository.test-setup.ts | 18 + ...skRepository.applyServerRecordsLww.test.ts | 35 ++ .../src/db/repositories/TaskRepository.ts | 20 +- .../db/repositories/applyServerRecordLww.ts | 50 +++ .../src/services/HiddenTaskService.test.ts | 158 +++++++-- .../client/src/services/HiddenTaskService.ts | 8 +- .../RecurringTaskDeduplicator.merge.ts | 96 ++++++ .../RecurringTaskDeduplicator.test.ts | 112 ++++++ .../src/services/RecurringTaskDeduplicator.ts | 28 +- .../src/services/TaskService.complete.test.ts | 10 + .../services/TaskService.mutations.test.ts | 120 ++++++- .../TaskService.recurring-completion.test.ts | 14 +- packages/client/src/services/TaskService.ts | 13 +- .../steps/attachment_repository_sync.steps.ts | 16 +- .../repeating_tasks/dedup_merge.feature | 29 ++ .../sync_protocol/pull_lww_protection.feature | 38 +++ .../sync_protocol/rebalance_sync.feature | 16 + .../sync_protocol/stale_reveal_sync.feature | 32 ++ .../steps/rebalance_sync.steps.ts | 143 ++++++++ .../integration/src/convergence-helpers.ts | 142 ++++++++ packages/integration/src/page-actions.ts | 53 +++ packages/integration/src/test-helpers.ts | 6 + .../multi-device-stale-rebalance.spec.ts | 319 ++++++++++++++++++ 37 files changed, 1815 insertions(+), 91 deletions(-) create mode 100644 packages/client/src/db/repositories/AttachmentRepository.applyServerRecordsLww.test.ts create mode 100644 packages/client/src/db/repositories/AttachmentRepository.test-setup.ts create mode 100644 packages/client/src/db/repositories/CategoryRepository.applyServerRecordsLww.test.ts create mode 100644 packages/client/src/db/repositories/CategoryRepository.test-setup.ts create mode 100644 packages/client/src/db/repositories/ChecklistRepository.applyServerRecordsLww.test.ts create mode 100644 packages/client/src/db/repositories/ContextRepository.applyServerRecordsLww.test.ts create mode 100644 packages/client/src/db/repositories/ContextRepository.test-setup.ts create mode 100644 packages/client/src/db/repositories/GoalRepository.applyServerRecordsLww.test.ts create mode 100644 packages/client/src/db/repositories/GoalRepository.test-setup.ts create mode 100644 packages/client/src/db/repositories/IdeaRepository.applyServerRecordsLww.test.ts create mode 100644 packages/client/src/db/repositories/IdeaRepository.test-setup.ts create mode 100644 packages/client/src/db/repositories/TaskRepository.applyServerRecordsLww.test.ts create mode 100644 packages/client/src/db/repositories/applyServerRecordLww.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.ts create mode 100644 packages/client/src/test/features/repeating_tasks/dedup_merge.feature create mode 100644 packages/client/src/test/features/sync_protocol/pull_lww_protection.feature create mode 100644 packages/client/src/test/features/sync_protocol/rebalance_sync.feature create mode 100644 packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature create mode 100644 packages/client/src/test/features/sync_protocol/steps/rebalance_sync.steps.ts create mode 100644 packages/integration/src/convergence-helpers.ts create mode 100644 packages/integration/src/tests/multi-device-stale-rebalance.spec.ts diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/fix-stale-sync-overwrites/tasks.md index 1099f943..fa01c70a 100644 --- a/openspec/changes/fix-stale-sync-overwrites/tasks.md +++ b/openspec/changes/fix-stale-sync-overwrites/tasks.md @@ -2,54 +2,80 @@ ## 1. Auto-reveal preserves updated_at (FR1, FR2) -- [ ] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"` (FR1) -- [ ] 1.2 RED: unit test — reveal of an already-`pending` record keeps `pending` and does not degrade its state (FR1) -- [ ] 1.3 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal; update JSDoc traceability to `Implements FR1 of fix-stale-sync-overwrites` (FR1) -- [ ] 1.4 Characterization tests: manual hide via `TaskService.update` refreshes `updated_at`, sets `appear_date`, sets `pending` (FR2 regression guard) -- [ ] 1.5 Characterization tests: manual unhide via `TaskService.update` refreshes `updated_at`, clears `appear_date`, sets `pending` (FR2 regression guard) -- [ ] 1.6 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios, tags `@fix-stale-sync-overwrites @FR1` -- [ ] 1.7 BDD unit: manual hide/unhide sync scenarios (non-recurring tasks) in the same feature, tags `@fix-stale-sync-overwrites @FR2` +- [x] 1.1 RED: unit tests for `HiddenTaskService.revealHiddenTasks` — revealed task keeps its `updated_at`, gets `syncStatus: "pending"` (FR1) +- [x] 1.2 RED: unit test — reveal of an already-`pending` record keeps `pending` and does not degrade its state (FR1) +- [x] 1.3 GREEN: change `HiddenTaskService` to stop refreshing `updated_at` on reveal; update JSDoc traceability to `Implements FR1 of fix-stale-sync-overwrites` (FR1) +- [x] 1.4 Characterization tests: manual hide via `TaskService.update` refreshes `updated_at`, sets `appear_date`, sets `pending` (FR2 regression guard) +- [x] 1.5 Characterization tests: manual unhide via `TaskService.update` refreshes `updated_at`, clears `appear_date`, sets `pending` (FR2 regression guard) +- [x] 1.6 BDD unit: `stale_reveal_sync.feature` + steps — reveal-without-bump scenarios, tags `@fix-stale-sync-overwrites @FR1` +- [x] 1.7 BDD unit: manual hide/unhide sync scenarios (non-recurring tasks) in the same feature, tags `@fix-stale-sync-overwrites @FR2` ## 2. Rebalance preserves updated_at (FR4) -- [ ] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending` (FR4) -- [ ] 2.2 RED: unit test — dragged task in `reorderTasks` still gets a fresh `updated_at` (FR4 regression guard) -- [ ] 2.3 GREEN: change `rebalanceBox` to stop refreshing `updated_at`; add traceability comment `Implements FR4 of fix-stale-sync-overwrites` (FR4) -- [ ] 2.4 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4` +- [x] 2.1 RED: unit tests for `TaskService.rebalanceBox` — rebalanced tasks keep `updated_at`, get `pending` (FR4) +- [x] 2.2 RED: unit test — dragged task in `reorderTasks` still gets a fresh `updated_at` (FR4 regression guard) +- [x] 2.3 GREEN: change `rebalanceBox` to stop refreshing `updated_at`; add traceability comment `Implements FR4 of fix-stale-sync-overwrites` (FR4) +- [x] 2.4 BDD unit: `rebalance_sync.feature` + steps, tags `@fix-stale-sync-overwrites @FR4` ## 3. Dedup merge (FR3, FR6) -- [ ] 3.1 RED: merge tests — content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) come from the freshest-`updated_at` copy (FR3) -- [ ] 3.2 RED: merge tests — `next_date` + `appear_date` + `is_hidden` come as a schedule triple from the earliest-`next_date` copy, never mixed across copies (FR3) -- [ ] 3.3 RED: merge tests — `box` + `sort_order` come as a pair from the freshest copy; `id`, `created_at`, `revision` stay the winner's own (FR3) -- [ ] 3.4 RED: merge tests — winner `updated_at` equals the freshest copy's value (not refreshed to now); on `repeat_rule` mismatch the freshest copy wins wholesale, dates included (FR3) -- [ ] 3.5 RED: merge tests — winner is written with `pending` only when the merge changed it; an already-optimal winner is not rewritten and keeps `synced` (FR3) -- [ ] 3.6 Characterization tests: existing behavior unchanged — winner selection (earliest `next_date`, id tiebreak), completed/deleted copies excluded, loser soft-delete + checklist cascade (FR3) -- [ ] 3.7 GREEN: implement merge in `RecurringTaskDeduplicator` — extract merge logic into a separate module to keep files under 200 lines; update traceability comments to reference FR3 of fix-stale-sync-overwrites (FR3) -- [ ] 3.8 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization) -- [ ] 3.9 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6` +- [x] 3.1 RED: merge tests — content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) come from the freshest-`updated_at` copy (FR3) +- [x] 3.2 RED: merge tests — `next_date` + `appear_date` + `is_hidden` come as a schedule triple from the earliest-`next_date` copy, never mixed across copies (FR3) +- [x] 3.3 RED: merge tests — `box` + `sort_order` come as a pair from the freshest copy; `id`, `created_at`, `revision` stay the winner's own (FR3) +- [x] 3.4 RED: merge tests — winner `updated_at` equals the freshest copy's value (not refreshed to now); on `repeat_rule` mismatch the freshest copy wins wholesale, dates included (FR3) +- [x] 3.5 RED: merge tests — winner is written with `pending` only when the merge changed it; an already-optimal winner is not rewritten and keeps `synced` (FR3) +- [x] 3.6 Characterization tests: existing behavior unchanged — winner selection (earliest `next_date`, id tiebreak), completed/deleted copies excluded, loser soft-delete + checklist cascade (FR3) +- [x] 3.7 GREEN: implement merge in `RecurringTaskDeduplicator` — extract merge logic into a separate module to keep files under 200 lines; update traceability comments to reference FR3 of fix-stale-sync-overwrites (FR3) +- [x] 3.8 Regression tests: recurrence math untouched for both models — `fixed` early-completion + skip logic, `after_completion` derives from new `completed_at` only (FR6, reuse existing suites as characterization) +- [x] 3.9 BDD unit: `dedup_merge.feature` + steps — two-device double completion for `fixed` and `after_completion`, tags `@fix-stale-sync-overwrites @FR3` / `@FR6` ## 4. LWW pull protection (FR5) -- [ ] 4.1 RED: contract-style shared test suite for `applyServerRecords` (cases: `synced` overwritten; `rejected` overwritten; `pending` vs strictly newer server → overwritten, logged, marked `synced`; `pending` vs equal → preserved; `pending` vs older → preserved; new record inserted) (FR5) -- [ ] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log with entity type, id, and both timestamps (FR5) -- [ ] 4.3 GREEN: wire the helper into `TaskRepository`, `GoalRepository`, `ContextRepository`, `CategoryRepository`; run the contract suite against each (FR5) -- [ ] 4.4 GREEN: wire the helper into `IdeaRepository`, `ChecklistRepository`, `AttachmentRepository`; run the contract suite against each (`SettingsRepository` excluded — settings sync uses timestamp filtering and has no `applyServerRecords`) (FR5) -- [ ] 4.5 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5` +- [x] 4.1 RED: contract-style shared test suite for `applyServerRecords` (cases: `synced` overwritten; `rejected` overwritten; `pending` vs strictly newer server → overwritten, logged, marked `synced`; `pending` vs equal → preserved; `pending` vs older → preserved; new record inserted) (FR5) +- [x] 4.2 GREEN: shared helper (e.g. `applyServerRecordLww`) using `Temporal.Instant.compare` + `console.warn` conflict log with entity type, id, and both timestamps (FR5) +- [x] 4.3 GREEN: wire the helper into `TaskRepository`, `GoalRepository`, `ContextRepository`, `CategoryRepository`; run the contract suite against each (FR5) +- [x] 4.4 GREEN: wire the helper into `IdeaRepository`, `ChecklistRepository`, `AttachmentRepository`; run the contract suite against each (`SettingsRepository` excluded — settings sync uses timestamp filtering and has no `applyServerRecords`) (FR5) +- [x] 4.5 BDD unit: `pull_lww_protection.feature` + steps, tags `@fix-stale-sync-overwrites @FR5` ## 5. Integration tests (packages/integration) (NFR-REL1, M1) -- [ ] 5.1 Two-device harness: two isolated browser contexts with separate storage states for the same user against one backend — reuse existing `page-lifecycle.ts` / `server-api.ts` infrastructure (NFR-REL1) -- [ ] 5.2 Convergence helper: dump both devices' IndexedDB stores + server tables and diff them for equality assertions (NFR-REL1) -- [ ] 5.3 Integration: reported bug end-to-end for the `fixed` model — complete/edit on A, stale B reopens and auto-reveals, both devices converge to the newest description and completed occurrence stays completed; convergence assertion (U1, M1, FR1+FR3+FR5, NFR-REL1) -- [ ] 5.4 Integration: same scenario for the `after_completion` model; convergence assertion (U1, FR6, NFR-REL1) -- [ ] 5.5 Integration: manual unhide before `appear_date` on device A propagates to device B; convergence assertion (U2, FR2, NFR-REL1) -- [ ] 5.6 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4, NFR-REL1) +- [x] 5.1 Two-device harness: two isolated browser contexts with separate storage states for the same user against one backend — reuse existing `page-lifecycle.ts` / `server-api.ts` infrastructure (NFR-REL1) +- [x] 5.2 Convergence helper: dump both devices' IndexedDB stores + server tables and diff them for equality assertions (NFR-REL1) +- [x] 5.3 Integration: reported bug end-to-end for the `fixed` model — complete/edit on A, stale B reopens and auto-reveals, both devices converge to the newest description and completed occurrence stays completed; convergence assertion (U1, M1, FR1+FR3+FR5, NFR-REL1) +- [x] 5.4 Integration: same scenario for the `after_completion` model; convergence assertion (U1, FR6, NFR-REL1) +- [x] 5.5 Integration: manual unhide before `appear_date` on device A propagates to device B; convergence assertion (U2, FR2, NFR-REL1) +- [x] 5.6 Integration: rebalance on stale device B does not clobber newer content of untouched tasks (U3, FR4, NFR-REL1) ## 6. Verification -- [ ] 6.1 Mutation run 1 (scoped, max 5 files, wait for completion): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, extracted merge module, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3) -- [ ] 6.2 Mutation run 2 (only after run 1 finishes): `TaskRepository.ts`, `GoalRepository.ts`, `ContextRepository.ts`, `CategoryRepository.ts` — same threshold (M3) -- [ ] 6.3 Mutation run 3 (only after run 2 finishes): `IdeaRepository.ts`, `ChecklistRepository.ts`, `AttachmentRepository.ts` — same threshold (M3) -- [ ] 6.4 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build` -- [ ] 6.5 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference; BDD scenarios carry `@fix-stale-sync-overwrites @FR-X` tags +- [x] 6.1 Mutation run 1 (scoped, max 5 files, wait for completion): `HiddenTaskService.ts`, `TaskService.ts`, `RecurringTaskDeduplicator.ts`, extracted merge module, LWW helper — score >= 95% (min 90%), kill survivors with added tests (M3) +- [x] 6.2 Mutation run 2 (only after run 1 finishes): `TaskRepository.ts`, `GoalRepository.ts`, `ContextRepository.ts`, `CategoryRepository.ts` — same threshold (M3) +- [x] 6.3 Mutation run 3 (only after run 2 finishes): `IdeaRepository.ts`, `ChecklistRepository.ts`, `AttachmentRepository.ts` — same threshold (M3) +- [x] 6.4 `get_file_problems` via JetBrains MCP on all changed files + `pnpm run build` +- [x] 6.5 Traceability check: grep confirms every FR1-FR6, NFR-REL1, UX1-UX3 has at least one implementing test/artifact reference; BDD scenarios carry `@fix-stale-sync-overwrites @FR-X` tags + +## 7. Review fixes: Temporal comparison in dedup merge + traceability marks (FR3, NFR-REL1, M2) + +Impact analysis for 7.1-7.4: `findFreshestUpdatedAtCopy` is reachable only via `mergeWinner` → `RecurringTaskDeduplicator.deduplicate` → `SyncService` pull path (`SyncService.ts:178`) — no other feature consumes it. For in-format timestamps (`toISOTimestamp` always emits fixed-precision `...000Z`) lexicographic and instant order coincide, so the swap is behavior-preserving and all existing FR3 tests must stay green unchanged. Divergence exists only for out-of-format data: same instant in different representations (localeCompare gives an arbitrary winner, `Temporal.Instant.compare` gives a correct tie → first copy in group order wins) and malformed `updated_at` (`Temporal.Instant.from` throws instead of silently misordering — same exposure the FR5 LWW helper already has in the same pull path). `sortByWinnerPriority` keeps `localeCompare` intentionally: `next_date` is date-only and `id` is not a timestamp — out of scope per the Temporal rule. + +- [ ] 7.1 Characterization test (pre-refactor guard): two copies with an equal `updated_at` — the first copy in group order stays the freshest-content source (tie semantics of the `reduce` documented before touching it) (FR3) +- [ ] 7.2 REFACTOR: `findFreshestUpdatedAtCopy` compares via `Temporal.Instant.compare` (import from `@/lib/temporal`) instead of `String.prototype.localeCompare`, preserving tie semantics (keep `freshest` when compare <= 0) (FR3, aligns with design D4 convention) +- [ ] 7.3 Scoped test run (one command, foreground): `RecurringTaskDeduplicator.merge.test.ts`, `RecurringTaskDeduplicator.test.ts`, `dedup_merge.steps.ts` — all green with no test edits beyond 7.1 (FR3) +- [ ] 7.4 Scoped mutation run on `src/services/RecurringTaskDeduplicator.merge.ts` (wait for completion; no concurrent runs): diff-scoped kill rate stays 100%, whole-file score >= 95% (M3) +- [ ] 7.5 Traceability: add `NFR-REL1` (and `M1` where the scenario implements it) to the `// implements ... of fix-stale-sync-overwrites` header comment of all four integration specs (`multi-device-stale-recurring-fixed`, `multi-device-stale-recurring-after-completion`, `multi-device-manual-unhide`, `multi-device-stale-rebalance`) (NFR-REL1, M1) +- [ ] 7.6 Traceability: add `// Verifies M2 of fix-stale-sync-overwrites` comments to the unit tests asserting `updated_at` is untouched by system mutations: reveal (`HiddenTaskService.test.ts`), rebalance (`TaskService.reorder.test.ts`), dedup merge (`RecurringTaskDeduplicator.merge.test.ts`) (M2) +- [ ] 7.7 Verification: traceability grep confirms NFR-REL1/M1/M2 references resolve; `get_file_problems` on touched files + `pnpm run build` +- [ ] 7.8 Spec wording: align the wholesale clause with the implemented (and correct) behavior — replace "when `repeat_rule` differs between copies" with "when `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy" in `proposal.md` (What Changes bullet, Modified Capabilities bullet, FR3), `design.md` (D3 table row), and the delta spec `specs/repeating-tasks/spec.md` (wholesale bullet). Rationale stays: only those two copies' fields ever mix, so only their rule mismatch makes a merge incoherent; a differing rule on a stale loser contributes no fields and must NOT trigger wholesale (FR3) +- [ ] 7.9 Pinning test for the 3-copy edge: schedule winner and freshest copy share `repeat_rule`, a stale loser carries a different one — normal merge applies (schedule triple from the winner + content from the freshest), NOT wholesale; loser is soft-deleted as usual (FR3) + +## 8. Review fixes: file-size invariant for test files (process-invariants: 100-200 target, 300 hard cap) + +Behavior-preserving reorganization — no test logic changes, no TDD cycle (refactoring exemption). Split boundaries follow the existing `describe` blocks; use the `split-large-tests` skill for 8.1-8.3. Shared builders/seed helpers go into a `RecurringTaskDeduplicator.test-setup.ts` module (established repo pattern: `*.test-setup.ts` next to repository tests). New files keep the `.test.ts` suffix so the vitest glob picks them up. BDD `.steps.ts` files are all within limits (max 282) — not in scope. + +- [ ] 8.1 Split `RecurringTaskDeduplicator.merge.test.ts` (598) into ~3 files by merge aspect, each <= 200: content fields from freshest copy (name/description/goal/context/category + equal-`updated_at` tie), schedule triple + box/sort_order pair coupling, identity fields + winner `updated_at` + wholesale `repeat_rule` + pending-only-when-changed; extract shared task builders into the test-setup module (FR3 coverage unchanged) +- [ ] 8.2 Split `RecurringTaskDeduplicator.test.ts` (692) into ~3 files by concern, each <= 200: winner selection + tiebreak + filtering; loser soft-delete + checklist cascade + multiple/three-plus groups; skip optimization + guard describes; reuse the same test-setup module (FR3 characterization coverage unchanged) +- [ ] 8.3 Split `HiddenTaskService.test.ts` (300): move the `revealHiddenTasks preserving updated_at` describe (FR1 block) into a sibling `HiddenTaskService.reveal-preserves-updated-at.test.ts`; both files end up <= 200 (FR1 coverage unchanged) +- [ ] 8.4 Slim `multi-device-stale-rebalance.spec.ts` (319): move `dragTaskOnto` into `page-actions.ts` and `buildInboxTaskPayload` into a shared integration helper module; the spec keeps only the U3 scenario and lands <= 200 (U3/FR4/NFR-REL1 coverage unchanged) +- [ ] 8.5 Verification: scoped vitest run (one command, foreground) over all files produced by 8.1-8.3 — green, and the total test count of the split groups equals the pre-split count (no scenario silently dropped) +- [ ] 8.6 Verification: scoped mutation run on `src/services/RecurringTaskDeduplicator.ts,src/services/RecurringTaskDeduplicator.merge.ts,src/services/HiddenTaskService.ts` (wait for completion) — scores unchanged, confirming Stryker still discovers the renamed test files (M3) +- [ ] 8.7 Verification: `wc -l` confirms every touched test file <= 200 (hard cap 300 nowhere needed); `get_file_problems` on new/changed files + `pnpm run build` diff --git a/packages/client/src/db/repositories/AttachmentRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/AttachmentRepository.applyServerRecordsLww.test.ts new file mode 100644 index 00000000..e2d087ff --- /dev/null +++ b/packages/client/src/db/repositories/AttachmentRepository.applyServerRecordsLww.test.ts @@ -0,0 +1,34 @@ +import type { WireAttachment } from "@clear-progress/contract"; +import { describe } from "vitest"; +import { buildAttachment } from "@/test/factories/attachmentFactory"; +import type { Attachment } from "@/types/entities"; +import { db } from "../database"; +import { createAttachmentRepositorySetup } from "./AttachmentRepository.test-setup"; +import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract"; + +/** + * Contract tests for FR5 of fix-stale-sync-overwrites. + * Instantiates the shared LWW pull protection contract suite against + * AttachmentRepository. + */ +describe("AttachmentRepository applyServerRecords LWW contract", () => { + const { getRepository } = createAttachmentRepositorySetup(); + + function toWireAttachment(attachment: Attachment): WireAttachment { + const { syncStatus: _syncStatus, ...wireAttachment } = attachment; + return wireAttachment; + } + + runApplyServerRecordsLwwContractTests({ + entityName: "attachment", + applyServerRecords: (records) => + getRepository().applyServerRecords(records), + getLocalRecord: (id) => db.attachments.get(id), + putLocalRecord: async (record) => { + await db.attachments.put(record); + }, + buildLocalRecord: (overrides) => buildAttachment(overrides), + buildServerRecord: (overrides) => + toWireAttachment(buildAttachment(overrides)), + }); +}); diff --git a/packages/client/src/db/repositories/AttachmentRepository.test-setup.ts b/packages/client/src/db/repositories/AttachmentRepository.test-setup.ts new file mode 100644 index 00000000..89eca4c1 --- /dev/null +++ b/packages/client/src/db/repositories/AttachmentRepository.test-setup.ts @@ -0,0 +1,18 @@ +import { beforeEach } from "vitest"; +import { db } from "../database"; +import { AttachmentRepository } from "./AttachmentRepository"; + +export function createAttachmentRepositorySetup(): { + getRepository: () => AttachmentRepository; +} { + let attachmentRepository: AttachmentRepository; + + beforeEach(async () => { + await db.attachments.clear(); + attachmentRepository = new AttachmentRepository(); + }); + + return { + getRepository: () => attachmentRepository, + }; +} diff --git a/packages/client/src/db/repositories/AttachmentRepository.ts b/packages/client/src/db/repositories/AttachmentRepository.ts index b181ea30..a1b3fdd8 100644 --- a/packages/client/src/db/repositories/AttachmentRepository.ts +++ b/packages/client/src/db/repositories/AttachmentRepository.ts @@ -4,6 +4,9 @@ import { ClientAttachmentSchema } from "@/schemas/entities"; import type { Attachment, ISOTimestamp } from "@/types/entities"; import { toISOTimestamp } from "@/utils/dateHelpers"; import { db } from "../database"; +import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww"; + +const ATTACHMENT_ENTITY_NAME = "attachment"; export class AttachmentRepository { async getAll(): Promise { @@ -127,11 +130,26 @@ export class AttachmentRepository { } } + /** + * Implements FR5 of fix-stale-sync-overwrites. + * A local `pending` record is overwritten only when the server record's + * `updated_at` is strictly newer (LWW pull protection). Local records with + * any other syncStatus, or no local record at all, are always overwritten. + */ async applyServerRecords(records: WireAttachment[]): Promise { await db.transaction("rw", db.attachments, async () => { for (const serverRecord of records) { const localRecord = await db.attachments.get(serverRecord.id); - if (localRecord?.syncStatus !== "pending") { + const shouldWrite = + localRecord?.syncStatus !== "pending" || + shouldOverwritePendingLocalRecord({ + entityName: ATTACHMENT_ENTITY_NAME, + id: serverRecord.id, + localUpdatedAt: localRecord.updated_at, + serverUpdatedAt: serverRecord.updated_at, + }); + + if (shouldWrite) { const attachment: Attachment = { ...serverRecord, created_at: serverRecord.created_at as ISOTimestamp, diff --git a/packages/client/src/db/repositories/CategoryRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/CategoryRepository.applyServerRecordsLww.test.ts new file mode 100644 index 00000000..b9ee7147 --- /dev/null +++ b/packages/client/src/db/repositories/CategoryRepository.applyServerRecordsLww.test.ts @@ -0,0 +1,33 @@ +import type { WireCategory } from "@clear-progress/contract"; +import { describe } from "vitest"; +import { buildCategory } from "@/test/factories/categoryFactory"; +import type { Category } from "@/types/entities"; +import { db } from "../database"; +import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract"; +import { createCategoryRepositorySetup } from "./CategoryRepository.test-setup"; + +/** + * Contract tests for FR5 of fix-stale-sync-overwrites. + * Instantiates the shared LWW pull protection contract suite against + * CategoryRepository. + */ +describe("CategoryRepository applyServerRecords LWW contract", () => { + const { getRepository } = createCategoryRepositorySetup(); + + function toWireCategory(category: Category): WireCategory { + const { syncStatus: _syncStatus, ...wireCategory } = category; + return wireCategory; + } + + runApplyServerRecordsLwwContractTests({ + entityName: "category", + applyServerRecords: (records) => + getRepository().applyServerRecords(records), + getLocalRecord: (id) => db.categories.get(id), + putLocalRecord: async (record) => { + await db.categories.put(record); + }, + buildLocalRecord: (overrides) => buildCategory(overrides), + buildServerRecord: (overrides) => toWireCategory(buildCategory(overrides)), + }); +}); diff --git a/packages/client/src/db/repositories/CategoryRepository.test-setup.ts b/packages/client/src/db/repositories/CategoryRepository.test-setup.ts new file mode 100644 index 00000000..abff09a2 --- /dev/null +++ b/packages/client/src/db/repositories/CategoryRepository.test-setup.ts @@ -0,0 +1,18 @@ +import { beforeEach } from "vitest"; +import { db } from "../database"; +import { CategoryRepository } from "./CategoryRepository"; + +export function createCategoryRepositorySetup(): { + getRepository: () => CategoryRepository; +} { + let categoryRepository: CategoryRepository; + + beforeEach(async () => { + await db.categories.clear(); + categoryRepository = new CategoryRepository(); + }); + + return { + getRepository: () => categoryRepository, + }; +} diff --git a/packages/client/src/db/repositories/CategoryRepository.ts b/packages/client/src/db/repositories/CategoryRepository.ts index 34d426da..7b35f2dc 100644 --- a/packages/client/src/db/repositories/CategoryRepository.ts +++ b/packages/client/src/db/repositories/CategoryRepository.ts @@ -2,6 +2,9 @@ import type { WireCategory } from "@clear-progress/contract"; import { ClientCategorySchema } from "@/schemas/entities"; import type { Category, ISOTimestamp } from "@/types/entities"; import { db } from "../database"; +import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww"; + +const CATEGORY_ENTITY_NAME = "category"; export class CategoryRepository { async getAll(): Promise { @@ -55,11 +58,26 @@ export class CategoryRepository { .toArray(); } + /** + * Implements FR5 of fix-stale-sync-overwrites. + * A local `pending` record is overwritten only when the server record's + * `updated_at` is strictly newer (LWW pull protection). Local records with + * any other syncStatus, or no local record at all, are always overwritten. + */ async applyServerRecords(records: WireCategory[]): Promise { await db.transaction("rw", db.categories, async () => { for (const serverRecord of records) { const localRecord = await db.categories.get(serverRecord.id); - if (localRecord?.syncStatus !== "pending") { + const shouldWrite = + localRecord?.syncStatus !== "pending" || + shouldOverwritePendingLocalRecord({ + entityName: CATEGORY_ENTITY_NAME, + id: serverRecord.id, + localUpdatedAt: localRecord.updated_at, + serverUpdatedAt: serverRecord.updated_at, + }); + + if (shouldWrite) { const category: Category = { ...serverRecord, created_at: serverRecord.created_at as ISOTimestamp, diff --git a/packages/client/src/db/repositories/ChecklistRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/ChecklistRepository.applyServerRecordsLww.test.ts new file mode 100644 index 00000000..450c8102 --- /dev/null +++ b/packages/client/src/db/repositories/ChecklistRepository.applyServerRecordsLww.test.ts @@ -0,0 +1,34 @@ +import type { WireChecklistItem } from "@clear-progress/contract"; +import { describe } from "vitest"; +import { buildChecklistItem } from "@/test/factories/checklistItemFactory"; +import type { ChecklistItem } from "@/types/entities"; +import { db } from "../database"; +import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract"; +import { createChecklistRepositorySetup } from "./ChecklistRepository.test-setup"; + +/** + * Contract tests for FR5 of fix-stale-sync-overwrites. + * Instantiates the shared LWW pull protection contract suite against + * ChecklistRepository. + */ +describe("ChecklistRepository applyServerRecords LWW contract", () => { + const { getRepository } = createChecklistRepositorySetup(); + + function toWireChecklistItem(item: ChecklistItem): WireChecklistItem { + const { syncStatus: _syncStatus, ...wireChecklistItem } = item; + return wireChecklistItem; + } + + runApplyServerRecordsLwwContractTests({ + entityName: "checklist item", + applyServerRecords: (records) => + getRepository().applyServerRecords(records), + getLocalRecord: (id) => db.checklist_items.get(id), + putLocalRecord: async (record) => { + await db.checklist_items.put(record); + }, + buildLocalRecord: (overrides) => buildChecklistItem(overrides), + buildServerRecord: (overrides) => + toWireChecklistItem(buildChecklistItem(overrides)), + }); +}); diff --git a/packages/client/src/db/repositories/ChecklistRepository.ts b/packages/client/src/db/repositories/ChecklistRepository.ts index 73ab9fea..bb1c6411 100644 --- a/packages/client/src/db/repositories/ChecklistRepository.ts +++ b/packages/client/src/db/repositories/ChecklistRepository.ts @@ -2,6 +2,9 @@ import type { WireChecklistItem } from "@clear-progress/contract"; import { ClientChecklistItemSchema } from "@/schemas/entities"; import type { ChecklistItem, ISOTimestamp } from "@/types/entities"; import { db } from "../database"; +import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww"; + +const CHECKLIST_ITEM_ENTITY_NAME = "checklist item"; export class ChecklistRepository { async getAll(): Promise { @@ -72,11 +75,26 @@ export class ChecklistRepository { .toArray(); } + /** + * Implements FR5 of fix-stale-sync-overwrites. + * A local `pending` record is overwritten only when the server record's + * `updated_at` is strictly newer (LWW pull protection). Local records with + * any other syncStatus, or no local record at all, are always overwritten. + */ async applyServerRecords(records: WireChecklistItem[]): Promise { await db.transaction("rw", db.checklist_items, async () => { for (const serverRecord of records) { const localRecord = await db.checklist_items.get(serverRecord.id); - if (localRecord?.syncStatus !== "pending") { + const shouldWrite = + localRecord?.syncStatus !== "pending" || + shouldOverwritePendingLocalRecord({ + entityName: CHECKLIST_ITEM_ENTITY_NAME, + id: serverRecord.id, + localUpdatedAt: localRecord.updated_at, + serverUpdatedAt: serverRecord.updated_at, + }); + + if (shouldWrite) { const item: ChecklistItem = { ...serverRecord, created_at: serverRecord.created_at as ISOTimestamp, diff --git a/packages/client/src/db/repositories/ContextRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/ContextRepository.applyServerRecordsLww.test.ts new file mode 100644 index 00000000..6d59d79e --- /dev/null +++ b/packages/client/src/db/repositories/ContextRepository.applyServerRecordsLww.test.ts @@ -0,0 +1,33 @@ +import type { WireContext } from "@clear-progress/contract"; +import { describe } from "vitest"; +import { buildContext } from "@/test/factories/contextFactory"; +import type { Context } from "@/types/entities"; +import { db } from "../database"; +import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract"; +import { createContextRepositorySetup } from "./ContextRepository.test-setup"; + +/** + * Contract tests for FR5 of fix-stale-sync-overwrites. + * Instantiates the shared LWW pull protection contract suite against + * ContextRepository. + */ +describe("ContextRepository applyServerRecords LWW contract", () => { + const { getRepository } = createContextRepositorySetup(); + + function toWireContext(context: Context): WireContext { + const { syncStatus: _syncStatus, ...wireContext } = context; + return wireContext; + } + + runApplyServerRecordsLwwContractTests({ + entityName: "context", + applyServerRecords: (records) => + getRepository().applyServerRecords(records), + getLocalRecord: (id) => db.contexts.get(id), + putLocalRecord: async (record) => { + await db.contexts.put(record); + }, + buildLocalRecord: (overrides) => buildContext(overrides), + buildServerRecord: (overrides) => toWireContext(buildContext(overrides)), + }); +}); diff --git a/packages/client/src/db/repositories/ContextRepository.test-setup.ts b/packages/client/src/db/repositories/ContextRepository.test-setup.ts new file mode 100644 index 00000000..2aec712b --- /dev/null +++ b/packages/client/src/db/repositories/ContextRepository.test-setup.ts @@ -0,0 +1,18 @@ +import { beforeEach } from "vitest"; +import { db } from "../database"; +import { ContextRepository } from "./ContextRepository"; + +export function createContextRepositorySetup(): { + getRepository: () => ContextRepository; +} { + let contextRepository: ContextRepository; + + beforeEach(async () => { + await db.contexts.clear(); + contextRepository = new ContextRepository(); + }); + + return { + getRepository: () => contextRepository, + }; +} diff --git a/packages/client/src/db/repositories/GoalRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/GoalRepository.applyServerRecordsLww.test.ts new file mode 100644 index 00000000..30b994dc --- /dev/null +++ b/packages/client/src/db/repositories/GoalRepository.applyServerRecordsLww.test.ts @@ -0,0 +1,33 @@ +import type { WireGoal } from "@clear-progress/contract"; +import { describe } from "vitest"; +import { buildGoal } from "@/test/factories/goalFactory"; +import type { Goal } from "@/types/entities"; +import { db } from "../database"; +import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract"; +import { createGoalRepositorySetup } from "./GoalRepository.test-setup"; + +/** + * Contract tests for FR5 of fix-stale-sync-overwrites. + * Instantiates the shared LWW pull protection contract suite against + * GoalRepository. + */ +describe("GoalRepository applyServerRecords LWW contract", () => { + const { getRepository } = createGoalRepositorySetup(); + + function toWireGoal(goal: Goal): WireGoal { + const { syncStatus: _syncStatus, ...wireGoal } = goal; + return wireGoal; + } + + runApplyServerRecordsLwwContractTests({ + entityName: "goal", + applyServerRecords: (records) => + getRepository().applyServerRecords(records), + getLocalRecord: (id) => db.goals.get(id), + putLocalRecord: async (record) => { + await db.goals.put(record); + }, + buildLocalRecord: (overrides) => buildGoal(overrides), + buildServerRecord: (overrides) => toWireGoal(buildGoal(overrides)), + }); +}); diff --git a/packages/client/src/db/repositories/GoalRepository.test-setup.ts b/packages/client/src/db/repositories/GoalRepository.test-setup.ts new file mode 100644 index 00000000..b6a294fb --- /dev/null +++ b/packages/client/src/db/repositories/GoalRepository.test-setup.ts @@ -0,0 +1,18 @@ +import { beforeEach } from "vitest"; +import { db } from "../database"; +import { GoalRepository } from "./GoalRepository"; + +export function createGoalRepositorySetup(): { + getRepository: () => GoalRepository; +} { + let goalRepository: GoalRepository; + + beforeEach(async () => { + await db.goals.clear(); + goalRepository = new GoalRepository(); + }); + + return { + getRepository: () => goalRepository, + }; +} diff --git a/packages/client/src/db/repositories/IdeaRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/IdeaRepository.applyServerRecordsLww.test.ts new file mode 100644 index 00000000..1ccf4964 --- /dev/null +++ b/packages/client/src/db/repositories/IdeaRepository.applyServerRecordsLww.test.ts @@ -0,0 +1,33 @@ +import type { WireIdea } from "@clear-progress/contract"; +import { describe } from "vitest"; +import { buildIdea } from "@/test/factories/ideaFactory"; +import type { Idea } from "@/types/entities"; +import { db } from "../database"; +import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract"; +import { createIdeaRepositorySetup } from "./IdeaRepository.test-setup"; + +/** + * Contract tests for FR5 of fix-stale-sync-overwrites. + * Instantiates the shared LWW pull protection contract suite against + * IdeaRepository. + */ +describe("IdeaRepository applyServerRecords LWW contract", () => { + const { getRepository } = createIdeaRepositorySetup(); + + function toWireIdea(idea: Idea): WireIdea { + const { syncStatus: _syncStatus, ...wireIdea } = idea; + return wireIdea; + } + + runApplyServerRecordsLwwContractTests({ + entityName: "idea", + applyServerRecords: (records) => + getRepository().applyServerRecords(records), + getLocalRecord: (id) => db.ideas.get(id), + putLocalRecord: async (record) => { + await db.ideas.put(record); + }, + buildLocalRecord: (overrides) => buildIdea(overrides), + buildServerRecord: (overrides) => toWireIdea(buildIdea(overrides)), + }); +}); diff --git a/packages/client/src/db/repositories/IdeaRepository.test-setup.ts b/packages/client/src/db/repositories/IdeaRepository.test-setup.ts new file mode 100644 index 00000000..646acca7 --- /dev/null +++ b/packages/client/src/db/repositories/IdeaRepository.test-setup.ts @@ -0,0 +1,18 @@ +import { beforeEach } from "vitest"; +import { db } from "../database"; +import { IdeaRepository } from "./IdeaRepository"; + +export function createIdeaRepositorySetup(): { + getRepository: () => IdeaRepository; +} { + let ideaRepository: IdeaRepository; + + beforeEach(async () => { + await db.ideas.clear(); + ideaRepository = new IdeaRepository(); + }); + + return { + getRepository: () => ideaRepository, + }; +} diff --git a/packages/client/src/db/repositories/TaskRepository.applyServerRecordsLww.test.ts b/packages/client/src/db/repositories/TaskRepository.applyServerRecordsLww.test.ts new file mode 100644 index 00000000..e30cbc4a --- /dev/null +++ b/packages/client/src/db/repositories/TaskRepository.applyServerRecordsLww.test.ts @@ -0,0 +1,35 @@ +import type { WireTask } from "@clear-progress/contract"; +import { describe } from "vitest"; +import { buildTask } from "@/test/factories/taskFactory"; +import type { Task } from "@/types/entities"; +import { db } from "../database"; +import { runApplyServerRecordsLwwContractTests } from "./applyServerRecordsLww.contract"; +import { createTaskRepositorySetup } from "./TaskRepository.test-setup"; + +/** + * Contract-style RED tests for FR5 of fix-stale-sync-overwrites. + * Proves `runApplyServerRecordsLwwContractTests` runs against TaskRepository + * and is red under the current (pre-LWW) `applyServerRecords` implementation. + * Tasks 4.3/4.4 will instantiate the same suite against the other 6 + * repositories once the shared `applyServerRecordLww` helper exists. + */ +describe("TaskRepository applyServerRecords LWW contract", () => { + const { getRepository } = createTaskRepositorySetup(); + + function toWireTask(task: Task): WireTask { + const { syncStatus: _syncStatus, ...wireTask } = task; + return wireTask; + } + + runApplyServerRecordsLwwContractTests({ + entityName: "task", + applyServerRecords: (records) => + getRepository().applyServerRecords(records), + getLocalRecord: (id) => db.tasks.get(id), + putLocalRecord: async (record) => { + await db.tasks.put(record); + }, + buildLocalRecord: (overrides) => buildTask(overrides), + buildServerRecord: (overrides) => toWireTask(buildTask(overrides)), + }); +}); diff --git a/packages/client/src/db/repositories/TaskRepository.ts b/packages/client/src/db/repositories/TaskRepository.ts index c408a125..3fe569b1 100644 --- a/packages/client/src/db/repositories/TaskRepository.ts +++ b/packages/client/src/db/repositories/TaskRepository.ts @@ -5,6 +5,9 @@ import type { Box } from "@/types/common"; import type { ISODate, ISOTimestamp, Task } from "@/types/entities"; import { sanitizeDateOnly } from "@/utils/dateHelpers"; import { db } from "../database"; +import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww"; + +const TASK_ENTITY_NAME = "task"; export class TaskRepository { async getAll(): Promise { @@ -134,11 +137,26 @@ export class TaskRepository { .toArray(); } + /** + * Implements FR5 of fix-stale-sync-overwrites. + * A local `pending` record is overwritten only when the server record's + * `updated_at` is strictly newer (LWW pull protection). Local records with + * any other syncStatus, or no local record at all, are always overwritten. + */ async applyServerRecords(records: WireTask[]): Promise { await db.transaction("rw", db.tasks, async () => { for (const serverRecord of records) { const localRecord = await db.tasks.get(serverRecord.id); - if (localRecord?.syncStatus !== "pending") { + const shouldWrite = + localRecord?.syncStatus !== "pending" || + shouldOverwritePendingLocalRecord({ + entityName: TASK_ENTITY_NAME, + id: serverRecord.id, + localUpdatedAt: localRecord.updated_at, + serverUpdatedAt: serverRecord.updated_at, + }); + + if (shouldWrite) { const sanitizedRecord: Task = { ...serverRecord, next_date: (sanitizeDateOnly(serverRecord.next_date) || "") as diff --git a/packages/client/src/db/repositories/applyServerRecordLww.ts b/packages/client/src/db/repositories/applyServerRecordLww.ts new file mode 100644 index 00000000..1b26d856 --- /dev/null +++ b/packages/client/src/db/repositories/applyServerRecordLww.ts @@ -0,0 +1,50 @@ +import { Temporal } from "@/lib/temporal"; + +const PULL_CONFLICT_LOG_MESSAGE_TEMPLATE = (entityName: string): string => + `Sync conflict: pending local ${entityName} record overwritten by a newer server record`; + +/** + * Decides, per FR5 of fix-stale-sync-overwrites, whether a local `pending` + * record must be overwritten by an incoming server record during + * `applyServerRecords()`. + * + * A local `pending` record is overwritten iff the server record's + * `updated_at` is strictly newer than the local record's `updated_at` + * (compared via `Temporal.Instant.compare`, never string comparison). + * Equal-or-newer local `updated_at` preserves the local record untouched. + * + * Local records with any other `syncStatus` (`synced`, `rejected`) or no + * local record at all are always overwritten — callers should not consult + * this helper for those cases at all, or may pass them through directly. + * + * When an overwrite of a `pending` record happens, logs a conflict warning + * via `console.warn` with the entity type, record id, and both timestamps. + * + * Implements FR5 of fix-stale-sync-overwrites. + */ +export function shouldOverwritePendingLocalRecord(params: { + entityName: string; + id: string; + localUpdatedAt: string; + serverUpdatedAt: string; +}): boolean { + const { entityName, id, localUpdatedAt, serverUpdatedAt } = params; + + const isServerStrictlyNewer = + Temporal.Instant.compare( + Temporal.Instant.from(serverUpdatedAt), + Temporal.Instant.from(localUpdatedAt), + ) > 0; + + if (!isServerStrictlyNewer) { + return false; + } + + console.warn(PULL_CONFLICT_LOG_MESSAGE_TEMPLATE(entityName), { + id, + localUpdatedAt, + serverUpdatedAt, + }); + + return true; +} diff --git a/packages/client/src/services/HiddenTaskService.test.ts b/packages/client/src/services/HiddenTaskService.test.ts index d3c15559..915346a1 100644 --- a/packages/client/src/services/HiddenTaskService.test.ts +++ b/packages/client/src/services/HiddenTaskService.test.ts @@ -170,45 +170,131 @@ describe("HiddenTaskService", () => { }); }); - it("should update updated_at timestamp when revealing", async () => { - const clock = fakeClock("2026-04-16T10:30:00Z"); - service = new HiddenTaskService(mockTaskRepository, clock); + // implements FR1 of fix-stale-sync-overwrites + describe("revealHiddenTasks preserving updated_at", () => { + it("should NOT modify updated_at when revealing a hidden task", async () => { + const clock = fakeClock("2026-04-16T10:30:00Z"); + service = new HiddenTaskService(mockTaskRepository, clock); - const hiddenTask: Task = { - id: "task-timestamp-1", - name: "Hidden task", - description: "", - box: "today", - goal_id: "", - context_id: "", - category_id: "", - is_completed: false, - completed_at: "" as ISOTimestamp, - repeat_rule: "", - is_hidden: true, - next_date: "" as ISODate, - appear_date: "2026-04-16" as ISODate, - original_task_id: "", - sort_order: "0", - is_deleted: false, - created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, - updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, - revision: 1, - syncStatus: "synced" as const, - }; + const hiddenTask: Task = { + id: "task-preserve-updated-at-1", + name: "Hidden task", + description: "", + box: "today", + goal_id: "", + context_id: "", + category_id: "", + is_completed: false, + completed_at: "" as ISOTimestamp, + repeat_rule: "", + is_hidden: true, + next_date: "" as ISODate, + appear_date: "2026-04-16" as ISODate, + original_task_id: "", + sort_order: "0", + is_deleted: false, + created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + revision: 1, + syncStatus: "synced" as const, + }; - ( - mockTaskRepository.getTasksToReveal as ReturnType - ).mockResolvedValue([hiddenTask]); - (mockTaskRepository.update as ReturnType).mockResolvedValue( - undefined, - ); + ( + mockTaskRepository.getTasksToReveal as ReturnType + ).mockResolvedValue([hiddenTask]); + (mockTaskRepository.update as ReturnType).mockResolvedValue( + undefined, + ); - const revealed = await service.revealHiddenTasks(); + const revealed = await service.revealHiddenTasks(); - expect(revealed[0].updated_at).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - ); - expect(revealed[0].updated_at).not.toBe("2026-04-15T10:00:00.000Z"); + expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z"); + expect(mockTaskRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: "task-preserve-updated-at-1", + updated_at: "2026-04-15T10:00:00.000Z", + }), + ); + }); + + it("should set syncStatus to pending when revealing a hidden task", async () => { + const clock = fakeClock("2026-04-16T10:30:00Z"); + service = new HiddenTaskService(mockTaskRepository, clock); + + const hiddenTask: Task = { + id: "task-preserve-updated-at-2", + name: "Hidden task", + description: "", + box: "today", + goal_id: "", + context_id: "", + category_id: "", + is_completed: false, + completed_at: "" as ISOTimestamp, + repeat_rule: "", + is_hidden: true, + next_date: "" as ISODate, + appear_date: "2026-04-16" as ISODate, + original_task_id: "", + sort_order: "0", + is_deleted: false, + created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + revision: 1, + syncStatus: "synced" as const, + }; + + ( + mockTaskRepository.getTasksToReveal as ReturnType + ).mockResolvedValue([hiddenTask]); + (mockTaskRepository.update as ReturnType).mockResolvedValue( + undefined, + ); + + const revealed = await service.revealHiddenTasks(); + + expect(revealed[0].syncStatus).toBe("pending"); + expect(revealed[0].is_hidden).toBe(false); + }); + + it("should keep syncStatus as pending without degrading state when task is already pending", async () => { + const clock = fakeClock("2026-04-16T10:30:00Z"); + service = new HiddenTaskService(mockTaskRepository, clock); + + const alreadyPendingHiddenTask: Task = { + id: "task-preserve-updated-at-3", + name: "Hidden task already pending", + description: "", + box: "today", + goal_id: "", + context_id: "", + category_id: "", + is_completed: false, + completed_at: "" as ISOTimestamp, + repeat_rule: "", + is_hidden: true, + next_date: "" as ISODate, + appear_date: "2026-04-16" as ISODate, + original_task_id: "", + sort_order: "0", + is_deleted: false, + created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + revision: 1, + syncStatus: "pending" as const, + }; + + ( + mockTaskRepository.getTasksToReveal as ReturnType + ).mockResolvedValue([alreadyPendingHiddenTask]); + (mockTaskRepository.update as ReturnType).mockResolvedValue( + undefined, + ); + + const revealed = await service.revealHiddenTasks(); + + expect(revealed[0].syncStatus).toBe("pending"); + expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z"); + }); }); }); diff --git a/packages/client/src/services/HiddenTaskService.ts b/packages/client/src/services/HiddenTaskService.ts index 6b14a40f..6104901a 100644 --- a/packages/client/src/services/HiddenTaskService.ts +++ b/packages/client/src/services/HiddenTaskService.ts @@ -1,7 +1,6 @@ import type { TaskRepository } from "@/db/repositories/TaskRepository"; import { type Clock, systemClock } from "@/lib/temporal"; import type { ISODate, Task } from "@/types/entities"; -import { toISOTimestamp } from "@/utils/dateHelpers"; export class HiddenTaskService { constructor( @@ -9,7 +8,10 @@ export class HiddenTaskService { private readonly clock: Clock = systemClock, ) {} - /** Implements FR4 of day-boundary */ + /** + * Implements FR1 of fix-stale-sync-overwrites (preserves updated_at on reveal). + * Implements FR4 of day-boundary (current-date resolution for reveal check). + */ async revealHiddenTasks(logicalDate?: ISODate): Promise { const currentDate = logicalDate ?? this.clock.plainDateISO().toString(); const tasksToReveal = @@ -19,14 +21,12 @@ export class HiddenTaskService { return []; } - const now = toISOTimestamp(this.clock); const revealedTasks: Task[] = []; for (const task of tasksToReveal) { const updatedTask: Task = { ...task, is_hidden: false, - updated_at: now, syncStatus: "pending" as const, }; diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.ts new file mode 100644 index 00000000..594889e4 --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.ts @@ -0,0 +1,96 @@ +import type { Task } from "@/types/entities"; + +/** + * Merges a duplicate recurring-task group into a single winner record. + * Implements FR3 of fix-stale-sync-overwrites. + */ + +const SCHEDULE_TRIPLE_FIELDS = [ + "next_date", + "appear_date", + "is_hidden", +] as const; +const CONTENT_FIELDS = [ + "name", + "description", + "goal_id", + "context_id", + "category_id", +] as const; +const BOX_PAIR_FIELDS = ["box", "sort_order"] as const; +const MERGE_RELEVANT_FIELDS = [ + ...SCHEDULE_TRIPLE_FIELDS, + ...CONTENT_FIELDS, + ...BOX_PAIR_FIELDS, + "repeat_rule", + "updated_at", +] as const; + +/** + * Finds the copy with the freshest `updated_at` among all copies in a + * duplicate group (schedule winner included, per FR3: the winner itself + * may already be the freshest). + */ +function findFreshestUpdatedAtCopy(copies: Task[]): Task { + return copies.reduce((freshest, candidate) => + candidate.updated_at.localeCompare(freshest.updated_at) > 0 + ? candidate + : freshest, + ); +} + +/** + * Computes the merged winner record per FR3 / Decision D3: + * - schedule triple (next_date, appear_date, is_hidden) from the schedule winner + * - content fields + box/sort_order pair from the freshest-updated_at copy + * - identity fields (id, created_at, revision) stay the schedule winner's own + * - updated_at is the freshest copy's own value, never refreshed to now + * - if repeat_rule differs between the schedule winner and the freshest + * copy, the freshest copy wins wholesale (all fields, dates included) + * + * @param scheduleWinner - the copy with the earliest next_date (tiebreak by id) + * @param allCopiesInGroup - every active copy in the duplicate group, winner included + */ +export function mergeWinner( + scheduleWinner: Task, + allCopiesInGroup: Task[], +): Task { + const freshestCopy = findFreshestUpdatedAtCopy(allCopiesInGroup); + + if (freshestCopy.repeat_rule !== scheduleWinner.repeat_rule) { + return { + ...freshestCopy, + id: scheduleWinner.id, + created_at: scheduleWinner.created_at, + revision: scheduleWinner.revision, + syncStatus: scheduleWinner.syncStatus, + }; + } + + return { + ...scheduleWinner, + name: freshestCopy.name, + description: freshestCopy.description, + goal_id: freshestCopy.goal_id, + context_id: freshestCopy.context_id, + category_id: freshestCopy.category_id, + box: freshestCopy.box, + sort_order: freshestCopy.sort_order, + updated_at: freshestCopy.updated_at, + }; +} + +/** + * Compares the merged winner to the schedule winner's originally stored + * values on the merge-relevant fields only (identity/bookkeeping fields + * are excluded — they never change). Used to decide whether the merge + * actually changed anything and thus whether to persist an update. + */ +export function hasMergeChangedWinner( + mergedWinner: Task, + originalScheduleWinner: Task, +): boolean { + return MERGE_RELEVANT_FIELDS.some( + (field) => mergedWinner[field] !== originalScheduleWinner[field], + ); +} diff --git a/packages/client/src/services/RecurringTaskDeduplicator.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.test.ts index 75986fc0..df4ae3a0 100644 --- a/packages/client/src/services/RecurringTaskDeduplicator.test.ts +++ b/packages/client/src/services/RecurringTaskDeduplicator.test.ts @@ -287,6 +287,46 @@ describe("RecurringTaskDeduplicator", () => { const active = await db.tasks.get(UUID_A); expect(active?.is_deleted).toBe(false); }); + + // FR1: a completed copy sharing original_task_id with two active + // duplicates must not be pulled into the dup group — the group is + // formed only from the two non-completed, non-deleted copies, so the + // winner is chosen among those two and the completed copy is untouched. + it("should form the duplicate group only from active copies when a completed copy shares the original_task_id with two active duplicates", async () => { + const earlyActiveTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const lateActiveTask = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + const completedTask = buildTask({ + id: UUID_C, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-06-20", + is_completed: true, + is_deleted: false, + }); + await db.tasks.bulkAdd([earlyActiveTask, lateActiveTask, completedTask]); + + await deduplicator.deduplicate([ORIGINAL_ID_1]); + + const earlyActiveResult = await db.tasks.get(UUID_A); + const lateActiveResult = await db.tasks.get(UUID_B); + const completedResult = await db.tasks.get(UUID_C); + expect(earlyActiveResult?.is_deleted).toBe(false); + expect(lateActiveResult?.is_deleted).toBe(true); + expect(lateActiveResult?.syncStatus).toBe("pending"); + expect(completedResult?.is_deleted).toBe(false); + expect(completedResult?.syncStatus).toBe("synced"); + }); }); // FR3: cascade to checklist items @@ -530,6 +570,78 @@ describe("RecurringTaskDeduplicator", () => { expect(updateSpy).not.toHaveBeenCalled(); }); + + // Guards the `continue` statement directly: a group can only reach this + // guard with activeCopies.length < MINIMUM_DUPLICATE_GROUP_SIZE (0 or 1) + // since findDuplicateRecurringGroups never emits an empty group in + // practice — so length-1 groups are a legitimate but observationally + // equivalent case (merging a single copy against itself is a no-op + // either way). Forcing an empty-array group via a mocked repository + // makes the two mutants observable: without the `continue` (or with the + // guard replaced by `if (false)`), the loop falls through to + // `sortByWinnerPriority([])` and then destructures an undefined + // scheduleWinner, which throws when merging — proving `continue` really + // skips groups below the minimum size rather than merely coinciding with + // a no-op merge. + it("should not throw when a mocked group is empty, proving continue actually skips it", async () => { + const taskRepository = new TaskRepository(); + const checklistRepository = new ChecklistRepository(); + const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); + vi.spyOn( + taskRepository, + "findDuplicateRecurringGroups", + ).mockResolvedValue(new Map([[ORIGINAL_ID_1, []]])); + const localDeduplicator = new RecurringTaskDeduplicator( + taskRepository, + checklistRepository, + clock, + ); + + await expect( + localDeduplicator.deduplicate([ORIGINAL_ID_1]), + ).resolves.toBeUndefined(); + }); + + // Guards the `continue` statement itself (not just the guarded call): + // replacing `continue` with an empty block would still skip the update + // for the singleton group (since the guard body has no other statements) + // but would fall through to loop code the real implementation intends to + // skip, rather than jumping to the next group. Processing a singleton + // group FIRST and a real duplicate group SECOND in the same call proves + // the loop actually advances past the singleton to still dedupe group 2 — + // an `if (...) {}` mutant that fails to skip to the next iteration would + // still coincidentally pass this too, but combined with the single-copy + // assertions above, it locks down that iteration order is unaffected. + it("should still deduplicate a later group after an earlier singleton group in the same call", async () => { + const singleTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const groupTwoWinner = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_2, + next_date: "2026-07-02", + is_completed: false, + is_deleted: false, + }); + const groupTwoLoser = buildTask({ + id: UUID_C, + original_task_id: ORIGINAL_ID_2, + next_date: "2026-07-08", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([singleTask, groupTwoWinner, groupTwoLoser]); + + await deduplicator.deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]); + + expect((await db.tasks.get(UUID_A))?.syncStatus).toBe("synced"); + expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false); + expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true); + }); }); // Mutant 5: id tiebreak when next_date is equal diff --git a/packages/client/src/services/RecurringTaskDeduplicator.ts b/packages/client/src/services/RecurringTaskDeduplicator.ts index 28f931cc..04fe79ec 100644 --- a/packages/client/src/services/RecurringTaskDeduplicator.ts +++ b/packages/client/src/services/RecurringTaskDeduplicator.ts @@ -4,12 +4,16 @@ import type { TaskRepository } from "@/db/repositories/TaskRepository"; import { type Clock, systemClock } from "@/lib/temporal"; import type { Task } from "@/types/entities"; import { toISOTimestamp } from "@/utils/dateHelpers"; +import { + hasMergeChangedWinner, + mergeWinner, +} from "./RecurringTaskDeduplicator.merge"; const MINIMUM_DUPLICATE_GROUP_SIZE = 2; /** * Deduplicates recurring task copies after a pull batch. - * Implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull. + * Implements FR3 of fix-stale-sync-overwrites. */ export class RecurringTaskDeduplicator { constructor( @@ -20,7 +24,7 @@ export class RecurringTaskDeduplicator { /** * Deduplicates recurring copies for the given original_task_id values. - * Implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull. + * Implements FR3 of fix-stale-sync-overwrites. * * @param originalTaskIds - original_task_id values from the pull batch */ @@ -40,10 +44,12 @@ export class RecurringTaskDeduplicator { } const sortedCopies = this.sortByWinnerPriority(activeCopies); - const [, ...losers] = sortedCopies; + const [scheduleWinner, ...losers] = sortedCopies; const now = toISOTimestamp(this.clock); + await this.mergeAndUpdateWinner(scheduleWinner, activeCopies); + for (const loser of losers) { await this.softDeleteTask(loser, now); await this.cascadeSoftDeleteChecklistItems(loser.id, now); @@ -51,6 +57,22 @@ export class RecurringTaskDeduplicator { } } + private async mergeAndUpdateWinner( + scheduleWinner: Task, + activeCopies: Task[], + ): Promise { + const mergedWinner = mergeWinner(scheduleWinner, activeCopies); + + if (!hasMergeChangedWinner(mergedWinner, scheduleWinner)) { + return; + } + + await this.taskRepository.update({ + ...mergedWinner, + syncStatus: RECORD_SYNC_STATUS.PENDING, + }); + } + private extractUniqueNonEmptyIds(originalTaskIds: string[]): string[] { return [...new Set(originalTaskIds.filter((id) => id !== ""))]; } diff --git a/packages/client/src/services/TaskService.complete.test.ts b/packages/client/src/services/TaskService.complete.test.ts index 9685d9e2..d5f76fe9 100644 --- a/packages/client/src/services/TaskService.complete.test.ts +++ b/packages/client/src/services/TaskService.complete.test.ts @@ -30,6 +30,16 @@ describe("TaskService", () => { expect(completed.completed_at).not.toBe(""); }); + it("should throw an error naming the missing task id when task is not found", async () => { + const missingTaskId = "missing-task-id"; + const { taskService } = createTestContext({ + getById: vi.fn().mockResolvedValue(undefined), + }); + await expect(taskService.complete(missingTaskId)).rejects.toThrow( + `Task not found: ${missingTaskId}`, + ); + }); + it("should return not_recurring status when repeat_rule is empty", async () => { const task = buildTask({ repeat_rule: "" }); const { taskService } = createTestContext({ diff --git a/packages/client/src/services/TaskService.mutations.test.ts b/packages/client/src/services/TaskService.mutations.test.ts index f9e57c48..b2817143 100644 --- a/packages/client/src/services/TaskService.mutations.test.ts +++ b/packages/client/src/services/TaskService.mutations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { BOX } from "@/constants"; -import { Temporal } from "@/lib/temporal"; +import { fakeClock, Temporal } from "@/lib/temporal"; import { buildTask } from "@/test/factories/taskFactory"; import { toISOTimestamp } from "@/utils/dateHelpers"; import { createTestContext } from "./TaskService-test-utils"; @@ -31,6 +31,124 @@ describe("TaskService", () => { }); }); + describe("manual hide via update", () => { + // Characterization tests: FR2 of fix-stale-sync-overwrites requires manual + // hide to remain a regular user edit. These document EXISTING behavior + // that must NOT change. + it("should refresh updated_at to now when manually hiding a task", async () => { + const staleTimestamp = toISOTimestamp( + fakeClock("2025-01-01T00:00:00.000Z"), + ); + const task = buildTask({ + is_hidden: false, + appear_date: "", + updated_at: staleTimestamp, + }); + const { taskService } = createTestContext({ + getById: vi.fn().mockResolvedValue(task), + }); + + const hidden = await taskService.update(task.id, { + is_hidden: true, + appear_date: "2026-08-01", + }); + + expect(hidden.updated_at).not.toBe(staleTimestamp); + }); + + it("should set appear_date to the given value when manually hiding a task", async () => { + const task = buildTask({ is_hidden: false, appear_date: "" }); + const { taskService } = createTestContext({ + getById: vi.fn().mockResolvedValue(task), + }); + + const hidden = await taskService.update(task.id, { + is_hidden: true, + appear_date: "2026-08-01", + }); + + expect(hidden.appear_date).toBe("2026-08-01"); + expect(hidden.is_hidden).toBe(true); + }); + + it("should set syncStatus to pending when manually hiding a task", async () => { + const task = buildTask({ + is_hidden: false, + appear_date: "", + syncStatus: "synced" as const, + }); + const { taskService } = createTestContext({ + getById: vi.fn().mockResolvedValue(task), + }); + + const hidden = await taskService.update(task.id, { + is_hidden: true, + appear_date: "2026-08-01", + }); + + expect(hidden.syncStatus).toBe("pending"); + }); + }); + + describe("manual unhide via update", () => { + // Characterization tests: FR2 of fix-stale-sync-overwrites requires manual + // unhide to remain a regular user edit. These document EXISTING behavior + // that must NOT change. + it("should refresh updated_at to now when manually unhiding a task", async () => { + const staleTimestamp = toISOTimestamp( + fakeClock("2025-01-01T00:00:00.000Z"), + ); + const task = buildTask({ + is_hidden: true, + appear_date: "2026-08-01", + updated_at: staleTimestamp, + }); + const { taskService } = createTestContext({ + getById: vi.fn().mockResolvedValue(task), + }); + + const unhidden = await taskService.update(task.id, { + is_hidden: false, + appear_date: "", + }); + + expect(unhidden.updated_at).not.toBe(staleTimestamp); + }); + + it("should clear appear_date to empty string when manually unhiding a task", async () => { + const task = buildTask({ is_hidden: true, appear_date: "2026-08-01" }); + const { taskService } = createTestContext({ + getById: vi.fn().mockResolvedValue(task), + }); + + const unhidden = await taskService.update(task.id, { + is_hidden: false, + appear_date: "", + }); + + expect(unhidden.appear_date).toBe(""); + expect(unhidden.is_hidden).toBe(false); + }); + + it("should set syncStatus to pending when manually unhiding a task", async () => { + const task = buildTask({ + is_hidden: true, + appear_date: "2026-08-01", + syncStatus: "synced" as const, + }); + const { taskService } = createTestContext({ + getById: vi.fn().mockResolvedValue(task), + }); + + const unhidden = await taskService.update(task.id, { + is_hidden: false, + appear_date: "", + }); + + expect(unhidden.syncStatus).toBe("pending"); + }); + }); + describe("noncomplete", () => { it("should set is_completed to false", async () => { const task = buildTask({ is_completed: true, box: "inbox" }); diff --git a/packages/client/src/services/TaskService.recurring-completion.test.ts b/packages/client/src/services/TaskService.recurring-completion.test.ts index a0f614b9..395d12a0 100644 --- a/packages/client/src/services/TaskService.recurring-completion.test.ts +++ b/packages/client/src/services/TaskService.recurring-completion.test.ts @@ -212,13 +212,15 @@ describe("TaskService - Recurring Tasks Integration", () => { }); const completedTask = buildCompletedTask(existingTask); + const cloneCreationError = new Error("DB error"); mockTaskRepository.getById = vi.fn().mockResolvedValue(existingTask); mockTaskRepository.update = vi.fn().mockResolvedValue(completedTask); - mockTaskRepository.create = vi - .fn() - .mockRejectedValue(new Error("DB error")); + mockTaskRepository.create = vi.fn().mockRejectedValue(cloneCreationError); mockChecklistRepository.getActiveByTaskId = vi.fn().mockResolvedValue([]); + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); const result = await taskService.complete("task-1"); @@ -226,6 +228,12 @@ describe("TaskService - Recurring Tasks Integration", () => { expect(result.completed).toBeDefined(); expect(result.completed.is_completed).toBe(true); expect(result.recurringResult.status).toBe("skipped_invalid_rule"); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Failed to create recurring task:", + cloneCreationError, + ); + + consoleErrorSpy.mockRestore(); }); }); diff --git a/packages/client/src/services/TaskService.ts b/packages/client/src/services/TaskService.ts index cc65cdda..3716116d 100644 --- a/packages/client/src/services/TaskService.ts +++ b/packages/client/src/services/TaskService.ts @@ -431,11 +431,18 @@ export class TaskService { }); if (needsRebalancing(newSortOrder)) { - await this.rebalanceBox(task.box); + await this.rebalanceBox(task.box, taskId); } } - private async rebalanceBox(box: Box): Promise { + /** + * Implements FR4 of fix-stale-sync-overwrites. + * Assigns fresh sort_order keys and syncStatus "pending" to every task + * in the box without touching their updated_at, since rebalancing is a + * system-initiated reshuffle, not a user edit. The dragged task (already + * refreshed by reorderTasks) keeps its freshly refreshed updated_at. + */ + private async rebalanceBox(box: Box, draggedTaskId: string): Promise { const tasks = await this.taskRepository.getByBox(box); const sorted = this.sortBySortOrder(tasks); const newKeys = rebalanceKeys(sorted.length); @@ -443,7 +450,7 @@ export class TaskService { const rebalancedTasks = sorted.map((task, index) => ({ ...task, sort_order: newKeys[index], - updated_at: now, + updated_at: task.id === draggedTaskId ? now : task.updated_at, syncStatus: "pending" as const, })); await this.taskRepository.bulkUpsert(rebalancedTasks); diff --git a/packages/client/src/test/features/file_attachments/steps/attachment_repository_sync.steps.ts b/packages/client/src/test/features/file_attachments/steps/attachment_repository_sync.steps.ts index 3f0ac34d..77fa7a72 100644 --- a/packages/client/src/test/features/file_attachments/steps/attachment_repository_sync.steps.ts +++ b/packages/client/src/test/features/file_attachments/steps/attachment_repository_sync.steps.ts @@ -5,15 +5,24 @@ import type { WireAttachment } from "@clear-progress/contract"; import { expect, type TestContext } from "vitest"; import { db } from "@/db/database"; import { AttachmentRepository } from "@/db/repositories/AttachmentRepository"; +import { Temporal } from "@/lib/temporal"; import { buildAttachment } from "@/test/factories/attachmentFactory"; import type { Attachment } from "@/types/entities"; +import { toISOTimestamp } from "@/utils/dateHelpers"; const feature = await loadFeature("../attachment_repository_sync.feature"); +// FR5 of fix-stale-sync-overwrites: strictly before any `buildAttachment()` +// call in this suite, so a server record built with this timestamp is never +// strictly newer than a local pending record. +const OLDER_THAN_LOCAL_ATTACHMENT_TIMESTAMP = toISOTimestamp( + Temporal.Instant.from("2020-01-01T00:00:00.000Z"), +); + function buildWireAttachment( overrides: Partial = {}, ): WireAttachment { - const now = new Date().toISOString(); + const now = toISOTimestamp(); return { id: crypto.randomUUID(), entity_type: "task", @@ -159,9 +168,14 @@ describeFeature( When( "applyServerRecords is called with a record having the same id", async (_ctx: TestContext) => { + // FR5 of fix-stale-sync-overwrites: a pending local record is only + // preserved when the server record is not strictly newer, so the + // server record here is given an older updated_at than the local + // pending record to deterministically exercise that branch. const serverRecord = buildWireAttachment({ id: localAttachment.id, filename: "server-version.pdf", + updated_at: OLDER_THAN_LOCAL_ATTACHMENT_TIMESTAMP, }); await repository.applyServerRecords([serverRecord]); }, diff --git a/packages/client/src/test/features/repeating_tasks/dedup_merge.feature b/packages/client/src/test/features/repeating_tasks/dedup_merge.feature new file mode 100644 index 00000000..7b522b0f --- /dev/null +++ b/packages/client/src/test/features/repeating_tasks/dedup_merge.feature @@ -0,0 +1,29 @@ +Feature: Dedup Merge After Two-Device Double Completion + Implements FR3, FR6 of fix-stale-sync-overwrites. + + @fix-stale-sync-overwrites @FR3 + Scenario: Fixed schedule — earlier device's schedule wins with the later device's fresh content + Given device A completed and edited the occurrence, producing a copy with next_date "2026-07-10" and description "v2" as the freshest update + And device B completed the same occurrence offline, producing a copy with next_date "2026-07-09" and description "v1" as a staler update + When deduplication runs after device B pulls both copies + Then the surviving copy has next_date "2026-07-09" + And the surviving copy has description "v2" + And the surviving copy's updated_at equals device A's update time + And device A's copy is soft-deleted with syncStatus "pending" + + @fix-stale-sync-overwrites @FR6 + Scenario: After-completion schedule — the same merge rules apply regardless of the recurrence model + Given device A completed the occurrence under an after_completion repeat_rule, producing a copy with next_date "2026-07-04" as the freshest update + And device B completed the same occurrence offline, producing a copy with next_date "2026-07-05" as a staler update + When deduplication runs after device B pulls both copies + Then the surviving copy has next_date "2026-07-04" + And the surviving copy's repeat_rule is unchanged from before the merge + And the surviving copy's appear_date is consistent with its own next_date + + @fix-stale-sync-overwrites @FR3 + Scenario: Merge is not a verbatim keep-earliest — schedule and content are combined from different copies + Given device A completed and edited the occurrence, producing a copy with next_date "2026-07-10" and description "v2" as the freshest update + And device B completed the same occurrence offline, producing a copy with next_date "2026-07-09" and description "v1" as a staler update + When deduplication runs after device B pulls both copies + Then the surviving copy does not equal device B's copy verbatim + And the surviving copy does not equal device A's copy verbatim diff --git a/packages/client/src/test/features/sync_protocol/pull_lww_protection.feature b/packages/client/src/test/features/sync_protocol/pull_lww_protection.feature new file mode 100644 index 00000000..10cbf6df --- /dev/null +++ b/packages/client/src/test/features/sync_protocol/pull_lww_protection.feature @@ -0,0 +1,38 @@ +Feature: Pull LWW Protection + Implements FR5 of fix-stale-sync-overwrites. + + @fix-stale-sync-overwrites @FR5 + Scenario: A locally-pending edit is overwritten when another device's edit is newer + Given a task edited locally and pending sync, with a locally-pending goal edited the same way + When the same records are pulled from the server with a strictly newer edit from another device + Then both records show the other device's edit and are marked as synced + + @fix-stale-sync-overwrites @FR5 + Scenario: A locally-pending edit survives a pull of an equally old server record + Given a task edited locally and pending sync, with a locally-pending goal edited the same way + When the same records are pulled from the server with no newer edit from another device, timestamps tied + Then both records still show the local edit and remain pending sync + + @fix-stale-sync-overwrites @FR5 + Scenario: A locally-pending edit survives a pull of an older server record + Given a task edited locally and pending sync, with a locally-pending goal edited the same way + When the same records are pulled from the server with an older, stale edit from another device + Then both records still show the local edit and remain pending sync + + @fix-stale-sync-overwrites @FR5 + Scenario: A synced record always reflects the latest pull + Given a task already in sync with the server, with a goal already in sync the same way + When the same records are pulled from the server with a newer edit from another device + Then both records show the other device's edit and are marked as synced + + @fix-stale-sync-overwrites @FR5 + Scenario: A new record from another device appears after its first pull + Given no local copy of a task or a goal exists yet + When the same records are pulled from the server for the first time + Then both records appear locally and are marked as synced + + @fix-stale-sync-overwrites @FR5 + Scenario: An overwritten pending edit is logged as a sync conflict for debugging + Given a task edited locally and pending sync + When the task is pulled from the server with a strictly newer edit from another device + Then a sync conflict is logged with the task's id and both devices' timestamps diff --git a/packages/client/src/test/features/sync_protocol/rebalance_sync.feature b/packages/client/src/test/features/sync_protocol/rebalance_sync.feature new file mode 100644 index 00000000..c1abfdf6 --- /dev/null +++ b/packages/client/src/test/features/sync_protocol/rebalance_sync.feature @@ -0,0 +1,16 @@ +Feature: Sort Order Rebalance Sync + Implements FR4 of fix-stale-sync-overwrites. + + @fix-stale-sync-overwrites @FR4 + Scenario: A task swept into rebalancing keeps the timestamp of its last real edit + Given a box "inbox" with tasks needing rebalance, where task "other" has updated_at "2025-01-01T00:00:00.000Z" + When the user drags task "dragged" to trigger rebalancing + Then task "other" has syncStatus "pending" + And task "other"'s updated_at is still "2025-01-01T00:00:00.000Z" + + @fix-stale-sync-overwrites @FR4 + Scenario: The task the user actually reordered gets a fresh timestamp + Given a box "inbox" with tasks needing rebalance, where task "dragged" has updated_at "2025-01-01T00:00:00.000Z" + When the user drags task "dragged" to trigger rebalancing + Then task "dragged" has syncStatus "pending" + And task "dragged"'s updated_at is refreshed past "2025-01-01T00:00:00.000Z" diff --git a/packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature b/packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature new file mode 100644 index 00000000..0490c1cb --- /dev/null +++ b/packages/client/src/test/features/sync_protocol/stale_reveal_sync.feature @@ -0,0 +1,32 @@ +Feature: Stale-Safe Reveal Sync + Implements FR1 of fix-stale-sync-overwrites. + + @fix-stale-sync-overwrites @FR1 + Scenario: Task revealed when appear date arrives preserves updated_at + Given a hidden task with appear_date "2026-06-15", updated_at "2026-06-01T10:00:00.000Z" and today is "2026-06-15" + When system reveals hidden tasks + Then the task has is_hidden false and syncStatus "pending" + And the task's updated_at is still "2026-06-01T10:00:00.000Z" + + @fix-stale-sync-overwrites @FR1 + Scenario: Reveal of an already-pending record does not degrade its state + Given a hidden task with appear_date "2026-06-15", updated_at "2026-06-01T10:00:00.000Z", syncStatus "pending" and today is "2026-06-15" + When system reveals hidden tasks + Then the task has is_hidden false and syncStatus "pending" + And the task's updated_at is still "2026-06-01T10:00:00.000Z" + + @fix-stale-sync-overwrites @FR2 + Scenario: Manual hide sets appear_date, refreshes updated_at, and marks pending for sync + Given a visible task with updated_at "2026-06-01T10:00:00.000Z" and syncStatus "synced" + When the user manually hides the task with appear_date "2026-08-01" + Then the task has is_hidden true and appear_date "2026-08-01" + And the task's updated_at is refreshed past "2026-06-01T10:00:00.000Z" + And the task has syncStatus "pending" + + @fix-stale-sync-overwrites @FR2 + Scenario: Manual unhide before appear date clears appear_date, refreshes updated_at, and marks pending for sync + Given a hidden task with appear_date "2026-08-01", updated_at "2026-06-01T10:00:00.000Z" and syncStatus "synced" + When the user manually unhides the task + Then the task has is_hidden false and appear_date "" + And the task's updated_at is refreshed past "2026-06-01T10:00:00.000Z" + And the task has syncStatus "pending" diff --git a/packages/client/src/test/features/sync_protocol/steps/rebalance_sync.steps.ts b/packages/client/src/test/features/sync_protocol/steps/rebalance_sync.steps.ts new file mode 100644 index 00000000..aa572a88 --- /dev/null +++ b/packages/client/src/test/features/sync_protocol/steps/rebalance_sync.steps.ts @@ -0,0 +1,143 @@ +// implements FR4 of fix-stale-sync-overwrites +import "fake-indexeddb/auto"; +import type { FeatureDescriibeCallbackParams } from "@amiceli/vitest-cucumber"; +import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber"; +import { expect, type TestContext } from "vitest"; +import { SORT_ORDER_REBALANCE_THRESHOLD } from "@/constants"; +import { db } from "@/db/database"; +import { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; +import { TaskRepository } from "@/db/repositories/TaskRepository"; +import { TaskService } from "@/services/TaskService"; +import { buildTask } from "@/test/factories/taskFactory"; +import type { Task } from "@/types/entities"; + +const feature = await loadFeature("../rebalance_sync.feature"); + +type Context = Record; + +const REBALANCE_TRIGGER_SORT_ORDER = "a".repeat( + SORT_ORDER_REBALANCE_THRESHOLD + 1, +); + +describeFeature(feature, (f: FeatureDescriibeCallbackParams) => { + let taskService: TaskService; + let taskIdsByLabel: Record; + + f.BeforeEachScenario(async () => { + await db.tasks.clear(); + taskService = new TaskService( + new TaskRepository(), + new ChecklistRepository(), + ); + taskIdsByLabel = {}; + }); + + async function seedBoxNeedingRebalance( + labelWithStaleTimestamp: string, + staleTimestamp: string, + ) { + const draggedTask = buildTask({ + box: "inbox", + sort_order: "a0", + updated_at: + labelWithStaleTimestamp === "dragged" + ? staleTimestamp + : "2025-01-01T00:00:00.000Z", + syncStatus: "synced" as const, + }); + const otherTask = buildTask({ + box: "inbox", + sort_order: "b0", + updated_at: + labelWithStaleTimestamp === "other" + ? staleTimestamp + : "2025-01-01T00:00:00.000Z", + syncStatus: "synced" as const, + }); + taskIdsByLabel.dragged = draggedTask.id; + taskIdsByLabel.other = otherTask.id; + await db.tasks.bulkAdd([draggedTask, otherTask]); + } + + // @fix-stale-sync-overwrites @FR4 + f.Scenario( + "A task swept into rebalancing keeps the timestamp of its last real edit", + ({ Given, When, Then, And }) => { + Given( + 'a box "inbox" with tasks needing rebalance, where task "other" has updated_at "2025-01-01T00:00:00.000Z"', + async (_ctx: TestContext) => { + await seedBoxNeedingRebalance("other", "2025-01-01T00:00:00.000Z"); + }, + ); + + When( + 'the user drags task "dragged" to trigger rebalancing', + async (_ctx: TestContext) => { + await taskService.reorderTasks( + taskIdsByLabel.dragged, + REBALANCE_TRIGGER_SORT_ORDER, + ); + }, + ); + + Then( + 'task "other" has syncStatus "pending"', + async (_ctx: TestContext) => { + const otherTask = (await db.tasks.get(taskIdsByLabel.other)) as Task; + expect(otherTask.syncStatus).toBe("pending"); + }, + ); + + And( + 'task "other"\'s updated_at is still "2025-01-01T00:00:00.000Z"', + async (_ctx: TestContext) => { + const otherTask = (await db.tasks.get(taskIdsByLabel.other)) as Task; + expect(otherTask.updated_at).toBe("2025-01-01T00:00:00.000Z"); + }, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR4 + f.Scenario( + "The task the user actually reordered gets a fresh timestamp", + ({ Given, When, Then, And }) => { + Given( + 'a box "inbox" with tasks needing rebalance, where task "dragged" has updated_at "2025-01-01T00:00:00.000Z"', + async (_ctx: TestContext) => { + await seedBoxNeedingRebalance("dragged", "2025-01-01T00:00:00.000Z"); + }, + ); + + When( + 'the user drags task "dragged" to trigger rebalancing', + async (_ctx: TestContext) => { + await taskService.reorderTasks( + taskIdsByLabel.dragged, + REBALANCE_TRIGGER_SORT_ORDER, + ); + }, + ); + + Then( + 'task "dragged" has syncStatus "pending"', + async (_ctx: TestContext) => { + const draggedTask = (await db.tasks.get( + taskIdsByLabel.dragged, + )) as Task; + expect(draggedTask.syncStatus).toBe("pending"); + }, + ); + + And( + 'task "dragged"\'s updated_at is refreshed past "2025-01-01T00:00:00.000Z"', + async (_ctx: TestContext) => { + const draggedTask = (await db.tasks.get( + taskIdsByLabel.dragged, + )) as Task; + expect(draggedTask.updated_at).not.toBe("2025-01-01T00:00:00.000Z"); + }, + ); + }, + ); +}); diff --git a/packages/integration/src/convergence-helpers.ts b/packages/integration/src/convergence-helpers.ts new file mode 100644 index 00000000..94c40d04 --- /dev/null +++ b/packages/integration/src/convergence-helpers.ts @@ -0,0 +1,142 @@ +// implements NFR-REL1 of fix-stale-sync-overwrites +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import type { ServerCallCredentials } from "./server-api.js"; +import { pullFromServer } from "./server-api.js"; + +/** Dexie database name — must match `DB_NAME` in packages/client/src/constants/index.ts. */ +const DEXIE_DB_NAME = "clear-progress"; + +/** Entity table keys this helper knows how to dump, mapped to their Dexie store name. */ +const ENTITY_TABLE_NAMES = { + tasks: "tasks", + goals: "goals", + contexts: "contexts", + categories: "categories", + checklist_items: "checklist_items", + ideas: "ideas", +} as const; + +/** Entity key accepted by the convergence helpers (extensible to all synced tables). */ +export type ConvergenceEntityKey = keyof typeof ENTITY_TABLE_NAMES; + +/** Pull response entity array key matching each `ConvergenceEntityKey` (server payload uses the same name). */ +const SERVER_ENTITY_KEYS = ENTITY_TABLE_NAMES; + +/** + * Client-only bookkeeping fields that never appear in the server record and + * must be stripped before comparing a device snapshot against the server. + */ +const CLIENT_ONLY_FIELDS = ["syncStatus"] as const; + +/** A single entity record read from either an IndexedDB store or the server. */ +type EntityRecord = Record; + +/** + * Reads every record from a single Dexie object store on the given page, + * via the raw IndexedDB API (no app code involved, works regardless of + * whether the app's Dexie instance is currently open in that page). + */ +async function dumpIndexedDbStore( + testPage: Page, + storeName: string, +): Promise { + return testPage.evaluate( + ({ dbName, store }) => { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + if (!database.objectStoreNames.contains(store)) { + database.close(); + resolve([]); + return; + } + const transaction = database.transaction(store, "readonly"); + const getAllRequest = transaction.objectStore(store).getAll(); + getAllRequest.onsuccess = () => { + database.close(); + resolve(getAllRequest.result as EntityRecord[]); + }; + getAllRequest.onerror = () => { + database.close(); + reject(getAllRequest.error); + }; + }; + }); + }, + { dbName: DEXIE_DB_NAME, store: storeName }, + ); +} + +/** Strips client-only fields so a device snapshot is comparable to a server snapshot. */ +function stripClientOnlyFields(record: EntityRecord): EntityRecord { + const normalized = { ...record }; + for (const field of CLIENT_ONLY_FIELDS) { + delete normalized[field]; + } + return normalized; +} + +/** Sorts records by `id` so array order never causes a spurious diff. */ +function sortById(records: EntityRecord[]): EntityRecord[] { + return [...records].sort((a, b) => + String(a.id ?? "").localeCompare(String(b.id ?? "")), + ); +} + +/** + * Dumps one entity table from a device's IndexedDB, normalized for + * comparison: client-only fields stripped, sorted by `id`. + * Implements NFR-REL1 of fix-stale-sync-overwrites. + */ +export async function dumpDeviceState( + testPage: Page, + entityKey: ConvergenceEntityKey, +): Promise { + const records = await dumpIndexedDbStore( + testPage, + ENTITY_TABLE_NAMES[entityKey], + ); + return sortById(records.map(stripClientOnlyFields)); +} + +/** + * Dumps one entity table from the server via the pull endpoint, normalized + * the same way as `dumpDeviceState` so the two are directly comparable. + * Implements NFR-REL1 of fix-stale-sync-overwrites. + */ +export async function dumpServerState( + credentials: ServerCallCredentials, + entityKey: ConvergenceEntityKey, +): Promise { + const pullResponse = + await pullFromServer>( + credentials, + ); + const records = pullResponse[SERVER_ENTITY_KEYS[entityKey]] ?? []; + return sortById(records.map(stripClientOnlyFields)); +} + +/** + * Asserts that device A, device B, and the server all hold an identical set + * of records for the given entity table — the NFR-REL1 convergence check. + * Call after both devices have completed a push+pull cycle. + * Implements NFR-REL1 of fix-stale-sync-overwrites. + */ +export async function assertConverged( + pageA: Page, + pageB: Page, + credentials: ServerCallCredentials, + entityKey: ConvergenceEntityKey, +): Promise { + const [deviceAState, deviceBState, serverState] = await Promise.all([ + dumpDeviceState(pageA, entityKey), + dumpDeviceState(pageB, entityKey), + dumpServerState(credentials, entityKey), + ]); + + expect(deviceAState).toEqual(serverState); + expect(deviceBState).toEqual(serverState); +} diff --git a/packages/integration/src/page-actions.ts b/packages/integration/src/page-actions.ts index 747b384c..bba3125c 100644 --- a/packages/integration/src/page-actions.ts +++ b/packages/integration/src/page-actions.ts @@ -47,6 +47,59 @@ export async function updateTaskName( await page.getByTestId("task-detail-name").blur(); } +/** + * Updates a task's description from the task detail panel. + * `EditableDescription` is click-to-edit: at rest it renders a div, and only + * becomes a textarea after being clicked. Saving happens on blur. + */ +export async function updateTaskDescription( + page: Page, + newDescription: string, +): Promise { + const descriptionField = page.getByTestId("task-detail-description"); + await descriptionField.click(); + await descriptionField.fill(newDescription); + await descriptionField.blur(); +} + +/** + * Hides a non-recurring task until the given ISO date via the task detail + * panel's "Hide until" drill-down row. Assumes the task detail panel is + * already open (see `openTaskDetail`). + * Implements FR2 of fix-stale-sync-overwrites. + */ +export async function hideTaskUntil( + page: Page, + appearDateIso: string, +): Promise { + await page + .getByTestId("task-detail-panel") + .getByRole("button", { name: /hide until/i }) + .click(); + const hidePanel = page.getByTestId("hide-task-panel"); + await hidePanel.waitFor({ state: "visible" }); + await hidePanel.getByLabel(/select date/i).fill(appearDateIso); + await hidePanel.getByRole("button", { name: /^hide$/i }).click(); + await hidePanel.waitFor({ state: "hidden" }); +} + +/** + * Manually unhides a task before its appear_date via the task detail panel's + * "Hide until" drill-down row, which shows the unhide control while the task + * is hidden. Assumes the task detail panel is already open. + * Implements FR2 of fix-stale-sync-overwrites. + */ +export async function unhideTask(page: Page): Promise { + await page + .getByTestId("task-detail-panel") + .getByRole("button", { name: /hide until/i }) + .click(); + const hidePanel = page.getByTestId("hide-task-panel"); + await hidePanel.waitFor({ state: "visible" }); + await hidePanel.getByRole("button", { name: /unhide/i }).click(); + await hidePanel.waitFor({ state: "hidden" }); +} + export async function deleteTaskFromDetail(page: Page): Promise { await page .getByTestId("task-detail-panel") diff --git a/packages/integration/src/test-helpers.ts b/packages/integration/src/test-helpers.ts index 58e69ada..ae7a27ab 100644 --- a/packages/integration/src/test-helpers.ts +++ b/packages/integration/src/test-helpers.ts @@ -1,6 +1,12 @@ // implements FR6 of add-supabase-integration-tests // Barrel re-export — actual code lives in focused modules. +export type { ConvergenceEntityKey } from "./convergence-helpers.js"; +export { + assertConverged, + dumpDeviceState, + dumpServerState, +} from "./convergence-helpers.js"; export { createIsolatedUser } from "./cross-tenant-helpers.js"; export { buildCategoryPayload, diff --git a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts new file mode 100644 index 00000000..f6ad4af2 --- /dev/null +++ b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts @@ -0,0 +1,319 @@ +// implements U3, FR4 of fix-stale-sync-overwrites +import { randomUUID } from "node:crypto"; +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { + findTaskItem, + openTaskDetail, + updateTaskDescription, +} from "../page-actions.js"; +import { + assertConverged, + pullFromServer, + pushToServer, + setupTwoDeviceTest, + triggerSyncAndWait, +} from "../test-helpers.js"; + +const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest(); + +// Mirrors SORT_ORDER_REBALANCE_THRESHOLD in packages/client/src/constants/index.ts +// (integration tests never import client internals — see module boundary rule). +const SORT_ORDER_REBALANCE_THRESHOLD = 10; + +const TASK_VISIBLE_TIMEOUT_MS = 15_000; +const DRAG_STEPS = 12; +const DRAG_SETTLE_MS = 300; + +interface RebalancePullResponse { + ok: boolean; + tasks: Array<{ + id: string; + name: string; + description: string; + box: string; + sort_order: string; + is_deleted: boolean; + updated_at: string; + }>; + current_revision: number; +} + +/** + * Builds a full push payload carrying a single "inbox" task with an explicit + * `sort_order`, so tests can pre-seed keys near/at the rebalance threshold + * without going through the app's own key-generation logic. + */ +function buildInboxTaskPayload(options: { + id: string; + name: string; + sortOrder: string; + updatedAt: string; +}): Record { + return { + tasks: [ + { + id: options.id, + name: options.name, + description: "", + box: "inbox", + is_completed: false, + is_deleted: false, + completed_at: "", + next_date: "", + appear_date: "", + context_id: "", + category_id: "", + goal_id: "", + repeat_rule: "", + is_hidden: false, + original_task_id: "", + sort_order: options.sortOrder, + created_at: options.updatedAt, + updated_at: options.updatedAt, + version: 1, + revision: 0, + }, + ], + goals: [], + ideas: [], + contexts: [], + categories: [], + checklist_items: [], + attachments: [], + settings: [], + }; +} + +/** + * Drags a task item (located by its drag handle) vertically onto another + * task item's position, using low-level mouse events so dnd-kit's + * PointerSensor (activation distance 8px) reliably picks up the gesture. + */ +async function dragTaskOnto( + page: Page, + sourceTaskName: string, + targetTaskName: string, +): Promise { + const sourceHandle = findTaskItem(page, sourceTaskName).getByRole("button", { + name: /drag/i, + }); + const targetItem = findTaskItem(page, targetTaskName); + + const sourceBox = await sourceHandle.boundingBox(); + const targetBox = await targetItem.boundingBox(); + if (!sourceBox || !targetBox) { + throw new Error("dragTaskOnto: could not resolve bounding boxes"); + } + + const startX = sourceBox.x + sourceBox.width / 2; + const startY = sourceBox.y + sourceBox.height / 2; + const endX = targetBox.x + targetBox.width / 2; + const endY = targetBox.y + targetBox.height / 2; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + for (let step = 1; step <= DRAG_STEPS; step++) { + const progress = step / DRAG_STEPS; + await page.mouse.move( + startX + (endX - startX) * progress, + startY + (endY - startY) * progress, + ); + } + await page.mouse.up(); + await page.waitForTimeout(DRAG_SETTLE_MS); +} + +/** + * U3 / FR4 / NFR-REL1 — a rebalance triggered on a stale device does not + * clobber content that a different device edited more recently. + * + * Mechanism: `TaskService.rebalanceBox()` (packages/client/src/services/TaskService.ts) + * fires whenever a drag's freshly-generated `sort_order` key exceeds + * SORT_ORDER_REBALANCE_THRESHOLD (10) characters — regardless of how many + * tasks are in the box. `fractional-indexing`'s `generateKeyBetween` grows a + * key by roughly one character whenever it inserts strictly between two + * keys that already differ only in their last character, so seeding two + * adjacent 10-char keys and dragging a third task to land exactly between + * them reliably produces an 11-char key in a single real drag — a genuine + * `rebalanceBox` invocation via the actual UI code path, not a mocked one. + * + * Scenario: + * 1. Seed the inbox box directly on the server (bypassing the UI, since + * crafting exact colliding sort keys via real drags would need many + * sequential drags): a "dragged" task at the top (highest key), task X + * in the middle (the one A will edit), and two adjacent anchor tasks + * with 10-char keys one character apart at the bottom. + * 2. Both devices sync to a converged baseline including all four tasks. + * 3. Device B goes stale — no more syncing until step 6. + * 4. Device A edits task X's description and pushes — X gets a strictly + * newer `updated_at` on the server. + * 5. Device B (still unaware of A's edit) drags "dragged" down onto + * "anchorHigh"'s position — anchorHigh shifts down to sit between + * "dragged" and "anchorLow", so "dragged"'s new key is generated + * strictly between anchorLow and anchorHigh. The resulting >10-char key + * triggers `rebalanceBox` for the whole box: X and both anchors are marked + * `pending` locally on B WITHOUT their `updated_at` being bumped (FR4); + * only "dragged" (the task the user actually moved) gets a fresh + * `updated_at`. + * 6. Device B syncs (pull then push). FR5's LWW pull protection must let + * A's genuinely newer server copy of X win over B's stale-but-`pending` + * local copy — B's rebalance-caused `pending` flag must not block it. + * The anchors and "dragged", which nobody else edited, keep their + * rebalanced `sort_order` (rebalance's own effect survives). + * 7. Convergence: device A, device B, and the server agree (NFR-REL1). + */ +test("Rebalance on a stale device does not clobber a task edited more recently elsewhere", async () => { + const pageA = getPageA(); + const pageB = getPageB(); + const credentials = getCredentials(); + + const suffix = Date.now(); + const draggedName = `Rebalance Dragged ${suffix}`; + const taskXName = `Rebalance TaskX ${suffix}`; + const anchorHighName = `Rebalance AnchorHigh ${suffix}`; + const anchorLowName = `Rebalance AnchorLow ${suffix}`; + const freshDescription = `Freshest description from A ${suffix}`; + + const draggedId = randomUUID(); + const taskXId = randomUUID(); + const anchorHighId = randomUUID(); + const anchorLowId = randomUUID(); + + const staleTimestamp = "2020-01-01T00:00:00.000Z"; + // 10-char anchor keys, one character apart at the last position: inserting + // strictly between them via generateKeyBetween yields an 11-char key. + const anchorLowKey = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD); + const anchorHighKey = `${"a".repeat(SORT_ORDER_REBALANCE_THRESHOLD - 1)}b`; + // DESC order (highest key first): dragged ("z") > taskX ("m") > anchorHigh > anchorLow. + const draggedKey = "z"; + const taskXKey = "m"; + + const baseline = await pullFromServer(credentials); + const baselineRevision = baseline.current_revision; + + // --- 1. Seed the box directly on the server --- + await pushToServer( + credentials, + buildInboxTaskPayload({ + id: draggedId, + name: draggedName, + sortOrder: draggedKey, + updatedAt: staleTimestamp, + }), + ); + await pushToServer( + credentials, + buildInboxTaskPayload({ + id: taskXId, + name: taskXName, + sortOrder: taskXKey, + updatedAt: staleTimestamp, + }), + ); + await pushToServer( + credentials, + buildInboxTaskPayload({ + id: anchorHighId, + name: anchorHighName, + sortOrder: anchorHighKey, + updatedAt: staleTimestamp, + }), + ); + await pushToServer( + credentials, + buildInboxTaskPayload({ + id: anchorLowId, + name: anchorLowName, + sortOrder: anchorLowKey, + updatedAt: staleTimestamp, + }), + ); + + // --- 2. Both devices sync to a converged baseline --- + await pageA.goto("/tasks"); + await pageA.waitForSelector('[data-testid="active-tasks-page"]'); + await triggerSyncAndWait(pageA); + for (const name of [draggedName, taskXName, anchorHighName, anchorLowName]) { + await findTaskItem(pageA, name).waitFor({ + state: "visible", + timeout: TASK_VISIBLE_TIMEOUT_MS, + }); + } + + await pageB.goto("/tasks"); + await pageB.waitForSelector('[data-testid="active-tasks-page"]'); + await triggerSyncAndWait(pageB); + for (const name of [draggedName, taskXName, anchorHighName, anchorLowName]) { + await findTaskItem(pageB, name).waitFor({ + state: "visible", + timeout: TASK_VISIBLE_TIMEOUT_MS, + }); + } + + // --- 3. Device B goes stale from here — no more syncing until step 6 --- + + // --- 4. Device A edits task X's description and pushes --- + await openTaskDetail(pageA, taskXName); + await updateTaskDescription(pageA, freshDescription); + await triggerSyncAndWait(pageA); + + const { tasks: tasksAfterEdit } = await pullFromServer( + credentials, + baselineRevision, + ); + const taskXAfterEdit = tasksAfterEdit.find( + (task) => task.id === taskXId && !task.is_deleted, + ); + expect(taskXAfterEdit).toBeDefined(); + expect(taskXAfterEdit?.description).toBe(freshDescription); + expect(taskXAfterEdit?.updated_at).not.toBe(staleTimestamp); + + // --- 5. Device B (still stale) drags "dragged" onto anchorHigh's + // position, landing it strictly between the two anchor keys and + // triggering rebalanceBox for the whole box --- + await dragTaskOnto(pageB, draggedName, anchorHighName); + + // --- 6. Device B syncs — FR5 LWW pull protection must let A's newer + // task X win over B's stale-but-pending rebalanced copy --- + await triggerSyncAndWait(pageB); + + // --- 7. Convergence: A, B, and the server agree (NFR-REL1) --- + await assertConverged(pageA, pageB, credentials, "tasks"); + + const { tasks: finalTasks } = await pullFromServer( + credentials, + baselineRevision, + ); + const finalTaskX = finalTasks.find( + (task) => task.id === taskXId && !task.is_deleted, + ); + const finalAnchorHigh = finalTasks.find( + (task) => task.id === anchorHighId && !task.is_deleted, + ); + const finalAnchorLow = finalTasks.find( + (task) => task.id === anchorLowId && !task.is_deleted, + ); + const finalDragged = finalTasks.find( + (task) => task.id === draggedId && !task.is_deleted, + ); + + // A's newer content survives — B's rebalance-caused pending flag on task X + // did not block the pull from applying A's genuinely newer server copy. + expect(finalTaskX?.description).toBe(freshDescription); + expect(finalTaskX?.updated_at).not.toBe(staleTimestamp); + + // The other rebalanced tasks (untouched by anyone but B's rebalance) keep + // freshly-generated, non-colliding sort keys — rebalance's own effect is + // not lost. All four keys must be distinct after rebalancing. + const allFinalKeys = [ + finalDragged?.sort_order, + finalTaskX?.sort_order, + finalAnchorHigh?.sort_order, + finalAnchorLow?.sort_order, + ]; + expect( + allFinalKeys.every((key) => typeof key === "string" && key.length > 0), + ).toBe(true); + expect(new Set(allFinalKeys).size).toBe(allFinalKeys.length); +}); From eb3c5e4c92a8c6c6a1cbc4be134370dfc0771bd4 Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:38:27 +0500 Subject: [PATCH 4/8] implement fix-stale-sync-overwrites OpenSpec --- mutation-coverage-gap-analysis.md | 75 ++ .../fix-stale-sync-overwrites/design.md | 16 +- .../fix-stale-sync-overwrites/proposal.md | 6 +- .../specs/repeating-tasks/spec.md | 2 +- .../fix-stale-sync-overwrites/tasks.md | 44 +- .../src/db/repositories/ContextRepository.ts | 20 +- ...ervice.reveal-preserves-updated-at.test.ts | 144 ++++ .../src/services/HiddenTaskService.test.ts | 128 ---- .../RecurringTaskDeduplicator.guards.test.ts | 158 ++++ ...TaskDeduplicator.loser-soft-delete.test.ts | 105 +++ ...ator.merge.identity-and-updated-at.test.ts | 114 +++ ...licator.merge.pending-when-changed.test.ts | 151 ++++ ...eduplicator.merge.schedule-and-box.test.ts | 158 ++++ .../RecurringTaskDeduplicator.merge.ts | 6 +- ...ngTaskDeduplicator.multiple-groups.test.ts | 134 ++++ ...TaskDeduplicator.skip-optimization.test.ts | 118 +++ .../RecurringTaskDeduplicator.test-setup.ts | 60 ++ .../RecurringTaskDeduplicator.test.ts | 692 ------------------ ...ingTaskDeduplicator.tiebreak-by-id.test.ts | 67 ++ ...gTaskDeduplicator.winner-selection.test.ts | 184 +++++ packages/integration/docker-compose.yml | 7 +- packages/integration/kong.yml | 4 + .../integration/src/convergence-helpers.ts | 16 +- packages/integration/src/page-actions.ts | 272 ------- .../src/page-attachment-actions.ts | 27 + .../integration/src/page-checklist-actions.ts | 26 + .../src/page-goal-category-actions.ts | 115 +++ packages/integration/src/page-task-actions.ts | 159 ++++ .../src/tests/attachments-offline.spec.ts | 2 +- .../tests/attachments-purge-advanced.spec.ts | 9 +- .../tests/attachments-ref-counting.spec.ts | 9 +- .../src/tests/attachments-sync.spec.ts | 2 +- .../tests/cascade-checklist-delete.spec.ts | 6 +- .../src/tests/categories-sync.spec.ts | 2 +- .../src/tests/checklists-sync.spec.ts | 7 +- .../integration/src/tests/covers-sync.spec.ts | 2 +- .../tests/file-download-cross-device.spec.ts | 2 +- .../src/tests/file-mime-detection.spec.ts | 7 +- .../integration/src/tests/full-flow.spec.ts | 12 +- .../integration/src/tests/goals-sync.spec.ts | 2 +- .../multi-device-dirty-protection.spec.ts | 6 +- .../multi-device-stale-rebalance.spec.ts | 242 ++---- .../src/tests/multi-device-sync.spec.ts | 8 +- .../src/tests/push-poison-pill-fk.spec.ts | 5 +- .../src/tests/push-rejected-indicator.spec.ts | 2 +- .../src/tests/stale-rebalance-helpers.ts | 61 ++ .../src/tests/sync-indicator-race.spec.ts | 5 +- .../integration/src/tests/tasks-sync.spec.ts | 2 +- 48 files changed, 2056 insertions(+), 1345 deletions(-) create mode 100644 mutation-coverage-gap-analysis.md create mode 100644 packages/client/src/services/HiddenTaskService.reveal-preserves-updated-at.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.guards.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.loser-soft-delete.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.identity-and-updated-at.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.pending-when-changed.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.multiple-groups.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.skip-optimization.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts delete mode 100644 packages/client/src/services/RecurringTaskDeduplicator.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.tiebreak-by-id.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.winner-selection.test.ts delete mode 100644 packages/integration/src/page-actions.ts create mode 100644 packages/integration/src/page-attachment-actions.ts create mode 100644 packages/integration/src/page-checklist-actions.ts create mode 100644 packages/integration/src/page-goal-category-actions.ts create mode 100644 packages/integration/src/page-task-actions.ts create mode 100644 packages/integration/src/tests/stale-rebalance-helpers.ts diff --git a/mutation-coverage-gap-analysis.md b/mutation-coverage-gap-analysis.md new file mode 100644 index 00000000..51340b10 --- /dev/null +++ b/mutation-coverage-gap-analysis.md @@ -0,0 +1,75 @@ +# Mutation coverage gap analysis — task 6.2 (fix-stale-sync-overwrites) + +Scope: `TaskRepository.ts`, `GoalRepository.ts`, `ContextRepository.ts`, `CategoryRepository.ts`. + +## Summary + +Stryker's whole-file mutation score for these 4 files (53–56% for Goal/Context/Category, 94% for Task) +is below the M3 threshold (>=95%, min 90%). All surviving/uncovered mutants sit in code this change +did **not** touch. The code this change actually modified — the FR5 LWW wiring inside +`applyServerRecords()` (via `shouldOverwritePendingLocalRecord` from `applyServerRecordLww.ts`) — has +a 100% mutant kill rate in all 4 files. No mutant inside the diff introduced by this change survived +or went uncovered. + +## Uncovered functionality, by file + +### GoalRepository.ts / ContextRepository.ts / CategoryRepository.ts (same shape in all three) + +1. **`create()` schema-validation branch** (Goal L22-29, Context L22-29, Category L22-29) + - Survived mutant: `if (!result.success)` → `if (false)` at line 24. + - Meaning: no test ever calls `create()` with data that fails `Client*Schema.safeParse`. The + error-logging (`console.error(...)`) and `throw new Error(...)` branch is never exercised, so a + mutant that deletes the branch condition survives. + +2. **`update()` schema-validation branch** (Goal L31-38, Context L31-38, Category L31-38) + - NoCoverage on the whole `if` block, both string literals (`""`,` `` `). + - Same gap as `create()`, but for `update()`. + +3. **`bulkUpsert()` per-item schema-validation branch** (Goal L40-49, Context L40-49, Category L40-49) + - NoCoverage on the `if (!result.success)` block inside the loop, including the `result.success` + boolean literal and both `ConditionalExpression` mutants (true/false). + - Meaning: no test calls `bulkUpsert()` with an item that fails validation; the loop's early + `throw` for a bad item is never reached. + +4. **`getChangedSince()`** (Goal L51-53, Context L51-53, Category L51-53) + - NoCoverage on the whole method body and its string literal. + - Meaning: no test calls `getChangedSince()` at all for these three repositories. + +5. **`getNeedingSync()` filter predicate** (Goal L55-57, Context L55-59, Category L55-59) + - NoCoverage on the whole filter arrow function, the `MethodExpression` (`db.goals`/`db.contexts`/ + `db.categories`), the `EqualityOperator` (`syncStatus === "pending"` → `!== "pending"`), and both + `ConditionalExpression` mutants. + - Meaning: no test calls `getNeedingSync()` for these three repositories, so the pending-status + filter logic is unverified. + +### TaskRepository.ts + +1. **`getByGoalId()` visibility filter** (L35-47) + - Survived mutant: `options?.includeHidden || !task.is_hidden` → `true` at line 44 (the whole + filter condition replaced by a constant `true`). + - Meaning: existing tests call `getByGoalId()` but never assert that a *hidden* task is excluded + when `includeHidden` is falsy/omitted — so a mutant that makes the filter always pass survives. + Need a case where a hidden task exists and `includeHidden` is not set (or `false`), asserting it + is excluded from the result, alongside a case where `includeHidden: true` returns it. + +2. **Delete/soft-delete style validation branches** (L79-101, three near-identical blocks) + - NoCoverage on three `BlockStatement`s plus their string literals — same shape as the + create/update/bulkUpsert validation gaps above (this file has 3 write-path methods with + validation instead of 3 repositories × 1 pattern each). + +## Why this is out of this change's scope + +Every one of the above gaps pre-dates `fix-stale-sync-overwrites`: `create`, `update`, `bulkUpsert`, +`getChangedSince`, `getNeedingSync`, and `getByGoalId` are unmodified by this change (confirmed via +`git diff main` for each file — the diff is confined to the new import, the `*_ENTITY_NAME` constant, +and the body of `applyServerRecords()`). FR5 and M3 (proposal.md) scope mutation testing to the +behavior this change introduces; closing these gaps would mean writing schema-validation and +query-filter characterization tests unrelated to LWW pull protection. + +## Disposition + +Per user decision: analysis recorded here; task 6.2 is considered satisfied on a diff-scoped basis +(100% mutant kill rate within the actual change). No new tests were added for the pre-existing gaps +listed above. If closing them is wanted later, it should be a separate, explicitly-scoped change +(new tests for `create`/`update`/`bulkUpsert` validation branches, `getChangedSince`, `getNeedingSync`, +and `getByGoalId`'s hidden-task filter across these 4 repositories). diff --git a/openspec/changes/fix-stale-sync-overwrites/design.md b/openspec/changes/fix-stale-sync-overwrites/design.md index 2fd80b9f..2d87797d 100644 --- a/openspec/changes/fix-stale-sync-overwrites/design.md +++ b/openspec/changes/fix-stale-sync-overwrites/design.md @@ -48,14 +48,14 @@ Consequence accepted: when a rebalanced record loses push LWW to a newer server Winner selection is unchanged (earliest `next_date`, tiebreak by `id`). The winner record is then merged: -| Fields | Source copy | -|-------------------------------------------------------------------|--------------------------------------------------| -| `next_date`, `appear_date`, `is_hidden` (always as a triple) | earliest `next_date` | -| `name`, `description`, `goal_id`, `context_id`, `category_id` | freshest `updated_at` | -| `box`, `sort_order` (as a pair — sort keys are per-box grids) | freshest `updated_at` | -| `id`, `created_at`, `revision` (identity/bookkeeping) | winner's own | -| `updated_at` | freshest copy's value — **not** refreshed to now | -| `repeat_rule` differs between copies | freshest copy wins **wholesale**, dates included | +| Fields | Source copy | +|--------------------------------------------------------------------------------------|--------------------------------------------------| +| `next_date`, `appear_date`, `is_hidden` (always as a triple) | earliest `next_date` | +| `name`, `description`, `goal_id`, `context_id`, `category_id` | freshest `updated_at` | +| `box`, `sort_order` (as a pair — sort keys are per-box grids) | freshest `updated_at` | +| `id`, `created_at`, `revision` (identity/bookkeeping) | winner's own | +| `updated_at` | freshest copy's value — **not** refreshed to now | +| `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy | freshest copy wins **wholesale**, dates included | `is_hidden` travels with the schedule triple because it is derived from that copy's `appear_date` (hidden iff not yet due); pairing it with another copy's dates would break reveal timing. `sort_order` travels with `box` because sort keys only order within a box. Completion fields need no rule: every copy in a duplicate group is non-completed by the group filter. diff --git a/openspec/changes/fix-stale-sync-overwrites/proposal.md b/openspec/changes/fix-stale-sync-overwrites/proposal.md index 4a2429b4..ccee006e 100644 --- a/openspec/changes/fix-stale-sync-overwrites/proposal.md +++ b/openspec/changes/fix-stale-sync-overwrites/proposal.md @@ -8,7 +8,7 @@ A multi-device user loses data on recurring tasks: a device that was offline for - **MODIFIED**: Auto-reveal of hidden tasks (`HiddenTaskService`) marks records `pending` but no longer refreshes `updated_at` — the timestamp stays at the last real user edit. - **MODIFIED**: Sort-order rebalancing (`TaskService.rebalanceBox`) marks rebalanced records `pending` but no longer refreshes their `updated_at`; only the task the user actually dragged gets a fresh timestamp. -- **MODIFIED**: Recurring-copy deduplication after pull merges instead of blindly keeping the earliest copy: schedule fields (`next_date` + `appear_date`, always as a pair) come from the copy with the earliest `next_date`, content fields from the copy with the freshest `updated_at`; if `repeat_rule` differs between copies, the freshest copy wins wholesale. +- **MODIFIED**: Recurring-copy deduplication after pull merges instead of blindly keeping the earliest copy: schedule fields (`next_date` + `appear_date`, always as a pair) come from the copy with the earliest `next_date`, content fields from the copy with the freshest `updated_at`; if `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy, the freshest copy wins wholesale. - **MODIFIED**: Pull protection becomes LWW-consistent: a server record overwrites a local `pending` record when the server's `updated_at` is strictly newer; ties and older server records preserve the local record. Conflicts are logged. - **UNCHANGED (guarded by tests)**: Manual hide and manual unhide-before-appear-date remain real user edits — they refresh `updated_at` and sync to other devices. They apply to non-recurring tasks only (recurring tasks expose no manual hide/unhide controls per `manual-task-hiding`); hidden recurring copies are governed solely by auto-reveal (FR1). - Specs gain mermaid sequence diagrams for every multi-device sync scenario covered by this change. @@ -22,7 +22,7 @@ A multi-device user loses data on recurring tasks: a device that was offline for ### Modified Capabilities - `sync-protocol`: "Pull protects local dirty records" changes from status-based (never overwrite `pending`) to LWW-based (overwrite `pending` only when server `updated_at` is strictly newer). -- `repeating-tasks`: "System reveals hidden tasks when appear date arrives" no longer refreshes `updated_at`; "System deduplicates recurring copies after pull" changes from keep-earliest-copy to merge (earliest schedule pair + the freshest content, freshest-wholesale on `repeat_rule` mismatch). +- `repeating-tasks`: "System reveals hidden tasks when appear date arrives" no longer refreshes `updated_at`; "System deduplicates recurring copies after pull" changes from keep-earliest-copy to merge (earliest schedule pair + the freshest content, freshest-wholesale when `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy). - `manual-task-hiding`: "Hidden tasks auto-reveal on appear date" no longer refreshes `updated_at`; new requirement that manual unhide before the appear date is a synced user edit visible on other devices. - `tasks`: "Lazy rebalancing when key exceeds threshold" — rebalanced tasks are marked `pending` without an `updated_at` refresh. @@ -52,7 +52,7 @@ A multi-device user loses data on recurring tasks: a device that was offline for - FR1: `HiddenTaskService.revealHiddenTasks()` SHALL set `syncStatus: "pending"` and `is_hidden: false` without modifying `updated_at`. If the record is already `pending`, its state is not degraded. - FR2: Manual hide and manual unhide (non-recurring tasks — the only kind with manual hide/unhide controls) SHALL remain regular user updates: refresh `updated_at`, set `pending`, clear `appear_date` on unhide — and SHALL propagate to other devices via ordinary push/pull. -- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: the winner record keeps `next_date`, `appear_date`, and `is_hidden` (always as a schedule triple — `is_hidden` is derived from `appear_date`) from the copy with the earliest `next_date` (tiebreak by `id`); content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys are per-box) from the copy with the freshest `updated_at`; identity fields (`id`, `created_at`, `revision`) stay the winner's own. When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now), and the winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it. +- FR3: `RecurringTaskDeduplicator` SHALL merge duplicate groups: the winner record keeps `next_date`, `appear_date`, and `is_hidden` (always as a schedule triple — `is_hidden` is derived from `appear_date`) from the copy with the earliest `next_date` (tiebreak by `id`); content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys are per-box) from the copy with the freshest `updated_at`; identity fields (`id`, `created_at`, `revision`) stay the winner's own. When `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy, the copy with the freshest `updated_at` SHALL win wholesale (all fields including dates). Losers are soft-deleted as today. The merged winner's `updated_at` SHALL be the freshest copy's `updated_at` (not refreshed to now), and the winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it. - FR4: `TaskService.rebalanceBox()` SHALL assign new `sort_order` keys and `syncStatus: "pending"` without modifying `updated_at`. `reorderTasks()` SHALL keep refreshing `updated_at` only for the dragged task. - FR5: `applyServerRecords()` in every entity repository SHALL overwrite a local `pending` record when the server record's `updated_at` is strictly newer than the local one, setting `syncStatus: "synced"`. When local `updated_at` is equal or newer, the local record SHALL be preserved. Each overwrite of a `pending` record SHALL be logged with entity type, id, and both timestamps. - FR6: Behavior of both recurring models SHALL be preserved: `after_completion` next date derives only from the new `completed_at`; `fixed` keeps early-completion preservation and skip logic. (Regression guard — no recurrence math changes.) diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md index 1e9433d7..928f4399 100644 --- a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md +++ b/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md @@ -46,7 +46,7 @@ The winner SHALL be merged, not kept verbatim: - Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys only order within a box) SHALL be taken from the copy with the freshest `updated_at`. - Identity fields (`id`, `created_at`, `revision`) SHALL stay the winner's own. Completion fields need no merge rule: every copy in a duplicate group is non-completed by the group filter. - The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time. The winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it; an already-optimal winner is not rewritten. -- When `repeat_rule` differs between copies, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule. +- When `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule. Merge semantics apply identically to both recurring models (`fixed` and `after_completion`); the earliest-`next_date` winner rule for schedules is unchanged, and for `after_completion` any suboptimal winner self-corrects at the next completion because its next date derives only from the new `completed_at`. diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/fix-stale-sync-overwrites/tasks.md index fa01c70a..676a26e7 100644 --- a/openspec/changes/fix-stale-sync-overwrites/tasks.md +++ b/openspec/changes/fix-stale-sync-overwrites/tasks.md @@ -58,24 +58,36 @@ Impact analysis for 7.1-7.4: `findFreshestUpdatedAtCopy` is reachable only via `mergeWinner` → `RecurringTaskDeduplicator.deduplicate` → `SyncService` pull path (`SyncService.ts:178`) — no other feature consumes it. For in-format timestamps (`toISOTimestamp` always emits fixed-precision `...000Z`) lexicographic and instant order coincide, so the swap is behavior-preserving and all existing FR3 tests must stay green unchanged. Divergence exists only for out-of-format data: same instant in different representations (localeCompare gives an arbitrary winner, `Temporal.Instant.compare` gives a correct tie → first copy in group order wins) and malformed `updated_at` (`Temporal.Instant.from` throws instead of silently misordering — same exposure the FR5 LWW helper already has in the same pull path). `sortByWinnerPriority` keeps `localeCompare` intentionally: `next_date` is date-only and `id` is not a timestamp — out of scope per the Temporal rule. -- [ ] 7.1 Characterization test (pre-refactor guard): two copies with an equal `updated_at` — the first copy in group order stays the freshest-content source (tie semantics of the `reduce` documented before touching it) (FR3) -- [ ] 7.2 REFACTOR: `findFreshestUpdatedAtCopy` compares via `Temporal.Instant.compare` (import from `@/lib/temporal`) instead of `String.prototype.localeCompare`, preserving tie semantics (keep `freshest` when compare <= 0) (FR3, aligns with design D4 convention) -- [ ] 7.3 Scoped test run (one command, foreground): `RecurringTaskDeduplicator.merge.test.ts`, `RecurringTaskDeduplicator.test.ts`, `dedup_merge.steps.ts` — all green with no test edits beyond 7.1 (FR3) -- [ ] 7.4 Scoped mutation run on `src/services/RecurringTaskDeduplicator.merge.ts` (wait for completion; no concurrent runs): diff-scoped kill rate stays 100%, whole-file score >= 95% (M3) -- [ ] 7.5 Traceability: add `NFR-REL1` (and `M1` where the scenario implements it) to the `// implements ... of fix-stale-sync-overwrites` header comment of all four integration specs (`multi-device-stale-recurring-fixed`, `multi-device-stale-recurring-after-completion`, `multi-device-manual-unhide`, `multi-device-stale-rebalance`) (NFR-REL1, M1) -- [ ] 7.6 Traceability: add `// Verifies M2 of fix-stale-sync-overwrites` comments to the unit tests asserting `updated_at` is untouched by system mutations: reveal (`HiddenTaskService.test.ts`), rebalance (`TaskService.reorder.test.ts`), dedup merge (`RecurringTaskDeduplicator.merge.test.ts`) (M2) -- [ ] 7.7 Verification: traceability grep confirms NFR-REL1/M1/M2 references resolve; `get_file_problems` on touched files + `pnpm run build` -- [ ] 7.8 Spec wording: align the wholesale clause with the implemented (and correct) behavior — replace "when `repeat_rule` differs between copies" with "when `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy" in `proposal.md` (What Changes bullet, Modified Capabilities bullet, FR3), `design.md` (D3 table row), and the delta spec `specs/repeating-tasks/spec.md` (wholesale bullet). Rationale stays: only those two copies' fields ever mix, so only their rule mismatch makes a merge incoherent; a differing rule on a stale loser contributes no fields and must NOT trigger wholesale (FR3) -- [ ] 7.9 Pinning test for the 3-copy edge: schedule winner and freshest copy share `repeat_rule`, a stale loser carries a different one — normal merge applies (schedule triple from the winner + content from the freshest), NOT wholesale; loser is soft-deleted as usual (FR3) +- [x] 7.1 Characterization test (pre-refactor guard): two copies with an equal `updated_at` — the first copy in group order stays the freshest-content source (tie semantics of the `reduce` documented before touching it) (FR3) +- [x] 7.2 REFACTOR: `findFreshestUpdatedAtCopy` compares via `Temporal.Instant.compare` (import from `@/lib/temporal`) instead of `String.prototype.localeCompare`, preserving tie semantics (keep `freshest` when compare <= 0) (FR3, aligns with design D4 convention) +- [x] 7.3 Scoped test run (one command, foreground): `RecurringTaskDeduplicator.merge.test.ts`, `RecurringTaskDeduplicator.test.ts`, `dedup_merge.steps.ts` — all green with no test edits beyond 7.1 (FR3) +- [x] 7.4 Scoped mutation run on `src/services/RecurringTaskDeduplicator.merge.ts` (wait for completion; no concurrent runs): diff-scoped kill rate stays 100%, whole-file score >= 95% (M3) +- [x] 7.5 Traceability: add `NFR-REL1` (and `M1` where the scenario implements it) to the `// implements ... of fix-stale-sync-overwrites` header comment of all four integration specs (`multi-device-stale-recurring-fixed`, `multi-device-stale-recurring-after-completion`, `multi-device-manual-unhide`, `multi-device-stale-rebalance`) (NFR-REL1, M1) +- [x] 7.6 Traceability: add `// Verifies M2 of fix-stale-sync-overwrites` comments to the unit tests asserting `updated_at` is untouched by system mutations: reveal (`HiddenTaskService.test.ts`), rebalance (`TaskService.reorder.test.ts`), dedup merge (`RecurringTaskDeduplicator.merge.test.ts`) (M2) +- [x] 7.7 Verification: traceability grep confirms NFR-REL1/M1/M2 references resolve; `get_file_problems` on touched files + `pnpm run build` +- [x] 7.8 Spec wording: align the wholesale clause with the implemented (and correct) behavior — replace "when `repeat_rule` differs between copies" with "when `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy" in `proposal.md` (What Changes bullet, Modified Capabilities bullet, FR3), `design.md` (D3 table row), and the delta spec `specs/repeating-tasks/spec.md` (wholesale bullet). Rationale stays: only those two copies' fields ever mix, so only their rule mismatch makes a merge incoherent; a differing rule on a stale loser contributes no fields and must NOT trigger wholesale (FR3) +- [x] 7.9 Pinning test for the 3-copy edge: schedule winner and freshest copy share `repeat_rule`, a stale loser carries a different one — normal merge applies (schedule triple from the winner + content from the freshest), NOT wholesale; loser is soft-deleted as usual (FR3) ## 8. Review fixes: file-size invariant for test files (process-invariants: 100-200 target, 300 hard cap) Behavior-preserving reorganization — no test logic changes, no TDD cycle (refactoring exemption). Split boundaries follow the existing `describe` blocks; use the `split-large-tests` skill for 8.1-8.3. Shared builders/seed helpers go into a `RecurringTaskDeduplicator.test-setup.ts` module (established repo pattern: `*.test-setup.ts` next to repository tests). New files keep the `.test.ts` suffix so the vitest glob picks them up. BDD `.steps.ts` files are all within limits (max 282) — not in scope. -- [ ] 8.1 Split `RecurringTaskDeduplicator.merge.test.ts` (598) into ~3 files by merge aspect, each <= 200: content fields from freshest copy (name/description/goal/context/category + equal-`updated_at` tie), schedule triple + box/sort_order pair coupling, identity fields + winner `updated_at` + wholesale `repeat_rule` + pending-only-when-changed; extract shared task builders into the test-setup module (FR3 coverage unchanged) -- [ ] 8.2 Split `RecurringTaskDeduplicator.test.ts` (692) into ~3 files by concern, each <= 200: winner selection + tiebreak + filtering; loser soft-delete + checklist cascade + multiple/three-plus groups; skip optimization + guard describes; reuse the same test-setup module (FR3 characterization coverage unchanged) -- [ ] 8.3 Split `HiddenTaskService.test.ts` (300): move the `revealHiddenTasks preserving updated_at` describe (FR1 block) into a sibling `HiddenTaskService.reveal-preserves-updated-at.test.ts`; both files end up <= 200 (FR1 coverage unchanged) -- [ ] 8.4 Slim `multi-device-stale-rebalance.spec.ts` (319): move `dragTaskOnto` into `page-actions.ts` and `buildInboxTaskPayload` into a shared integration helper module; the spec keeps only the U3 scenario and lands <= 200 (U3/FR4/NFR-REL1 coverage unchanged) -- [ ] 8.5 Verification: scoped vitest run (one command, foreground) over all files produced by 8.1-8.3 — green, and the total test count of the split groups equals the pre-split count (no scenario silently dropped) -- [ ] 8.6 Verification: scoped mutation run on `src/services/RecurringTaskDeduplicator.ts,src/services/RecurringTaskDeduplicator.merge.ts,src/services/HiddenTaskService.ts` (wait for completion) — scores unchanged, confirming Stryker still discovers the renamed test files (M3) -- [ ] 8.7 Verification: `wc -l` confirms every touched test file <= 200 (hard cap 300 nowhere needed); `get_file_problems` on new/changed files + `pnpm run build` +- [x] 8.1 Split `RecurringTaskDeduplicator.merge.test.ts` (598) into ~3 files by merge aspect, each <= 200: content fields from the freshest copy (name/description/goal/context/category + equal-`updated_at` tie), schedule triple + box/sort_order pair coupling, identity fields + winner `updated_at` + wholesale `repeat_rule` + pending-only-when-changed; extract shared task builders into the test-setup module (FR3 coverage unchanged) +- [x] 8.2 Split `RecurringTaskDeduplicator.test.ts` (692) into ~3 files by concern, each <= 200: winner selection + tiebreak + filtering; loser soft-delete + checklist cascade + multiple/three-plus groups; skip optimization + guard describes; reuse the same test-setup module (FR3 characterization coverage unchanged) +- [x] 8.3 Split `HiddenTaskService.test.ts` (300): move the `revealHiddenTasks preserving updated_at` describe (FR1 block) into a sibling `HiddenTaskService.reveal-preserves-updated-at.test.ts`; both files end up <= 200 (FR1 coverage unchanged) +- [x] 8.4 Slim `multi-device-stale-rebalance.spec.ts` (319): move `dragTaskOnto` into `page-actions.ts` and `buildInboxTaskPayload` into a shared integration helper module; the spec keeps only the U3 scenario and lands <= 200 (U3/FR4/NFR-REL1 coverage unchanged) +- [x] 8.4a Follow-up: `page-actions.ts` grew to 312 lines after 8.4 added `dragTaskOnto`, breaching the 300-line hard cap. Split it by action category (task actions, checklist actions, goal/category/context actions, attachment actions) into sibling files under 200 lines each, reusing existing repo module patterns; update all importers; no behavior change (process-invariants file-size cap) +- [x] 8.5 Verification: scoped vitest run (one command, foreground) over all files produced by 8.1-8.3 — green, and the total test count of the split groups equals the pre-split count (no scenario silently dropped) +- [x] 8.6 Verification: scoped mutation run on `src/services/RecurringTaskDeduplicator.ts,src/services/RecurringTaskDeduplicator.merge.ts,src/services/HiddenTaskService.ts` (wait for completion) — scores unchanged, confirming Stryker still discovers the renamed test files (M3) +- [x] 8.7 Verification: `wc -l` confirms every touched test file <= 200 (hard cap 300 nowhere needed) — confirmed, all pass; `get_file_problems` on new/changed files — clean. At 8.7 time, `pnpm run preflight` failed on 4 pre-existing integration spec bugs from section 5 (unrelated to the section-8 reorg — see 9.1-9.4 below); investigation confirmed all section-8 moved/split code is byte-identical to its pre-move source and correctly imported, so section 8's own scope was verified complete. Those 4 specs were real bugs (not flakes) and are now fixed in section 9; `pnpm run preflight` and `pnpm run build` are both green. + +## 9. Follow-up: pre-existing integration spec bugs surfaced by preflight (found during 8.7 verification, not caused by section 8's reorg) — FIXED + +Root causes (all fixed): the specs assumed tasks stay clickable from `/tasks` in states the app correctly hides/filters them; deeper issues surfaced once those were fixed — invalid seeded sort keys poisoning shared-inbox key generation, a strict-mode drag-handle locator collision, missing final pull-on-A convergence rounds, a token refresh triggered by the faked clock jump (Kong CORS + short JWT), and an over-broad shared-user convergence assertion. See per-item notes below. + +- [x] 9.1 Fixed `multi-device-stale-recurring-fixed.spec.ts` and `multi-device-stale-recurring-after-completion.spec.ts`: the description edit now precedes completion (the new occurrence is cloned from the original's fields at completion time, so both occurrences carry the fresh description), preceded by a clean `/tasks` navigation so the detail panel stays mounted; a final `triggerSyncAndWait(pageA)` pulls B's reveal so all three converge (U1, FR6, NFR-REL1) +- [x] 9.2 Fixed `multi-device-manual-unhide.spec.ts`: click the `command-bar-eye-toggle` control (the actual sidebar reveal toggle; `hidden-tasks-toggle` is an older e2e locator) on `pageA` before step 4's `openTaskDetail` so the hidden task is clickable (U2, FR2, NFR-REL1) +- [x] 9.3 Fixed `multi-device-stale-rebalance.spec.ts`: the seeded tasks live in the inbox box, so the flow now runs on `/inbox` (not `/tasks`, which shows only today/week/later); `dragTaskOnto` matches the drag handle by its exact "Drag task" label (a task named "…Dragged" collided with `/drag/i`); seed keys are valid fractional-indexing keys ("ac"/"ab", not "z"/"m" which pass the app's lax check but throw in `generateKeyBetween`, poisoning inbox key generation for later specs); a final `triggerSyncAndWait(pageA)` pulls B's rebalance (U3, FR4, NFR-REL1) +- [x] 9.4 Verification: `pnpm run preflight` green (85 integration + 7959 client tests pass). Also fixed two cross-cutting harness issues surfaced once the specs progressed past the clickability bugs: (a) Kong CORS now allows the `x-supabase-api-version` header, and the test backend issues 7-day JWTs (`GOTRUE_JWT_EXP`) so a device's day-long clock jump never triggers a token refresh that raced/failed under the faked clock — the `advanceClockPastDate` helper uses `page.clock.install({ time }) + resume()`; (b) `assertConverged` takes an optional record filter so the shared-user convergence check is scoped to each test's own records (a clock-advanced device also reveals unrelated specs' hidden occurrences) (NFR-REL1, M1) + +Still not in scope for this change (flag separately, do not fix here): `TaskService.complete()`'s broad `catch` (lines ~212-219) mislabels any exception during recurring-copy creation as `skipped_invalid_rule`, surfacing a misleading "Repeat rule issue" dialog; it masked the invalid-sort-key root cause during 9.3's investigation. diff --git a/packages/client/src/db/repositories/ContextRepository.ts b/packages/client/src/db/repositories/ContextRepository.ts index 38b9b267..859a570d 100644 --- a/packages/client/src/db/repositories/ContextRepository.ts +++ b/packages/client/src/db/repositories/ContextRepository.ts @@ -2,6 +2,9 @@ import type { WireContext } from "@clear-progress/contract"; import { ClientContextSchema } from "@/schemas/entities"; import type { Context, ISOTimestamp } from "@/types/entities"; import { db } from "../database"; +import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww"; + +const CONTEXT_ENTITY_NAME = "context"; export class ContextRepository { async getAll(): Promise { @@ -55,11 +58,26 @@ export class ContextRepository { .toArray(); } + /** + * Implements FR5 of fix-stale-sync-overwrites. + * A local `pending` record is overwritten only when the server record's + * `updated_at` is strictly newer (LWW pull protection). Local records with + * any other syncStatus, or no local record at all, are always overwritten. + */ async applyServerRecords(records: WireContext[]): Promise { await db.transaction("rw", db.contexts, async () => { for (const serverRecord of records) { const localRecord = await db.contexts.get(serverRecord.id); - if (localRecord?.syncStatus !== "pending") { + const shouldWrite = + localRecord?.syncStatus !== "pending" || + shouldOverwritePendingLocalRecord({ + entityName: CONTEXT_ENTITY_NAME, + id: serverRecord.id, + localUpdatedAt: localRecord.updated_at, + serverUpdatedAt: serverRecord.updated_at, + }); + + if (shouldWrite) { const context: Context = { ...serverRecord, created_at: serverRecord.created_at as ISOTimestamp, diff --git a/packages/client/src/services/HiddenTaskService.reveal-preserves-updated-at.test.ts b/packages/client/src/services/HiddenTaskService.reveal-preserves-updated-at.test.ts new file mode 100644 index 00000000..189ebd61 --- /dev/null +++ b/packages/client/src/services/HiddenTaskService.reveal-preserves-updated-at.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TaskRepository } from "@/db/repositories/TaskRepository"; +import { fakeClock } from "@/lib/temporal"; +import type { ISODate, ISOTimestamp, Task } from "@/types/entities"; +import { HiddenTaskService } from "./HiddenTaskService"; + +// implements FR1 of fix-stale-sync-overwrites +describe("revealHiddenTasks preserving updated_at", () => { + let mockTaskRepository: TaskRepository; + let service: HiddenTaskService; + + beforeEach(() => { + mockTaskRepository = { + getTasksToReveal: vi.fn(), + update: vi.fn(), + } as unknown as TaskRepository; + }); + + // Verifies M2 of fix-stale-sync-overwrites + it("should NOT modify updated_at when revealing a hidden task", async () => { + const clock = fakeClock("2026-04-16T10:30:00Z"); + service = new HiddenTaskService(mockTaskRepository, clock); + + const hiddenTask: Task = { + id: "task-preserve-updated-at-1", + name: "Hidden task", + description: "", + box: "today", + goal_id: "", + context_id: "", + category_id: "", + is_completed: false, + completed_at: "" as ISOTimestamp, + repeat_rule: "", + is_hidden: true, + next_date: "" as ISODate, + appear_date: "2026-04-16" as ISODate, + original_task_id: "", + sort_order: "0", + is_deleted: false, + created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + revision: 1, + syncStatus: "synced" as const, + }; + + ( + mockTaskRepository.getTasksToReveal as ReturnType + ).mockResolvedValue([hiddenTask]); + (mockTaskRepository.update as ReturnType).mockResolvedValue( + undefined, + ); + + const revealed = await service.revealHiddenTasks(); + + expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z"); + expect(mockTaskRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: "task-preserve-updated-at-1", + updated_at: "2026-04-15T10:00:00.000Z", + }), + ); + }); + + it("should set syncStatus to pending when revealing a hidden task", async () => { + const clock = fakeClock("2026-04-16T10:30:00Z"); + service = new HiddenTaskService(mockTaskRepository, clock); + + const hiddenTask: Task = { + id: "task-preserve-updated-at-2", + name: "Hidden task", + description: "", + box: "today", + goal_id: "", + context_id: "", + category_id: "", + is_completed: false, + completed_at: "" as ISOTimestamp, + repeat_rule: "", + is_hidden: true, + next_date: "" as ISODate, + appear_date: "2026-04-16" as ISODate, + original_task_id: "", + sort_order: "0", + is_deleted: false, + created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + revision: 1, + syncStatus: "synced" as const, + }; + + ( + mockTaskRepository.getTasksToReveal as ReturnType + ).mockResolvedValue([hiddenTask]); + (mockTaskRepository.update as ReturnType).mockResolvedValue( + undefined, + ); + + const revealed = await service.revealHiddenTasks(); + + expect(revealed[0].syncStatus).toBe("pending"); + expect(revealed[0].is_hidden).toBe(false); + }); + + it("should keep syncStatus as pending without degrading state when task is already pending", async () => { + const clock = fakeClock("2026-04-16T10:30:00Z"); + service = new HiddenTaskService(mockTaskRepository, clock); + + const alreadyPendingHiddenTask: Task = { + id: "task-preserve-updated-at-3", + name: "Hidden task already pending", + description: "", + box: "today", + goal_id: "", + context_id: "", + category_id: "", + is_completed: false, + completed_at: "" as ISOTimestamp, + repeat_rule: "", + is_hidden: true, + next_date: "" as ISODate, + appear_date: "2026-04-16" as ISODate, + original_task_id: "", + sort_order: "0", + is_deleted: false, + created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, + revision: 1, + syncStatus: "pending" as const, + }; + + ( + mockTaskRepository.getTasksToReveal as ReturnType + ).mockResolvedValue([alreadyPendingHiddenTask]); + (mockTaskRepository.update as ReturnType).mockResolvedValue( + undefined, + ); + + const revealed = await service.revealHiddenTasks(); + + expect(revealed[0].syncStatus).toBe("pending"); + expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z"); + }); +}); diff --git a/packages/client/src/services/HiddenTaskService.test.ts b/packages/client/src/services/HiddenTaskService.test.ts index 915346a1..94c7757e 100644 --- a/packages/client/src/services/HiddenTaskService.test.ts +++ b/packages/client/src/services/HiddenTaskService.test.ts @@ -169,132 +169,4 @@ describe("HiddenTaskService", () => { ); }); }); - - // implements FR1 of fix-stale-sync-overwrites - describe("revealHiddenTasks preserving updated_at", () => { - it("should NOT modify updated_at when revealing a hidden task", async () => { - const clock = fakeClock("2026-04-16T10:30:00Z"); - service = new HiddenTaskService(mockTaskRepository, clock); - - const hiddenTask: Task = { - id: "task-preserve-updated-at-1", - name: "Hidden task", - description: "", - box: "today", - goal_id: "", - context_id: "", - category_id: "", - is_completed: false, - completed_at: "" as ISOTimestamp, - repeat_rule: "", - is_hidden: true, - next_date: "" as ISODate, - appear_date: "2026-04-16" as ISODate, - original_task_id: "", - sort_order: "0", - is_deleted: false, - created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, - updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, - revision: 1, - syncStatus: "synced" as const, - }; - - ( - mockTaskRepository.getTasksToReveal as ReturnType - ).mockResolvedValue([hiddenTask]); - (mockTaskRepository.update as ReturnType).mockResolvedValue( - undefined, - ); - - const revealed = await service.revealHiddenTasks(); - - expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z"); - expect(mockTaskRepository.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: "task-preserve-updated-at-1", - updated_at: "2026-04-15T10:00:00.000Z", - }), - ); - }); - - it("should set syncStatus to pending when revealing a hidden task", async () => { - const clock = fakeClock("2026-04-16T10:30:00Z"); - service = new HiddenTaskService(mockTaskRepository, clock); - - const hiddenTask: Task = { - id: "task-preserve-updated-at-2", - name: "Hidden task", - description: "", - box: "today", - goal_id: "", - context_id: "", - category_id: "", - is_completed: false, - completed_at: "" as ISOTimestamp, - repeat_rule: "", - is_hidden: true, - next_date: "" as ISODate, - appear_date: "2026-04-16" as ISODate, - original_task_id: "", - sort_order: "0", - is_deleted: false, - created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, - updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, - revision: 1, - syncStatus: "synced" as const, - }; - - ( - mockTaskRepository.getTasksToReveal as ReturnType - ).mockResolvedValue([hiddenTask]); - (mockTaskRepository.update as ReturnType).mockResolvedValue( - undefined, - ); - - const revealed = await service.revealHiddenTasks(); - - expect(revealed[0].syncStatus).toBe("pending"); - expect(revealed[0].is_hidden).toBe(false); - }); - - it("should keep syncStatus as pending without degrading state when task is already pending", async () => { - const clock = fakeClock("2026-04-16T10:30:00Z"); - service = new HiddenTaskService(mockTaskRepository, clock); - - const alreadyPendingHiddenTask: Task = { - id: "task-preserve-updated-at-3", - name: "Hidden task already pending", - description: "", - box: "today", - goal_id: "", - context_id: "", - category_id: "", - is_completed: false, - completed_at: "" as ISOTimestamp, - repeat_rule: "", - is_hidden: true, - next_date: "" as ISODate, - appear_date: "2026-04-16" as ISODate, - original_task_id: "", - sort_order: "0", - is_deleted: false, - created_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, - updated_at: "2026-04-15T10:00:00.000Z" as ISOTimestamp, - revision: 1, - syncStatus: "pending" as const, - }; - - ( - mockTaskRepository.getTasksToReveal as ReturnType - ).mockResolvedValue([alreadyPendingHiddenTask]); - (mockTaskRepository.update as ReturnType).mockResolvedValue( - undefined, - ); - - const revealed = await service.revealHiddenTasks(); - - expect(revealed[0].syncStatus).toBe("pending"); - expect(revealed[0].updated_at).toBe("2026-04-15T10:00:00.000Z"); - }); - }); }); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.guards.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.guards.test.ts new file mode 100644 index 00000000..85afc3bc --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.guards.test.ts @@ -0,0 +1,158 @@ +// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull +import "fake-indexeddb/auto"; +import { describe, expect, it, vi } from "vitest"; +import { db } from "@/db/database"; +import { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; +import { TaskRepository } from "@/db/repositories/TaskRepository"; +import { fakeClock } from "@/lib/temporal"; +import { buildTask } from "@/test/factories/taskFactory"; +import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator"; +import { + createRecurringTaskDeduplicatorMergeSetup, + ORIGINAL_ID_1, + ORIGINAL_ID_2, + UUID_A, + UUID_B, + UUID_C, +} from "./RecurringTaskDeduplicator.test-setup"; + +const FIXED_CLOCK_TIMESTAMP = "2026-07-10T12:00:00Z"; + +describe("RecurringTaskDeduplicator", () => { + const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup(); + + // Mutants 1-2: early return guard when ids are empty + describe("optimization guard — empty ids skip repository call", () => { + it("should not call findDuplicateRecurringGroups when ids list is empty", async () => { + const taskRepository = new TaskRepository(); + const checklistRepository = new ChecklistRepository(); + const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); + const findSpy = vi.spyOn(taskRepository, "findDuplicateRecurringGroups"); + const localDeduplicator = new RecurringTaskDeduplicator( + taskRepository, + checklistRepository, + clock, + ); + + await localDeduplicator.deduplicate([]); + + expect(findSpy).not.toHaveBeenCalled(); + }); + + it("should not call findDuplicateRecurringGroups when all ids are empty strings", async () => { + const taskRepository = new TaskRepository(); + const checklistRepository = new ChecklistRepository(); + const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); + const findSpy = vi.spyOn(taskRepository, "findDuplicateRecurringGroups"); + const localDeduplicator = new RecurringTaskDeduplicator( + taskRepository, + checklistRepository, + clock, + ); + + await localDeduplicator.deduplicate(["", ""]); + + expect(findSpy).not.toHaveBeenCalled(); + }); + }); + + // Mutants 3-4: continue guard when group has single copy + describe("optimization guard — single copy skips update", () => { + it("should not call update when group has only one active copy", async () => { + const taskRepository = new TaskRepository(); + const checklistRepository = new ChecklistRepository(); + const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); + const updateSpy = vi.spyOn(taskRepository, "update"); + const localDeduplicator = new RecurringTaskDeduplicator( + taskRepository, + checklistRepository, + clock, + ); + + const singleTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + await db.tasks.add(singleTask); + + await localDeduplicator.deduplicate([ORIGINAL_ID_1]); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + // Guards the `continue` statement directly: a group can only reach this + // guard with activeCopies.length < MINIMUM_DUPLICATE_GROUP_SIZE (0 or 1) + // since findDuplicateRecurringGroups never emits an empty group in + // practice — so length-1 groups are a legitimate but observationally + // equivalent case (merging a single copy against itself is a no-op + // either way). Forcing an empty-array group via a mocked repository + // makes the two mutants observable: without the `continue` (or with the + // guard replaced by `if (false)`), the loop falls through to + // `sortByWinnerPriority([])` and then destructures an undefined + // scheduleWinner, which throws when merging — proving `continue` really + // skips groups below the minimum size rather than merely coinciding with + // a no-op merge. + it("should not throw when a mocked group is empty, proving continue actually skips it", async () => { + const taskRepository = new TaskRepository(); + const checklistRepository = new ChecklistRepository(); + const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); + vi.spyOn( + taskRepository, + "findDuplicateRecurringGroups", + ).mockResolvedValue(new Map([[ORIGINAL_ID_1, []]])); + const localDeduplicator = new RecurringTaskDeduplicator( + taskRepository, + checklistRepository, + clock, + ); + + await expect( + localDeduplicator.deduplicate([ORIGINAL_ID_1]), + ).resolves.toBeUndefined(); + }); + + // Guards the `continue` statement itself (not just the guarded call): + // replacing `continue` with an empty block would still skip the update + // for the singleton group (since the guard body has no other statements) + // but would fall through to loop code the real implementation intends to + // skip, rather than jumping to the next group. Processing a singleton + // group FIRST and a real duplicate group SECOND in the same call proves + // the loop actually advances past the singleton to still dedupe group 2 — + // an `if (...) {}` mutant that fails to skip to the next iteration would + // still coincidentally pass this too, but combined with the single-copy + // assertions above, it locks down that iteration order is unaffected. + it("should still deduplicate a later group after an earlier singleton group in the same call", async () => { + const singleTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const groupTwoWinner = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_2, + next_date: "2026-07-02", + is_completed: false, + is_deleted: false, + }); + const groupTwoLoser = buildTask({ + id: UUID_C, + original_task_id: ORIGINAL_ID_2, + next_date: "2026-07-08", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([singleTask, groupTwoWinner, groupTwoLoser]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]); + + expect((await db.tasks.get(UUID_A))?.syncStatus).toBe("synced"); + expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false); + expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.loser-soft-delete.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.loser-soft-delete.test.ts new file mode 100644 index 00000000..50d72cb4 --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.loser-soft-delete.test.ts @@ -0,0 +1,105 @@ +// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { db } from "@/db/database"; +import { buildChecklistItem } from "@/test/factories/checklistItemFactory"; +import { buildTask } from "@/test/factories/taskFactory"; +import { + createRecurringTaskDeduplicatorMergeSetup, + ORIGINAL_ID_1, + UUID_A, + UUID_B, +} from "./RecurringTaskDeduplicator.test-setup"; + +describe("RecurringTaskDeduplicator", () => { + const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup(); + + // FR2: loser soft-delete fields + describe("loser soft-delete", () => { + it("should set updated_at on loser to current clock timestamp", async () => { + const taskA = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const taskB = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([taskA, taskB]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const loser = await db.tasks.get(UUID_B); + expect(loser?.updated_at).toBe("2026-07-10T12:00:00.000Z"); + }); + }); + + // FR3: cascade to checklist items + describe("checklist cascade", () => { + it("should soft-delete checklist items of loser task", async () => { + const winnerTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const loserTask = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-10", + is_completed: false, + is_deleted: false, + }); + const checklistItem = buildChecklistItem({ + task_id: UUID_B, + is_deleted: false, + }); + await db.tasks.bulkAdd([winnerTask, loserTask]); + await db.checklist_items.add(checklistItem); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const item = await db.checklist_items.get(checklistItem.id); + expect(item?.is_deleted).toBe(true); + expect(item?.syncStatus).toBe("pending"); + expect(item?.updated_at).toBe("2026-07-10T12:00:00.000Z"); + }); + + it("should not modify checklist items of winner task", async () => { + const winnerTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const loserTask = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-10", + is_completed: false, + is_deleted: false, + }); + const winnerItem = buildChecklistItem({ + task_id: UUID_A, + is_deleted: false, + syncStatus: "synced", + }); + await db.tasks.bulkAdd([winnerTask, loserTask]); + await db.checklist_items.add(winnerItem); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const item = await db.checklist_items.get(winnerItem.id); + expect(item?.is_deleted).toBe(false); + expect(item?.syncStatus).toBe("synced"); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.identity-and-updated-at.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.identity-and-updated-at.test.ts new file mode 100644 index 00000000..fab8abe3 --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.identity-and-updated-at.test.ts @@ -0,0 +1,114 @@ +// implements FR3 of fix-stale-sync-overwrites +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { RECORD_SYNC_STATUS } from "@/constants"; +import { db } from "@/db/database"; +import { buildTask } from "@/test/factories/taskFactory"; +import { + createRecurringTaskDeduplicatorMergeSetup, + FIXED_CLOCK_TIMESTAMP, +} from "./RecurringTaskDeduplicator.test-setup"; + +// Third scenario reused here: proves identity/bookkeeping fields (id, +// created_at, revision) always stay the winner's own regardless of which +// copy is freshest. +const UUID_BOX_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000030"; +const UUID_BOX_FRESH_CONTENT_LOSER = "00000000-0000-4000-a000-000000000031"; +const ORIGINAL_ID_3 = "33333333-3333-4333-a333-333333333333"; + +// Fourth scenario: proves the merged winner's updated_at is taken verbatim +// from the freshest copy — never refreshed to the clock's "now". +const UUID_UPDATED_AT_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000040"; +const UUID_UPDATED_AT_FRESH_CONTENT_LOSER = + "00000000-0000-4000-a000-000000000041"; +const ORIGINAL_ID_4 = "44444444-4444-4444-a444-444444444444"; + +describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => { + const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup(); + + // FR3: id, created_at and revision are identity/bookkeeping fields that + // always stay the schedule winner's own, regardless of which copy is + // freshest by updated_at or which has different content. The winner + // record survives (only the loser is soft-deleted), so these fields are + // never overwritten by the merge. + describe("identity fields stay the winner's own regardless of merge", () => { + it("should keep id, created_at and revision as the schedule winner's own values", async () => { + const scheduleWinner = buildTask({ + id: UUID_BOX_SCHEDULE_WINNER, + original_task_id: ORIGINAL_ID_3, + next_date: "2026-07-01", + created_at: "2026-01-01T00:00:00.000Z", + revision: 5, + name: "Stale name", + description: "Stale description", + box: "inbox", + sort_order: "stale-sort-key", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + }); + const freshContentLoser = buildTask({ + id: UUID_BOX_FRESH_CONTENT_LOSER, + original_task_id: ORIGINAL_ID_3, + next_date: "2026-07-10", + created_at: "2026-02-02T00:00:00.000Z", + revision: 9, + name: "Fresh name", + description: "Fresh description", + box: "later", + sort_order: "fresh-sort-key", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + }); + await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_3]); + + const survivor = await db.tasks.get(UUID_BOX_SCHEDULE_WINNER); + expect(survivor?.id).toBe(UUID_BOX_SCHEDULE_WINNER); + expect(survivor?.created_at).toBe("2026-01-01T00:00:00.000Z"); + expect(survivor?.revision).toBe(5); + }); + }); + + // FR3: the merged winner's updated_at must equal the freshest copy's + // updated_at verbatim, never refreshed to the current clock time — unlike + // a loser's soft-delete updated_at, which the implementation does set to + // now. FIXED_CLOCK_TIMESTAMP here is a third distinct timestamp from both + // copies' updated_at, so a bug that clobbers the winner's updated_at with + // "now" is caught. + // Verifies M2 of fix-stale-sync-overwrites + describe("winner's updated_at takes the freshest copy's value, never refreshed to now", () => { + it("should set the merged winner's updated_at to the freshest copy's updated_at, not the current clock time", async () => { + const scheduleWinner = buildTask({ + id: UUID_UPDATED_AT_SCHEDULE_WINNER, + original_task_id: ORIGINAL_ID_4, + next_date: "2026-07-01", + name: "Stale name", + description: "Stale description", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + }); + const freshContentLoser = buildTask({ + id: UUID_UPDATED_AT_FRESH_CONTENT_LOSER, + original_task_id: ORIGINAL_ID_4, + next_date: "2026-07-10", + name: "Fresh name", + description: "Fresh description", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + }); + await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_4]); + + const survivor = await db.tasks.get(UUID_UPDATED_AT_SCHEDULE_WINNER); + expect(survivor?.updated_at).toBe("2026-07-09T10:00:00.000Z"); + expect(survivor?.updated_at).not.toBe(FIXED_CLOCK_TIMESTAMP); + expect(survivor?.syncStatus).toBe(RECORD_SYNC_STATUS.PENDING); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.pending-when-changed.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.pending-when-changed.test.ts new file mode 100644 index 00000000..b0cce043 --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.pending-when-changed.test.ts @@ -0,0 +1,151 @@ +// implements FR3 of fix-stale-sync-overwrites +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RECORD_SYNC_STATUS } from "@/constants"; +import { db } from "@/db/database"; +import { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; +import { TaskRepository } from "@/db/repositories/TaskRepository"; +import { fakeClock } from "@/lib/temporal"; +import { buildTask } from "@/test/factories/taskFactory"; +import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator"; +import { FIXED_CLOCK_TIMESTAMP } from "./RecurringTaskDeduplicator.test-setup"; + +// Sixth scenario: proves the winner is written with syncStatus "pending" +// ONLY when the merge actually changed its stored record, per FR3's last +// sentence. Two subcases below use the same original_task_id family but +// distinct task ids so each test starts from a clean, independent group. +const UUID_CHANGED_WINNER = "00000000-0000-4000-a000-000000000060"; +const UUID_CHANGED_LOSER = "00000000-0000-4000-a000-000000000061"; +const ORIGINAL_ID_6 = "66666666-6666-4666-a666-666666666666"; + +const UUID_OPTIMAL_WINNER = "00000000-0000-4000-a000-000000000070"; +const UUID_OPTIMAL_LOSER = "00000000-0000-4000-a000-000000000071"; +const ORIGINAL_ID_7 = "77777777-7777-4777-a777-777777777777"; + +// FR3 (last sentence): the winner SHALL be written with syncStatus +// "pending" only when the merge actually changed the stored record. This +// requires the deduplicator to call taskRepository.update() for the +// winner whenever content/schedule/box fields differ from what the +// winner already had on disk — something the current implementation +// never does (it only calls update() for losers' soft-deletes), so this +// first test is expected to FAIL until the merge-and-conditionally-write +// behavior is implemented. +// +// This file constructs its own TaskRepository/ChecklistRepository/clock +// (rather than using the shared test-setup factory) because each test +// spies on taskRepository.update() directly. +describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => { + beforeEach(async () => { + await db.tasks.clear(); + await db.checklist_items.clear(); + }); + + describe("winner syncStatus is pending only when the merge actually changed it", () => { + it("should write the winner with syncStatus pending and call update when the merge changes its stored content", async () => { + const taskRepository = new TaskRepository(); + const checklistRepository = new ChecklistRepository(); + const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); + const updateSpy = vi.spyOn(taskRepository, "update"); + const localDeduplicator = new RecurringTaskDeduplicator( + taskRepository, + checklistRepository, + clock, + ); + + const scheduleWinner = buildTask({ + id: UUID_CHANGED_WINNER, + original_task_id: ORIGINAL_ID_6, + next_date: "2026-07-01", + name: "Stale name", + description: "Stale description", + box: "inbox", + sort_order: "stale-sort-key", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + syncStatus: "synced", + }); + const freshContentLoser = buildTask({ + id: UUID_CHANGED_LOSER, + original_task_id: ORIGINAL_ID_6, + next_date: "2026-07-10", + name: "Fresh name", + description: "Fresh description", + box: "later", + sort_order: "fresh-sort-key", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + }); + await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]); + + await localDeduplicator.deduplicate([ORIGINAL_ID_6]); + + const survivor = await db.tasks.get(UUID_CHANGED_WINNER); + expect(survivor?.name).toBe("Fresh name"); + expect(survivor?.syncStatus).toBe(RECORD_SYNC_STATUS.PENDING); + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: UUID_CHANGED_WINNER }), + ); + }); + + // Spec scenario: "Already-optimal winner is not rewritten." The winner + // already carries both the earliest schedule triple AND the freshest + // content/box/sort_order — merging is a no-op. This may already pass + // today, since the current implementation never calls update() for the + // schedule winner at all, so its syncStatus naturally stays whatever it + // started as. Kept here as a characterization/regression guard so a + // future merge implementation cannot start unconditionally rewriting + // the winner. + it("should not rewrite the winner when it is already optimal, keeping syncStatus synced", async () => { + const taskRepository = new TaskRepository(); + const checklistRepository = new ChecklistRepository(); + const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); + const updateSpy = vi.spyOn(taskRepository, "update"); + const localDeduplicator = new RecurringTaskDeduplicator( + taskRepository, + checklistRepository, + clock, + ); + + const optimalWinner = buildTask({ + id: UUID_OPTIMAL_WINNER, + original_task_id: ORIGINAL_ID_7, + next_date: "2026-07-01", + appear_date: "2026-06-28", + is_hidden: false, + name: "Freshest name", + description: "Freshest description", + box: "inbox", + sort_order: "optimal-sort-key", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + syncStatus: "synced", + }); + const staleLoser = buildTask({ + id: UUID_OPTIMAL_LOSER, + original_task_id: ORIGINAL_ID_7, + next_date: "2026-07-10", + appear_date: "2026-07-08", + is_hidden: true, + name: "Stale name", + description: "Stale description", + box: "later", + sort_order: "stale-sort-key", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + }); + await db.tasks.bulkAdd([optimalWinner, staleLoser]); + + await localDeduplicator.deduplicate([ORIGINAL_ID_7]); + + const survivor = await db.tasks.get(UUID_OPTIMAL_WINNER); + expect(survivor?.syncStatus).toBe("synced"); + expect(updateSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ id: UUID_OPTIMAL_WINNER }), + ); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts new file mode 100644 index 00000000..5a434d9f --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts @@ -0,0 +1,158 @@ +// implements FR3 of fix-stale-sync-overwrites +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { db } from "@/db/database"; +import { buildTask } from "@/test/factories/taskFactory"; +import { + createRecurringTaskDeduplicatorMergeSetup, + ORIGINAL_ID_1, + UUID_FRESH_CONTENT_LOSER, + UUID_SCHEDULE_WINNER, +} from "./RecurringTaskDeduplicator.test-setup"; + +// Second scenario: schedule winner is NOT the freshest-updated_at copy, +// used to prove the schedule triple (next_date + appear_date + is_hidden) +// stays coupled to the schedule winner's copy even though content merges +// from the other copy. +const UUID_HIDDEN_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000020"; +const UUID_REVEALED_FRESH_CONTENT_LOSER = + "00000000-0000-4000-a000-000000000021"; +const ORIGINAL_ID_2 = "22222222-2222-4222-a222-222222222222"; + +// Third scenario: proves box + sort_order travel together as a pair from +// the freshest-updated_at copy. +const UUID_BOX_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000030"; +const UUID_BOX_FRESH_CONTENT_LOSER = "00000000-0000-4000-a000-000000000031"; +const ORIGINAL_ID_3 = "33333333-3333-4333-a333-333333333333"; + +describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => { + const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup(); + + // FR3: next_date, appear_date and is_hidden always travel together as a + // schedule triple from the earliest-next_date copy, even when that copy + // is not the freshest-updated_at copy (content merges from the other one). + describe("schedule triple (next_date, appear_date, is_hidden) stays coupled to the schedule winner", () => { + it("should take appear_date and is_hidden from the SAME copy as next_date, not from the freshest-content copy", async () => { + // Schedule winner: earliest next_date, but stale content and NOT the + // freshest-updated_at copy. Its own appear_date/is_hidden must survive + // the merge untouched by the other copy's schedule fields. + const scheduleWinner = buildTask({ + id: UUID_SCHEDULE_WINNER, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + appear_date: "2026-06-28", + is_hidden: false, + name: "Stale name", + description: "Stale description", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + }); + // Freshest-content copy: later next_date/appear_date and a different + // is_hidden value. Without a real schedule-triple merge, a naive + // "take is_hidden/appear_date from the freshest copy" implementation + // would leak this copy's schedule fields onto the winner. + const freshContentLoser = buildTask({ + id: UUID_FRESH_CONTENT_LOSER, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-10", + appear_date: "2026-07-08", + is_hidden: true, + name: "Fresh name", + description: "Fresh description", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + }); + await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER); + expect(survivor?.next_date).toBe("2026-07-01"); + expect(survivor?.appear_date).toBe("2026-06-28"); + expect(survivor?.is_hidden).toBe(false); + // Content still merges from the freshest copy in the same pass. + expect(survivor?.name).toBe("Fresh name"); + expect(survivor?.description).toBe("Fresh description"); + }); + + // Spec scenario: "Hidden and revealed copies merge without breaking + // reveal timing." Schedule winner is hidden with a future appear_date; + // the freshest-content copy is already revealed. The merged winner must + // stay hidden with the schedule the winner's appear_date — content merge + // must never silently reveal the task. + it("should keep the winner hidden with the schedule winner's appear_date when the freshest-content copy is revealed", async () => { + const hiddenScheduleWinner = buildTask({ + id: UUID_HIDDEN_SCHEDULE_WINNER, + original_task_id: ORIGINAL_ID_2, + next_date: "2026-08-01", + appear_date: "2026-07-30", + is_hidden: true, + name: "Stale hidden copy", + description: "Stale description", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + }); + const revealedFreshContentLoser = buildTask({ + id: UUID_REVEALED_FRESH_CONTENT_LOSER, + original_task_id: ORIGINAL_ID_2, + next_date: "2026-08-15", + appear_date: "", + is_hidden: false, + name: "Fresh revealed copy", + description: "Fresh description", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + }); + await db.tasks.bulkAdd([hiddenScheduleWinner, revealedFreshContentLoser]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_2]); + + const survivor = await db.tasks.get(UUID_HIDDEN_SCHEDULE_WINNER); + expect(survivor?.is_hidden).toBe(true); + expect(survivor?.appear_date).toBe("2026-07-30"); + expect(survivor?.next_date).toBe("2026-08-01"); + // Content still merges from the freshest (revealed) copy. + expect(survivor?.name).toBe("Fresh revealed copy"); + expect(survivor?.description).toBe("Fresh description"); + }); + }); + + // FR3: box and sort_order are a per-box sort-key pair — they must travel + // together from the freshest-updated_at copy, never mixed across copies, + // even when that copy loses the schedule comparison. + describe("box and sort_order merge from freshest updated_at copy as a pair", () => { + it("should take box and sort_order together from the freshest-updated_at copy, not the schedule winner", async () => { + const scheduleWinner = buildTask({ + id: UUID_BOX_SCHEDULE_WINNER, + original_task_id: ORIGINAL_ID_3, + next_date: "2026-07-01", + box: "inbox", + sort_order: "stale-sort-key", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + }); + const freshContentLoser = buildTask({ + id: UUID_BOX_FRESH_CONTENT_LOSER, + original_task_id: ORIGINAL_ID_3, + next_date: "2026-07-10", + box: "later", + sort_order: "fresh-sort-key", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + }); + await db.tasks.bulkAdd([scheduleWinner, freshContentLoser]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_3]); + + const survivor = await db.tasks.get(UUID_BOX_SCHEDULE_WINNER); + expect(survivor?.box).toBe("later"); + expect(survivor?.sort_order).toBe("fresh-sort-key"); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.ts index 594889e4..4bd82230 100644 --- a/packages/client/src/services/RecurringTaskDeduplicator.merge.ts +++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.ts @@ -1,3 +1,4 @@ +import { Temporal } from "@/lib/temporal"; import type { Task } from "@/types/entities"; /** @@ -33,7 +34,10 @@ const MERGE_RELEVANT_FIELDS = [ */ function findFreshestUpdatedAtCopy(copies: Task[]): Task { return copies.reduce((freshest, candidate) => - candidate.updated_at.localeCompare(freshest.updated_at) > 0 + Temporal.Instant.compare( + Temporal.Instant.from(candidate.updated_at), + Temporal.Instant.from(freshest.updated_at), + ) > 0 ? candidate : freshest, ); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.multiple-groups.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.multiple-groups.test.ts new file mode 100644 index 00000000..71d910ab --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.multiple-groups.test.ts @@ -0,0 +1,134 @@ +// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { db } from "@/db/database"; +import { buildTask } from "@/test/factories/taskFactory"; +import { + createRecurringTaskDeduplicatorMergeSetup, + ORIGINAL_ID_1, + ORIGINAL_ID_2, + UUID_A, + UUID_B, + UUID_C, +} from "./RecurringTaskDeduplicator.test-setup"; + +describe("RecurringTaskDeduplicator", () => { + const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup(); + + // Multiple original_task_ids + describe("multiple groups", () => { + it("should deduplicate multiple original_task_id groups independently", async () => { + const groupAWinner = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const groupALoser = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + const groupBWinner = buildTask({ + original_task_id: ORIGINAL_ID_2, + next_date: "2026-07-02", + is_completed: false, + is_deleted: false, + }); + const groupBLoser = buildTask({ + original_task_id: ORIGINAL_ID_2, + next_date: "2026-07-08", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([ + groupAWinner, + groupALoser, + groupBWinner, + groupBLoser, + ]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]); + + expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false); + expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true); + // Group B: winner has earlier next_date + const groupBTasks = await db.tasks + .where("original_task_id") + .equals(ORIGINAL_ID_2) + .toArray(); + const groupBWinnerResult = groupBTasks.find( + (task) => task.next_date === "2026-07-02", + ); + const groupBLoserResult = groupBTasks.find( + (task) => task.next_date === "2026-07-08", + ); + expect(groupBWinnerResult?.is_deleted).toBe(false); + expect(groupBLoserResult?.is_deleted).toBe(true); + }); + + it("should deduplicate unique ids only (no double processing)", async () => { + const winnerTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const loserTask = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([winnerTask, loserTask]); + + await getDeduplicator().deduplicate([ + ORIGINAL_ID_1, + ORIGINAL_ID_1, + ORIGINAL_ID_1, + ]); + + expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false); + expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true); + }); + }); + + // Three or more duplicates + describe("three or more duplicates", () => { + it("should keep only the winner among three duplicates", async () => { + const taskA = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-03", + is_completed: false, + is_deleted: false, + }); + const taskB = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const taskC = buildTask({ + id: UUID_C, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([taskA, taskB, taskC]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false); + expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(true); + expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.skip-optimization.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.skip-optimization.test.ts new file mode 100644 index 00000000..ab2f318f --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.skip-optimization.test.ts @@ -0,0 +1,118 @@ +// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { db } from "@/db/database"; +import { buildTask } from "@/test/factories/taskFactory"; +import { + createRecurringTaskDeduplicatorMergeSetup, + ORIGINAL_ID_1, + UUID_A, + UUID_B, +} from "./RecurringTaskDeduplicator.test-setup"; + +describe("RecurringTaskDeduplicator", () => { + const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup(); + + // FR5: skip when list is empty + describe("skip optimization", () => { + it("should not modify any tasks when originalTaskIds is empty", async () => { + const originalUpdatedAt = "2026-06-01T10:00:00.000Z"; + const taskA = buildTask({ + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + updated_at: originalUpdatedAt, + }); + const taskB = buildTask({ + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + updated_at: originalUpdatedAt, + }); + await db.tasks.bulkAdd([taskA, taskB]); + + await getDeduplicator().deduplicate([]); + + const resultA = await db.tasks.get(taskA.id); + const resultB = await db.tasks.get(taskB.id); + expect(resultA?.is_deleted).toBe(false); + expect(resultB?.is_deleted).toBe(false); + expect(resultA?.updated_at).toBe(originalUpdatedAt); + expect(resultB?.updated_at).toBe(originalUpdatedAt); + }); + + it("should filter out empty strings from originalTaskIds", async () => { + const taskA = buildTask({ + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const taskB = buildTask({ + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([taskA, taskB]); + + await getDeduplicator().deduplicate(["", ""]); + + const resultA = await db.tasks.get(taskA.id); + const resultB = await db.tasks.get(taskB.id); + expect(resultA?.is_deleted).toBe(false); + expect(resultB?.is_deleted).toBe(false); + }); + + it("should process real ids but ignore empty strings in mixed list", async () => { + const winnerTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const loserTask = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + // Non-recurring tasks that should never be touched by dedup + const nonRecurringTaskA = buildTask({ + original_task_id: "", + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const nonRecurringTaskB = buildTask({ + original_task_id: "", + next_date: "2026-07-02", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([ + winnerTask, + loserTask, + nonRecurringTaskA, + nonRecurringTaskB, + ]); + + await getDeduplicator().deduplicate(["", ORIGINAL_ID_1, ""]); + + // Duplicates should be deduped + expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false); + expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true); + // Non-recurring tasks must not be touched + const nonRecA = await db.tasks.get(nonRecurringTaskA.id); + const nonRecB = await db.tasks.get(nonRecurringTaskB.id); + expect(nonRecA?.is_deleted).toBe(false); + expect(nonRecB?.is_deleted).toBe(false); + expect(nonRecA?.syncStatus).toBe("synced"); + expect(nonRecB?.syncStatus).toBe("synced"); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts b/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts new file mode 100644 index 00000000..881b1c14 --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts @@ -0,0 +1,60 @@ +// implements FR3 of fix-stale-sync-overwrites +import { beforeEach } from "vitest"; +import { db } from "@/db/database"; +import { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; +import { TaskRepository } from "@/db/repositories/TaskRepository"; +import { fakeClock } from "@/lib/temporal"; +import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator"; + +// This must remain a THIRD distinct timestamp from every copy's updated_at +// used across the merge test files, so tests can prove the winner's +// updated_at is taken from the freshest copy verbatim — never refreshed to +// "now" the way a loser's soft-delete updated_at is. +export const FIXED_CLOCK_TIMESTAMP = "2026-07-10T12:00:00Z"; + +/** + * Shared beforeEach for RecurringTaskDeduplicator merge test files: clears + * the tasks/checklist_items tables and constructs a deduplicator wired to a + * fake clock fixed at FIXED_CLOCK_TIMESTAMP. + */ +export function createRecurringTaskDeduplicatorMergeSetup(): { + getDeduplicator: () => RecurringTaskDeduplicator; +} { + let deduplicator: RecurringTaskDeduplicator; + + beforeEach(async () => { + await db.tasks.clear(); + await db.checklist_items.clear(); + const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); + deduplicator = new RecurringTaskDeduplicator( + new TaskRepository(), + new ChecklistRepository(), + clock, + ); + }); + + return { + getDeduplicator: () => deduplicator, + }; +} + +// Deterministic UUIDs for predictable winner-by-schedule ordering, shared +// across the merge test files that use scenario 1 (content fields). +export const UUID_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000010"; +export const UUID_FRESH_CONTENT_LOSER = "00000000-0000-4000-a000-000000000011"; +export const ORIGINAL_ID_1 = "11111111-1111-4111-a111-111111111111"; +export const STALE_GOAL_ID = "aaaaaaaa-0000-4000-a000-000000000001"; +export const STALE_CONTEXT_ID = "aaaaaaaa-0000-4000-a000-000000000002"; +export const STALE_CATEGORY_ID = "aaaaaaaa-0000-4000-a000-000000000003"; +export const FRESH_GOAL_ID = "bbbbbbbb-0000-4000-a000-000000000001"; +export const FRESH_CONTEXT_ID = "bbbbbbbb-0000-4000-a000-000000000002"; +export const FRESH_CATEGORY_ID = "bbbbbbbb-0000-4000-a000-000000000003"; + +// Deterministic UUIDs for predictable tiebreak ordering, shared across the +// characterization test files (winner selection, loser soft-delete, +// multiple groups, skip optimization, guards). +export const UUID_A = "00000000-0000-4000-a000-000000000001"; +export const UUID_B = "00000000-0000-4000-a000-000000000002"; +export const UUID_C = "00000000-0000-4000-a000-000000000003"; +export const UUID_Z = "ffffffff-ffff-4fff-afff-ffffffffffff"; +export const ORIGINAL_ID_2 = "22222222-2222-4222-a222-222222222222"; diff --git a/packages/client/src/services/RecurringTaskDeduplicator.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.test.ts deleted file mode 100644 index df4ae3a0..00000000 --- a/packages/client/src/services/RecurringTaskDeduplicator.test.ts +++ /dev/null @@ -1,692 +0,0 @@ -// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull -import "fake-indexeddb/auto"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { db } from "@/db/database"; -import { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; -import { TaskRepository } from "@/db/repositories/TaskRepository"; -import { fakeClock } from "@/lib/temporal"; -import { buildChecklistItem } from "@/test/factories/checklistItemFactory"; -import { buildTask } from "@/test/factories/taskFactory"; -import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator"; - -const FIXED_CLOCK_TIMESTAMP = "2026-07-10T12:00:00Z"; - -// Deterministic UUIDs for predictable tiebreak ordering -const UUID_A = "00000000-0000-4000-a000-000000000001"; -const UUID_B = "00000000-0000-4000-a000-000000000002"; -const UUID_C = "00000000-0000-4000-a000-000000000003"; -const UUID_Z = "ffffffff-ffff-4fff-afff-ffffffffffff"; -const ORIGINAL_ID_1 = "11111111-1111-4111-a111-111111111111"; -const ORIGINAL_ID_2 = "22222222-2222-4222-a222-222222222222"; - -describe("RecurringTaskDeduplicator", () => { - let deduplicator: RecurringTaskDeduplicator; - - beforeEach(async () => { - await db.tasks.clear(); - await db.checklist_items.clear(); - const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); - deduplicator = new RecurringTaskDeduplicator( - new TaskRepository(), - new ChecklistRepository(), - clock, - ); - }); - - // FR5: skip when list is empty - describe("skip optimization", () => { - it("should not modify any tasks when originalTaskIds is empty", async () => { - const originalUpdatedAt = "2026-06-01T10:00:00.000Z"; - const taskA = buildTask({ - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - updated_at: originalUpdatedAt, - }); - const taskB = buildTask({ - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - updated_at: originalUpdatedAt, - }); - await db.tasks.bulkAdd([taskA, taskB]); - - await deduplicator.deduplicate([]); - - const resultA = await db.tasks.get(taskA.id); - const resultB = await db.tasks.get(taskB.id); - expect(resultA?.is_deleted).toBe(false); - expect(resultB?.is_deleted).toBe(false); - expect(resultA?.updated_at).toBe(originalUpdatedAt); - expect(resultB?.updated_at).toBe(originalUpdatedAt); - }); - - it("should filter out empty strings from originalTaskIds", async () => { - const taskA = buildTask({ - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const taskB = buildTask({ - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([taskA, taskB]); - - await deduplicator.deduplicate(["", ""]); - - const resultA = await db.tasks.get(taskA.id); - const resultB = await db.tasks.get(taskB.id); - expect(resultA?.is_deleted).toBe(false); - expect(resultB?.is_deleted).toBe(false); - }); - - it("should process real ids but ignore empty strings in mixed list", async () => { - const winnerTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const loserTask = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - }); - // Non-recurring tasks that should never be touched by dedup - const nonRecurringTaskA = buildTask({ - original_task_id: "", - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const nonRecurringTaskB = buildTask({ - original_task_id: "", - next_date: "2026-07-02", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([ - winnerTask, - loserTask, - nonRecurringTaskA, - nonRecurringTaskB, - ]); - - await deduplicator.deduplicate(["", ORIGINAL_ID_1, ""]); - - // Duplicates should be deduped - expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false); - expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true); - // Non-recurring tasks must not be touched - const nonRecA = await db.tasks.get(nonRecurringTaskA.id); - const nonRecB = await db.tasks.get(nonRecurringTaskB.id); - expect(nonRecA?.is_deleted).toBe(false); - expect(nonRecB?.is_deleted).toBe(false); - expect(nonRecA?.syncStatus).toBe("synced"); - expect(nonRecB?.syncStatus).toBe("synced"); - }); - }); - - // FR1 + FR2: detect duplicates and pick winner - describe("winner selection", () => { - it("should keep the copy with earliest next_date", async () => { - const earlyTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const lateTask = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-10", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([earlyTask, lateTask]); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - const winner = await db.tasks.get(UUID_A); - const loser = await db.tasks.get(UUID_B); - expect(winner?.is_deleted).toBe(false); - expect(loser?.is_deleted).toBe(true); - expect(loser?.syncStatus).toBe("pending"); - }); - - it("should tiebreak by lexicographically smallest id when next_date is same", async () => { - // Insert UUID_Z first so stable sort without tiebreak would keep it as winner - const taskZ = buildTask({ - id: UUID_Z, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const taskA = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([taskZ, taskA]); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - const winner = await db.tasks.get(UUID_A); - const loser = await db.tasks.get(UUID_Z); - expect(winner?.is_deleted).toBe(false); - expect(loser?.is_deleted).toBe(true); - expect(loser?.syncStatus).toBe("pending"); - }); - - it("should not modify a single copy (no duplicates)", async () => { - const originalUpdatedAt = "2026-06-01T10:00:00.000Z"; - const singleTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - updated_at: originalUpdatedAt, - }); - await db.tasks.add(singleTask); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - const result = await db.tasks.get(UUID_A); - expect(result?.is_deleted).toBe(false); - expect(result?.syncStatus).toBe("synced"); - expect(result?.updated_at).toBe(originalUpdatedAt); - }); - }); - - // FR2: loser soft-delete fields - describe("loser soft-delete", () => { - it("should set updated_at on loser to current clock timestamp", async () => { - const taskA = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const taskB = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([taskA, taskB]); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - const loser = await db.tasks.get(UUID_B); - expect(loser?.updated_at).toBe("2026-07-10T12:00:00.000Z"); - }); - }); - - // FR1: ignore completed and deleted tasks - describe("filtering", () => { - it("should ignore completed copies when detecting duplicates", async () => { - const activeTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - }); - const completedTask = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: true, - is_deleted: false, - }); - await db.tasks.bulkAdd([activeTask, completedTask]); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - const active = await db.tasks.get(UUID_A); - const completed = await db.tasks.get(UUID_B); - expect(active?.is_deleted).toBe(false); - expect(completed?.is_deleted).toBe(false); - }); - - it("should ignore already-deleted copies when detecting duplicates", async () => { - const activeTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - }); - const deletedTask = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: true, - }); - await db.tasks.bulkAdd([activeTask, deletedTask]); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - const active = await db.tasks.get(UUID_A); - expect(active?.is_deleted).toBe(false); - }); - - // FR1: a completed copy sharing original_task_id with two active - // duplicates must not be pulled into the dup group — the group is - // formed only from the two non-completed, non-deleted copies, so the - // winner is chosen among those two and the completed copy is untouched. - it("should form the duplicate group only from active copies when a completed copy shares the original_task_id with two active duplicates", async () => { - const earlyActiveTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const lateActiveTask = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - }); - const completedTask = buildTask({ - id: UUID_C, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-06-20", - is_completed: true, - is_deleted: false, - }); - await db.tasks.bulkAdd([earlyActiveTask, lateActiveTask, completedTask]); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - const earlyActiveResult = await db.tasks.get(UUID_A); - const lateActiveResult = await db.tasks.get(UUID_B); - const completedResult = await db.tasks.get(UUID_C); - expect(earlyActiveResult?.is_deleted).toBe(false); - expect(lateActiveResult?.is_deleted).toBe(true); - expect(lateActiveResult?.syncStatus).toBe("pending"); - expect(completedResult?.is_deleted).toBe(false); - expect(completedResult?.syncStatus).toBe("synced"); - }); - }); - - // FR3: cascade to checklist items - describe("checklist cascade", () => { - it("should soft-delete checklist items of loser task", async () => { - const winnerTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const loserTask = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-10", - is_completed: false, - is_deleted: false, - }); - const checklistItem = buildChecklistItem({ - task_id: UUID_B, - is_deleted: false, - }); - await db.tasks.bulkAdd([winnerTask, loserTask]); - await db.checklist_items.add(checklistItem); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - const item = await db.checklist_items.get(checklistItem.id); - expect(item?.is_deleted).toBe(true); - expect(item?.syncStatus).toBe("pending"); - expect(item?.updated_at).toBe("2026-07-10T12:00:00.000Z"); - }); - - it("should not modify checklist items of winner task", async () => { - const winnerTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const loserTask = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-10", - is_completed: false, - is_deleted: false, - }); - const winnerItem = buildChecklistItem({ - task_id: UUID_A, - is_deleted: false, - syncStatus: "synced", - }); - await db.tasks.bulkAdd([winnerTask, loserTask]); - await db.checklist_items.add(winnerItem); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - const item = await db.checklist_items.get(winnerItem.id); - expect(item?.is_deleted).toBe(false); - expect(item?.syncStatus).toBe("synced"); - }); - }); - - // Multiple original_task_ids - describe("multiple groups", () => { - it("should deduplicate multiple original_task_id groups independently", async () => { - const groupAWinner = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const groupALoser = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - }); - const groupBWinner = buildTask({ - original_task_id: ORIGINAL_ID_2, - next_date: "2026-07-02", - is_completed: false, - is_deleted: false, - }); - const groupBLoser = buildTask({ - original_task_id: ORIGINAL_ID_2, - next_date: "2026-07-08", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([ - groupAWinner, - groupALoser, - groupBWinner, - groupBLoser, - ]); - - await deduplicator.deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]); - - expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false); - expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true); - // Group B: winner has earlier next_date - const groupBTasks = await db.tasks - .where("original_task_id") - .equals(ORIGINAL_ID_2) - .toArray(); - const groupBWinnerResult = groupBTasks.find( - (task) => task.next_date === "2026-07-02", - ); - const groupBLoserResult = groupBTasks.find( - (task) => task.next_date === "2026-07-08", - ); - expect(groupBWinnerResult?.is_deleted).toBe(false); - expect(groupBLoserResult?.is_deleted).toBe(true); - }); - - it("should deduplicate unique ids only (no double processing)", async () => { - const winnerTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const loserTask = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([winnerTask, loserTask]); - - await deduplicator.deduplicate([ - ORIGINAL_ID_1, - ORIGINAL_ID_1, - ORIGINAL_ID_1, - ]); - - expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(false); - expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(true); - }); - }); - - // Three or more duplicates - describe("three or more duplicates", () => { - it("should keep only the winner among three duplicates", async () => { - const taskA = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-03", - is_completed: false, - is_deleted: false, - }); - const taskB = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const taskC = buildTask({ - id: UUID_C, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-05", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([taskA, taskB, taskC]); - - await deduplicator.deduplicate([ORIGINAL_ID_1]); - - expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false); - expect((await db.tasks.get(UUID_A))?.is_deleted).toBe(true); - expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true); - }); - }); - - // Mutants 1-2: early return guard when ids are empty - describe("optimization guard — empty ids skip repository call", () => { - it("should not call findDuplicateRecurringGroups when ids list is empty", async () => { - const taskRepository = new TaskRepository(); - const checklistRepository = new ChecklistRepository(); - const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); - const findSpy = vi.spyOn(taskRepository, "findDuplicateRecurringGroups"); - const localDeduplicator = new RecurringTaskDeduplicator( - taskRepository, - checklistRepository, - clock, - ); - - await localDeduplicator.deduplicate([]); - - expect(findSpy).not.toHaveBeenCalled(); - }); - - it("should not call findDuplicateRecurringGroups when all ids are empty strings", async () => { - const taskRepository = new TaskRepository(); - const checklistRepository = new ChecklistRepository(); - const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); - const findSpy = vi.spyOn(taskRepository, "findDuplicateRecurringGroups"); - const localDeduplicator = new RecurringTaskDeduplicator( - taskRepository, - checklistRepository, - clock, - ); - - await localDeduplicator.deduplicate(["", ""]); - - expect(findSpy).not.toHaveBeenCalled(); - }); - }); - - // Mutants 3-4: continue guard when group has single copy - describe("optimization guard — single copy skips update", () => { - it("should not call update when group has only one active copy", async () => { - const taskRepository = new TaskRepository(); - const checklistRepository = new ChecklistRepository(); - const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); - const updateSpy = vi.spyOn(taskRepository, "update"); - const localDeduplicator = new RecurringTaskDeduplicator( - taskRepository, - checklistRepository, - clock, - ); - - const singleTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - await db.tasks.add(singleTask); - - await localDeduplicator.deduplicate([ORIGINAL_ID_1]); - - expect(updateSpy).not.toHaveBeenCalled(); - }); - - // Guards the `continue` statement directly: a group can only reach this - // guard with activeCopies.length < MINIMUM_DUPLICATE_GROUP_SIZE (0 or 1) - // since findDuplicateRecurringGroups never emits an empty group in - // practice — so length-1 groups are a legitimate but observationally - // equivalent case (merging a single copy against itself is a no-op - // either way). Forcing an empty-array group via a mocked repository - // makes the two mutants observable: without the `continue` (or with the - // guard replaced by `if (false)`), the loop falls through to - // `sortByWinnerPriority([])` and then destructures an undefined - // scheduleWinner, which throws when merging — proving `continue` really - // skips groups below the minimum size rather than merely coinciding with - // a no-op merge. - it("should not throw when a mocked group is empty, proving continue actually skips it", async () => { - const taskRepository = new TaskRepository(); - const checklistRepository = new ChecklistRepository(); - const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); - vi.spyOn( - taskRepository, - "findDuplicateRecurringGroups", - ).mockResolvedValue(new Map([[ORIGINAL_ID_1, []]])); - const localDeduplicator = new RecurringTaskDeduplicator( - taskRepository, - checklistRepository, - clock, - ); - - await expect( - localDeduplicator.deduplicate([ORIGINAL_ID_1]), - ).resolves.toBeUndefined(); - }); - - // Guards the `continue` statement itself (not just the guarded call): - // replacing `continue` with an empty block would still skip the update - // for the singleton group (since the guard body has no other statements) - // but would fall through to loop code the real implementation intends to - // skip, rather than jumping to the next group. Processing a singleton - // group FIRST and a real duplicate group SECOND in the same call proves - // the loop actually advances past the singleton to still dedupe group 2 — - // an `if (...) {}` mutant that fails to skip to the next iteration would - // still coincidentally pass this too, but combined with the single-copy - // assertions above, it locks down that iteration order is unaffected. - it("should still deduplicate a later group after an earlier singleton group in the same call", async () => { - const singleTask = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const groupTwoWinner = buildTask({ - id: UUID_B, - original_task_id: ORIGINAL_ID_2, - next_date: "2026-07-02", - is_completed: false, - is_deleted: false, - }); - const groupTwoLoser = buildTask({ - id: UUID_C, - original_task_id: ORIGINAL_ID_2, - next_date: "2026-07-08", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([singleTask, groupTwoWinner, groupTwoLoser]); - - await deduplicator.deduplicate([ORIGINAL_ID_1, ORIGINAL_ID_2]); - - expect((await db.tasks.get(UUID_A))?.syncStatus).toBe("synced"); - expect((await db.tasks.get(UUID_B))?.is_deleted).toBe(false); - expect((await db.tasks.get(UUID_C))?.is_deleted).toBe(true); - }); - }); - - // Mutant 5: id tiebreak when next_date is equal - describe("tiebreak by id — independent of repository order", () => { - it("should pick lexicographically smallest id even when repository returns reverse order", async () => { - const taskRepository = new TaskRepository(); - const checklistRepository = new ChecklistRepository(); - const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); - - const taskZ = buildTask({ - id: UUID_Z, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - const taskA = buildTask({ - id: UUID_A, - original_task_id: ORIGINAL_ID_1, - next_date: "2026-07-01", - is_completed: false, - is_deleted: false, - }); - await db.tasks.bulkAdd([taskZ, taskA]); - - // Mock repository to return tasks in reverse order (UUID_Z first) - // so stable sort without id tiebreak would keep UUID_Z as winner - const reversedGroups = new Map([[ORIGINAL_ID_1, [taskZ, taskA]]]); - vi.spyOn( - taskRepository, - "findDuplicateRecurringGroups", - ).mockResolvedValue(reversedGroups); - - const localDeduplicator = new RecurringTaskDeduplicator( - taskRepository, - checklistRepository, - clock, - ); - - await localDeduplicator.deduplicate([ORIGINAL_ID_1]); - - const winner = await db.tasks.get(UUID_A); - const loser = await db.tasks.get(UUID_Z); - expect(winner?.is_deleted).toBe(false); - expect(loser?.is_deleted).toBe(true); - }); - }); -}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.tiebreak-by-id.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.tiebreak-by-id.test.ts new file mode 100644 index 00000000..e16228e4 --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.tiebreak-by-id.test.ts @@ -0,0 +1,67 @@ +// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull +import "fake-indexeddb/auto"; +import { describe, expect, it, vi } from "vitest"; +import { db } from "@/db/database"; +import { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; +import { TaskRepository } from "@/db/repositories/TaskRepository"; +import { fakeClock } from "@/lib/temporal"; +import { buildTask } from "@/test/factories/taskFactory"; +import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator"; +import { + createRecurringTaskDeduplicatorMergeSetup, + ORIGINAL_ID_1, + UUID_A, + UUID_Z, +} from "./RecurringTaskDeduplicator.test-setup"; + +const FIXED_CLOCK_TIMESTAMP = "2026-07-10T12:00:00Z"; + +describe("RecurringTaskDeduplicator", () => { + createRecurringTaskDeduplicatorMergeSetup(); + + // Mutant 5: id tiebreak when next_date is equal + describe("tiebreak by id — independent of repository order", () => { + it("should pick lexicographically smallest id even when repository returns reverse order", async () => { + const taskRepository = new TaskRepository(); + const checklistRepository = new ChecklistRepository(); + const clock = fakeClock(FIXED_CLOCK_TIMESTAMP); + + const taskZ = buildTask({ + id: UUID_Z, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const taskA = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([taskZ, taskA]); + + // Mock repository to return tasks in reverse order (UUID_Z first) + // so stable sort without id tiebreak would keep UUID_Z as winner + const reversedGroups = new Map([[ORIGINAL_ID_1, [taskZ, taskA]]]); + vi.spyOn( + taskRepository, + "findDuplicateRecurringGroups", + ).mockResolvedValue(reversedGroups); + + const localDeduplicator = new RecurringTaskDeduplicator( + taskRepository, + checklistRepository, + clock, + ); + + await localDeduplicator.deduplicate([ORIGINAL_ID_1]); + + const winner = await db.tasks.get(UUID_A); + const loser = await db.tasks.get(UUID_Z); + expect(winner?.is_deleted).toBe(false); + expect(loser?.is_deleted).toBe(true); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.winner-selection.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.winner-selection.test.ts new file mode 100644 index 00000000..addd807b --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.winner-selection.test.ts @@ -0,0 +1,184 @@ +// implements FR1, FR2, FR3, FR5 of dedup-recurring-after-pull +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { db } from "@/db/database"; +import { buildTask } from "@/test/factories/taskFactory"; +import { + createRecurringTaskDeduplicatorMergeSetup, + ORIGINAL_ID_1, + UUID_A, + UUID_B, + UUID_C, + UUID_Z, +} from "./RecurringTaskDeduplicator.test-setup"; + +describe("RecurringTaskDeduplicator", () => { + const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup(); + + // FR1 + FR2: detect duplicates and pick winner + describe("winner selection", () => { + it("should keep the copy with earliest next_date", async () => { + const earlyTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const lateTask = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-10", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([earlyTask, lateTask]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const winner = await db.tasks.get(UUID_A); + const loser = await db.tasks.get(UUID_B); + expect(winner?.is_deleted).toBe(false); + expect(loser?.is_deleted).toBe(true); + expect(loser?.syncStatus).toBe("pending"); + }); + + it("should tiebreak by lexicographically smallest id when next_date is same", async () => { + // Insert UUID_Z first so stable sort without tiebreak would keep it as winner + const taskZ = buildTask({ + id: UUID_Z, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const taskA = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + await db.tasks.bulkAdd([taskZ, taskA]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const winner = await db.tasks.get(UUID_A); + const loser = await db.tasks.get(UUID_Z); + expect(winner?.is_deleted).toBe(false); + expect(loser?.is_deleted).toBe(true); + expect(loser?.syncStatus).toBe("pending"); + }); + + it("should not modify a single copy (no duplicates)", async () => { + const originalUpdatedAt = "2026-06-01T10:00:00.000Z"; + const singleTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + updated_at: originalUpdatedAt, + }); + await db.tasks.add(singleTask); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const result = await db.tasks.get(UUID_A); + expect(result?.is_deleted).toBe(false); + expect(result?.syncStatus).toBe("synced"); + expect(result?.updated_at).toBe(originalUpdatedAt); + }); + }); + + // FR1: ignore completed and deleted tasks + describe("filtering", () => { + it("should ignore completed copies when detecting duplicates", async () => { + const activeTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + const completedTask = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: true, + is_deleted: false, + }); + await db.tasks.bulkAdd([activeTask, completedTask]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const active = await db.tasks.get(UUID_A); + const completed = await db.tasks.get(UUID_B); + expect(active?.is_deleted).toBe(false); + expect(completed?.is_deleted).toBe(false); + }); + + it("should ignore already-deleted copies when detecting duplicates", async () => { + const activeTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + const deletedTask = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: true, + }); + await db.tasks.bulkAdd([activeTask, deletedTask]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const active = await db.tasks.get(UUID_A); + expect(active?.is_deleted).toBe(false); + }); + + // FR1: a completed copy sharing original_task_id with two active + // duplicates must not be pulled into the dup group — the group is + // formed only from the two non-completed, non-deleted copies, so the + // winner is chosen among those two and the completed copy is untouched. + it("should form the duplicate group only from active copies when a completed copy shares the original_task_id with two active duplicates", async () => { + const earlyActiveTask = buildTask({ + id: UUID_A, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + is_completed: false, + is_deleted: false, + }); + const lateActiveTask = buildTask({ + id: UUID_B, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-05", + is_completed: false, + is_deleted: false, + }); + const completedTask = buildTask({ + id: UUID_C, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-06-20", + is_completed: true, + is_deleted: false, + }); + await db.tasks.bulkAdd([earlyActiveTask, lateActiveTask, completedTask]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const earlyActiveResult = await db.tasks.get(UUID_A); + const lateActiveResult = await db.tasks.get(UUID_B); + const completedResult = await db.tasks.get(UUID_C); + expect(earlyActiveResult?.is_deleted).toBe(false); + expect(lateActiveResult?.is_deleted).toBe(true); + expect(lateActiveResult?.syncStatus).toBe("pending"); + expect(completedResult?.is_deleted).toBe(false); + expect(completedResult?.syncStatus).toBe("synced"); + }); + }); +}); diff --git a/packages/integration/docker-compose.yml b/packages/integration/docker-compose.yml index 862587f7..82191a0f 100644 --- a/packages/integration/docker-compose.yml +++ b/packages/integration/docker-compose.yml @@ -54,7 +54,12 @@ services: GOTRUE_JWT_ADMIN_ROLES: service_role GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated - GOTRUE_JWT_EXP: "3600" + # 7-day access tokens: multi-device stale-sync tests fast-forward a + # device's browser clock up to ~2 days to simulate reconnecting later. + # A 1-hour token would appear expired after that jump and trigger a + # refresh, which races/fails under the faked clock. A token that outlasts + # the jump is never refreshed, keeping those tests deterministic. + GOTRUE_JWT_EXP: "604800" GOTRUE_JWT_SECRET: ${JWT_SECRET} GOTRUE_MAILER_AUTOCONFIRM: "true" GOTRUE_SMTP_HOST: "" diff --git a/packages/integration/kong.yml b/packages/integration/kong.yml index d7210426..e47fcc6d 100644 --- a/packages/integration/kong.yml +++ b/packages/integration/kong.yml @@ -22,6 +22,7 @@ services: - Authorization - apikey - x-client-info + - x-supabase-api-version - name: rest-v1 url: http://rest:3000 @@ -40,6 +41,7 @@ services: - Authorization - apikey - x-client-info + - x-supabase-api-version - Prefer - name: storage-v1 @@ -59,6 +61,7 @@ services: - Authorization - apikey - x-client-info + - x-supabase-api-version - name: functions-v1 url: http://edge-functions:9000 @@ -77,3 +80,4 @@ services: - Authorization - apikey - x-client-info + - x-supabase-api-version diff --git a/packages/integration/src/convergence-helpers.ts b/packages/integration/src/convergence-helpers.ts index 94c40d04..3a1a7a7e 100644 --- a/packages/integration/src/convergence-helpers.ts +++ b/packages/integration/src/convergence-helpers.ts @@ -123,6 +123,13 @@ export async function dumpServerState( * Asserts that device A, device B, and the server all hold an identical set * of records for the given entity table — the NFR-REL1 convergence check. * Call after both devices have completed a push+pull cycle. + * + * `recordFilter` scopes the comparison to the records a test actually touched. + * The backend user is shared across the whole integration suite, so unrelated + * records accumulate — and a clock-advanced device legitimately reveals OTHER + * specs' hidden occurrences whose `appear_date` falls on the faked date. Those + * are harness noise, not a convergence failure of the scenario under test; + * filtering by (e.g.) task name keeps the assertion about this test's records. * Implements NFR-REL1 of fix-stale-sync-overwrites. */ export async function assertConverged( @@ -130,6 +137,7 @@ export async function assertConverged( pageB: Page, credentials: ServerCallCredentials, entityKey: ConvergenceEntityKey, + recordFilter: (record: EntityRecord) => boolean = () => true, ): Promise { const [deviceAState, deviceBState, serverState] = await Promise.all([ dumpDeviceState(pageA, entityKey), @@ -137,6 +145,10 @@ export async function assertConverged( dumpServerState(credentials, entityKey), ]); - expect(deviceAState).toEqual(serverState); - expect(deviceBState).toEqual(serverState); + expect(deviceAState.filter(recordFilter)).toEqual( + serverState.filter(recordFilter), + ); + expect(deviceBState.filter(recordFilter)).toEqual( + serverState.filter(recordFilter), + ); } diff --git a/packages/integration/src/page-actions.ts b/packages/integration/src/page-actions.ts deleted file mode 100644 index bba3125c..00000000 --- a/packages/integration/src/page-actions.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Shared UI interaction helpers for integration tests. -// Eliminates duplication of common create / open / update / delete sequences. -import type { Page } from "@playwright/test"; - -// --------------------------------------------------------------------------- -// Task actions (assumes page is on /tasks) -// --------------------------------------------------------------------------- - -export async function createTask(page: Page, taskName: string): Promise { - // If box filter is present (Active Tasks page), select "today" to ensure - // the task is created in a visible box (defaultBox may be "inbox"). - const filterToggle = page.getByTestId("command-bar-filter-toggle"); - if (await filterToggle.isVisible()) { - await filterToggle.click(); - await page.getByTestId("box-filter-today").click(); - } - - const textarea = page.getByTestId("command-bar-textarea"); - await textarea.click(); - await textarea.fill(taskName); - await textarea.press("Enter"); -} - -export function findTaskItem(page: Page, taskName: string) { - return page.locator('[data-testid="task-item"]').filter({ - has: page.locator('[data-testid="task-item-name"]', { - hasText: taskName, - }), - }); -} - -export async function openTaskDetail( - page: Page, - taskName: string, -): Promise { - await findTaskItem(page, taskName) - .locator('[data-testid="task-item-body"]') - .click(); - await page.waitForSelector('[data-testid="task-detail-panel"]'); -} - -export async function updateTaskName( - page: Page, - newName: string, -): Promise { - await page.getByTestId("task-detail-name").fill(newName); - await page.getByTestId("task-detail-name").blur(); -} - -/** - * Updates a task's description from the task detail panel. - * `EditableDescription` is click-to-edit: at rest it renders a div, and only - * becomes a textarea after being clicked. Saving happens on blur. - */ -export async function updateTaskDescription( - page: Page, - newDescription: string, -): Promise { - const descriptionField = page.getByTestId("task-detail-description"); - await descriptionField.click(); - await descriptionField.fill(newDescription); - await descriptionField.blur(); -} - -/** - * Hides a non-recurring task until the given ISO date via the task detail - * panel's "Hide until" drill-down row. Assumes the task detail panel is - * already open (see `openTaskDetail`). - * Implements FR2 of fix-stale-sync-overwrites. - */ -export async function hideTaskUntil( - page: Page, - appearDateIso: string, -): Promise { - await page - .getByTestId("task-detail-panel") - .getByRole("button", { name: /hide until/i }) - .click(); - const hidePanel = page.getByTestId("hide-task-panel"); - await hidePanel.waitFor({ state: "visible" }); - await hidePanel.getByLabel(/select date/i).fill(appearDateIso); - await hidePanel.getByRole("button", { name: /^hide$/i }).click(); - await hidePanel.waitFor({ state: "hidden" }); -} - -/** - * Manually unhides a task before its appear_date via the task detail panel's - * "Hide until" drill-down row, which shows the unhide control while the task - * is hidden. Assumes the task detail panel is already open. - * Implements FR2 of fix-stale-sync-overwrites. - */ -export async function unhideTask(page: Page): Promise { - await page - .getByTestId("task-detail-panel") - .getByRole("button", { name: /hide until/i }) - .click(); - const hidePanel = page.getByTestId("hide-task-panel"); - await hidePanel.waitFor({ state: "visible" }); - await hidePanel.getByRole("button", { name: /unhide/i }).click(); - await hidePanel.waitFor({ state: "hidden" }); -} - -export async function deleteTaskFromDetail(page: Page): Promise { - await page - .getByTestId("task-detail-panel") - .getByRole("button", { name: /delete task/i }) - .click(); - await page.waitForSelector('[data-testid="task-detail-delete-confirm-btn"]'); - await page.getByTestId("task-detail-delete-confirm-btn").click(); - await page.waitForSelector('[data-testid="task-detail-panel"]', { - state: "detached", - }); -} - -// --------------------------------------------------------------------------- -// Checklist actions -// --------------------------------------------------------------------------- - -export async function switchToChecklistTab(page: Page): Promise { - await page.getByTestId("tab-checklist").click(); -} - -export async function addChecklistItem( - page: Page, - itemName: string, -): Promise { - const checklistInput = page - .getByTestId("task-detail-panel") - .locator('input[type="text"]'); - await checklistInput.fill(itemName); - await checklistInput.press("Enter"); - await page - .getByTestId("task-detail-panel") - .locator(`text=${itemName}`) - .waitFor({ state: "visible" }); -} - -// --------------------------------------------------------------------------- -// Goal actions -// --------------------------------------------------------------------------- - -export async function navigateToGoals(page: Page): Promise { - await page.goto("/goals"); - await page.waitForSelector('[data-testid="goals-page"]'); -} - -export async function createGoal(page: Page, goalName: string): Promise { - const textarea = page.getByTestId("command-bar-textarea"); - await textarea.click(); - await textarea.fill(goalName); - await textarea.press("Enter"); - await page - .locator('[data-testid="goal-item"]') - .filter({ has: page.locator(`text=${goalName}`) }) - .waitFor({ state: "visible" }); -} - -export async function openGoalDetail( - page: Page, - goalName: string, -): Promise { - await page - .locator('[data-testid="goal-item"]') - .filter({ has: page.locator(`text=${goalName}`) }) - .locator('[data-testid="goal-navigate-button"]') - .click(); - await page.waitForSelector('[data-testid="goal-detail-page"]'); -} - -export async function updateGoalName( - page: Page, - newName: string, -): Promise { - await page.getByTestId("edit-goal-button").click(); - await page.getByTestId("goal-name-input").fill(newName); - await page.getByTestId("goal-save-button").click(); - await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); -} - -export async function deleteGoalFromDetail(page: Page): Promise { - await page.getByTestId("edit-goal-button").click(); - await page.getByTestId("goal-delete-button").click(); - await page.waitForSelector('[data-testid="goal-delete-confirm"]'); - await page.getByTestId("goal-delete-confirm-btn").click(); - await page.waitForSelector('[data-testid="goals-page"]'); -} - -export async function uploadCoverToGoal( - page: Page, - file: { name: string; mimeType: string; buffer: Buffer }, -): Promise { - await page.getByTestId("edit-goal-button").click(); - await page.getByTestId("cover-file-input").setInputFiles(file); - await page.getByTestId("cover-preview-img").waitFor({ state: "visible" }); - await page.getByTestId("goal-save-button").click(); - await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); -} - -export async function removeCoverFromGoal(page: Page): Promise { - await page.getByTestId("edit-goal-button").click(); - await page.getByTestId("cover-remove-button").click(); - await page.getByTestId("goal-save-button").click(); - await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); -} - -// --------------------------------------------------------------------------- -// Category actions -// --------------------------------------------------------------------------- - -export async function navigateToCategories(page: Page): Promise { - await page.goto("/categories"); - await page.waitForSelector('[data-testid="categories-page"]'); -} - -export async function createCategory( - page: Page, - categoryName: string, -): Promise { - const textarea = page.getByTestId("command-bar-textarea"); - await textarea.click(); - await textarea.fill(categoryName); - await textarea.press("Enter"); - await page.locator(`text=${categoryName}`).waitFor({ state: "visible" }); -} - -export async function openCategoryDetail( - page: Page, - categoryName: string, -): Promise { - await page.locator(`text=${categoryName}`).click(); - await page.waitForSelector('[data-testid="category-detail-page"]'); -} - -export async function updateCategoryName( - page: Page, - newName: string, -): Promise { - await page.getByTestId("category-edit-btn").click(); - await page.getByTestId("category-name-input").fill(newName); - await page.getByTestId("category-save-btn").click(); -} - -export async function deleteCategoryFromDetail(page: Page): Promise { - await page.getByTestId("category-edit-btn").click(); - await page.getByTestId("category-delete-btn").click(); - await page.getByTestId("category-delete-confirm-btn").click(); - await page.waitForSelector('[data-testid="categories-page"]'); -} - -// --------------------------------------------------------------------------- -// Attachment actions -// --------------------------------------------------------------------------- - -export async function deleteFirstAttachment(page: Page): Promise { - await page.locator('[data-testid^="attachment-delete-"]').first().click(); - const confirmButton = page.getByTestId("confirm-dialog-confirm"); - if (await confirmButton.isVisible().catch(() => false)) { - await confirmButton.click(); - } -} - -export async function attachFileToEntity( - page: Page, - file: { name: string; mimeType: string; buffer: Buffer }, -): Promise { - await page.getByTestId("tab-attachments").click(); - await page.getByTestId("attach-file-input").setInputFiles(file); - await page - .locator('[data-testid^="attachment-item-"]') - .first() - .waitFor({ state: "visible" }); -} diff --git a/packages/integration/src/page-attachment-actions.ts b/packages/integration/src/page-attachment-actions.ts new file mode 100644 index 00000000..e1a79af5 --- /dev/null +++ b/packages/integration/src/page-attachment-actions.ts @@ -0,0 +1,27 @@ +// Shared UI interaction helpers for integration tests: attachment actions. +// Split from page-actions.ts (process-invariants file-size cap). +import type { Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Attachment actions +// --------------------------------------------------------------------------- + +export async function deleteFirstAttachment(page: Page): Promise { + await page.locator('[data-testid^="attachment-delete-"]').first().click(); + const confirmButton = page.getByTestId("confirm-dialog-confirm"); + if (await confirmButton.isVisible().catch(() => false)) { + await confirmButton.click(); + } +} + +export async function attachFileToEntity( + page: Page, + file: { name: string; mimeType: string; buffer: Buffer }, +): Promise { + await page.getByTestId("tab-attachments").click(); + await page.getByTestId("attach-file-input").setInputFiles(file); + await page + .locator('[data-testid^="attachment-item-"]') + .first() + .waitFor({ state: "visible" }); +} diff --git a/packages/integration/src/page-checklist-actions.ts b/packages/integration/src/page-checklist-actions.ts new file mode 100644 index 00000000..d3efc182 --- /dev/null +++ b/packages/integration/src/page-checklist-actions.ts @@ -0,0 +1,26 @@ +// Shared UI interaction helpers for integration tests: checklist actions. +// Split from page-actions.ts (process-invariants file-size cap). +import type { Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Checklist actions +// --------------------------------------------------------------------------- + +export async function switchToChecklistTab(page: Page): Promise { + await page.getByTestId("tab-checklist").click(); +} + +export async function addChecklistItem( + page: Page, + itemName: string, +): Promise { + const checklistInput = page + .getByTestId("task-detail-panel") + .locator('input[type="text"]'); + await checklistInput.fill(itemName); + await checklistInput.press("Enter"); + await page + .getByTestId("task-detail-panel") + .locator(`text=${itemName}`) + .waitFor({ state: "visible" }); +} diff --git a/packages/integration/src/page-goal-category-actions.ts b/packages/integration/src/page-goal-category-actions.ts new file mode 100644 index 00000000..3ba25828 --- /dev/null +++ b/packages/integration/src/page-goal-category-actions.ts @@ -0,0 +1,115 @@ +// Shared UI interaction helpers for integration tests: goal and category actions. +// Split from page-actions.ts (process-invariants file-size cap). +import type { Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Goal actions +// --------------------------------------------------------------------------- + +export async function navigateToGoals(page: Page): Promise { + await page.goto("/goals"); + await page.waitForSelector('[data-testid="goals-page"]'); +} + +export async function createGoal(page: Page, goalName: string): Promise { + const textarea = page.getByTestId("command-bar-textarea"); + await textarea.click(); + await textarea.fill(goalName); + await textarea.press("Enter"); + await page + .locator('[data-testid="goal-item"]') + .filter({ has: page.locator(`text=${goalName}`) }) + .waitFor({ state: "visible" }); +} + +export async function openGoalDetail( + page: Page, + goalName: string, +): Promise { + await page + .locator('[data-testid="goal-item"]') + .filter({ has: page.locator(`text=${goalName}`) }) + .locator('[data-testid="goal-navigate-button"]') + .click(); + await page.waitForSelector('[data-testid="goal-detail-page"]'); +} + +export async function updateGoalName( + page: Page, + newName: string, +): Promise { + await page.getByTestId("edit-goal-button").click(); + await page.getByTestId("goal-name-input").fill(newName); + await page.getByTestId("goal-save-button").click(); + await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); +} + +export async function deleteGoalFromDetail(page: Page): Promise { + await page.getByTestId("edit-goal-button").click(); + await page.getByTestId("goal-delete-button").click(); + await page.waitForSelector('[data-testid="goal-delete-confirm"]'); + await page.getByTestId("goal-delete-confirm-btn").click(); + await page.waitForSelector('[data-testid="goals-page"]'); +} + +export async function uploadCoverToGoal( + page: Page, + file: { name: string; mimeType: string; buffer: Buffer }, +): Promise { + await page.getByTestId("edit-goal-button").click(); + await page.getByTestId("cover-file-input").setInputFiles(file); + await page.getByTestId("cover-preview-img").waitFor({ state: "visible" }); + await page.getByTestId("goal-save-button").click(); + await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); +} + +export async function removeCoverFromGoal(page: Page): Promise { + await page.getByTestId("edit-goal-button").click(); + await page.getByTestId("cover-remove-button").click(); + await page.getByTestId("goal-save-button").click(); + await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); +} + +// --------------------------------------------------------------------------- +// Category actions +// --------------------------------------------------------------------------- + +export async function navigateToCategories(page: Page): Promise { + await page.goto("/categories"); + await page.waitForSelector('[data-testid="categories-page"]'); +} + +export async function createCategory( + page: Page, + categoryName: string, +): Promise { + const textarea = page.getByTestId("command-bar-textarea"); + await textarea.click(); + await textarea.fill(categoryName); + await textarea.press("Enter"); + await page.locator(`text=${categoryName}`).waitFor({ state: "visible" }); +} + +export async function openCategoryDetail( + page: Page, + categoryName: string, +): Promise { + await page.locator(`text=${categoryName}`).click(); + await page.waitForSelector('[data-testid="category-detail-page"]'); +} + +export async function updateCategoryName( + page: Page, + newName: string, +): Promise { + await page.getByTestId("category-edit-btn").click(); + await page.getByTestId("category-name-input").fill(newName); + await page.getByTestId("category-save-btn").click(); +} + +export async function deleteCategoryFromDetail(page: Page): Promise { + await page.getByTestId("category-edit-btn").click(); + await page.getByTestId("category-delete-btn").click(); + await page.getByTestId("category-delete-confirm-btn").click(); + await page.waitForSelector('[data-testid="categories-page"]'); +} diff --git a/packages/integration/src/page-task-actions.ts b/packages/integration/src/page-task-actions.ts new file mode 100644 index 00000000..c1175877 --- /dev/null +++ b/packages/integration/src/page-task-actions.ts @@ -0,0 +1,159 @@ +// Shared UI interaction helpers for integration tests: task actions. +// Eliminates duplication of common create / open / update / delete sequences. +// Split from page-actions.ts (process-invariants file-size cap). +import type { Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Task actions (assumes page is on /tasks) +// --------------------------------------------------------------------------- + +export async function createTask(page: Page, taskName: string): Promise { + // If box filter is present (Active Tasks page), select "today" to ensure + // the task is created in a visible box (defaultBox may be "inbox"). + const filterToggle = page.getByTestId("command-bar-filter-toggle"); + if (await filterToggle.isVisible()) { + await filterToggle.click(); + await page.getByTestId("box-filter-today").click(); + } + + const textarea = page.getByTestId("command-bar-textarea"); + await textarea.click(); + await textarea.fill(taskName); + await textarea.press("Enter"); +} + +export function findTaskItem(page: Page, taskName: string) { + return page.locator('[data-testid="task-item"]').filter({ + has: page.locator('[data-testid="task-item-name"]', { + hasText: taskName, + }), + }); +} + +export async function openTaskDetail( + page: Page, + taskName: string, +): Promise { + await findTaskItem(page, taskName) + .locator('[data-testid="task-item-body"]') + .click(); + await page.waitForSelector('[data-testid="task-detail-panel"]'); +} + +export async function updateTaskName( + page: Page, + newName: string, +): Promise { + await page.getByTestId("task-detail-name").fill(newName); + await page.getByTestId("task-detail-name").blur(); +} + +/** + * Updates a task's description from the task detail panel. + * `EditableDescription` is click-to-edit: at rest it renders a div, and only + * becomes a textarea after being clicked. Saving happens on blur. + */ +export async function updateTaskDescription( + page: Page, + newDescription: string, +): Promise { + const descriptionField = page.getByTestId("task-detail-description"); + await descriptionField.click(); + await descriptionField.fill(newDescription); + await descriptionField.blur(); +} + +/** + * Hides a non-recurring task until the given ISO date via the task detail + * panel's "Hide until" drill-down row. Assumes the task detail panel is + * already open (see `openTaskDetail`). + * Implements FR2 of fix-stale-sync-overwrites. + */ +export async function hideTaskUntil( + page: Page, + appearDateIso: string, +): Promise { + await page + .getByTestId("task-detail-panel") + .getByRole("button", { name: /hide until/i }) + .click(); + const hidePanel = page.getByTestId("hide-task-panel"); + await hidePanel.waitFor({ state: "visible" }); + await hidePanel.getByLabel(/select date/i).fill(appearDateIso); + await hidePanel.getByRole("button", { name: /^hide$/i }).click(); + await hidePanel.waitFor({ state: "hidden" }); +} + +/** + * Manually unhides a task before its appear_date via the task detail panel's + * "Hide until" drill-down row, which shows the unhide control while the task + * is hidden. Assumes the task detail panel is already open. + * Implements FR2 of fix-stale-sync-overwrites. + */ +export async function unhideTask(page: Page): Promise { + await page + .getByTestId("task-detail-panel") + .getByRole("button", { name: /hide until/i }) + .click(); + const hidePanel = page.getByTestId("hide-task-panel"); + await hidePanel.waitFor({ state: "visible" }); + await hidePanel.getByRole("button", { name: /unhide/i }).click(); + await hidePanel.waitFor({ state: "hidden" }); +} + +export async function deleteTaskFromDetail(page: Page): Promise { + await page + .getByTestId("task-detail-panel") + .getByRole("button", { name: /delete task/i }) + .click(); + await page.waitForSelector('[data-testid="task-detail-delete-confirm-btn"]'); + await page.getByTestId("task-detail-delete-confirm-btn").click(); + await page.waitForSelector('[data-testid="task-detail-panel"]', { + state: "detached", + }); +} + +const DRAG_STEPS = 12; +const DRAG_SETTLE_MS = 300; + +/** + * Drags a task item (by its drag handle) vertically onto another task + * item's position, using low-level mouse events so dnd-kit's PointerSensor + * (activation distance 8px) reliably picks up the gesture. + */ +export async function dragTaskOnto( + page: Page, + sourceTaskName: string, + targetTaskName: string, +): Promise { + // Match the drag handle by its exact accessible label — a substring/regex + // like /drag/i would also match a task whose name contains "drag" + // (e.g. "Rebalance Dragged"), whose body button carries the name as its + // accessible name, triggering a strict-mode violation. + const sourceHandle = findTaskItem(page, sourceTaskName).getByRole("button", { + name: "Drag task", + exact: true, + }); + const targetItem = findTaskItem(page, targetTaskName); + const sourceBox = await sourceHandle.boundingBox(); + const targetBox = await targetItem.boundingBox(); + if (!sourceBox || !targetBox) { + throw new Error("dragTaskOnto: could not resolve bounding boxes"); + } + const startX = sourceBox.x + sourceBox.width / 2; + const startY = sourceBox.y + sourceBox.height / 2; + const endX = targetBox.x + targetBox.width / 2; + const endY = targetBox.y + targetBox.height / 2; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + for (let step = 1; step <= DRAG_STEPS; step++) { + const progress = step / DRAG_STEPS; + await page.mouse.move( + startX + (endX - startX) * progress, + startY + (endY - startY) * progress, + ); + } + await page.mouse.up(); + await page.waitForTimeout(DRAG_SETTLE_MS); +} diff --git a/packages/integration/src/tests/attachments-offline.spec.ts b/packages/integration/src/tests/attachments-offline.spec.ts index 626ab9a1..8e5fd6b8 100644 --- a/packages/integration/src/tests/attachments-offline.spec.ts +++ b/packages/integration/src/tests/attachments-offline.spec.ts @@ -2,7 +2,7 @@ // Integration test for offline attachment creation -> pending file -> sync on reconnect. // Task 13.7 from add-file-attachments change. import { expect, test } from "@playwright/test"; -import { createTask, openTaskDetail } from "../page-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { createMinimalPng, pullFromServer, diff --git a/packages/integration/src/tests/attachments-purge-advanced.spec.ts b/packages/integration/src/tests/attachments-purge-advanced.spec.ts index af167912..ba39c496 100644 --- a/packages/integration/src/tests/attachments-purge-advanced.spec.ts +++ b/packages/integration/src/tests/attachments-purge-advanced.spec.ts @@ -4,15 +4,16 @@ import { expect, test } from "@playwright/test"; import { attachFileToEntity, - createGoal, - createTask, deleteFirstAttachment, +} from "../page-attachment-actions.js"; +import { + createGoal, navigateToGoals, openGoalDetail, - openTaskDetail, removeCoverFromGoal, uploadCoverToGoal, -} from "../page-actions.js"; +} from "../page-goal-category-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { createMinimalPng, getFileFromServer, diff --git a/packages/integration/src/tests/attachments-ref-counting.spec.ts b/packages/integration/src/tests/attachments-ref-counting.spec.ts index 682b184a..c159e618 100644 --- a/packages/integration/src/tests/attachments-ref-counting.spec.ts +++ b/packages/integration/src/tests/attachments-ref-counting.spec.ts @@ -4,13 +4,14 @@ import { expect, test } from "@playwright/test"; import { attachFileToEntity, - createGoal, - createTask, deleteFirstAttachment, +} from "../page-attachment-actions.js"; +import { + createGoal, navigateToGoals, openGoalDetail, - openTaskDetail, -} from "../page-actions.js"; +} from "../page-goal-category-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { createMinimalPng, getFileFromServer, diff --git a/packages/integration/src/tests/attachments-sync.spec.ts b/packages/integration/src/tests/attachments-sync.spec.ts index 5ffff07a..ea965350 100644 --- a/packages/integration/src/tests/attachments-sync.spec.ts +++ b/packages/integration/src/tests/attachments-sync.spec.ts @@ -2,7 +2,7 @@ // Integration tests for attachment sync between devices. // Tasks 13.3-13.7 from add-file-attachments change. import { expect, test } from "@playwright/test"; -import { createTask, openTaskDetail } from "../page-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { createMinimalPng, pullFromServer, diff --git a/packages/integration/src/tests/cascade-checklist-delete.spec.ts b/packages/integration/src/tests/cascade-checklist-delete.spec.ts index 05c98390..97e5fb9f 100644 --- a/packages/integration/src/tests/cascade-checklist-delete.spec.ts +++ b/packages/integration/src/tests/cascade-checklist-delete.spec.ts @@ -2,11 +2,13 @@ import { expect, test } from "@playwright/test"; import { addChecklistItem, + switchToChecklistTab, +} from "../page-checklist-actions.js"; +import { createTask, deleteTaskFromDetail, openTaskDetail, - switchToChecklistTab, -} from "../page-actions.js"; +} from "../page-task-actions.js"; import { pullFromServer, setupSingleDeviceTest, diff --git a/packages/integration/src/tests/categories-sync.spec.ts b/packages/integration/src/tests/categories-sync.spec.ts index df6b22d5..40c02ec6 100644 --- a/packages/integration/src/tests/categories-sync.spec.ts +++ b/packages/integration/src/tests/categories-sync.spec.ts @@ -6,7 +6,7 @@ import { navigateToCategories, openCategoryDetail, updateCategoryName, -} from "../page-actions.js"; +} from "../page-goal-category-actions.js"; import { pullFromServer, setupSingleDeviceTest, diff --git a/packages/integration/src/tests/checklists-sync.spec.ts b/packages/integration/src/tests/checklists-sync.spec.ts index 38c8fbf3..9d6e8916 100644 --- a/packages/integration/src/tests/checklists-sync.spec.ts +++ b/packages/integration/src/tests/checklists-sync.spec.ts @@ -1,10 +1,7 @@ // implements FR6, FR8 of add-supabase-integration-tests import { expect, test } from "@playwright/test"; -import { - createTask, - openTaskDetail, - switchToChecklistTab, -} from "../page-actions.js"; +import { switchToChecklistTab } from "../page-checklist-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { pullFromServer, setupSingleDeviceTest, diff --git a/packages/integration/src/tests/covers-sync.spec.ts b/packages/integration/src/tests/covers-sync.spec.ts index e002294f..575f0521 100644 --- a/packages/integration/src/tests/covers-sync.spec.ts +++ b/packages/integration/src/tests/covers-sync.spec.ts @@ -4,7 +4,7 @@ import { createGoal, navigateToGoals, openGoalDetail, -} from "../page-actions.js"; +} from "../page-goal-category-actions.js"; import { createMinimalPng, getFileFromServer, diff --git a/packages/integration/src/tests/file-download-cross-device.spec.ts b/packages/integration/src/tests/file-download-cross-device.spec.ts index 1195b06b..d8422e73 100644 --- a/packages/integration/src/tests/file-download-cross-device.spec.ts +++ b/packages/integration/src/tests/file-download-cross-device.spec.ts @@ -6,7 +6,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { expect, test } from "@playwright/test"; -import { createTask, openTaskDetail } from "../page-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { getFileFromServer, pullFromServer, diff --git a/packages/integration/src/tests/file-mime-detection.spec.ts b/packages/integration/src/tests/file-mime-detection.spec.ts index 81f39e5e..938f3407 100644 --- a/packages/integration/src/tests/file-mime-detection.spec.ts +++ b/packages/integration/src/tests/file-mime-detection.spec.ts @@ -6,15 +6,14 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; +import { attachFileToEntity } from "../page-attachment-actions.js"; import { - attachFileToEntity, createGoal, - createTask, navigateToGoals, openGoalDetail, - openTaskDetail, uploadCoverToGoal, -} from "../page-actions.js"; +} from "../page-goal-category-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { findServerAttachmentForTask, getFileFromServer, diff --git a/packages/integration/src/tests/full-flow.spec.ts b/packages/integration/src/tests/full-flow.spec.ts index 57c111aa..b5b0aa5f 100644 --- a/packages/integration/src/tests/full-flow.spec.ts +++ b/packages/integration/src/tests/full-flow.spec.ts @@ -3,20 +3,22 @@ import { expect, test } from "@playwright/test"; import { createCategory, createGoal, - createTask, deleteCategoryFromDetail, deleteGoalFromDetail, - deleteTaskFromDetail, - findTaskItem, navigateToCategories, navigateToGoals, openCategoryDetail, openGoalDetail, - openTaskDetail, updateCategoryName, updateGoalName, +} from "../page-goal-category-actions.js"; +import { + createTask, + deleteTaskFromDetail, + findTaskItem, + openTaskDetail, updateTaskName, -} from "../page-actions.js"; +} from "../page-task-actions.js"; import { pullFromServer, setupSingleDeviceTest, diff --git a/packages/integration/src/tests/goals-sync.spec.ts b/packages/integration/src/tests/goals-sync.spec.ts index 2aafff51..2a0761da 100644 --- a/packages/integration/src/tests/goals-sync.spec.ts +++ b/packages/integration/src/tests/goals-sync.spec.ts @@ -5,7 +5,7 @@ import { deleteGoalFromDetail, navigateToGoals, openGoalDetail, -} from "../page-actions.js"; +} from "../page-goal-category-actions.js"; import { pullFromServer, setupSingleDeviceTest, diff --git a/packages/integration/src/tests/multi-device-dirty-protection.spec.ts b/packages/integration/src/tests/multi-device-dirty-protection.spec.ts index e8a55f1d..15bdd7aa 100644 --- a/packages/integration/src/tests/multi-device-dirty-protection.spec.ts +++ b/packages/integration/src/tests/multi-device-dirty-protection.spec.ts @@ -1,6 +1,10 @@ // implements FR15 of add-supabase-integration-tests import { expect, test } from "@playwright/test"; -import { createTask, openTaskDetail, updateTaskName } from "../page-actions.js"; +import { + createTask, + openTaskDetail, + updateTaskName, +} from "../page-task-actions.js"; import { pullFromServer, setupTwoDeviceTest, diff --git a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts index f6ad4af2..b0201242 100644 --- a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts +++ b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts @@ -1,12 +1,12 @@ -// implements U3, FR4 of fix-stale-sync-overwrites +// implements U3, FR4, NFR-REL1 of fix-stale-sync-overwrites import { randomUUID } from "node:crypto"; -import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; import { + dragTaskOnto, findTaskItem, openTaskDetail, updateTaskDescription, -} from "../page-actions.js"; +} from "../page-task-actions.js"; import { assertConverged, pullFromServer, @@ -14,6 +14,10 @@ import { setupTwoDeviceTest, triggerSyncAndWait, } from "../test-helpers.js"; +import { + buildInboxTaskPayload, + type RebalancePullResponse, +} from "./stale-rebalance-helpers.js"; const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest(); @@ -22,107 +26,6 @@ const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest(); const SORT_ORDER_REBALANCE_THRESHOLD = 10; const TASK_VISIBLE_TIMEOUT_MS = 15_000; -const DRAG_STEPS = 12; -const DRAG_SETTLE_MS = 300; - -interface RebalancePullResponse { - ok: boolean; - tasks: Array<{ - id: string; - name: string; - description: string; - box: string; - sort_order: string; - is_deleted: boolean; - updated_at: string; - }>; - current_revision: number; -} - -/** - * Builds a full push payload carrying a single "inbox" task with an explicit - * `sort_order`, so tests can pre-seed keys near/at the rebalance threshold - * without going through the app's own key-generation logic. - */ -function buildInboxTaskPayload(options: { - id: string; - name: string; - sortOrder: string; - updatedAt: string; -}): Record { - return { - tasks: [ - { - id: options.id, - name: options.name, - description: "", - box: "inbox", - is_completed: false, - is_deleted: false, - completed_at: "", - next_date: "", - appear_date: "", - context_id: "", - category_id: "", - goal_id: "", - repeat_rule: "", - is_hidden: false, - original_task_id: "", - sort_order: options.sortOrder, - created_at: options.updatedAt, - updated_at: options.updatedAt, - version: 1, - revision: 0, - }, - ], - goals: [], - ideas: [], - contexts: [], - categories: [], - checklist_items: [], - attachments: [], - settings: [], - }; -} - -/** - * Drags a task item (located by its drag handle) vertically onto another - * task item's position, using low-level mouse events so dnd-kit's - * PointerSensor (activation distance 8px) reliably picks up the gesture. - */ -async function dragTaskOnto( - page: Page, - sourceTaskName: string, - targetTaskName: string, -): Promise { - const sourceHandle = findTaskItem(page, sourceTaskName).getByRole("button", { - name: /drag/i, - }); - const targetItem = findTaskItem(page, targetTaskName); - - const sourceBox = await sourceHandle.boundingBox(); - const targetBox = await targetItem.boundingBox(); - if (!sourceBox || !targetBox) { - throw new Error("dragTaskOnto: could not resolve bounding boxes"); - } - - const startX = sourceBox.x + sourceBox.width / 2; - const startY = sourceBox.y + sourceBox.height / 2; - const endX = targetBox.x + targetBox.width / 2; - const endY = targetBox.y + targetBox.height / 2; - - await page.mouse.move(startX, startY); - await page.mouse.down(); - for (let step = 1; step <= DRAG_STEPS; step++) { - const progress = step / DRAG_STEPS; - await page.mouse.move( - startX + (endX - startX) * progress, - startY + (endY - startY) * progress, - ); - } - await page.mouse.up(); - await page.waitForTimeout(DRAG_SETTLE_MS); -} /** * U3 / FR4 / NFR-REL1 — a rebalance triggered on a stale device does not @@ -130,38 +33,30 @@ async function dragTaskOnto( * * Mechanism: `TaskService.rebalanceBox()` (packages/client/src/services/TaskService.ts) * fires whenever a drag's freshly-generated `sort_order` key exceeds - * SORT_ORDER_REBALANCE_THRESHOLD (10) characters — regardless of how many - * tasks are in the box. `fractional-indexing`'s `generateKeyBetween` grows a - * key by roughly one character whenever it inserts strictly between two - * keys that already differ only in their last character, so seeding two - * adjacent 10-char keys and dragging a third task to land exactly between - * them reliably produces an 11-char key in a single real drag — a genuine - * `rebalanceBox` invocation via the actual UI code path, not a mocked one. + * SORT_ORDER_REBALANCE_THRESHOLD (10) chars, regardless of box size. + * `fractional-indexing`'s `generateKeyBetween` grows a key by ~1 char when + * inserting strictly between two keys differing only in their last + * character, so seeding two adjacent 10-char keys and dragging a third task + * between them reliably produces an 11-char key in one real drag — a + * genuine `rebalanceBox` invocation via the actual UI code path. * - * Scenario: - * 1. Seed the inbox box directly on the server (bypassing the UI, since - * crafting exact colliding sort keys via real drags would need many - * sequential drags): a "dragged" task at the top (highest key), task X - * in the middle (the one A will edit), and two adjacent anchor tasks - * with 10-char keys one character apart at the bottom. - * 2. Both devices sync to a converged baseline including all four tasks. - * 3. Device B goes stale — no more syncing until step 6. - * 4. Device A edits task X's description and pushes — X gets a strictly - * newer `updated_at` on the server. - * 5. Device B (still unaware of A's edit) drags "dragged" down onto - * "anchorHigh"'s position — anchorHigh shifts down to sit between - * "dragged" and "anchorLow", so "dragged"'s new key is generated - * strictly between anchorLow and anchorHigh. The resulting >10-char key - * triggers `rebalanceBox` for the whole box: X and both anchors are marked - * `pending` locally on B WITHOUT their `updated_at` being bumped (FR4); - * only "dragged" (the task the user actually moved) gets a fresh - * `updated_at`. - * 6. Device B syncs (pull then push). FR5's LWW pull protection must let - * A's genuinely newer server copy of X win over B's stale-but-`pending` - * local copy — B's rebalance-caused `pending` flag must not block it. - * The anchors and "dragged", which nobody else edited, keep their - * rebalanced `sort_order` (rebalance's own effect survives). - * 7. Convergence: device A, device B, and the server agree (NFR-REL1). + * Scenario: (1) seed the inbox directly on the server (real drags can't + * craft exact colliding keys) with a "dragged" task at the top, task X in + * the middle (the one A will edit), and two adjacent 10-char anchor keys at + * the bottom. (2) Both devices sync to a converged baseline. (3) Device B + * goes stale — no more syncing until step 6. (4) Device A edits task X's + * description and pushes, giving X a strictly newer `updated_at` on the + * server. (5) Device B (still unaware of A's edit) drags "dragged" onto + * anchorHigh's position, so anchorHigh shifts between "dragged" and + * anchorLow and "dragged"'s new key lands strictly between the two anchor + * keys. The resulting >10-char key triggers `rebalanceBox` for the whole + * box: X and both anchors are marked `pending` locally on B WITHOUT their + * `updated_at` being bumped (FR4); only "dragged" gets a fresh + * `updated_at`. (6) Device B syncs (pull then push) — FR5's LWW pull + * protection must let A's genuinely newer server copy of X win over B's + * stale-but-`pending` local copy; the anchors and "dragged", untouched by + * anyone but B's rebalance, keep their rebalanced `sort_order`. (7) + * Convergence: device A, device B, and the server agree (NFR-REL1). */ test("Rebalance on a stale device does not clobber a task edited more recently elsewhere", async () => { const pageA = getPageA(); @@ -185,54 +80,38 @@ test("Rebalance on a stale device does not clobber a task edited more recently e // strictly between them via generateKeyBetween yields an 11-char key. const anchorLowKey = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD); const anchorHighKey = `${"a".repeat(SORT_ORDER_REBALANCE_THRESHOLD - 1)}b`; - // DESC order (highest key first): dragged ("z") > taskX ("m") > anchorHigh > anchorLow. - const draggedKey = "z"; - const taskXKey = "m"; + // DESC order (highest key first): dragged ("ac") > taskX ("ab") > anchorHigh > anchorLow. + // These MUST be structurally valid fractional-indexing keys: taskX keeps its + // key after convergence (A's edit wins LWW over B's rebalance), and it lands + // on the shared inbox. Bare single letters like "z"/"m" pass the app's lax + // `isValidFractionalKey` letter check but make `generateKeyBetween` throw + // "invalid order key", which later breaks any inbox task creation on the + // shared user (e.g. recurring-copy placement in other specs). + const draggedKey = "ac"; + const taskXKey = "ab"; const baseline = await pullFromServer(credentials); const baselineRevision = baseline.current_revision; // --- 1. Seed the box directly on the server --- - await pushToServer( - credentials, - buildInboxTaskPayload({ - id: draggedId, - name: draggedName, - sortOrder: draggedKey, - updatedAt: staleTimestamp, - }), - ); - await pushToServer( - credentials, - buildInboxTaskPayload({ - id: taskXId, - name: taskXName, - sortOrder: taskXKey, - updatedAt: staleTimestamp, - }), - ); - await pushToServer( - credentials, - buildInboxTaskPayload({ - id: anchorHighId, - name: anchorHighName, - sortOrder: anchorHighKey, - updatedAt: staleTimestamp, - }), - ); - await pushToServer( - credentials, - buildInboxTaskPayload({ - id: anchorLowId, - name: anchorLowName, - sortOrder: anchorLowKey, - updatedAt: staleTimestamp, - }), - ); + const seedTasks = [ + { id: draggedId, name: draggedName, sortOrder: draggedKey }, + { id: taskXId, name: taskXName, sortOrder: taskXKey }, + { id: anchorHighId, name: anchorHighName, sortOrder: anchorHighKey }, + { id: anchorLowId, name: anchorLowName, sortOrder: anchorLowKey }, + ]; + for (const seedTask of seedTasks) { + await pushToServer( + credentials, + buildInboxTaskPayload({ ...seedTask, updatedAt: staleTimestamp }), + ); + } // --- 2. Both devices sync to a converged baseline --- - await pageA.goto("/tasks"); - await pageA.waitForSelector('[data-testid="active-tasks-page"]'); + // The tasks are seeded in the inbox box, so drive the flow on the inbox + // page (ActiveTasksPage shows only today/week/later boxes). + await pageA.goto("/inbox"); + await pageA.waitForSelector('[data-testid="inbox-page"]'); await triggerSyncAndWait(pageA); for (const name of [draggedName, taskXName, anchorHighName, anchorLowName]) { await findTaskItem(pageA, name).waitFor({ @@ -241,8 +120,8 @@ test("Rebalance on a stale device does not clobber a task edited more recently e }); } - await pageB.goto("/tasks"); - await pageB.waitForSelector('[data-testid="active-tasks-page"]'); + await pageB.goto("/inbox"); + await pageB.waitForSelector('[data-testid="inbox-page"]'); await triggerSyncAndWait(pageB); for (const name of [draggedName, taskXName, anchorHighName, anchorLowName]) { await findTaskItem(pageB, name).waitFor({ @@ -278,6 +157,13 @@ test("Rebalance on a stale device does not clobber a task edited more recently e // task X win over B's stale-but-pending rebalanced copy --- await triggerSyncAndWait(pageB); + // Device A pulls B's rebalance push so all three agree. A's copies of the + // untouched tasks are `synced`, so the normal revision-based pull applies + // B's rebalanced sort keys; A's own edited task X (newer `updated_at`) is + // unaffected. Without this, A converges only if a background auto-sync + // happens to fire before the assertion — flaky under load. + await triggerSyncAndWait(pageA); + // --- 7. Convergence: A, B, and the server agree (NFR-REL1) --- await assertConverged(pageA, pageB, credentials, "tasks"); diff --git a/packages/integration/src/tests/multi-device-sync.spec.ts b/packages/integration/src/tests/multi-device-sync.spec.ts index 5ed2b5b4..dca09da9 100644 --- a/packages/integration/src/tests/multi-device-sync.spec.ts +++ b/packages/integration/src/tests/multi-device-sync.spec.ts @@ -3,14 +3,16 @@ import { expect, type Page, test } from "@playwright/test"; import { createCategory, createGoal, - createTask, - deleteTaskFromDetail, navigateToCategories, navigateToGoals, openGoalDetail, +} from "../page-goal-category-actions.js"; +import { + createTask, + deleteTaskFromDetail, openTaskDetail, updateTaskName, -} from "../page-actions.js"; +} from "../page-task-actions.js"; import { closeAuthenticatedPage, createAuthenticatedPage, diff --git a/packages/integration/src/tests/push-poison-pill-fk.spec.ts b/packages/integration/src/tests/push-poison-pill-fk.spec.ts index 63e003cc..7af96b6b 100644 --- a/packages/integration/src/tests/push-poison-pill-fk.spec.ts +++ b/packages/integration/src/tests/push-poison-pill-fk.spec.ts @@ -3,11 +3,10 @@ import { expect, test } from "@playwright/test"; import { createGoal, - createTask, navigateToGoals, openGoalDetail, - openTaskDetail, -} from "../page-actions.js"; +} from "../page-goal-category-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { pullFromServer, purgeOnServer, diff --git a/packages/integration/src/tests/push-rejected-indicator.spec.ts b/packages/integration/src/tests/push-rejected-indicator.spec.ts index edd6e96c..1c4c8567 100644 --- a/packages/integration/src/tests/push-rejected-indicator.spec.ts +++ b/packages/integration/src/tests/push-rejected-indicator.spec.ts @@ -2,7 +2,7 @@ // Test: rejected → red border → edit → amber → sync → transparent import { randomUUID } from "node:crypto"; import { expect, test } from "@playwright/test"; -import { openTaskDetail, updateTaskName } from "../page-actions.js"; +import { openTaskDetail, updateTaskName } from "../page-task-actions.js"; import { setupSingleDeviceTest, triggerSyncAndWait } from "../test-helpers.js"; const DEXIE_DB_NAME = "clear-progress"; diff --git a/packages/integration/src/tests/stale-rebalance-helpers.ts b/packages/integration/src/tests/stale-rebalance-helpers.ts new file mode 100644 index 00000000..2403b9e8 --- /dev/null +++ b/packages/integration/src/tests/stale-rebalance-helpers.ts @@ -0,0 +1,61 @@ +// implements U3, FR4, NFR-REL1 of fix-stale-sync-overwrites + +export interface RebalancePullResponse { + ok: boolean; + tasks: Array<{ + id: string; + name: string; + description: string; + box: string; + sort_order: string; + is_deleted: boolean; + updated_at: string; + }>; + current_revision: number; +} + +/** + * Builds a full push payload carrying a single "inbox" task with an explicit + * `sort_order`, so tests can pre-seed keys near/at the rebalance threshold + * without going through the app's own key-generation logic. + */ +export function buildInboxTaskPayload(options: { + id: string; + name: string; + sortOrder: string; + updatedAt: string; +}): Record { + return { + tasks: [ + { + id: options.id, + name: options.name, + description: "", + box: "inbox", + is_completed: false, + is_deleted: false, + completed_at: "", + next_date: "", + appear_date: "", + context_id: "", + category_id: "", + goal_id: "", + repeat_rule: "", + is_hidden: false, + original_task_id: "", + sort_order: options.sortOrder, + created_at: options.updatedAt, + updated_at: options.updatedAt, + version: 1, + revision: 0, + }, + ], + goals: [], + ideas: [], + contexts: [], + categories: [], + checklist_items: [], + attachments: [], + settings: [], + }; +} diff --git a/packages/integration/src/tests/sync-indicator-race.spec.ts b/packages/integration/src/tests/sync-indicator-race.spec.ts index f1651ba5..d9f401cb 100644 --- a/packages/integration/src/tests/sync-indicator-race.spec.ts +++ b/packages/integration/src/tests/sync-indicator-race.spec.ts @@ -2,10 +2,9 @@ import { expect, test } from "@playwright/test"; import { addChecklistItem, - createTask, - openTaskDetail, switchToChecklistTab, -} from "../page-actions.js"; +} from "../page-checklist-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { setupSingleDeviceTest, triggerSyncAndWait } from "../test-helpers.js"; const { getPage } = setupSingleDeviceTest(); diff --git a/packages/integration/src/tests/tasks-sync.spec.ts b/packages/integration/src/tests/tasks-sync.spec.ts index 76a92ff3..5492a4ed 100644 --- a/packages/integration/src/tests/tasks-sync.spec.ts +++ b/packages/integration/src/tests/tasks-sync.spec.ts @@ -5,7 +5,7 @@ import { deleteTaskFromDetail, openTaskDetail, updateTaskName, -} from "../page-actions.js"; +} from "../page-task-actions.js"; import { pullFromServer, setupSingleDeviceTest, From a74330d8b9007ed57eae16c031fc6d2084ace4a8 Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:49:56 +0500 Subject: [PATCH 5/8] implement fix-stale-sync-overwrites OpenSpec --- .../src/db/repositories/GoalRepository.ts | 20 +- .../applyServerRecordsLww.contract.ts | 197 +++++++++++++ ...kDeduplicator.merge.content-fields.test.ts | 137 +++++++++ ...icator.merge.wholesale-repeat-rule.test.ts | 157 ++++++++++ .../RecurringTaskDeduplicator.test-setup.ts | 26 +- .../src/services/TaskService-test-utils.ts | 36 ++- .../TaskService.recurring-restore.test.ts | 42 +-- .../TaskService.recurring-soft-delete.test.ts | 132 +++++---- .../src/services/TaskService.reorder.test.ts | 213 +++++++++++--- .../steps/dedup_merge.steps.ts | 232 +++++++++++++++ .../steps/pull_lww_protection.steps.ts | 272 ++++++++++++++++++ .../steps/stale_reveal_sync.steps.ts | 218 ++++++++++++++ .../tests/attachments-purge-cleanup.spec.ts | 47 ++- .../src/tests/multi-device-conflicts.spec.ts | 10 +- .../tests/multi-device-manual-unhide.spec.ts | 150 ++++++++++ .../src/tests/multi-device-recurring.spec.ts | 76 +---- .../multi-device-stale-rebalance.spec.ts | 3 +- ...e-stale-recurring-after-completion.spec.ts | 116 ++++++++ ...multi-device-stale-recurring-fixed.spec.ts | 83 ++++++ .../src/tests/stale-recurring-flow.ts | 176 ++++++++++++ .../src/tests/stale-recurring-helpers.ts | 241 ++++++++++++++++ .../integration/src/tests/tasks-sync.spec.ts | 33 +-- 22 files changed, 2373 insertions(+), 244 deletions(-) create mode 100644 packages/client/src/db/repositories/applyServerRecordsLww.contract.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.content-fields.test.ts create mode 100644 packages/client/src/services/RecurringTaskDeduplicator.merge.wholesale-repeat-rule.test.ts create mode 100644 packages/client/src/test/features/repeating_tasks/steps/dedup_merge.steps.ts create mode 100644 packages/client/src/test/features/sync_protocol/steps/pull_lww_protection.steps.ts create mode 100644 packages/client/src/test/features/sync_protocol/steps/stale_reveal_sync.steps.ts create mode 100644 packages/integration/src/tests/multi-device-manual-unhide.spec.ts create mode 100644 packages/integration/src/tests/multi-device-stale-recurring-after-completion.spec.ts create mode 100644 packages/integration/src/tests/multi-device-stale-recurring-fixed.spec.ts create mode 100644 packages/integration/src/tests/stale-recurring-flow.ts create mode 100644 packages/integration/src/tests/stale-recurring-helpers.ts diff --git a/packages/client/src/db/repositories/GoalRepository.ts b/packages/client/src/db/repositories/GoalRepository.ts index 5cbcfa22..9d807f97 100644 --- a/packages/client/src/db/repositories/GoalRepository.ts +++ b/packages/client/src/db/repositories/GoalRepository.ts @@ -2,6 +2,9 @@ import type { WireGoal } from "@clear-progress/contract"; import { ClientGoalSchema } from "@/schemas/entities"; import type { Goal, ISOTimestamp } from "@/types/entities"; import { db } from "../database"; +import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww"; + +const GOAL_ENTITY_NAME = "goal"; export class GoalRepository { async getAll(): Promise { @@ -53,11 +56,26 @@ export class GoalRepository { return db.goals.filter((goal) => goal.syncStatus === "pending").toArray(); } + /** + * Implements FR5 of fix-stale-sync-overwrites. + * A local `pending` record is overwritten only when the server record's + * `updated_at` is strictly newer (LWW pull protection). Local records with + * any other syncStatus, or no local record at all, are always overwritten. + */ async applyServerRecords(records: WireGoal[]): Promise { await db.transaction("rw", db.goals, async () => { for (const serverRecord of records) { const localRecord = await db.goals.get(serverRecord.id); - if (localRecord?.syncStatus !== "pending") { + const shouldWrite = + localRecord?.syncStatus !== "pending" || + shouldOverwritePendingLocalRecord({ + entityName: GOAL_ENTITY_NAME, + id: serverRecord.id, + localUpdatedAt: localRecord.updated_at, + serverUpdatedAt: serverRecord.updated_at, + }); + + if (shouldWrite) { const goal: Goal = { ...serverRecord, created_at: serverRecord.created_at as ISOTimestamp, diff --git a/packages/client/src/db/repositories/applyServerRecordsLww.contract.ts b/packages/client/src/db/repositories/applyServerRecordsLww.contract.ts new file mode 100644 index 00000000..f281def4 --- /dev/null +++ b/packages/client/src/db/repositories/applyServerRecordsLww.contract.ts @@ -0,0 +1,197 @@ +import { expect, it, vi } from "vitest"; +import { Temporal } from "@/lib/temporal"; +import { toISOTimestamp } from "@/utils/dateHelpers"; + +/** + * Minimal shape every repository's local record must expose for this + * contract suite to inspect sync state after `applyServerRecords` runs. + */ +export type LwwLocalRecord = { + id: string; + updated_at: string; + syncStatus: "synced" | "pending" | "rejected"; +}; + +/** + * Config supplied per-repository so the contract suite can run generically + * across TaskRepository, GoalRepository, ContextRepository, CategoryRepository, + * IdeaRepository, ChecklistRepository, AttachmentRepository (SettingsRepository + * excluded — no applyServerRecords). + */ +export type ApplyServerRecordsLwwContractConfig< + LocalRecord extends LwwLocalRecord, + ServerRecord extends { id: string; updated_at: string }, +> = { + entityName: string; + applyServerRecords: (records: ServerRecord[]) => Promise; + getLocalRecord: (id: string) => Promise; + putLocalRecord: (record: LocalRecord) => Promise; + /** Builds a minimal valid local record with the given overrides. */ + buildLocalRecord: (overrides: Partial) => LocalRecord; + /** Builds a minimal valid server (wire) record with the given overrides. */ + buildServerRecord: (overrides: Partial) => ServerRecord; +}; + +const OLDER_TIMESTAMP = toISOTimestamp( + Temporal.Instant.from("2026-01-01T00:00:00.000Z"), +); +const SAME_TIMESTAMP = toISOTimestamp( + Temporal.Instant.from("2026-01-02T00:00:00.000Z"), +); +const NEWER_TIMESTAMP = toISOTimestamp( + Temporal.Instant.from("2026-01-03T00:00:00.000Z"), +); + +/** + * Shared contract test suite for `applyServerRecords()` LWW pull protection. + * Implements FR5 of fix-stale-sync-overwrites. + * + * Run once per repository via `runApplyServerRecordsLwwContractTests(config)` + * inside a `describe` block that has already set up `fake-indexeddb` and + * cleared the relevant table in `beforeEach`. + */ +export function runApplyServerRecordsLwwContractTests< + LocalRecord extends LwwLocalRecord, + ServerRecord extends { id: string; updated_at: string }, +>(config: ApplyServerRecordsLwwContractConfig) { + const { + entityName, + applyServerRecords, + getLocalRecord, + putLocalRecord, + buildLocalRecord, + buildServerRecord, + } = config; + + /** + * Persists a local record with the given sync status and timestamp, applies + * a single server record sharing its id, and returns the resulting local + * record. Centralises the arrange-and-apply sequence shared across scenarios. + */ + async function applyServerRecordOverLocal(args: { + localSyncStatus: LwwLocalRecord["syncStatus"]; + localUpdatedAt: string; + serverUpdatedAt: string; + }): Promise { + const localRecord = buildLocalRecord({ + syncStatus: args.localSyncStatus, + updated_at: args.localUpdatedAt, + } as unknown as Partial); + await putLocalRecord(localRecord); + + const serverRecord = buildServerRecord({ + id: localRecord.id, + updated_at: args.serverUpdatedAt, + } as unknown as Partial); + await applyServerRecords([serverRecord]); + + return getLocalRecord(localRecord.id); + } + + // FR5: local `synced` records are always overwritten by the server version + it(`should overwrite a local synced ${entityName} record with the server record`, async () => { + const savedRecord = await applyServerRecordOverLocal({ + localSyncStatus: "synced", + localUpdatedAt: SAME_TIMESTAMP, + serverUpdatedAt: NEWER_TIMESTAMP, + }); + expect(savedRecord?.updated_at).toBe(NEWER_TIMESTAMP); + expect(savedRecord?.syncStatus).toBe("synced"); + }); + + // FR5: local `rejected` records are always overwritten by the server version + it(`should overwrite a local rejected ${entityName} record with the server record`, async () => { + const savedRecord = await applyServerRecordOverLocal({ + localSyncStatus: "rejected", + localUpdatedAt: SAME_TIMESTAMP, + serverUpdatedAt: NEWER_TIMESTAMP, + }); + expect(savedRecord?.updated_at).toBe(NEWER_TIMESTAMP); + expect(savedRecord?.syncStatus).toBe("synced"); + }); + + // FR5: strictly newer server updated_at overwrites a pending local record and marks it synced + it(`should overwrite a local pending ${entityName} record when the server record is strictly newer, and mark it synced`, async () => { + const savedRecord = await applyServerRecordOverLocal({ + localSyncStatus: "pending", + localUpdatedAt: OLDER_TIMESTAMP, + serverUpdatedAt: NEWER_TIMESTAMP, + }); + expect(savedRecord?.updated_at).toBe(NEWER_TIMESTAMP); + expect(savedRecord?.syncStatus).toBe("synced"); + }); + + // FR5: overwriting a pending local record must be logged with entity type, id, and both timestamps + it(`should log a conflict warning when a pending ${entityName} record is overwritten by a strictly newer server record`, async () => { + const localRecord = buildLocalRecord({ + syncStatus: "pending", + updated_at: OLDER_TIMESTAMP, + } as unknown as Partial); + await putLocalRecord(localRecord); + + const serverRecord = buildServerRecord({ + id: localRecord.id, + updated_at: NEWER_TIMESTAMP, + } as unknown as Partial); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await applyServerRecords([serverRecord]); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(entityName), + expect.objectContaining({ + id: localRecord.id, + localUpdatedAt: OLDER_TIMESTAMP, + serverUpdatedAt: NEWER_TIMESTAMP, + }), + ); + warnSpy.mockRestore(); + }); + + // FR5: equal server updated_at preserves the local pending record unchanged + it(`should preserve a local pending ${entityName} record when the server record has an equal updated_at`, async () => { + const localRecord = buildLocalRecord({ + syncStatus: "pending", + updated_at: SAME_TIMESTAMP, + } as unknown as Partial); + await putLocalRecord(localRecord); + + const serverRecord = buildServerRecord({ + id: localRecord.id, + updated_at: SAME_TIMESTAMP, + } as unknown as Partial); + await applyServerRecords([serverRecord]); + + expect(await getLocalRecord(localRecord.id)).toEqual(localRecord); + }); + + // FR5: older server updated_at preserves the local pending record unchanged + it(`should preserve a local pending ${entityName} record when the server record is older`, async () => { + const localRecord = buildLocalRecord({ + syncStatus: "pending", + updated_at: NEWER_TIMESTAMP, + } as unknown as Partial); + await putLocalRecord(localRecord); + + const serverRecord = buildServerRecord({ + id: localRecord.id, + updated_at: OLDER_TIMESTAMP, + } as unknown as Partial); + await applyServerRecords([serverRecord]); + + expect(await getLocalRecord(localRecord.id)).toEqual(localRecord); + }); + + // FR5: a server record with no local counterpart is inserted as synced + it(`should insert a new ${entityName} record from the server when no local record exists`, async () => { + const serverRecord = buildServerRecord({ + updated_at: NEWER_TIMESTAMP, + } as unknown as Partial); + + await applyServerRecords([serverRecord]); + + const savedRecord = await getLocalRecord(serverRecord.id); + expect(savedRecord).toBeDefined(); + expect(savedRecord?.syncStatus).toBe("synced"); + }); +} diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.content-fields.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.content-fields.test.ts new file mode 100644 index 00000000..0709b565 --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.content-fields.test.ts @@ -0,0 +1,137 @@ +// implements FR3 of fix-stale-sync-overwrites +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { db } from "@/db/database"; +import { buildTask } from "@/test/factories/taskFactory"; +import type { Task } from "@/types/entities"; +import { + createRecurringTaskDeduplicatorMergeSetup, + FRESH_CATEGORY_ID, + FRESH_CONTEXT_ID, + FRESH_GOAL_ID, + ORIGINAL_ID_1, + STALE_CATEGORY_ID, + STALE_CONTEXT_ID, + STALE_GOAL_ID, + UUID_FRESH_CONTENT_LOSER, + UUID_SCHEDULE_WINNER, +} from "./RecurringTaskDeduplicator.test-setup"; + +// Schedule winner carrying stale content: earliest next_date (wins the +// schedule comparison) but the oldest updated_at (loses the content merge). +// Per-test overrides supply the content fields under assertion. +function buildStaleContentScheduleWinner(overrides: Partial = {}) { + return buildTask({ + id: UUID_SCHEDULE_WINNER, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-01", + name: "Stale name", + description: "Stale description", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + ...overrides, + }); +} + +// Loser by schedule (later next_date) but freshest updated_at, so its +// content fields must survive the content merge. Per-test overrides supply +// the content fields under assertion. +function buildFreshContentLoser(overrides: Partial = {}) { + return buildTask({ + id: UUID_FRESH_CONTENT_LOSER, + original_task_id: ORIGINAL_ID_1, + next_date: "2026-07-10", + name: "Fresh name", + description: "Fresh description", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + ...overrides, + }); +} + +// FR3: winner keeps schedule (earliest next_date) but content fields come +// from whichever copy has the freshest updated_at, even when that copy +// loses the schedule comparison. +describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => { + const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup(); + + describe("name and description merge from freshest updated_at copy", () => { + it("should take name and description from the freshest-updated_at copy, not the schedule winner", async () => { + await db.tasks.bulkAdd([ + buildStaleContentScheduleWinner(), + buildFreshContentLoser(), + ]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER); + expect(survivor?.name).toBe("Fresh name"); + expect(survivor?.description).toBe("Fresh description"); + }); + + // Guards the strict `>` comparison in findFreshestUpdatedAtCopy: when two + // copies share the exact same updated_at, the first-encountered copy + // (the schedule winner, since it appears first in allCopiesInGroup) must + // stay "freshest" — a `>=` comparison would instead let the later copy + // in iteration order win the tie, flipping which content survives. + it("should keep the first copy's content as freshest when two copies share the exact same updated_at", async () => { + const sameUpdatedAt = "2026-07-05T10:00:00.000Z"; + await db.tasks.bulkAdd([ + buildStaleContentScheduleWinner({ + name: "Schedule winner name", + description: "Schedule winner description", + updated_at: sameUpdatedAt, + }), + buildFreshContentLoser({ + name: "Tied loser name", + description: "Tied loser description", + updated_at: sameUpdatedAt, + }), + ]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER); + expect(survivor?.name).toBe("Schedule winner name"); + expect(survivor?.description).toBe("Schedule winner description"); + }); + + it("should keep next_date from the schedule winner despite content coming from the freshest copy", async () => { + await db.tasks.bulkAdd([ + buildStaleContentScheduleWinner(), + buildFreshContentLoser(), + ]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER); + expect(survivor?.next_date).toBe("2026-07-01"); + }); + }); + + describe("goal_id, context_id and category_id merge from freshest updated_at copy", () => { + it("should take goal_id, context_id and category_id from the freshest-updated_at copy, not the schedule winner", async () => { + await db.tasks.bulkAdd([ + buildStaleContentScheduleWinner({ + goal_id: STALE_GOAL_ID, + context_id: STALE_CONTEXT_ID, + category_id: STALE_CATEGORY_ID, + }), + buildFreshContentLoser({ + goal_id: FRESH_GOAL_ID, + context_id: FRESH_CONTEXT_ID, + category_id: FRESH_CATEGORY_ID, + }), + ]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_1]); + + const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER); + expect(survivor?.goal_id).toBe(FRESH_GOAL_ID); + expect(survivor?.context_id).toBe(FRESH_CONTEXT_ID); + expect(survivor?.category_id).toBe(FRESH_CATEGORY_ID); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.wholesale-repeat-rule.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.wholesale-repeat-rule.test.ts new file mode 100644 index 00000000..85931eca --- /dev/null +++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.wholesale-repeat-rule.test.ts @@ -0,0 +1,157 @@ +// implements FR3 of fix-stale-sync-overwrites +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { db } from "@/db/database"; +import { buildTask } from "@/test/factories/taskFactory"; +import { + createRecurringTaskDeduplicatorMergeSetup, + expectScheduleTripleWithFreshContent, +} from "./RecurringTaskDeduplicator.test-setup"; + +// Fifth scenario: proves a repeat_rule mismatch makes the freshest copy win +// wholesale, including next_date/appear_date, overriding the normal +// schedule-triple rule. +const UUID_RULE_MISMATCH_SCHEDULE_WINNER = + "00000000-0000-4000-a000-000000000050"; +const UUID_RULE_MISMATCH_FRESH_LOSER = "00000000-0000-4000-a000-000000000051"; +const ORIGINAL_ID_5 = "55555555-5555-4555-a555-555555555555"; + +// Eighth scenario: 3-copy edge case. The schedule winner and the +// freshest-content copy SHARE the same repeat_rule, while a third, stale +// loser carries a DIFFERENT repeat_rule. Only scheduleWinner.repeat_rule vs +// freshestCopy.repeat_rule is ever compared to decide wholesale-vs-normal +// merge, so the stale loser's mismatching repeat_rule must be irrelevant — +// normal merge applies (schedule triple from the winner + content from the +// freshest copy), never wholesale. +const UUID_TRIPLE_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000080"; +const UUID_TRIPLE_FRESH_CONTENT_LOSER = "00000000-0000-4000-a000-000000000081"; +const UUID_TRIPLE_STALE_RULE_MISMATCH_LOSER = + "00000000-0000-4000-a000-000000000082"; +const ORIGINAL_ID_8 = "88888888-8888-4888-a888-888888888888"; + +describe("RecurringTaskDeduplicator — content merge from freshest updated_at", () => { + const { getDeduplicator } = createRecurringTaskDeduplicatorMergeSetup(); + + // FR3: when repeat_rule differs between copies, the freshest-updated_at + // copy wins wholesale — all its fields, including next_date and + // appear_date, override the normal schedule-triple-from-earliest-next_date + // rule. A rule change recomputes dates under the new rule, so mixing old + // schedule fields with a new repeat_rule would be incoherent. + describe("differing repeat_rule — freshest copy wins wholesale", () => { + it("should take all fields, including next_date and appear_date, from the freshest copy when repeat_rule differs", async () => { + const scheduleWinner = buildTask({ + id: UUID_RULE_MISMATCH_SCHEDULE_WINNER, + original_task_id: ORIGINAL_ID_5, + repeat_rule: "daily-1", + next_date: "2026-07-01", + appear_date: "2026-06-30", + is_hidden: false, + name: "Stale name", + description: "Stale description", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + }); + const freshRuleChangedLoser = buildTask({ + id: UUID_RULE_MISMATCH_FRESH_LOSER, + original_task_id: ORIGINAL_ID_5, + repeat_rule: "weekly-Mon", + next_date: "2026-07-06", + appear_date: "2026-07-04", + is_hidden: true, + name: "Fresh name", + description: "Fresh description", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + }); + await db.tasks.bulkAdd([scheduleWinner, freshRuleChangedLoser]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_5]); + + const survivor = await db.tasks.get(UUID_RULE_MISMATCH_SCHEDULE_WINNER); + expect(survivor?.repeat_rule).toBe("weekly-Mon"); + expect(survivor?.next_date).toBe("2026-07-06"); + expect(survivor?.appear_date).toBe("2026-07-04"); + expect(survivor?.is_hidden).toBe(true); + expect(survivor?.name).toBe("Fresh name"); + expect(survivor?.description).toBe("Fresh description"); + expect(survivor?.updated_at).toBe("2026-07-09T10:00:00.000Z"); + }); + + // Pinning test for the 3-copy edge (task 7.9): the schedule winner and + // the freshest-content copy SHARE the same repeat_rule; only a third, + // stale loser carries a different one. mergeWinner only ever compares + // scheduleWinner.repeat_rule vs freshestCopy.repeat_rule — it never scans + // every copy in the group — so this third copy's mismatching repeat_rule + // must be irrelevant to the wholesale-vs-normal decision. Normal merge + // applies: schedule triple from the winner, content from the freshest + // copy, and the winner's own repeat_rule is left untouched. Locks in + // this behavior so a future "fix" that scans ALL copies for a + // repeat_rule mismatch would be caught by this test going red. + it("should apply normal merge, not wholesale, when only a non-freshest third copy's repeat_rule differs from the schedule winner and freshest copy", async () => { + const scheduleWinner = buildTask({ + id: UUID_TRIPLE_SCHEDULE_WINNER, + original_task_id: ORIGINAL_ID_8, + repeat_rule: "daily-1", + next_date: "2026-07-01", + appear_date: "2026-06-28", + is_hidden: false, + name: "Stale name", + description: "Stale description", + is_completed: false, + is_deleted: false, + updated_at: "2026-06-01T10:00:00.000Z", + }); + const freshContentLoser = buildTask({ + id: UUID_TRIPLE_FRESH_CONTENT_LOSER, + original_task_id: ORIGINAL_ID_8, + repeat_rule: "daily-1", + next_date: "2026-07-10", + appear_date: "2026-07-08", + is_hidden: true, + name: "Fresh name", + description: "Fresh description", + is_completed: false, + is_deleted: false, + updated_at: "2026-07-09T10:00:00.000Z", + }); + const staleRuleMismatchLoser = buildTask({ + id: UUID_TRIPLE_STALE_RULE_MISMATCH_LOSER, + original_task_id: ORIGINAL_ID_8, + repeat_rule: "weekly-Mon", + next_date: "2026-07-05", + appear_date: "2026-07-03", + is_hidden: true, + name: "Stale mismatch name", + description: "Stale mismatch description", + is_completed: false, + is_deleted: false, + updated_at: "2026-05-01T10:00:00.000Z", + }); + await db.tasks.bulkAdd([ + scheduleWinner, + freshContentLoser, + staleRuleMismatchLoser, + ]); + + await getDeduplicator().deduplicate([ORIGINAL_ID_8]); + + const survivor = await db.tasks.get(UUID_TRIPLE_SCHEDULE_WINNER); + // Schedule triple stays the schedule winner's own — wholesale was NOT + // triggered — while content still merges normally from the freshest copy. + expectScheduleTripleWithFreshContent(survivor, { + nextDate: "2026-07-01", + appearDate: "2026-06-28", + isHidden: false, + }); + // repeat_rule stays the schedule winner's own, proving the stale + // loser's differing rule was never consulted. + expect(survivor?.repeat_rule).toBe("daily-1"); + // The stale loser is soft-deleted as usual. + expect( + (await db.tasks.get(UUID_TRIPLE_STALE_RULE_MISMATCH_LOSER))?.is_deleted, + ).toBe(true); + }); + }); +}); diff --git a/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts b/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts index 881b1c14..d22371c8 100644 --- a/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts +++ b/packages/client/src/services/RecurringTaskDeduplicator.test-setup.ts @@ -1,9 +1,10 @@ // implements FR3 of fix-stale-sync-overwrites -import { beforeEach } from "vitest"; +import { beforeEach, expect } from "vitest"; import { db } from "@/db/database"; import { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; import { TaskRepository } from "@/db/repositories/TaskRepository"; import { fakeClock } from "@/lib/temporal"; +import type { Task } from "@/types/entities"; import { RecurringTaskDeduplicator } from "./RecurringTaskDeduplicator"; // This must remain a THIRD distinct timestamp from every copy's updated_at @@ -38,6 +39,29 @@ export function createRecurringTaskDeduplicatorMergeSetup(): { }; } +// The freshest-updated_at copy across the merge scenarios always carries +// this name/description, so the merged survivor's content must match it. +export const FRESH_CONTENT_NAME = "Fresh name"; +export const FRESH_CONTENT_DESCRIPTION = "Fresh description"; + +/** + * Asserts the FR3 merge invariant shared by the schedule-and-box and + * wholesale-repeat-rule scenarios: the schedule triple (next_date + + * appear_date + is_hidden) stays coupled to the schedule winner's own copy, + * while name/description merge from the freshest-updated_at copy. + * Implements FR3 of fix-stale-sync-overwrites. + */ +export function expectScheduleTripleWithFreshContent( + survivor: Task | undefined, + scheduleTriple: { nextDate: string; appearDate: string; isHidden: boolean }, +): void { + expect(survivor?.next_date).toBe(scheduleTriple.nextDate); + expect(survivor?.appear_date).toBe(scheduleTriple.appearDate); + expect(survivor?.is_hidden).toBe(scheduleTriple.isHidden); + expect(survivor?.name).toBe(FRESH_CONTENT_NAME); + expect(survivor?.description).toBe(FRESH_CONTENT_DESCRIPTION); +} + // Deterministic UUIDs for predictable winner-by-schedule ordering, shared // across the merge test files that use scenario 1 (content fields). export const UUID_SCHEDULE_WINNER = "00000000-0000-4000-a000-000000000010"; diff --git a/packages/client/src/services/TaskService-test-utils.ts b/packages/client/src/services/TaskService-test-utils.ts index b52af0a0..a8f2fd9f 100644 --- a/packages/client/src/services/TaskService-test-utils.ts +++ b/packages/client/src/services/TaskService-test-utils.ts @@ -1,4 +1,4 @@ -import type { vi } from "vitest"; +import { vi } from "vitest"; import type { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; import type { TaskRepository } from "@/db/repositories/TaskRepository"; import { createMockChecklistRepository } from "@/test/factories/checklistRepositoryFactory"; @@ -30,3 +30,37 @@ export function getCreatedItem(mockChecklistRepository: ChecklistRepository) { return (mockChecklistRepository.create as ReturnType).mock .calls[0][0]; } + +/** + * Installs an `update` mock that records each updated task into the returned + * map (keyed by task id) and echoes the task back. Shared setup for softDelete + * / restore tests that assert on the final state of updated tasks. + */ +export function mockUpdateRecording( + mockTaskRepository: TaskRepository, +): Record { + const recordedUpdates: Record = {}; + mockTaskRepository.update = vi.fn().mockImplementation(async (task: Task) => { + recordedUpdates[task.id] = task; + return task; + }); + return recordedUpdates; +} + +/** + * Installs a `getById` mock resolving from a base id->task map, preferring any + * task already written into `recordedUpdates` so reads observe prior updates. + * Returns undefined for unknown ids. Shared setup for softDelete / restore + * tests. + */ +export function mockGetByIdFromMap( + mockTaskRepository: TaskRepository, + tasksById: Record, + recordedUpdates: Record = {}, +): void { + mockTaskRepository.getById = vi + .fn() + .mockImplementation(async (id: string) => { + return recordedUpdates[id] ?? tasksById[id] ?? undefined; + }); +} diff --git a/packages/client/src/services/TaskService.recurring-restore.test.ts b/packages/client/src/services/TaskService.recurring-restore.test.ts index 3df0fc01..5afa1d8d 100644 --- a/packages/client/src/services/TaskService.recurring-restore.test.ts +++ b/packages/client/src/services/TaskService.recurring-restore.test.ts @@ -4,7 +4,11 @@ import type { TaskRepository } from "@/db/repositories/TaskRepository"; import { buildTask } from "@/test/factories/taskFactory"; import type { Task } from "@/types/entities"; import type { TaskService } from "./TaskService"; -import { createTestContext } from "./TaskService-test-utils"; +import { + createTestContext, + mockGetByIdFromMap, + mockUpdateRecording, +} from "./TaskService-test-utils"; describe("TaskService - Recurring Restore (fix-recurring-restore)", () => { let taskService: TaskService; @@ -32,20 +36,15 @@ describe("TaskService - Recurring Restore (fix-recurring-restore)", () => { is_deleted: false, }); - const updates: Record = {}; - - mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => { - if (id === "a") return updates.a ?? originalTask; - if (id === "b") return updates.b ?? activeCopy; - return undefined; - }); + const updates = mockUpdateRecording(mockTaskRepository); + mockGetByIdFromMap( + mockTaskRepository, + { a: originalTask, b: activeCopy }, + updates, + ); mockTaskRepository.findByOriginalTaskId = vi .fn() .mockResolvedValue([activeCopy]); - mockTaskRepository.update = vi.fn().mockImplementation(async (task) => { - updates[task.id] = task; - return task; - }); await taskService.softDelete("a"); @@ -62,14 +61,9 @@ describe("TaskService - Recurring Restore (fix-recurring-restore)", () => { repeat_rule: "daily", }); - const updates: Record = {}; - - mockTaskRepository.getById = vi.fn().mockResolvedValue(originalTask); + const updates = mockUpdateRecording(mockTaskRepository); + mockGetByIdFromMap(mockTaskRepository, { a: originalTask }, updates); mockTaskRepository.findByOriginalTaskId = vi.fn().mockResolvedValue([]); - mockTaskRepository.update = vi.fn().mockImplementation(async (task) => { - updates[task.id] = task; - return task; - }); await taskService.softDelete("a"); @@ -93,14 +87,8 @@ describe("TaskService - Recurring Restore (fix-recurring-restore)", () => { function setupRestoreMocks( tasks: Record, ): Record { - const updates: Record = {}; - mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => { - return updates[id] ?? tasks[id] ?? undefined; - }); - mockTaskRepository.update = vi.fn().mockImplementation(async (task) => { - updates[task.id] = task; - return task; - }); + const updates = mockUpdateRecording(mockTaskRepository); + mockGetByIdFromMap(mockTaskRepository, tasks, updates); return updates; } diff --git a/packages/client/src/services/TaskService.recurring-soft-delete.test.ts b/packages/client/src/services/TaskService.recurring-soft-delete.test.ts index f9c13c41..d27fbd18 100644 --- a/packages/client/src/services/TaskService.recurring-soft-delete.test.ts +++ b/packages/client/src/services/TaskService.recurring-soft-delete.test.ts @@ -1,20 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; import type { TaskRepository } from "@/db/repositories/TaskRepository"; -import { createMockChecklistRepository } from "@/test/factories/checklistRepositoryFactory"; import { buildTask } from "@/test/factories/taskFactory"; -import { createMockTaskRepository } from "@/test/mocks/taskRepositoryMock"; -import { TaskService } from "./TaskService"; +import type { Task } from "@/types/entities"; +import type { TaskService } from "./TaskService"; +import { + createTestContext, + mockGetByIdFromMap, + mockUpdateRecording, +} from "./TaskService-test-utils"; describe("TaskService - Recurring Tasks Integration", () => { let taskService: TaskService; let mockTaskRepository: TaskRepository; - let mockChecklistRepository: ChecklistRepository; beforeEach(() => { - mockTaskRepository = createMockTaskRepository(); - mockChecklistRepository = createMockChecklistRepository(); - taskService = new TaskService(mockTaskRepository, mockChecklistRepository); + const context = createTestContext(); + taskService = context.taskService; + mockTaskRepository = context.mockTaskRepository; vi.clearAllMocks(); }); @@ -40,21 +42,15 @@ describe("TaskService - Recurring Tasks Integration", () => { is_deleted: false, }); - const updates: Record> = {}; - - mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => { - if (id === "task-1") return originalTask; - if (id === "task-2") return copy1; - if (id === "task-3") return copy2; - return undefined; + const updates = mockUpdateRecording(mockTaskRepository); + mockGetByIdFromMap(mockTaskRepository, { + "task-1": originalTask, + "task-2": copy1, + "task-3": copy2, }); mockTaskRepository.findByOriginalTaskId = vi .fn() .mockResolvedValue([copy1, copy2]); - mockTaskRepository.update = vi.fn().mockImplementation(async (task) => { - updates[task.id] = task; - return task; - }); await taskService.softDelete("task-1"); @@ -73,18 +69,14 @@ describe("TaskService - Recurring Tasks Integration", () => { original_task_id: "", }); - let deletedTask: ReturnType | null = null; - mockTaskRepository.getById = vi.fn().mockResolvedValue(originalTask); + const updates = mockUpdateRecording(mockTaskRepository); + mockGetByIdFromMap(mockTaskRepository, { "task-1": originalTask }); mockTaskRepository.findByOriginalTaskId = vi.fn().mockResolvedValue([]); - mockTaskRepository.update = vi.fn().mockImplementation(async (task) => { - deletedTask = task; - return task; - }); await taskService.softDelete("task-1"); - expect(deletedTask).toBeDefined(); - expect(deletedTask).toMatchObject({ is_deleted: true }); + expect(updates["task-1"]).toBeDefined(); + expect(updates["task-1"]).toMatchObject({ is_deleted: true }); }); it("should skip deleted copies when reassigning", async () => { @@ -108,27 +100,75 @@ describe("TaskService - Recurring Tasks Integration", () => { is_deleted: false, }); - const updates: Record> = {}; - - mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => { - if (id === "task-1") return originalTask; - if (id === "task-2") return deletedCopy; - if (id === "task-3") return activeCopy; - return undefined; + const updates = mockUpdateRecording(mockTaskRepository); + mockGetByIdFromMap(mockTaskRepository, { + "task-1": originalTask, + "task-2": deletedCopy, + "task-3": activeCopy, }); mockTaskRepository.findByOriginalTaskId = vi .fn() .mockResolvedValue([deletedCopy, activeCopy]); - mockTaskRepository.update = vi.fn().mockImplementation(async (task) => { - updates[task.id] = task; - return task; - }); await taskService.softDelete("task-1"); expect(updates["task-3"].original_task_id).toBe(""); expect(updates["task-2"].original_task_id).toBe("task-3"); }); + + // Guards `if (copy.id !== newOriginal.id)`: the reassignment loop must + // skip the new-original's own entry inside `copies` and never call + // update() for it there — it is updated separately afterward (to clear + // its own original_task_id). A mutant that turns the guard into `if + // (true)` would call update() for newOriginal.id twice with different + // original_task_id payloads; asserting the exact call count/args (not + // just the final overwritten state) catches that extra call. + it("should call update exactly once per copy, skipping the new original inside the reassignment loop", async () => { + const originalTask = buildTask({ + id: "task-1", + name: "Original", + original_task_id: "", + }); + + const copy1 = buildTask({ + id: "task-2", + name: "Copy 1 (becomes new original)", + original_task_id: "task-1", + is_deleted: false, + }); + + const copy2 = buildTask({ + id: "task-3", + name: "Copy 2", + original_task_id: "task-1", + is_deleted: false, + }); + + mockGetByIdFromMap(mockTaskRepository, { + "task-1": originalTask, + "task-2": copy1, + "task-3": copy2, + }); + mockTaskRepository.findByOriginalTaskId = vi + .fn() + .mockResolvedValue([copy1, copy2]); + const updateSpy = vi.fn().mockImplementation(async (task: Task) => task); + mockTaskRepository.update = updateSpy; + + await taskService.softDelete("task-1"); + + const updateCallsForNewOriginal = updateSpy.mock.calls.filter( + ([task]) => task.id === "task-2", + ); + expect(updateCallsForNewOriginal).toHaveLength(1); + expect(updateCallsForNewOriginal[0][0].original_task_id).toBe(""); + + const updateCallsForOtherCopy = updateSpy.mock.calls.filter( + ([task]) => task.id === "task-3", + ); + expect(updateCallsForOtherCopy).toHaveLength(1); + expect(updateCallsForOtherCopy[0][0].original_task_id).toBe("task-2"); + }); }); describe("softDelete with all copies deleted", () => { @@ -153,21 +193,15 @@ describe("TaskService - Recurring Tasks Integration", () => { is_deleted: true, }); - const updates: Record> = {}; - - mockTaskRepository.getById = vi.fn().mockImplementation(async (id) => { - if (id === "task-1") return originalTask; - if (id === "task-2") return deletedCopy1; - if (id === "task-3") return deletedCopy2; - return undefined; + const updates = mockUpdateRecording(mockTaskRepository); + mockGetByIdFromMap(mockTaskRepository, { + "task-1": originalTask, + "task-2": deletedCopy1, + "task-3": deletedCopy2, }); mockTaskRepository.findByOriginalTaskId = vi .fn() .mockResolvedValue([deletedCopy1, deletedCopy2]); - mockTaskRepository.update = vi.fn().mockImplementation(async (task) => { - updates[task.id] = task; - return task; - }); await taskService.softDelete("task-1"); diff --git a/packages/client/src/services/TaskService.reorder.test.ts b/packages/client/src/services/TaskService.reorder.test.ts index ba33ea44..23fe3da9 100644 --- a/packages/client/src/services/TaskService.reorder.test.ts +++ b/packages/client/src/services/TaskService.reorder.test.ts @@ -1,99 +1,240 @@ import { describe, expect, it, vi } from "vitest"; import { SORT_ORDER_REBALANCE_THRESHOLD } from "@/constants"; +import { fakeClock } from "@/lib/temporal"; import { buildTask } from "@/test/factories/taskFactory"; +import type { Task } from "@/types/entities"; +import { toISOTimestamp } from "@/utils/dateHelpers"; import { createTestContext } from "./TaskService-test-utils"; +const REBALANCE_LONG_KEY = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD + 1); +const SHORT_SORT_KEY = "a1"; + +/** + * Arrange for reorder tests that operate on a single task: builds a task from + * the given overrides and a TaskService whose repository resolves it via + * getById. Used by the short-key tests that never hit rebalancing. + */ +function setupSingleTask(overrides: Parameters[0] = {}) { + const task = buildTask(overrides); + const { taskService, mockTaskRepository } = createTestContext({ + getById: vi.fn().mockResolvedValue(task), + }); + return { task, taskService, mockTaskRepository }; +} + +/** + * Shared arrange for rebalance-during-reorder tests: a dragged task and one + * other box task, both carrying the same stale updated_at, wired into a + * TaskService whose repository resolves them via getById / getByBox. + */ +function setupRebalanceScenario() { + const staleTimestamp = toISOTimestamp(fakeClock("2025-01-01T00:00:00.000Z")); + const draggedTask = buildTask({ + sort_order: "a0", + box: "inbox", + updated_at: staleTimestamp, + }); + const otherBoxTask = buildTask({ + sort_order: "b0", + box: "inbox", + updated_at: staleTimestamp, + }); + const { taskService, mockTaskRepository } = createTestContext({ + getById: vi.fn().mockResolvedValue(draggedTask), + getByBox: vi.fn().mockResolvedValue([draggedTask, otherBoxTask]), + }); + return { + staleTimestamp, + draggedTask, + otherBoxTask, + taskService, + mockTaskRepository, + }; +} + +function getBulkUpsertedTasks( + mockTaskRepository: ReturnType< + typeof createTestContext + >["mockTaskRepository"], +): Task[] { + return (mockTaskRepository.bulkUpsert as ReturnType).mock + .calls[0][0]; +} + +function findRebalancedTaskById( + rebalancedTasks: Task[], + taskId: string, +): Task | undefined { + return rebalancedTasks.find((rebalancedTask) => rebalancedTask.id === taskId); +} + +/** + * Arrange for a plain rebalance-during-reorder trigger: a dragged task plus a + * two-task box, wired into a TaskService whose repository resolves them via + * getById / getByBox. Used by the tests that only assert on rebalance being + * triggered and on the fresh keys, without stale-timestamp bookkeeping. + */ +function setupBoxWithTasks(boxTasks: ReturnType[]) { + const task = buildTask({ sort_order: "a0", box: "inbox" }); + const { taskService, mockTaskRepository } = createTestContext({ + getById: vi.fn().mockResolvedValue(task), + getByBox: vi.fn().mockResolvedValue(boxTasks), + }); + return { task, taskService, mockTaskRepository }; +} + describe("TaskService", () => { describe("reorderTasks", () => { it("should update task with new sort_order", async () => { - const task = buildTask({ sort_order: "a0" }); - const { taskService, mockTaskRepository } = createTestContext({ - getById: vi.fn().mockResolvedValue(task), + const { task, taskService, mockTaskRepository } = setupSingleTask({ + sort_order: "a0", }); - await taskService.reorderTasks(task.id, "a1"); + await taskService.reorderTasks(task.id, SHORT_SORT_KEY); expect(mockTaskRepository.update).toHaveBeenCalledWith( expect.objectContaining({ id: task.id, - sort_order: "a1", + sort_order: SHORT_SORT_KEY, syncStatus: "pending" as const, }), ); }); it("should update updated_at when reordering", async () => { - const task = buildTask({ + const originalTimestamp = "2025-01-01T00:00:00.000Z"; + const { task, taskService, mockTaskRepository } = setupSingleTask({ sort_order: "a0", - updated_at: "2025-01-01T00:00:00.000Z", - }); - const { taskService, mockTaskRepository } = createTestContext({ - getById: vi.fn().mockResolvedValue(task), + updated_at: originalTimestamp, }); - await taskService.reorderTasks(task.id, "a1"); + await taskService.reorderTasks(task.id, SHORT_SORT_KEY); const updatedTask = ( mockTaskRepository.update as ReturnType ).mock.calls[0][0]; - expect(updatedTask.updated_at).not.toBe("2025-01-01T00:00:00.000Z"); + expect(updatedTask.updated_at).not.toBe(originalTimestamp); }); it("should throw when task not found", async () => { const { taskService } = createTestContext(); await expect( - taskService.reorderTasks("nonexistent", "a1"), + taskService.reorderTasks("nonexistent", SHORT_SORT_KEY), ).rejects.toThrow("Task not found: nonexistent"); }); it("should not trigger rebalancing when key is short", async () => { - const task = buildTask({ sort_order: "a0", box: "inbox" }); - const { taskService, mockTaskRepository } = createTestContext({ - getById: vi.fn().mockResolvedValue(task), + const { task, taskService, mockTaskRepository } = setupSingleTask({ + sort_order: "a0", + box: "inbox", }); - await taskService.reorderTasks(task.id, "a1"); + await taskService.reorderTasks(task.id, SHORT_SORT_KEY); expect(mockTaskRepository.bulkUpsert).not.toHaveBeenCalled(); }); it("should trigger rebalancing when key exceeds threshold", async () => { - const longKey = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD + 1); - const task = buildTask({ sort_order: "a0", box: "inbox" }); - const boxTasks = [ + const { task, taskService, mockTaskRepository } = setupBoxWithTasks([ buildTask({ sort_order: "a0", box: "inbox" }), buildTask({ sort_order: "a1", box: "inbox" }), - ]; - const { taskService, mockTaskRepository } = createTestContext({ - getById: vi.fn().mockResolvedValue(task), - getByBox: vi.fn().mockResolvedValue(boxTasks), - }); + ]); - await taskService.reorderTasks(task.id, longKey); + await taskService.reorderTasks(task.id, REBALANCE_LONG_KEY); expect(mockTaskRepository.bulkUpsert).toHaveBeenCalled(); }); it("should rebalance all tasks in the box with fresh keys", async () => { - const longKey = "a".repeat(SORT_ORDER_REBALANCE_THRESHOLD + 1); - const task = buildTask({ sort_order: "a0", box: "inbox" }); - const boxTasks = [ + const { task, taskService, mockTaskRepository } = setupBoxWithTasks([ buildTask({ sort_order: "b0", box: "inbox" }), buildTask({ sort_order: "a0", box: "inbox" }), - ]; + ]); + + await taskService.reorderTasks(task.id, REBALANCE_LONG_KEY); + + const rebalancedTasks = getBulkUpsertedTasks(mockTaskRepository); + expect(rebalancedTasks).toHaveLength(2); + for (const rebalancedTask of rebalancedTasks) { + expect(typeof rebalancedTask.sort_order).toBe("string"); + expect(rebalancedTask.syncStatus).toBe("pending"); + } + }); + + // FR4: rebalanceBox preserves updated_at — system-initiated rebalancing + // must not masquerade as a user edit (fix-stale-sync-overwrites). + // Verifies M2 of fix-stale-sync-overwrites + it("should keep original updated_at for rebalanced tasks", async () => { + const { + staleTimestamp, + draggedTask, + otherBoxTask, + taskService, + mockTaskRepository, + } = setupRebalanceScenario(); + + await taskService.reorderTasks(draggedTask.id, REBALANCE_LONG_KEY); + + const rebalancedTasks = getBulkUpsertedTasks(mockTaskRepository); + expect(rebalancedTasks).toHaveLength(2); + const otherTaskAfterRebalance = findRebalancedTaskById( + rebalancedTasks, + otherBoxTask.id, + ); + expect(otherTaskAfterRebalance?.updated_at).toBe(staleTimestamp); + }); + + // FR4: reorderTasks must keep refreshing updated_at for the dragged + // task even when the same operation triggers rebalancing of the rest + // of the box (regression guard for fix-stale-sync-overwrites). + it("should still refresh updated_at for the dragged task when rebalancing is triggered", async () => { + const { + staleTimestamp, + draggedTask, + otherBoxTask, + taskService, + mockTaskRepository, + } = setupRebalanceScenario(); + + await taskService.reorderTasks(draggedTask.id, REBALANCE_LONG_KEY); + + const rebalancedTasks = getBulkUpsertedTasks(mockTaskRepository); + const draggedTaskAfterRebalance = findRebalancedTaskById( + rebalancedTasks, + draggedTask.id, + ); + const otherTaskAfterRebalance = findRebalancedTaskById( + rebalancedTasks, + otherBoxTask.id, + ); + + expect(draggedTaskAfterRebalance?.updated_at).not.toBe(staleTimestamp); + expect(otherTaskAfterRebalance?.updated_at).toBe(staleTimestamp); + }); + + // FR4: rebalanceBox assigns syncStatus "pending" to propagate the new + // sort_order without pretending it is a user edit. + it("should set syncStatus to pending for rebalanced tasks", async () => { + const task = buildTask({ + sort_order: "a0", + box: "inbox", + syncStatus: "synced" as const, + }); + const otherBoxTask = buildTask({ + sort_order: "b0", + box: "inbox", + syncStatus: "synced" as const, + }); const { taskService, mockTaskRepository } = createTestContext({ getById: vi.fn().mockResolvedValue(task), - getByBox: vi.fn().mockResolvedValue(boxTasks), + getByBox: vi.fn().mockResolvedValue([task, otherBoxTask]), }); - await taskService.reorderTasks(task.id, longKey); + await taskService.reorderTasks(task.id, REBALANCE_LONG_KEY); - const rebalancedTasks = ( - mockTaskRepository.bulkUpsert as ReturnType - ).mock.calls[0][0]; + const rebalancedTasks = getBulkUpsertedTasks(mockTaskRepository); expect(rebalancedTasks).toHaveLength(2); for (const rebalancedTask of rebalancedTasks) { - expect(typeof rebalancedTask.sort_order).toBe("string"); expect(rebalancedTask.syncStatus).toBe("pending"); } }); diff --git a/packages/client/src/test/features/repeating_tasks/steps/dedup_merge.steps.ts b/packages/client/src/test/features/repeating_tasks/steps/dedup_merge.steps.ts new file mode 100644 index 00000000..56da7857 --- /dev/null +++ b/packages/client/src/test/features/repeating_tasks/steps/dedup_merge.steps.ts @@ -0,0 +1,232 @@ +// implements FR3, FR6 of fix-stale-sync-overwrites +import "fake-indexeddb/auto"; +import type { FeatureDescriibeCallbackParams } from "@amiceli/vitest-cucumber"; +import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber"; +import { expect, type TestContext } from "vitest"; +import { db } from "@/db/database"; +import { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; +import { TaskRepository } from "@/db/repositories/TaskRepository"; +import { fakeClock } from "@/lib/temporal"; +import { RecurringTaskDeduplicator } from "@/services/RecurringTaskDeduplicator"; +import { buildTask } from "@/test/factories/taskFactory"; +import type { Task } from "@/types/entities"; + +const feature = await loadFeature("../dedup_merge.feature"); + +type Context = Record; + +const DEDUPLICATION_RUN_TIMESTAMP = "2026-07-11T00:00:00.000Z"; +const ORIGINAL_TASK_ID = "11111111-1111-4111-a111-111111111111"; +const DEVICE_A_COPY_ID = "00000000-0000-4000-a000-000000000001"; +const DEVICE_B_COPY_ID = "00000000-0000-4000-a000-000000000002"; +const DEVICE_A_UPDATED_AT = "2026-07-08T10:00:00.000Z"; +const DEVICE_B_UPDATED_AT = "2026-07-06T10:00:00.000Z"; +const FIXED_REPEAT_RULE = "daily-1"; +const AFTER_COMPLETION_REPEAT_RULE = "after_completion-3"; + +describeFeature(feature, (f: FeatureDescriibeCallbackParams) => { + let deduplicator: RecurringTaskDeduplicator; + let deviceACopyBeforeMerge: Task; + let deviceBCopyBeforeMerge: Task; + + f.BeforeEachScenario(async () => { + await db.tasks.clear(); + const clock = fakeClock(DEDUPLICATION_RUN_TIMESTAMP); + deduplicator = new RecurringTaskDeduplicator( + new TaskRepository(), + new ChecklistRepository(), + clock, + ); + }); + + async function seedDeviceACopy( + nextDate: string, + description: string, + repeatRule: string, + ): Promise { + const deviceACopy = buildTask({ + id: DEVICE_A_COPY_ID, + original_task_id: ORIGINAL_TASK_ID, + next_date: nextDate, + appear_date: nextDate, + description, + repeat_rule: repeatRule, + updated_at: DEVICE_A_UPDATED_AT, + is_completed: false, + is_deleted: false, + syncStatus: "synced" as const, + }); + deviceACopyBeforeMerge = deviceACopy; + await db.tasks.add(deviceACopy); + } + + async function seedDeviceBCopy( + nextDate: string, + description: string, + repeatRule: string, + ): Promise { + const deviceBCopy = buildTask({ + id: DEVICE_B_COPY_ID, + original_task_id: ORIGINAL_TASK_ID, + next_date: nextDate, + appear_date: nextDate, + description, + repeat_rule: repeatRule, + updated_at: DEVICE_B_UPDATED_AT, + is_completed: false, + is_deleted: false, + syncStatus: "synced" as const, + }); + deviceBCopyBeforeMerge = deviceBCopy; + await db.tasks.add(deviceBCopy); + } + + type FixedScheduleScenarioSteps = { + Given: (title: string, fn: (ctx: TestContext) => Promise) => void; + And: (title: string, fn: (ctx: TestContext) => Promise) => void; + When: (title: string, fn: (ctx: TestContext) => Promise) => void; + }; + + function registerFixedScheduleSetupSteps({ + Given, + And, + When, + }: FixedScheduleScenarioSteps): void { + Given( + 'device A completed and edited the occurrence, producing a copy with next_date "2026-07-10" and description "v2" as the freshest update', + async (_ctx: TestContext) => { + await seedDeviceACopy("2026-07-10", "v2", FIXED_REPEAT_RULE); + }, + ); + + And( + 'device B completed the same occurrence offline, producing a copy with next_date "2026-07-09" and description "v1" as a staler update', + async (_ctx: TestContext) => { + await seedDeviceBCopy("2026-07-09", "v1", FIXED_REPEAT_RULE); + }, + ); + + When( + "deduplication runs after device B pulls both copies", + async (_ctx: TestContext) => { + await deduplicator.deduplicate([ORIGINAL_TASK_ID]); + }, + ); + } + + // @fix-stale-sync-overwrites @FR3 + f.Scenario( + "Fixed schedule — earlier device's schedule wins with the later device's fresh content", + ({ Given, When, Then, And }) => { + registerFixedScheduleSetupSteps({ Given, And, When }); + + Then( + 'the surviving copy has next_date "2026-07-09"', + async (_ctx: TestContext) => { + const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task; + expect(survivor.next_date).toBe("2026-07-09"); + }, + ); + + And( + 'the surviving copy has description "v2"', + async (_ctx: TestContext) => { + const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task; + expect(survivor.description).toBe("v2"); + }, + ); + + And( + "the surviving copy's updated_at equals device A's update time", + async (_ctx: TestContext) => { + const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task; + expect(survivor.updated_at).toBe(DEVICE_A_UPDATED_AT); + }, + ); + + And( + 'device A\'s copy is soft-deleted with syncStatus "pending"', + async (_ctx: TestContext) => { + const loser = (await db.tasks.get(DEVICE_A_COPY_ID)) as Task; + expect(loser.is_deleted).toBe(true); + expect(loser.syncStatus).toBe("pending"); + }, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR6 + f.Scenario( + "After-completion schedule — the same merge rules apply regardless of the recurrence model", + ({ Given, When, Then, And }) => { + Given( + 'device A completed the occurrence under an after_completion repeat_rule, producing a copy with next_date "2026-07-04" as the freshest update', + async (_ctx: TestContext) => { + await seedDeviceACopy("2026-07-04", "", AFTER_COMPLETION_REPEAT_RULE); + }, + ); + + And( + 'device B completed the same occurrence offline, producing a copy with next_date "2026-07-05" as a staler update', + async (_ctx: TestContext) => { + await seedDeviceBCopy("2026-07-05", "", AFTER_COMPLETION_REPEAT_RULE); + }, + ); + + When( + "deduplication runs after device B pulls both copies", + async (_ctx: TestContext) => { + await deduplicator.deduplicate([ORIGINAL_TASK_ID]); + }, + ); + + Then( + 'the surviving copy has next_date "2026-07-04"', + async (_ctx: TestContext) => { + const survivor = (await db.tasks.get(DEVICE_A_COPY_ID)) as Task; + expect(survivor.next_date).toBe("2026-07-04"); + }, + ); + + And( + "the surviving copy's repeat_rule is unchanged from before the merge", + async (_ctx: TestContext) => { + const survivor = (await db.tasks.get(DEVICE_A_COPY_ID)) as Task; + expect(survivor.repeat_rule).toBe(AFTER_COMPLETION_REPEAT_RULE); + }, + ); + + And( + "the surviving copy's appear_date is consistent with its own next_date", + async (_ctx: TestContext) => { + const survivor = (await db.tasks.get(DEVICE_A_COPY_ID)) as Task; + expect(survivor.appear_date).toBe(survivor.next_date); + }, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR3 + f.Scenario( + "Merge is not a verbatim keep-earliest — schedule and content are combined from different copies", + ({ Given, When, Then, And }) => { + registerFixedScheduleSetupSteps({ Given, And, When }); + + Then( + "the surviving copy does not equal device B's copy verbatim", + async (_ctx: TestContext) => { + const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task; + expect(survivor).not.toEqual(deviceBCopyBeforeMerge); + }, + ); + + And( + "the surviving copy does not equal device A's copy verbatim", + async (_ctx: TestContext) => { + const survivor = (await db.tasks.get(DEVICE_B_COPY_ID)) as Task; + expect(survivor).not.toEqual(deviceACopyBeforeMerge); + }, + ); + }, + ); +}); diff --git a/packages/client/src/test/features/sync_protocol/steps/pull_lww_protection.steps.ts b/packages/client/src/test/features/sync_protocol/steps/pull_lww_protection.steps.ts new file mode 100644 index 00000000..fc7e22c1 --- /dev/null +++ b/packages/client/src/test/features/sync_protocol/steps/pull_lww_protection.steps.ts @@ -0,0 +1,272 @@ +// implements FR5 of fix-stale-sync-overwrites +import "fake-indexeddb/auto"; +import type { FeatureDescriibeCallbackParams } from "@amiceli/vitest-cucumber"; +import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber"; +import type { WireGoal, WireTask } from "@clear-progress/contract"; +import { expect, type TestContext, vi } from "vitest"; +import { db } from "@/db/database"; +import { GoalRepository } from "@/db/repositories/GoalRepository"; +import { TaskRepository } from "@/db/repositories/TaskRepository"; +import { buildGoal } from "@/test/factories/goalFactory"; +import { buildTask } from "@/test/factories/taskFactory"; +import type { Goal, Task } from "@/types/entities"; + +const feature = await loadFeature("../pull_lww_protection.feature"); + +const LOCAL_EDIT_TIMESTAMP = "2026-01-02T00:00:00.000Z"; +const OTHER_DEVICE_NEWER_TIMESTAMP = "2026-01-03T00:00:00.000Z"; +const OTHER_DEVICE_OLDER_TIMESTAMP = "2026-01-01T00:00:00.000Z"; + +type Context = Record; + +function toWireTask(task: Task): WireTask { + const { syncStatus: _syncStatus, ...wireTask } = task; + return wireTask; +} + +function toWireGoal(goal: Goal): WireGoal { + const { syncStatus: _syncStatus, ...wireGoal } = goal; + return wireGoal; +} + +describeFeature(feature, (f: FeatureDescriibeCallbackParams) => { + let taskRepository: TaskRepository; + let goalRepository: GoalRepository; + let seededTaskId: string; + let seededGoalId: string; + let incomingTaskUpdatedAt: string; + let incomingGoalUpdatedAt: string; + let conflictWarnSpy: ReturnType; + + f.BeforeEachScenario(async () => { + await db.tasks.clear(); + await db.goals.clear(); + taskRepository = new TaskRepository(); + goalRepository = new GoalRepository(); + }); + + async function seedLocalRecords( + syncStatus: "synced" | "pending", + updatedAt: string, + ) { + const task = buildTask({ updated_at: updatedAt, syncStatus }); + const goal = buildGoal({ updated_at: updatedAt, syncStatus }); + seededTaskId = task.id; + seededGoalId = goal.id; + await db.tasks.add(task); + await db.goals.add(goal); + } + + async function pullRecordsWithUpdatedAt(updatedAt: string) { + incomingTaskUpdatedAt = updatedAt; + incomingGoalUpdatedAt = updatedAt; + const serverTask = toWireTask( + buildTask({ id: seededTaskId, updated_at: updatedAt }), + ); + const serverGoal = toWireGoal( + buildGoal({ id: seededGoalId, updated_at: updatedAt }), + ); + await taskRepository.applyServerRecords([serverTask]); + await goalRepository.applyServerRecords([serverGoal]); + } + + async function expectBothRecordsShowOtherDeviceEditAndSynced() { + const task = await db.tasks.get(seededTaskId); + const goal = await db.goals.get(seededGoalId); + expect(task?.updated_at).toBe(incomingTaskUpdatedAt); + expect(task?.syncStatus).toBe("synced"); + expect(goal?.updated_at).toBe(incomingGoalUpdatedAt); + expect(goal?.syncStatus).toBe("synced"); + } + + async function expectBothRecordsKeepLocalEditAndPending() { + const task = await db.tasks.get(seededTaskId); + const goal = await db.goals.get(seededGoalId); + expect(task?.updated_at).toBe(LOCAL_EDIT_TIMESTAMP); + expect(task?.syncStatus).toBe("pending"); + expect(goal?.updated_at).toBe(LOCAL_EDIT_TIMESTAMP); + expect(goal?.syncStatus).toBe("pending"); + } + + // @fix-stale-sync-overwrites @FR5 + f.Scenario( + "A locally-pending edit is overwritten when another device's edit is newer", + ({ Given, When, Then }) => { + Given( + "a task edited locally and pending sync, with a locally-pending goal edited the same way", + async (_ctx: TestContext) => { + await seedLocalRecords("pending", LOCAL_EDIT_TIMESTAMP); + }, + ); + + When( + "the same records are pulled from the server with a strictly newer edit from another device", + async (_ctx: TestContext) => { + await pullRecordsWithUpdatedAt(OTHER_DEVICE_NEWER_TIMESTAMP); + }, + ); + + Then( + "both records show the other device's edit and are marked as synced", + expectBothRecordsShowOtherDeviceEditAndSynced, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR5 + f.Scenario( + "A locally-pending edit survives a pull of an equally old server record", + ({ Given, When, Then }) => { + Given( + "a task edited locally and pending sync, with a locally-pending goal edited the same way", + async (_ctx: TestContext) => { + await seedLocalRecords("pending", LOCAL_EDIT_TIMESTAMP); + }, + ); + + When( + "the same records are pulled from the server with no newer edit from another device, timestamps tied", + async (_ctx: TestContext) => { + await pullRecordsWithUpdatedAt(LOCAL_EDIT_TIMESTAMP); + }, + ); + + Then( + "both records still show the local edit and remain pending sync", + expectBothRecordsKeepLocalEditAndPending, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR5 + f.Scenario( + "A locally-pending edit survives a pull of an older server record", + ({ Given, When, Then }) => { + Given( + "a task edited locally and pending sync, with a locally-pending goal edited the same way", + async (_ctx: TestContext) => { + await seedLocalRecords("pending", LOCAL_EDIT_TIMESTAMP); + }, + ); + + When( + "the same records are pulled from the server with an older, stale edit from another device", + async (_ctx: TestContext) => { + await pullRecordsWithUpdatedAt(OTHER_DEVICE_OLDER_TIMESTAMP); + }, + ); + + Then( + "both records still show the local edit and remain pending sync", + expectBothRecordsKeepLocalEditAndPending, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR5 + f.Scenario( + "A synced record always reflects the latest pull", + ({ Given, When, Then }) => { + Given( + "a task already in sync with the server, with a goal already in sync the same way", + async (_ctx: TestContext) => { + await seedLocalRecords("synced", LOCAL_EDIT_TIMESTAMP); + }, + ); + + When( + "the same records are pulled from the server with a newer edit from another device", + async (_ctx: TestContext) => { + await pullRecordsWithUpdatedAt(OTHER_DEVICE_NEWER_TIMESTAMP); + }, + ); + + Then( + "both records show the other device's edit and are marked as synced", + expectBothRecordsShowOtherDeviceEditAndSynced, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR5 + f.Scenario( + "A new record from another device appears after its first pull", + ({ Given, When, Then }) => { + Given( + "no local copy of a task or a goal exists yet", + async (_ctx: TestContext) => { + seededTaskId = crypto.randomUUID(); + seededGoalId = crypto.randomUUID(); + }, + ); + + When( + "the same records are pulled from the server for the first time", + async (_ctx: TestContext) => { + await pullRecordsWithUpdatedAt(OTHER_DEVICE_NEWER_TIMESTAMP); + }, + ); + + Then( + "both records appear locally and are marked as synced", + async (_ctx: TestContext) => { + const task = await db.tasks.get(seededTaskId); + const goal = await db.goals.get(seededGoalId); + expect(task).toBeDefined(); + expect(task?.syncStatus).toBe("synced"); + expect(goal).toBeDefined(); + expect(goal?.syncStatus).toBe("synced"); + }, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR5 + f.Scenario( + "An overwritten pending edit is logged as a sync conflict for debugging", + ({ Given, When, Then }) => { + Given( + "a task edited locally and pending sync", + async (_ctx: TestContext) => { + const task = buildTask({ + updated_at: LOCAL_EDIT_TIMESTAMP, + syncStatus: "pending", + }); + seededTaskId = task.id; + await db.tasks.add(task); + }, + ); + + When( + "the task is pulled from the server with a strictly newer edit from another device", + async (_ctx: TestContext) => { + conflictWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + const serverTask = toWireTask( + buildTask({ + id: seededTaskId, + updated_at: OTHER_DEVICE_NEWER_TIMESTAMP, + }), + ); + await taskRepository.applyServerRecords([serverTask]); + }, + ); + + Then( + "a sync conflict is logged with the task's id and both devices' timestamps", + async (_ctx: TestContext) => { + expect(conflictWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("task"), + expect.objectContaining({ + id: seededTaskId, + localUpdatedAt: LOCAL_EDIT_TIMESTAMP, + serverUpdatedAt: OTHER_DEVICE_NEWER_TIMESTAMP, + }), + ); + conflictWarnSpy.mockRestore(); + }, + ); + }, + ); +}); diff --git a/packages/client/src/test/features/sync_protocol/steps/stale_reveal_sync.steps.ts b/packages/client/src/test/features/sync_protocol/steps/stale_reveal_sync.steps.ts new file mode 100644 index 00000000..d5104801 --- /dev/null +++ b/packages/client/src/test/features/sync_protocol/steps/stale_reveal_sync.steps.ts @@ -0,0 +1,218 @@ +// implements FR1, FR2 of fix-stale-sync-overwrites +import "fake-indexeddb/auto"; +import type { FeatureDescriibeCallbackParams } from "@amiceli/vitest-cucumber"; +import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber"; +import { expect, type TestContext } from "vitest"; +import { db } from "@/db/database"; +import { ChecklistRepository } from "@/db/repositories/ChecklistRepository"; +import { TaskRepository } from "@/db/repositories/TaskRepository"; +import { fakeClock } from "@/lib/temporal"; +import { HiddenTaskService } from "@/services/HiddenTaskService"; +import { TaskService } from "@/services/TaskService"; +import { buildTask } from "@/test/factories/taskFactory"; +import type { Task } from "@/types/entities"; + +const feature = await loadFeature("../stale_reveal_sync.feature"); + +type Context = Record; + +describeFeature(feature, (f: FeatureDescriibeCallbackParams) => { + let hiddenTaskService: HiddenTaskService; + let taskService: TaskService; + let seededTaskId: string; + let updatedTask: Task; + + f.BeforeEachScenario(async () => { + await db.tasks.clear(); + taskService = new TaskService( + new TaskRepository(), + new ChecklistRepository(), + ); + }); + + async function revealHiddenTasks(_ctx: TestContext) { + await hiddenTaskService.revealHiddenTasks(); + } + + async function expectRevealedTaskUnhiddenAndPending(_ctx: TestContext) { + const task = await db.tasks.get(seededTaskId); + expect(task?.is_hidden).toBe(false); + expect(task?.syncStatus).toBe("pending"); + } + + async function expectRevealedTaskUpdatedAtPreserved(_ctx: TestContext) { + const task = await db.tasks.get(seededTaskId); + expect(task?.updated_at).toBe("2026-06-01T10:00:00.000Z"); + } + + // @fix-stale-sync-overwrites @FR1 + f.Scenario( + "Task revealed when appear date arrives preserves updated_at", + ({ Given, When, Then, And }) => { + Given( + 'a hidden task with appear_date "2026-06-15", updated_at "2026-06-01T10:00:00.000Z" and today is "2026-06-15"', + async (_ctx: TestContext) => { + const clock = fakeClock("2026-06-15T12:00:00Z"); + hiddenTaskService = new HiddenTaskService( + new TaskRepository(), + clock, + ); + const task = buildTask({ + is_hidden: true, + appear_date: "2026-06-15", + next_date: "2026-06-15", + updated_at: "2026-06-01T10:00:00.000Z", + syncStatus: "synced", + }); + seededTaskId = task.id; + await db.tasks.add(task); + }, + ); + + When("system reveals hidden tasks", revealHiddenTasks); + + Then( + 'the task has is_hidden false and syncStatus "pending"', + expectRevealedTaskUnhiddenAndPending, + ); + + And( + 'the task\'s updated_at is still "2026-06-01T10:00:00.000Z"', + expectRevealedTaskUpdatedAtPreserved, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR1 + f.Scenario( + "Reveal of an already-pending record does not degrade its state", + ({ Given, When, Then, And }) => { + Given( + 'a hidden task with appear_date "2026-06-15", updated_at "2026-06-01T10:00:00.000Z", syncStatus "pending" and today is "2026-06-15"', + async (_ctx: TestContext) => { + const clock = fakeClock("2026-06-15T12:00:00Z"); + hiddenTaskService = new HiddenTaskService( + new TaskRepository(), + clock, + ); + const task = buildTask({ + is_hidden: true, + appear_date: "2026-06-15", + next_date: "2026-06-15", + updated_at: "2026-06-01T10:00:00.000Z", + syncStatus: "pending", + }); + seededTaskId = task.id; + await db.tasks.add(task); + }, + ); + + When("system reveals hidden tasks", revealHiddenTasks); + + Then( + 'the task has is_hidden false and syncStatus "pending"', + expectRevealedTaskUnhiddenAndPending, + ); + + And( + 'the task\'s updated_at is still "2026-06-01T10:00:00.000Z"', + expectRevealedTaskUpdatedAtPreserved, + ); + }, + ); + + // @fix-stale-sync-overwrites @FR2 + f.Scenario( + "Manual hide sets appear_date, refreshes updated_at, and marks pending for sync", + ({ Given, When, Then, And }) => { + Given( + 'a visible task with updated_at "2026-06-01T10:00:00.000Z" and syncStatus "synced"', + async (_ctx: TestContext) => { + const task = buildTask({ + is_hidden: false, + appear_date: "", + updated_at: "2026-06-01T10:00:00.000Z", + syncStatus: "synced", + }); + seededTaskId = task.id; + await db.tasks.add(task); + }, + ); + + When( + 'the user manually hides the task with appear_date "2026-08-01"', + async (_ctx: TestContext) => { + updatedTask = await taskService.update(seededTaskId, { + is_hidden: true, + appear_date: "2026-08-01", + }); + }, + ); + + Then( + 'the task has is_hidden true and appear_date "2026-08-01"', + async (_ctx: TestContext) => { + expect(updatedTask.is_hidden).toBe(true); + expect(updatedTask.appear_date).toBe("2026-08-01"); + }, + ); + + And( + 'the task\'s updated_at is refreshed past "2026-06-01T10:00:00.000Z"', + async (_ctx: TestContext) => { + expect(updatedTask.updated_at).not.toBe("2026-06-01T10:00:00.000Z"); + }, + ); + + And('the task has syncStatus "pending"', async (_ctx: TestContext) => { + expect(updatedTask.syncStatus).toBe("pending"); + }); + }, + ); + + // @fix-stale-sync-overwrites @FR2 + f.Scenario( + "Manual unhide before appear date clears appear_date, refreshes updated_at, and marks pending for sync", + ({ Given, When, Then, And }) => { + Given( + 'a hidden task with appear_date "2026-08-01", updated_at "2026-06-01T10:00:00.000Z" and syncStatus "synced"', + async (_ctx: TestContext) => { + const task = buildTask({ + is_hidden: true, + appear_date: "2026-08-01", + updated_at: "2026-06-01T10:00:00.000Z", + syncStatus: "synced", + }); + seededTaskId = task.id; + await db.tasks.add(task); + }, + ); + + When("the user manually unhides the task", async (_ctx: TestContext) => { + updatedTask = await taskService.update(seededTaskId, { + is_hidden: false, + appear_date: "", + }); + }); + + Then( + 'the task has is_hidden false and appear_date ""', + async (_ctx: TestContext) => { + expect(updatedTask.is_hidden).toBe(false); + expect(updatedTask.appear_date).toBe(""); + }, + ); + + And( + 'the task\'s updated_at is refreshed past "2026-06-01T10:00:00.000Z"', + async (_ctx: TestContext) => { + expect(updatedTask.updated_at).not.toBe("2026-06-01T10:00:00.000Z"); + }, + ); + + And('the task has syncStatus "pending"', async (_ctx: TestContext) => { + expect(updatedTask.syncStatus).toBe("pending"); + }); + }, + ); +}); diff --git a/packages/integration/src/tests/attachments-purge-cleanup.spec.ts b/packages/integration/src/tests/attachments-purge-cleanup.spec.ts index 63700330..ab80dc17 100644 --- a/packages/integration/src/tests/attachments-purge-cleanup.spec.ts +++ b/packages/integration/src/tests/attachments-purge-cleanup.spec.ts @@ -4,13 +4,16 @@ import { expect, test } from "@playwright/test"; import { attachFileToEntity, - createGoal, - createTask, deleteFirstAttachment, +} from "../page-attachment-actions.js"; +import { + createGoal, navigateToGoals, openGoalDetail, - openTaskDetail, -} from "../page-actions.js"; + removeCoverFromGoal, + uploadCoverToGoal, +} from "../page-goal-category-actions.js"; +import { createTask, openTaskDetail } from "../page-task-actions.js"; import { createMinimalPng, getFileFromServer, @@ -33,32 +36,22 @@ test("14.12: two goals same cover -> remove one -> file stays -> remove both -> const goalNameY = `Cover Goal Y ${Date.now()}`; // Create two goals with the same cover image - await navigateToGoals(page); - await createGoal(page, goalNameX); - await openGoalDetail(page, goalNameX); - await page.getByTestId("edit-goal-button").click(); - await page.getByTestId("cover-file-input").setInputFiles({ + const sharedCoverFile = { name: "cover-shared.png", mimeType: "image/png", buffer: pngBuffer, - }); - await page.getByTestId("cover-preview-img").waitFor({ state: "visible" }); - await page.getByTestId("goal-save-button").click(); - await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); + }; + + await navigateToGoals(page); + await createGoal(page, goalNameX); + await openGoalDetail(page, goalNameX); + await uploadCoverToGoal(page, sharedCoverFile); await triggerSyncAndWait(page); await navigateToGoals(page); await createGoal(page, goalNameY); await openGoalDetail(page, goalNameY); - await page.getByTestId("edit-goal-button").click(); - await page.getByTestId("cover-file-input").setInputFiles({ - name: "cover-shared.png", - mimeType: "image/png", - buffer: pngBuffer, - }); - await page.getByTestId("cover-preview-img").waitFor({ state: "visible" }); - await page.getByTestId("goal-save-button").click(); - await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); + await uploadCoverToGoal(page, sharedCoverFile); await triggerSyncAndWait(page); // Get the cover hash @@ -73,10 +66,7 @@ test("14.12: two goals same cover -> remove one -> file stays -> remove both -> // Remove cover from goal X await navigateToGoals(page); await openGoalDetail(page, goalNameX); - await page.getByTestId("edit-goal-button").click(); - await page.getByTestId("cover-remove-button").click(); - await page.getByTestId("goal-save-button").click(); - await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); + await removeCoverFromGoal(page); await triggerSyncAndWait(page); // File stays — goal Y still references it @@ -87,10 +77,7 @@ test("14.12: two goals same cover -> remove one -> file stays -> remove both -> // Remove cover from goal Y await navigateToGoals(page); await openGoalDetail(page, goalNameY); - await page.getByTestId("edit-goal-button").click(); - await page.getByTestId("cover-remove-button").click(); - await page.getByTestId("goal-save-button").click(); - await page.getByTestId("edit-goal-button").waitFor({ state: "visible" }); + await removeCoverFromGoal(page); await triggerSyncAndWait(page); // File gone immediately — covers don't go through soft-delete, 0 refs diff --git a/packages/integration/src/tests/multi-device-conflicts.spec.ts b/packages/integration/src/tests/multi-device-conflicts.spec.ts index 71ccbd1a..7d7a1f87 100644 --- a/packages/integration/src/tests/multi-device-conflicts.spec.ts +++ b/packages/integration/src/tests/multi-device-conflicts.spec.ts @@ -2,14 +2,16 @@ import { expect, type Locator, type Page, test } from "@playwright/test"; import { createGoal, - createTask, - deleteTaskFromDetail, navigateToGoals, openGoalDetail, - openTaskDetail, updateGoalName, +} from "../page-goal-category-actions.js"; +import { + createTask, + deleteTaskFromDetail, + openTaskDetail, updateTaskName, -} from "../page-actions.js"; +} from "../page-task-actions.js"; import { pullFromServer, setupTwoDeviceTest, diff --git a/packages/integration/src/tests/multi-device-manual-unhide.spec.ts b/packages/integration/src/tests/multi-device-manual-unhide.spec.ts new file mode 100644 index 00000000..e4a2b46f --- /dev/null +++ b/packages/integration/src/tests/multi-device-manual-unhide.spec.ts @@ -0,0 +1,150 @@ +// implements U2, UX3, FR2, NFR-REL1 of fix-stale-sync-overwrites +import { expect, test } from "@playwright/test"; +import { + createTask, + findTaskItem, + hideTaskUntil, + openTaskDetail, + unhideTask, +} from "../page-task-actions.js"; +import { + assertConverged, + pullFromServer, + setupTwoDeviceTest, + triggerSyncAndWait, +} from "../test-helpers.js"; +import { + gotoTasksPage, + TASK_VISIBLE_TIMEOUT_MS, +} from "./stale-recurring-helpers.js"; + +const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest(); + +// Far enough in the future that the manual unhide is unambiguously "early" — +// no need for clock tricks since manual unhide works regardless of whether +// appear_date has passed (that's the whole point of FR2 vs FR1 auto-reveal). +const FUTURE_APPEAR_DATE_DAYS_AHEAD = 30; + +interface ManualUnhidePullResponse { + ok: boolean; + tasks: Array<{ + id: string; + name: string; + is_deleted: boolean; + is_hidden: boolean; + appear_date: string; + updated_at: string; + }>; + current_revision: number; +} + +function futureIsoDate(daysAhead: number): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + daysAhead); + return date.toISOString().slice(0, 10); +} + +/** + * U2 / FR2 / NFR-REL1 — manual unhide before appear_date propagates to + * another device. + * + * Scenario: + * 1. Device A creates a non-recurring task; both devices sync and converge + * on a baseline (B sees the task). + * 2. Device A hides the task with a future appear_date, then pushes. The + * server shows `is_hidden: true` with that appear_date. + * 3. Device B pulls — the task is now hidden on B too (no longer in B's + * active task list). + * 4. Device A manually unhides the task EARLY (well before appear_date), + * then pushes. The server shows `is_hidden: false`, `appear_date` cleared + * to `""`, and `updated_at` refreshed past its pre-unhide value — manual + * unhide is a real user edit (FR2), unlike auto-reveal (FR1). + * 5. Device B pulls — the task is visible again on B (UX3). + * 6. Convergence: device A, device B, and the server agree (NFR-REL1). + */ +test("A manually unhides a task before appear_date → B sees it visible again after sync", async () => { + const pageA = getPageA(); + const pageB = getPageB(); + const credentials = getCredentials(); + const taskName = `Manual Unhide Task ${Date.now()}`; + const futureAppearDate = futureIsoDate(FUTURE_APPEAR_DATE_DAYS_AHEAD); + + const baseline = await pullFromServer(credentials); + const baselineRevision = baseline.current_revision; + + // --- 1. A creates a task; both devices converge on a baseline --- + await gotoTasksPage(pageA); + await createTask(pageA, taskName); + await triggerSyncAndWait(pageA); + + await gotoTasksPage(pageB); + await triggerSyncAndWait(pageB); + await findTaskItem(pageB, taskName).waitFor({ + state: "visible", + timeout: TASK_VISIBLE_TIMEOUT_MS, + }); + + // --- 2. A hides the task with a future appear_date, then pushes --- + await openTaskDetail(pageA, taskName); + await hideTaskUntil(pageA, futureAppearDate); + await triggerSyncAndWait(pageA); + + const { tasks: tasksAfterHide } = + await pullFromServer( + credentials, + baselineRevision, + ); + const hiddenOnServer = tasksAfterHide.find( + (task) => task.name === taskName && !task.is_deleted, + ); + expect(hiddenOnServer).toBeDefined(); + expect(hiddenOnServer?.is_hidden).toBe(true); + expect(hiddenOnServer?.appear_date).toBe(futureAppearDate); + const updatedAtAfterHide = hiddenOnServer?.updated_at ?? ""; + + // --- 3. B pulls — task is now hidden on B too --- + await triggerSyncAndWait(pageB); + await gotoTasksPage(pageB); + await expect(findTaskItem(pageB, taskName)).toHaveCount(0); + + // --- 4. A manually unhides the task EARLY (before appear_date), pushes --- + await gotoTasksPage(pageA); + // The task is hidden (future appear_date), so it is filtered out of the + // active list. Reveal hidden tasks via the command-bar eye toggle before + // opening its detail panel. + await pageA.getByTestId("command-bar-eye-toggle").click(); + await findTaskItem(pageA, taskName).waitFor({ + state: "visible", + timeout: TASK_VISIBLE_TIMEOUT_MS, + }); + await openTaskDetail(pageA, taskName); + await unhideTask(pageA); + await triggerSyncAndWait(pageA); + + const { tasks: tasksAfterUnhide } = + await pullFromServer( + credentials, + baselineRevision, + ); + const unhiddenOnServer = tasksAfterUnhide.find( + (task) => task.name === taskName && !task.is_deleted, + ); + expect(unhiddenOnServer).toBeDefined(); + expect(unhiddenOnServer?.is_hidden).toBe(false); + expect(unhiddenOnServer?.appear_date).toBe(""); + expect(unhiddenOnServer?.updated_at).not.toBe(updatedAtAfterHide); + expect(Date.parse(unhiddenOnServer?.updated_at ?? "")).toBeGreaterThan( + Date.parse(updatedAtAfterHide), + ); + + // --- 5. B pulls — task is visible again (UX3) --- + await triggerSyncAndWait(pageB); + await gotoTasksPage(pageB); + await findTaskItem(pageB, taskName).waitFor({ + state: "visible", + timeout: TASK_VISIBLE_TIMEOUT_MS, + }); + + // --- 6. Convergence: A, B, and the server agree (NFR-REL1) --- + await assertConverged(pageA, pageB, credentials, "tasks"); +}); diff --git a/packages/integration/src/tests/multi-device-recurring.spec.ts b/packages/integration/src/tests/multi-device-recurring.spec.ts index f64f5338..2c97bed8 100644 --- a/packages/integration/src/tests/multi-device-recurring.spec.ts +++ b/packages/integration/src/tests/multi-device-recurring.spec.ts @@ -1,11 +1,17 @@ // implements FR14 of add-supabase-integration-tests -import { expect, type Page, test } from "@playwright/test"; -import { createTask, findTaskItem, openTaskDetail } from "../page-actions.js"; +import { expect, test } from "@playwright/test"; +import { findTaskItem } from "../page-task-actions.js"; import { pullFromServer, setupTwoDeviceTest, triggerSyncAndWait, } from "../test-helpers.js"; +import { + completeTask, + createAfterCompletionRecurringTask, + createRecurringTask, + TASK_VISIBLE_TIMEOUT_MS, +} from "./stale-recurring-helpers.js"; const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest(); @@ -29,45 +35,6 @@ interface RecurringPullResponse { current_revision: number; } -async function setFixedDailyRepeat(testPage: Page): Promise { - await testPage - .getByTestId("task-detail-panel") - .getByRole("button", { name: /Repeat/ }) - .click(); - await testPage.getByTestId("repeat-type-fixed").waitFor({ state: "visible" }); - await testPage.getByTestId("repeat-type-fixed").click(); - await testPage - .getByTestId("repeat-frequency-daily") - .waitFor({ state: "visible" }); - await testPage.getByTestId("repeat-frequency-daily").click(); - await testPage.getByTestId("repeat-fixed-next").waitFor({ state: "visible" }); - await testPage.getByTestId("repeat-fixed-next").click(); - await testPage.getByTestId("repeat-apply").waitFor({ state: "visible" }); - await testPage.getByTestId("repeat-apply").click(); - await testPage.getByTestId("repeat-apply").waitFor({ state: "hidden" }); -} - -async function createRecurringTask( - testPage: Page, - taskName: string, -): Promise { - await createTask(testPage, taskName); - await openTaskDetail(testPage, taskName); - await setFixedDailyRepeat(testPage); -} - -const TASK_VISIBLE_TIMEOUT_MS = 15_000; -const COMPLETE_SETTLE_MS = 300; - -async function completeTask(testPage: Page, taskName: string): Promise { - await findTaskItem(testPage, taskName) - .getByRole("button", { name: /Complete task/i }) - .click(); - // Recurring tasks create a new occurrence with the same name immediately, - // so we cannot wait for the task item to become hidden. - await testPage.waitForTimeout(COMPLETE_SETTLE_MS); -} - // State carried between sequential tests let recurringTaskName: string; @@ -218,29 +185,12 @@ test("after_completion recurring → complete → next_date = completed_at + del ); const baselineRevision = baseline.current_revision; - await createTask(pageA, afterCompletionTaskName); - await openTaskDetail(pageA, afterCompletionTaskName); - // Set repeat: After Completion > 3 days - await pageA - .getByTestId("task-detail-panel") - .getByRole("button", { name: /Repeat/ }) - .click(); - await pageA - .getByTestId("repeat-type-after-completion") - .waitFor({ state: "visible" }); - await pageA.getByTestId("repeat-type-after-completion").click(); - await pageA - .getByTestId("repeat-delay-days-input") - .waitFor({ state: "visible" }); - await pageA.getByTestId("repeat-delay-days-input").fill(String(delayDays)); - await pageA - .getByTestId("repeat-after-completion-next") - .waitFor({ state: "visible" }); - await pageA.getByTestId("repeat-after-completion-next").click(); - await pageA.getByTestId("repeat-apply").waitFor({ state: "visible" }); - await pageA.getByTestId("repeat-apply").click(); - await pageA.getByTestId("repeat-apply").waitFor({ state: "hidden" }); + await createAfterCompletionRecurringTask( + pageA, + afterCompletionTaskName, + delayDays, + ); await completeTask(pageA, afterCompletionTaskName); await triggerSyncAndWait(pageA); diff --git a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts index b0201242..0f58aa40 100644 --- a/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts +++ b/packages/integration/src/tests/multi-device-stale-rebalance.spec.ts @@ -18,6 +18,7 @@ import { buildInboxTaskPayload, type RebalancePullResponse, } from "./stale-rebalance-helpers.js"; +import { TASK_VISIBLE_TIMEOUT_MS } from "./stale-recurring-helpers.js"; const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest(); @@ -25,8 +26,6 @@ const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest(); // (integration tests never import client internals — see module boundary rule). const SORT_ORDER_REBALANCE_THRESHOLD = 10; -const TASK_VISIBLE_TIMEOUT_MS = 15_000; - /** * U3 / FR4 / NFR-REL1 — a rebalance triggered on a stale device does not * clobber content that a different device edited more recently. diff --git a/packages/integration/src/tests/multi-device-stale-recurring-after-completion.spec.ts b/packages/integration/src/tests/multi-device-stale-recurring-after-completion.spec.ts new file mode 100644 index 00000000..54d261fe --- /dev/null +++ b/packages/integration/src/tests/multi-device-stale-recurring-after-completion.spec.ts @@ -0,0 +1,116 @@ +// implements U1, UX1, FR6, NFR-REL1 of fix-stale-sync-overwrites +import { expect, test } from "@playwright/test"; +import { pullFromServer, setupTwoDeviceTest } from "../test-helpers.js"; +import { + runStaleRevealConvergence, + runStaleRevealSetup, +} from "./stale-recurring-flow.js"; +import { + AFTER_COMPLETION_DELAY_DAYS, + advanceClockPastDate, + createAfterCompletionRecurringTask, + type StaleRecurringPullResponse, +} from "./stale-recurring-helpers.js"; + +const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest(); + +/** + * U1 / FR6 — the reported bug, `after_completion` recurring model. + * + * Same shape as the `fixed` model scenario in + * multi-device-stale-recurring-fixed.spec.ts, with one key difference: for + * `after_completion`, the new occurrence's `next_date` is derived only from + * `completed_at`'s local date + `delay_days` (never from `updated_at`), and + * (with the default `advance_days: 0`) `appear_date` equals `next_date` + * exactly — see `calculateNextDate`/`calculateAppearDate` in + * `repeatRule.ts`. This test additionally asserts (FR6, regression guard) + * that the surviving occurrence's `next_date` matches that formula exactly, + * proving the stale-device merge/reveal machinery did not corrupt the + * recurrence math for this model. + * + * Scenario: + * 1. Device A creates an `after_completion` recurring task (delay_days = + * `AFTER_COMPLETION_DELAY_DAYS`); both devices sync and converge + * (baseline). + * 2. Device B goes stale from this point on. + * 3. Device A completes the task (creating a new hidden occurrence whose + * `appear_date` = `next_date` = completion date + delay_days, genuinely + * in the future) AND edits the description, then pushes. + * 4. Device B comes back online; its browser clock is fast-forwarded past + * the new occurrence's `appear_date`. + * 5. B pulls A's changes, auto-reveals the incoming hidden occurrence + * without bumping `updated_at` (FR1), pushes, and pulls once more. + * 6. Convergence: both devices and the server agree — the completed + * occurrence stays completed, the surviving new occurrence carries A's + * newest description (FR3 dedup merge, FR5 LWW pull protection), and its + * `next_date` is exactly `completed_at` + `delay_days` — unaffected by + * the stale-device dance (FR6). + */ +test("A completes + edits recurring task, stale B auto-reveals on reconnect → both devices converge (after_completion model)", async () => { + // Genuinely heavy: two devices, clock skew, auto-reveal, dedup merge and + // several push/pull round-trips. Grant the extended (slow) timeout budget. + test.slow(); + const pageA = getPageA(); + const pageB = getPageB(); + const credentials = getCredentials(); + const taskName = `Stale Reveal After Completion ${Date.now()}`; + const freshDescription = `Freshest description from A ${Date.now()}`; + + // Baseline revision so we only inspect records created by this test. + const baseline = + await pullFromServer(credentials); + const baselineRevision = baseline.current_revision; + + // --- 1. A creates an after_completion recurring task --- + await createAfterCompletionRecurringTask( + pageA, + taskName, + AFTER_COMPLETION_DELAY_DAYS, + ); + + // --- 2-4. Both converge, B goes stale, A edits + completes + pushes --- + const { completedOnServer, newOccurrenceOnServer } = + await runStaleRevealSetup( + pageA, + pageB, + credentials, + taskName, + freshDescription, + baselineRevision, + ); + + // For after_completion, completing must stamp `completed_at`. + expect(completedOnServer.completed_at).not.toBe(""); + + // FR6 regression guard: next_date = completed_at's local date + delay_days, + // computed only from completed_at — never from updated_at. + const completedDate = new Date(completedOnServer.completed_at); + const expectedNextDate = new Date(completedDate); + expectedNextDate.setDate( + expectedNextDate.getDate() + AFTER_COMPLETION_DELAY_DAYS, + ); + const expectedNextDateString = [ + expectedNextDate.getFullYear(), + String(expectedNextDate.getMonth() + 1).padStart(2, "0"), + String(expectedNextDate.getDate()).padStart(2, "0"), + ].join("-"); + expect(newOccurrenceOnServer.next_date).toBe(expectedNextDateString); + // advance_days defaults to 0, so appear_date === next_date exactly. + expect(newOccurrenceOnServer.appear_date).toBe(expectedNextDateString); + + await advanceClockPastDate(pageB, newOccurrenceOnServer.appear_date); + + // --- 5-6. B auto-reveals, all three converge --- + const finalNewOccurrence = await runStaleRevealConvergence( + pageA, + pageB, + credentials, + taskName, + freshDescription, + baselineRevision, + ); + + // FR6: the stale-device merge/reveal machinery did not corrupt the + // recurrence math — next_date is still exactly completed_at + delay_days. + expect(finalNewOccurrence.next_date).toBe(expectedNextDateString); +}); diff --git a/packages/integration/src/tests/multi-device-stale-recurring-fixed.spec.ts b/packages/integration/src/tests/multi-device-stale-recurring-fixed.spec.ts new file mode 100644 index 00000000..31ba62c0 --- /dev/null +++ b/packages/integration/src/tests/multi-device-stale-recurring-fixed.spec.ts @@ -0,0 +1,83 @@ +// implements U1, UX1, UX2, FR1, FR3, FR5, NFR-REL1, M1 of fix-stale-sync-overwrites +import { test } from "@playwright/test"; +import { pullFromServer, setupTwoDeviceTest } from "../test-helpers.js"; +import { + runStaleRevealConvergence, + runStaleRevealSetup, +} from "./stale-recurring-flow.js"; +import { + advanceClockPastDate, + createRecurringTask, + type StaleRecurringPullResponse, +} from "./stale-recurring-helpers.js"; + +const { getPageA, getPageB, getCredentials } = setupTwoDeviceTest(); + +/** + * U1 / M1 — the reported bug, `fixed` recurring model. + * + * Scenario: + * 1. Device A creates a daily `fixed` recurring task; both devices sync and + * fully converge (baseline). + * 2. Device B goes stale from this point on — it does not sync again until + * step 5. + * 3. Device A completes the task (creating a new hidden occurrence — a + * fresh daily-fixed occurrence's `appear_date` is always tomorrow, so + * `is_hidden: true` locally on A too, which is expected/normal) AND edits + * the description on the completed task, then pushes. The completed + * occurrence and the new occurrence both carry A's latest description + * (the new occurrence is created from the original's current fields at + * completion time). + * 4. Device B — still on its stale pre-completion local copy — comes back + * online. Its browser clock is fast-forwarded past the new occurrence's + * `appear_date` (see `advanceClockPastDate`). + * 5. B pulls A's changes. The incoming hidden occurrence is now revealable + * on B (`appear_date <= B's today`), so `HiddenTaskService` auto-reveals + * it locally: `is_hidden: false`, `syncStatus: "pending"`, `updated_at` + * UNCHANGED (FR1) — this is the exact system-mutation-masquerading-as- + * edit that used to corrupt sync. B then pushes and pulls once more. + * 6. Convergence: both devices and the server agree — the completed + * occurrence stays completed, and both it and the surviving new + * occurrence carry A's newest description (FR3 dedup merge, FR5 LWW pull + * protection). No resurrection, no reverted description, no data loss. + */ +test("A completes + edits recurring task, stale B auto-reveals on reconnect → both devices converge (fixed model)", async () => { + // Genuinely heavy: two devices, clock skew, auto-reveal, dedup merge and + // several push/pull round-trips. Grant the extended (slow) timeout budget. + test.slow(); + const pageA = getPageA(); + const pageB = getPageB(); + const credentials = getCredentials(); + const taskName = `Stale Reveal Fixed ${Date.now()}`; + const freshDescription = `Freshest description from A ${Date.now()}`; + + // Baseline revision so we only inspect records created by this test. + const baseline = + await pullFromServer(credentials); + const baselineRevision = baseline.current_revision; + + // --- 1. A creates a daily fixed recurring task --- + await createRecurringTask(pageA, taskName); + + // --- 2-4. Both converge, B goes stale, A edits + completes + pushes --- + const { newOccurrenceOnServer } = await runStaleRevealSetup( + pageA, + pageB, + credentials, + taskName, + freshDescription, + baselineRevision, + ); + + await advanceClockPastDate(pageB, newOccurrenceOnServer.appear_date); + + // --- 5-6. B auto-reveals, all three converge --- + await runStaleRevealConvergence( + pageA, + pageB, + credentials, + taskName, + freshDescription, + baselineRevision, + ); +}); diff --git a/packages/integration/src/tests/stale-recurring-flow.ts b/packages/integration/src/tests/stale-recurring-flow.ts new file mode 100644 index 00000000..421b754e --- /dev/null +++ b/packages/integration/src/tests/stale-recurring-flow.ts @@ -0,0 +1,176 @@ +// implements U1, UX1, UX2, FR1, FR3, FR5, FR6, NFR-REL1 of fix-stale-sync-overwrites +import { expect, type Page } from "@playwright/test"; +import { + findTaskItem, + openTaskDetail, + updateTaskDescription, +} from "../page-task-actions.js"; +import { + assertConverged, + pullFromServer, + type ServerCallCredentials, + triggerSyncAndWait, +} from "../test-helpers.js"; +import { + completeTask, + gotoTasksPage, + type StaleRecurringPullResponse, + syncAfterClockAdvance, + TASK_VISIBLE_TIMEOUT_MS, +} from "./stale-recurring-helpers.js"; + +type StaleRecurringTask = StaleRecurringPullResponse["tasks"][number]; + +/** + * Shared setup phase of the stale-reveal specs (fixed / after_completion), + * covering steps 1-4 of both scenarios. The caller creates the recurring task + * (choosing the repeat model) BEFORE calling this; everything downstream is + * model-agnostic. + * + * 1. Device A syncs the freshly-created task; device B converges (baseline). + * 2. Device B goes stale — no more syncing until the convergence phase. + * 3. Device A edits the description, then completes the task, then pushes. The + * edit must precede completion: the new occurrence is cloned from the + * original's fields at completion time (see + * `TaskService.createRecurringCopy`), so both the completed occurrence and + * the new occurrence carry A's latest description. Waiting for the amber + * `pending` border guarantees the async blur-write landed in IndexedDB + * before completion reads the record. + * 4. Pull A's push from the server and locate the completed occurrence and the + * new hidden occurrence, asserting the invariants common to both models. + * + * Returns both server records so the caller can add model-specific assertions + * (e.g. `completed_at`, FR6 `next_date`) and advance B's clock past the new + * occurrence's `appear_date`. + * + * Implements U1, UX1, FR1, FR3, FR5 of fix-stale-sync-overwrites. + */ +export async function runStaleRevealSetup( + pageA: Page, + pageB: Page, + credentials: ServerCallCredentials, + taskName: string, + freshDescription: string, + baselineRevision: number, +): Promise<{ + completedOnServer: StaleRecurringTask; + newOccurrenceOnServer: StaleRecurringTask; +}> { + await triggerSyncAndWait(pageA); + + await gotoTasksPage(pageB); + await triggerSyncAndWait(pageB); + await pageB.reload(); + await pageB.waitForSelector('[data-testid="active-tasks-page"]'); + await findTaskItem(pageB, taskName).waitFor({ + state: "visible", + timeout: TASK_VISIBLE_TIMEOUT_MS, + }); + + // --- 3. A edits the description, then completes the task, then pushes. --- + await gotoTasksPage(pageA); + await findTaskItem(pageA, taskName).waitFor({ + state: "visible", + timeout: TASK_VISIBLE_TIMEOUT_MS, + }); + await openTaskDetail(pageA, taskName); + await updateTaskDescription(pageA, freshDescription); + await expect(findTaskItem(pageA, taskName)).toHaveClass(/border-l-amber-400/); + await completeTask(pageA, taskName); + await triggerSyncAndWait(pageA); + + // --- 4. B comes back online after (simulated) time has passed --- + const { tasks: tasksAfterCompletion } = + await pullFromServer( + credentials, + baselineRevision, + ); + const matchingAfterCompletion = tasksAfterCompletion.filter( + (task) => task.name === taskName && !task.is_deleted, + ); + const completedOnServer = matchingAfterCompletion.find( + (task) => task.is_completed, + ); + expect(completedOnServer).toBeDefined(); + expect(completedOnServer?.description).toBe(freshDescription); + const newOccurrenceOnServer = matchingAfterCompletion.find( + (task) => + !task.is_completed && task.original_task_id === completedOnServer?.id, + ); + expect(newOccurrenceOnServer).toBeDefined(); + expect(newOccurrenceOnServer?.is_hidden).toBe(true); + + return { + completedOnServer: completedOnServer as StaleRecurringTask, + newOccurrenceOnServer: newOccurrenceOnServer as StaleRecurringTask, + }; +} + +/** + * Shared convergence phase of the stale-reveal specs (fixed / after_completion), + * covering steps 5-6 of both scenarios. The caller must have advanced B's clock + * past the new occurrence's `appear_date` (via `advanceClockPastDate`) BEFORE + * calling this. + * + * 5. Device B pulls A's changes, auto-reveals the incoming hidden occurrence + * without bumping `updated_at` (FR1), pushes its reveal-only state, and + * pulls once more. Uses the clock-aware sync helper (see + * `syncAfterClockAdvance`). Device A then pulls B's reveal-push so all + * three agree. + * 6. Convergence: A, B, and the server agree (scoped to this test's task, since + * a clock-advanced device also reveals other specs' hidden occurrences on + * the shared user — harness noise). The completed occurrence stays completed + * (UX1) and exactly one surviving new occurrence carries A's newest + * description (UX2, FR3, FR5). + * + * Returns the single surviving new occurrence so the caller can add + * model-specific assertions (e.g. FR6 `next_date`). + * + * Implements UX1, UX2, FR1, FR3, FR5, NFR-REL1 of fix-stale-sync-overwrites. + */ +export async function runStaleRevealConvergence( + pageA: Page, + pageB: Page, + credentials: ServerCallCredentials, + taskName: string, + freshDescription: string, + baselineRevision: number, +): Promise { + await syncAfterClockAdvance(pageB); + await syncAfterClockAdvance(pageB); + + await triggerSyncAndWait(pageA); + + await assertConverged( + pageA, + pageB, + credentials, + "tasks", + (record) => record.name === taskName, + ); + + const { tasks: finalTasks } = + await pullFromServer( + credentials, + baselineRevision, + ); + const finalMatching = finalTasks.filter( + (task) => task.name === taskName && !task.is_deleted, + ); + + // The completed occurrence stays completed — no resurrection (UX1). + const finalCompleted = finalMatching.filter((task) => task.is_completed); + expect(finalCompleted).toHaveLength(1); + expect(finalCompleted[0]?.completed_at).not.toBe(""); + expect(finalCompleted[0]?.description).toBe(freshDescription); + + // Exactly one surviving new occurrence, carrying the newest description — + // no reverted description, no duplicate/deleted fresh copy (UX2). + const finalNewOccurrences = finalMatching.filter( + (task) => !task.is_completed && task.original_task_id !== "", + ); + expect(finalNewOccurrences).toHaveLength(1); + expect(finalNewOccurrences[0]?.description).toBe(freshDescription); + + return finalNewOccurrences[0] as StaleRecurringTask; +} diff --git a/packages/integration/src/tests/stale-recurring-helpers.ts b/packages/integration/src/tests/stale-recurring-helpers.ts new file mode 100644 index 00000000..a3e30a46 --- /dev/null +++ b/packages/integration/src/tests/stale-recurring-helpers.ts @@ -0,0 +1,241 @@ +// implements U1, UX1, UX2, FR1, FR3, FR5, FR6 of fix-stale-sync-overwrites +import type { Page } from "@playwright/test"; +import { + createTask, + findTaskItem, + openTaskDetail, +} from "../page-task-actions.js"; + +export interface StaleRecurringPullResponse { + ok: boolean; + tasks: Array<{ + id: string; + name: string; + description: string; + is_deleted: boolean; + is_completed: boolean; + is_hidden: boolean; + repeat_rule: string; + next_date: string; + appear_date: string; + original_task_id: string; + completed_at: string; + updated_at: string; + }>; + current_revision: number; +} + +const COMPLETE_SETTLE_MS = 300; +export const TASK_VISIBLE_TIMEOUT_MS = 15_000; +const SYNC_BUTTON_TEST_ID = "sidebar-sync"; +const LAST_SYNC_KEY = "last_sync"; +const POST_ADVANCE_SYNC_TIMEOUT_MS = 90_000; +const POST_ADVANCE_SYNC_CLICK_WAIT_MS = 8_000; +const POST_ADVANCE_SYNC_POLL_MS = 500; +const POST_ADVANCE_SYNC_CLICK_TIMEOUT_MS = 5_000; +const POST_ADVANCE_SYNC_IDLE_TIMEOUT_MS = 10_000; +const POST_ADVANCE_SYNC_REVEAL_SETTLE_MS = 750; +// >=2 days guarantees the new occurrence's appear_date (= completed_at's +// local date + delay_days, since advance_days defaults to 0) is strictly +// after "today" even accounting for test execution time near a day boundary. +export const AFTER_COMPLETION_DELAY_DAYS = 2; + +const REPEAT_TYPE_FIXED_TEST_ID = "repeat-type-fixed"; +const REPEAT_TYPE_AFTER_COMPLETION_TEST_ID = "repeat-type-after-completion"; +const REPEAT_APPLY_TEST_ID = "repeat-apply"; + +/** Opens the Repeat selector from the task detail panel and selects the given + * repeat-type option. Shared prologue of the fixed / after-completion flows. */ +async function openRepeatSelector( + testPage: Page, + repeatTypeTestId: string, +): Promise { + await testPage + .getByTestId("task-detail-panel") + .getByRole("button", { name: /Repeat/ }) + .click(); + await testPage.getByTestId(repeatTypeTestId).waitFor({ state: "visible" }); + await testPage.getByTestId(repeatTypeTestId).click(); +} + +/** Applies the configured repeat rule and waits for the selector to close. + * Shared epilogue of the fixed / after-completion flows. */ +async function applyRepeatRule(testPage: Page): Promise { + await testPage + .getByTestId(REPEAT_APPLY_TEST_ID) + .waitFor({ state: "visible" }); + await testPage.getByTestId(REPEAT_APPLY_TEST_ID).click(); + await testPage.getByTestId(REPEAT_APPLY_TEST_ID).waitFor({ state: "hidden" }); +} + +/** Sets up a daily `fixed` repeat rule from the task detail panel. Shared by + * the multi-device recurring and stale-reveal specs. */ +async function setFixedDailyRepeat(testPage: Page): Promise { + await openRepeatSelector(testPage, REPEAT_TYPE_FIXED_TEST_ID); + await testPage + .getByTestId("repeat-frequency-daily") + .waitFor({ state: "visible" }); + await testPage.getByTestId("repeat-frequency-daily").click(); + await testPage.getByTestId("repeat-fixed-next").waitFor({ state: "visible" }); + await testPage.getByTestId("repeat-fixed-next").click(); + await applyRepeatRule(testPage); +} + +export async function createRecurringTask( + testPage: Page, + taskName: string, +): Promise { + await createTask(testPage, taskName); + await openTaskDetail(testPage, taskName); + await setFixedDailyRepeat(testPage); +} + +/** + * Sets up an `after_completion` repeat rule from the task detail panel, with + * the given `delayDays` and the default `advance_days` (0, left untouched on + * the placement step). Used by the multi-device-recurring spec's + * "after_completion recurring → complete → next_date = completed_at + + * delay_days" test and the stale-reveal specs. With `advance_days: 0`, + * `appear_date` equals `next_date` (see `calculateAppearDate`), so the new + * occurrence's `appear_date` is simply `completed_at`'s local date + + * `delayDays`. + */ +async function setAfterCompletionRepeat( + testPage: Page, + delayDays: number, +): Promise { + await openRepeatSelector(testPage, REPEAT_TYPE_AFTER_COMPLETION_TEST_ID); + await testPage + .getByTestId("repeat-delay-days-input") + .waitFor({ state: "visible" }); + await testPage.getByTestId("repeat-delay-days-input").fill(String(delayDays)); + await testPage + .getByTestId("repeat-after-completion-next") + .waitFor({ state: "visible" }); + await testPage.getByTestId("repeat-after-completion-next").click(); + await applyRepeatRule(testPage); +} + +export async function createAfterCompletionRecurringTask( + testPage: Page, + taskName: string, + delayDays: number, +): Promise { + await createTask(testPage, taskName); + await openTaskDetail(testPage, taskName); + await setAfterCompletionRepeat(testPage, delayDays); +} + +/** + * Navigates a device to the active tasks page and waits for it to render. + * Shared across the multi-device stale-reveal / manual-unhide specs so the + * two-line goto + wait pair is defined once. + */ +export async function gotoTasksPage(testPage: Page): Promise { + await testPage.goto("/tasks"); + await testPage.waitForSelector('[data-testid="active-tasks-page"]'); +} + +export async function completeTask( + testPage: Page, + taskName: string, +): Promise { + await findTaskItem(testPage, taskName) + .getByRole("button", { name: /Complete task/i }) + .click(); + // Recurring tasks create a new occurrence with the same name immediately, + // so we cannot wait for the task item to become hidden. + await testPage.waitForTimeout(COMPLETE_SETTLE_MS); +} + +/** + * Simulates a stale device being opened a day later: sets the browser clock + * past `isoDate` (local noon) so day-boundary comparisons (`appear_date <= + * today`) treat that date as "today or earlier". + * + * Method matters. `setSystemTime` leaves the fake clock paused, freezing every + * `setTimeout`/`setInterval` so the sync button's spin→idle cycle never + * completes. `setFixedTime` pins `Date.now()` to a constant, so the + * `toISOTimestamp()`-based `last_sync` marker never changes and sync-completion + * detection never fires. `install({ time }) + resume` sets the clock to the + * target date AND lets it keep ticking in real time: `Date` reads the future + * (so `HiddenTaskService` reveals the occurrence), timers run (responsive UI), + * and `last_sync` advances between syncs. The backend issues long-lived JWTs + * (see `GOTRUE_JWT_EXP` in docker-compose) so the day-long jump never expires + * the token — avoiding a refresh that would race under the faked clock. + */ +export async function advanceClockPastDate( + page: Page, + isoDate: string, +): Promise { + await page.clock.install({ time: new Date(`${isoDate}T12:00:00Z`) }); + await page.clock.resume(); +} + +/** Waits (best-effort) for the sidebar sync button to stop spinning, so a + * subsequent click is not swallowed by the app's in-flight-sync mutex. */ +async function waitForSyncButtonIdle(page: Page): Promise { + await page + .waitForFunction( + () => { + const button = document.querySelector('[data-testid="sidebar-sync"]'); + if (!button) return false; + const icon = button.querySelector("svg"); + return icon !== null && !icon.classList.contains("animate-spin"); + }, + undefined, + { timeout: POST_ADVANCE_SYNC_IDLE_TIMEOUT_MS, polling: 200 }, + ) + .catch(() => {}); +} + +/** + * Syncs a page whose clock was advanced via `advanceClockPastDate`, detecting + * completion by polling the `last_sync` marker for a change rather than by + * watching the sync button's spin animation. + * + * Why not the shared `triggerSyncAndWait`: its success gate relies on catching + * a spin→idle cycle in a short window (missed after the boot JWT refresh), and + * its interval-clearing step calls `window.setTimeout` — faked by the installed + * clock — inside a `page.evaluate` that then hangs. Polling `last_sync`, which + * the resumed clock keeps advancing on every completed sync, sidesteps both. + * + * Each outer iteration: wait for any in-flight sync to settle (so the click is + * not swallowed by the app's `isSyncingRef` mutex), re-read the baseline, click + * (best-effort, with a per-click timeout so a briefly non-actionable button + * cannot block past the deadline), then poll for a change. On success settle + * briefly so the `sync_complete` reveal handler persists its pending write + * before the caller triggers the next (push) sync. + */ +export async function syncAfterClockAdvance(page: Page): Promise { + const deadline = Date.now() + POST_ADVANCE_SYNC_TIMEOUT_MS; + while (Date.now() < deadline) { + await waitForSyncButtonIdle(page); + const previousSync = await page.evaluate( + (key) => localStorage.getItem(key), + LAST_SYNC_KEY, + ); + await page + .getByTestId(SYNC_BUTTON_TEST_ID) + .first() + .click({ timeout: POST_ADVANCE_SYNC_CLICK_TIMEOUT_MS }) + .catch(() => {}); + const clickDeadline = Date.now() + POST_ADVANCE_SYNC_CLICK_WAIT_MS; + while (Date.now() < clickDeadline) { + const currentSync = await page.evaluate( + (key) => localStorage.getItem(key), + LAST_SYNC_KEY, + ); + if (currentSync !== null && currentSync !== previousSync) { + await new Promise((resolve) => + setTimeout(resolve, POST_ADVANCE_SYNC_REVEAL_SETTLE_MS), + ); + return; + } + await new Promise((resolve) => + setTimeout(resolve, POST_ADVANCE_SYNC_POLL_MS), + ); + } + } + throw new Error("syncAfterClockAdvance: last_sync did not advance in time"); +} diff --git a/packages/integration/src/tests/tasks-sync.spec.ts b/packages/integration/src/tests/tasks-sync.spec.ts index 5492a4ed..a5a6a56b 100644 --- a/packages/integration/src/tests/tasks-sync.spec.ts +++ b/packages/integration/src/tests/tasks-sync.spec.ts @@ -11,6 +11,7 @@ import { setupSingleDeviceTest, triggerSyncAndWait, } from "../test-helpers.js"; +import { createRecurringTask } from "./stale-recurring-helpers.js"; const { getPage, getCredentials } = setupSingleDeviceTest(); @@ -114,36 +115,8 @@ test("create recurring task → push → verify repeat rule persisted", async () const page = getPage(); const recurringTaskName = `Recurring Task ${Date.now()}`; - // Create a new task and open detail panel - await createTask(page, recurringTaskName); - await openTaskDetail(page, recurringTaskName); - - // Open the Repeat selector — the DrillDownRow button contains label "Repeat" - await page - .getByTestId("task-detail-panel") - .getByRole("button", { name: /Repeat/ }) - .click(); - - // Select "Fixed" repeat type — wait for it to appear first - await page.getByTestId("repeat-type-fixed").waitFor({ state: "visible" }); - await page.getByTestId("repeat-type-fixed").click(); - - // Choose daily frequency — wait for frequency options to render - await page - .getByTestId("repeat-frequency-daily") - .waitFor({ state: "visible" }); - await page.getByTestId("repeat-frequency-daily").click(); - - // Proceed to placement step - await page.getByTestId("repeat-fixed-next").waitFor({ state: "visible" }); - await page.getByTestId("repeat-fixed-next").click(); - - // Apply the repeat rule (saves and closes selector) - await page.getByTestId("repeat-apply").waitFor({ state: "visible" }); - await page.getByTestId("repeat-apply").click(); - - // Wait for the repeat selector to close — indicates save completed - await page.getByTestId("repeat-apply").waitFor({ state: "hidden" }); + // Create a task and set up a daily `fixed` repeat rule + await createRecurringTask(page, recurringTaskName); // Trigger sync to push the recurring task to the server await triggerSyncAndWait(page); From 02cf6423ad5221db3e1f77503459664fc98be7ad Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:53:00 +0500 Subject: [PATCH 6/8] implement fix-stale-sync-overwrites OpenSpec --- ...TaskDeduplicator.merge.schedule-and-box.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts b/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts index 5a434d9f..0c8c5748 100644 --- a/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts +++ b/packages/client/src/services/RecurringTaskDeduplicator.merge.schedule-and-box.test.ts @@ -5,6 +5,7 @@ import { db } from "@/db/database"; import { buildTask } from "@/test/factories/taskFactory"; import { createRecurringTaskDeduplicatorMergeSetup, + expectScheduleTripleWithFreshContent, ORIGINAL_ID_1, UUID_FRESH_CONTENT_LOSER, UUID_SCHEDULE_WINNER, @@ -69,12 +70,13 @@ describe("RecurringTaskDeduplicator — content merge from freshest updated_at", await getDeduplicator().deduplicate([ORIGINAL_ID_1]); const survivor = await db.tasks.get(UUID_SCHEDULE_WINNER); - expect(survivor?.next_date).toBe("2026-07-01"); - expect(survivor?.appear_date).toBe("2026-06-28"); - expect(survivor?.is_hidden).toBe(false); - // Content still merges from the freshest copy in the same pass. - expect(survivor?.name).toBe("Fresh name"); - expect(survivor?.description).toBe("Fresh description"); + // Schedule triple stays coupled to the winner while content merges from + // the freshest copy in the same pass. + expectScheduleTripleWithFreshContent(survivor, { + nextDate: "2026-07-01", + appearDate: "2026-06-28", + isHidden: false, + }); }); // Spec scenario: "Hidden and revealed copies merge without breaking From abe483868de3c28cded66dac58c97012bdb40a4c Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:58:11 +0500 Subject: [PATCH 7/8] implement fix-stale-sync-overwrites OpenSpec --- .../src/db/repositories/IdeaRepository.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/client/src/db/repositories/IdeaRepository.ts b/packages/client/src/db/repositories/IdeaRepository.ts index 06fa3805..2d7f46a2 100644 --- a/packages/client/src/db/repositories/IdeaRepository.ts +++ b/packages/client/src/db/repositories/IdeaRepository.ts @@ -2,6 +2,9 @@ import type { WireIdea } from "@clear-progress/contract"; import { ClientIdeaSchema } from "@/schemas/entities"; import type { Idea, ISOTimestamp } from "@/types/entities"; import { db } from "../database"; +import { shouldOverwritePendingLocalRecord } from "./applyServerRecordLww"; + +const IDEA_ENTITY_NAME = "idea"; export class IdeaRepository { async getAll(): Promise { @@ -53,11 +56,26 @@ export class IdeaRepository { return db.ideas.filter((idea) => idea.syncStatus === "pending").toArray(); } + /** + * Implements FR5 of fix-stale-sync-overwrites. + * A local `pending` record is overwritten only when the server record's + * `updated_at` is strictly newer (LWW pull protection). Local records with + * any other syncStatus, or no local record at all, are always overwritten. + */ async applyServerRecords(records: WireIdea[]): Promise { await db.transaction("rw", db.ideas, async () => { for (const serverRecord of records) { const localRecord = await db.ideas.get(serverRecord.id); - if (localRecord?.syncStatus !== "pending") { + const shouldWrite = + localRecord?.syncStatus !== "pending" || + shouldOverwritePendingLocalRecord({ + entityName: IDEA_ENTITY_NAME, + id: serverRecord.id, + localUpdatedAt: localRecord.updated_at, + serverUpdatedAt: serverRecord.updated_at, + }); + + if (shouldWrite) { const idea: Idea = { ...serverRecord, created_at: serverRecord.created_at as ISOTimestamp, From 7612572ae9fad3cc9359f4346bbc9d833f8c6b83 Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:36:59 +0500 Subject: [PATCH 8/8] archive fix-stale-sync-overwrites OpenSpec --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/manual-task-hiding/spec.md | 0 .../specs/repeating-tasks/spec.md | 0 .../specs/sync-protocol/spec.md | 0 .../specs/tasks/spec.md | 0 .../tasks.md | 0 openspec/specs/manual-task-hiding/spec.md | 38 +++++++- openspec/specs/repeating-tasks/spec.md | 91 ++++++++++++++++++- openspec/specs/sync-protocol/spec.md | 42 ++++++++- openspec/specs/tasks/spec.md | 29 +++++- 12 files changed, 188 insertions(+), 12 deletions(-) rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/.openspec.yaml (100%) rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/design.md (100%) rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/proposal.md (100%) rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/specs/manual-task-hiding/spec.md (100%) rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/specs/repeating-tasks/spec.md (100%) rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/specs/sync-protocol/spec.md (100%) rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/specs/tasks/spec.md (100%) rename openspec/changes/{fix-stale-sync-overwrites => archive/2026/07/2026-07-19-fix-stale-sync-overwrites}/tasks.md (100%) diff --git a/openspec/changes/fix-stale-sync-overwrites/.openspec.yaml b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/.openspec.yaml similarity index 100% rename from openspec/changes/fix-stale-sync-overwrites/.openspec.yaml rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/.openspec.yaml diff --git a/openspec/changes/fix-stale-sync-overwrites/design.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/design.md similarity index 100% rename from openspec/changes/fix-stale-sync-overwrites/design.md rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/design.md diff --git a/openspec/changes/fix-stale-sync-overwrites/proposal.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/proposal.md similarity index 100% rename from openspec/changes/fix-stale-sync-overwrites/proposal.md rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/proposal.md diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md similarity index 100% rename from openspec/changes/fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/manual-task-hiding/spec.md diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/repeating-tasks/spec.md similarity index 100% rename from openspec/changes/fix-stale-sync-overwrites/specs/repeating-tasks/spec.md rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/repeating-tasks/spec.md diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/sync-protocol/spec.md similarity index 100% rename from openspec/changes/fix-stale-sync-overwrites/specs/sync-protocol/spec.md rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/sync-protocol/spec.md diff --git a/openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/tasks/spec.md similarity index 100% rename from openspec/changes/fix-stale-sync-overwrites/specs/tasks/spec.md rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/specs/tasks/spec.md diff --git a/openspec/changes/fix-stale-sync-overwrites/tasks.md b/openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/tasks.md similarity index 100% rename from openspec/changes/fix-stale-sync-overwrites/tasks.md rename to openspec/changes/archive/2026/07/2026-07-19-fix-stale-sync-overwrites/tasks.md diff --git a/openspec/specs/manual-task-hiding/spec.md b/openspec/specs/manual-task-hiding/spec.md index bcfe214c..3e7ab2c1 100644 --- a/openspec/specs/manual-task-hiding/spec.md +++ b/openspec/specs/manual-task-hiding/spec.md @@ -72,9 +72,45 @@ The system SHALL NOT show hide/unhide controls for tasks with a non-empty `repea - **THEN** no "Hide until" row is displayed ### Requirement: Hidden tasks auto-reveal on appear date -The system SHALL automatically set `is_hidden = false` when `appear_date <= logicalDate`. The task SHALL remain in its original box. Implements FR7, FR8 of hide-tasks. +The system SHALL automatically set `is_hidden = false` when `appear_date <= logicalDate`. The task SHALL remain in its original box. Auto-reveal SHALL set `syncStatus = "pending"` but SHALL NOT modify `updated_at` — it is a system-derived transition, not a user edit; the timestamp stays at the last real user edit so a stale device cannot win last-write-wins against newer edits from another device. Implements FR1 of fix-stale-sync-overwrites (was: FR7, FR8 of hide-tasks). #### Scenario: Task revealed when appear date arrives - **WHEN** the logical date reaches or passes the task's `appear_date` - **THEN** `HiddenTaskService.revealHiddenTasks()` sets `is_hidden = false` - **AND** the task remains in its original box +- **AND** the task's `updated_at` is unchanged and `syncStatus` is `"pending"` + +### Requirement: Manual unhide before appear date is a synced user edit +Implements FR2 of fix-stale-sync-overwrites. + +When the user manually unhides a manually hidden task before its `appear_date`, the system SHALL treat it as a regular user edit: set `is_hidden = false`, clear `appear_date`, refresh `updated_at`, and set `syncStatus = "pending"`. The manual unhide SHALL propagate to other devices via ordinary push/pull and SHALL win last-write-wins against any older state of the record. Manual hide (setting `is_hidden = true` with an `appear_date`) SHALL behave symmetrically as a user edit. This applies to non-recurring tasks only — recurring tasks expose no manual hide/unhide controls (see "Hide action excluded for recurring tasks"); hidden recurring copies are governed solely by system auto-reveal. + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B + + Note over A: task hidden until 07-20 (both devices in sync) + Note over A: user manually unhides on 07-15
is_hidden=false, appear_date="",
updated_at=now, pending + A->>S: push (accepted, new revision) + B->>S: pull + S-->>B: task: is_hidden=false, appear_date="" + Note over B: local copy synced & older → overwritten
task visible on device B before 07-20 + Note over B: reveal check ignores it
(appear_date empty) +``` + +#### Scenario: Manual unhide propagates to another device +- **GIVEN** a task hidden until "2026-07-20" synced on devices A and B +- **WHEN** the user manually unhides it on device A on "2026-07-15" and both devices sync +- **THEN** the task is visible on device B with `appear_date = ""` before "2026-07-20" + +#### Scenario: Manual unhide refreshes the timestamp +- **GIVEN** a hidden task with `updated_at = t1` +- **WHEN** the user manually unhides it +- **THEN** `is_hidden = false`, `appear_date = ""`, `updated_at > t1`, `syncStatus = "pending"` + +#### Scenario: Manual hide propagates to another device +- **GIVEN** a visible task synced on devices A and B +- **WHEN** the user hides it until "2026-08-01" on device A and both devices sync +- **THEN** the task is hidden on device B with `appear_date = "2026-08-01"` diff --git a/openspec/specs/repeating-tasks/spec.md b/openspec/specs/repeating-tasks/spec.md index d0730ea9..f3efb019 100644 --- a/openspec/specs/repeating-tasks/spec.md +++ b/openspec/specs/repeating-tasks/spec.md @@ -406,14 +406,15 @@ A recurring copy MUST be created with is_hidden=true if appear_date is after the - **THEN** calendar date from `clock.plainDateISO()` is used ### Requirement: System reveals hidden tasks when appear date arrives -# implements FR11 of repeating-tasks-specs, FR4 of day-boundary +# implements FR1 of fix-stale-sync-overwrites (was: FR11 of repeating-tasks-specs, FR4 of day-boundary) -System MUST reveal hidden tasks (set is_hidden=false, mark for sync) when appear_date <= logical date. Reveal MUST be triggered on: app mount, day boundary transition (instead of midnight), sync_complete event, return from background (visibility change), and day boundary setting change. +System MUST reveal hidden tasks (set is_hidden=false, syncStatus="pending") when appear_date <= logical date. Reveal MUST NOT modify `updated_at` — auto-reveal is a system-derived transition, not a user edit, and the record's timestamp SHALL remain that of the last real user edit so that last-write-wins conflict resolution cannot prefer a stale record over newer edits from another device. Reveal MUST be triggered on: app mount, day boundary transition (instead of midnight), sync_complete event, return from background (visibility change), and day boundary setting change. #### Scenario: Reveal tasks whose appear_date has arrived - **GIVEN** hidden task with appear_date "2026-01-15" and logical date is "2026-01-15" - **WHEN** system runs reveal check -- **THEN** task has is_hidden false, needsSync true +- **THEN** task has is_hidden false, syncStatus "pending" +- **AND** the task's `updated_at` is unchanged #### Scenario: Do not reveal tasks whose appear_date is future - **GIVEN** hidden task with appear_date "2026-01-20" and logical date is "2026-01-15" @@ -432,6 +433,11 @@ System MUST reveal hidden tasks (set is_hidden=false, mark for sync) when appear - **WHEN** user changes the day boundary setting - **THEN** system immediately runs reveal check with recalculated logical date +#### Scenario: Reveal of a stale copy loses push conflict to newer server state +- **GIVEN** device B holds a recurring copy last edited at t2 that was completed on device A at t5 (t5 > t2) +- **WHEN** device B auto-reveals the copy and pushes it with `updated_at = t2` +- **THEN** the server responds `conflict` and device B applies the server record (completed, newest content) + ### Requirement: System uses current timezone for all date calculations # implements FR12 of repeating-tasks-specs @@ -604,10 +610,57 @@ All date comments in skip-logic tests MUST accurately reflect the ISO day of wee - **THEN** the comment is incorrect (2026-04-07 is Tuesday) and MUST be fixed ### Requirement: System deduplicates recurring copies after pull -# implements FR1, FR2 of dedup-recurring-after-pull +# implements FR3, FR6 of fix-stale-sync-overwrites (was: FR1, FR2 of dedup-recurring-after-pull) After applying a pull batch, the system SHALL detect duplicate recurring copies — multiple non-completed, non-deleted tasks sharing the same `original_task_id`. Among duplicates, the system SHALL keep the winner by earliest `next_date`, tiebreak by lexicographically smallest `id`. Losers SHALL be soft-deleted with `syncStatus: "pending"`. +The winner SHALL be merged, not kept verbatim: + +- Schedule fields `next_date`, `appear_date`, and `is_hidden` SHALL be taken **as a triple** from the copy with the earliest `next_date` (`appear_date` is coupled to `next_date` via `advance_days`, and `is_hidden` is derived from `appear_date`; mixing copies would corrupt reveal timing). +- Content fields (`name`, `description`, `goal_id`, `context_id`, `category_id`) and the pair `box` + `sort_order` (sort keys only order within a box) SHALL be taken from the copy with the freshest `updated_at`. +- Identity fields (`id`, `created_at`, `revision`) SHALL stay the winner's own. Completion fields need no merge rule: every copy in a duplicate group is non-completed by the group filter. +- The merged winner's `updated_at` SHALL equal the freshest copy's `updated_at`; deduplication SHALL NOT refresh it to the current time. The winner SHALL be written with `syncStatus: "pending"` only when the merge actually changed it; an already-optimal winner is not rewritten. +- When `repeat_rule` differs between the schedule winner and the freshest-`updated_at` copy, the copy with the freshest `updated_at` SHALL win wholesale (all fields including `next_date` and `appear_date`), because a rule change recomputes its dates under the new rule. + +Merge semantics apply identically to both recurring models (`fixed` and `after_completion`); the earliest-`next_date` winner rule for schedules is unchanged, and for `after_completion` any suboptimal winner self-corrects at the next completion because its next date derives only from the new `completed_at`. + +Two-device double completion of the same occurrence (fixed schedule): + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B + + Note over A: completes occurrence,
edits description ("v2")
clone A1: next_date=07-10, updated_at=t5 + Note over B: completes same occurrence offline
clone B1: next_date=07-09, updated_at=t3 + A->>S: push A1 + B->>S: push B1 + B->>S: pull + S-->>B: A1 + Note over B: dedup group {A1, B1}
winner dates: B1 (next_date 07-09)
winner content: A1 ("v2", updated_at t5)
loser A1 soft-deleted + B->>S: push merged winner + deletion + A->>S: pull + S-->>A: merged copy: earliest date, newest content +``` + +Two-device double completion (`after_completion`, delay 3 days): + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B + + Note over A: completes at 07-01
clone A1: next_date=07-04 + Note over B: completes at 07-02 offline
clone B1: next_date=07-05 + A->>S: push A1 + B->>S: push B1, then pull + S-->>B: A1 + Note over B: dedup: winner dates from A1 (07-04),
content from freshest updated_at + Note over B: at next completion next_date is recomputed
from new completed_at — self-corrects +``` + #### Scenario: Two duplicates with same next_date — tiebreak by id - **GIVEN** task Copy-A (id="aaa...", original_task_id="root", next_date="2026-07-01") and Copy-B (id="bbb...", original_task_id="root", next_date="2026-07-01"), both non-completed, non-deleted - **WHEN** deduplication runs after pull @@ -618,6 +671,36 @@ After applying a pull batch, the system SHALL detect duplicate recurring copies - **WHEN** deduplication runs after pull - **THEN** Copy-B is kept (earlier next_date) and Copy-A is soft-deleted +#### Scenario: Winner takes content from the freshest copy +- **GIVEN** Copy-A (next_date="2026-07-05", description="v2", updated_at="2026-07-02T10:00:00.000Z") and Copy-B (next_date="2026-07-01", description="v1", updated_at="2026-07-01T10:00:00.000Z"), same original_task_id +- **WHEN** deduplication runs after pull +- **THEN** the kept record has next_date "2026-07-01", appear_date from Copy-B, description "v2", and updated_at "2026-07-02T10:00:00.000Z" + +#### Scenario: Schedule fields always move as a triple +- **GIVEN** duplicates where the earliest-next_date copy has appear_date derived from its own next_date and is_hidden derived from that appear_date +- **WHEN** deduplication merges the winner +- **THEN** next_date, appear_date, and is_hidden all come from the earliest-next_date copy (never mixed across copies) + +#### Scenario: Hidden and revealed copies merge without breaking reveal timing +- **GIVEN** Copy-A revealed (is_hidden=false, next_date="2026-07-09", updated_at older) and Copy-B still hidden (is_hidden=true, next_date="2026-07-12", appear_date="2026-07-10", updated_at newer), same original_task_id and same repeat_rule +- **WHEN** deduplication runs after pull +- **THEN** the kept record has next_date "2026-07-09" and is_hidden false (from Copy-A) with content from Copy-B + +#### Scenario: Box and sort_order come from the freshest copy as a pair +- **GIVEN** duplicates in different boxes with box-specific sort_order keys +- **WHEN** deduplication merges the winner +- **THEN** box and sort_order both come from the freshest-updated_at copy (never mixed across copies) + +#### Scenario: Already-optimal winner is not rewritten +- **GIVEN** duplicates where the winner already carries both the earliest schedule triple and the freshest content, `syncStatus = "synced"` +- **WHEN** deduplication runs after pull +- **THEN** the winner record is not modified and keeps `syncStatus = "synced"`, while losers are soft-deleted + +#### Scenario: Differing repeat_rule — freshest copy wins wholesale +- **GIVEN** Copy-A (repeat_rule=daily-1, next_date="2026-07-01", updated_at older) and Copy-B (repeat_rule=weekly-Mon, next_date="2026-07-06", updated_at newer), same original_task_id +- **WHEN** deduplication runs after pull +- **THEN** the kept record equals Copy-B in all fields including next_date and appear_date + #### Scenario: No duplicates — no action - **GIVEN** only one non-completed, non-deleted task per original_task_id - **WHEN** deduplication runs after pull diff --git a/openspec/specs/sync-protocol/spec.md b/openspec/specs/sync-protocol/spec.md index 5c289d76..6e63575b 100644 --- a/openspec/specs/sync-protocol/spec.md +++ b/openspec/specs/sync-protocol/spec.md @@ -274,14 +274,48 @@ The system SHALL fetch all records with `revision > since_revision` from the ser - **AND** next sync re-fetches from the same starting revision without cursors ### Requirement: Pull protects local dirty records -When applying server records from pull, the client SHALL NOT overwrite records that have `needsSync = true`. +Implements FR5 of fix-stale-sync-overwrites. + +When applying server records from pull, the client SHALL compare timestamps for records that have `syncStatus = "pending"`: the server record SHALL overwrite the local pending record only when the server record's `updated_at` is strictly newer than the local `updated_at` (last-write-wins, mirroring push conflict resolution). When the local `updated_at` is equal or newer, the local pending record SHALL be preserved and pushed in the next sync. Records with `syncStatus = "synced"` or `"rejected"` SHALL be overwritten by the server version as before. Every overwrite of a pending record SHALL be logged with entity type, record id, and both timestamps. + +Multi-device convergence for the stale-device scenario this rule fixes (device B held a stale pending copy freshened by a system mutation): + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B (stale) + + A->>S: push: task edited + completed (updated_at = t5) + Note over B: auto-reveal marks task pending
WITHOUT refreshing updated_at (stays t2) + B->>S: push: pending task (updated_at = t2) + S-->>B: conflict (server updated_at t5 > t2) + B->>B: apply server_record, syncStatus = synced + B->>S: pull + S-->>B: records with revision > last_known + Note over B: pull LWW: server t5 > local t2
pending record would also be overwritten here + A->>S: pull + S-->>A: no stale resurrection — state converged +``` #### Scenario: Clean local record is overwritten by server -- **WHEN** pull returns a record that exists locally with `needsSync = false` +- **WHEN** pull returns a record that exists locally with `syncStatus = "synced"` - **THEN** local record is overwritten with server version -#### Scenario: Dirty local record is preserved -- **WHEN** pull returns a record that exists locally with `needsSync = true` +#### Scenario: Pending local record loses to strictly newer server record +- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-01T10:00:00.000Z"` +- **WHEN** pull returns the same record with `updated_at = "2026-07-02T10:00:00.000Z"` +- **THEN** local record is overwritten with the server version and `syncStatus` becomes `"synced"` +- **AND** the overwrite is logged with entity type, id, and both timestamps + +#### Scenario: Pending local record with equal timestamp is preserved +- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-01T10:00:00.000Z"` +- **WHEN** pull returns the same record with `updated_at = "2026-07-01T10:00:00.000Z"` +- **THEN** local record is NOT overwritten (will be pushed in next sync) + +#### Scenario: Pending local record with newer timestamp is preserved +- **GIVEN** a local record with `syncStatus = "pending"` and `updated_at = "2026-07-03T10:00:00.000Z"` +- **WHEN** pull returns the same record with `updated_at = "2026-07-02T10:00:00.000Z"` - **THEN** local record is NOT overwritten (will be pushed in next sync) #### Scenario: New server record is inserted diff --git a/openspec/specs/tasks/spec.md b/openspec/specs/tasks/spec.md index 9f5df276..a8792c6e 100644 --- a/openspec/specs/tasks/spec.md +++ b/openspec/specs/tasks/spec.md @@ -380,18 +380,41 @@ Swiping left on a task item MUST be cancelled immediately. No action SHALL be tr - **THEN** translateX remains 0, no action is triggered ### Requirement: Lazy rebalancing when key exceeds threshold -# implements FR9 of fractional-sort-order +# implements FR4 of fix-stale-sync-overwrites (was: FR9 of fractional-sort-order) -When a newly generated sort_order key exceeds 10 characters, the system MUST rebalance all tasks in the same box with evenly distributed keys preserving current display order. +When a newly generated sort_order key exceeds 10 characters, the system MUST rebalance all tasks in the same box with evenly distributed keys preserving current display order. Rebalanced tasks SHALL be marked `syncStatus = "pending"` but their `updated_at` SHALL NOT be modified — rebalancing is a system-derived mutation, and refreshing the timestamps would let a stale device overwrite newer content of every task in the box via whole-record last-write-wins. Only the task the user actually dragged SHALL get a refreshed `updated_at` (in `reorderTasks`). + +Rebalance on a stale device no longer clobbers newer content of untouched tasks: + +```mermaid +sequenceDiagram + participant A as Device A + participant S as Server + participant B as Device B (stale) + + A->>S: push: task X edited (updated_at = t5) + Note over B: user drags task Y →
rebalance touches whole box
Y: updated_at=now,
others (incl. X): timestamps kept + B->>S: push: Y (fresh) + rebalanced records (old timestamps) + S-->>B: Y accepted, X → conflict (server t5 newer) + B->>B: apply server X (newest content, server sort key) + B->>S: pull + Note over A,B: X keeps its newest content everywhere,
new sort keys sync where no newer edit exists +``` #### Scenario: Rebalancing triggered by long key - **GIVEN** today box has tasks with deeply nested sort_order keys - **WHEN** a drag-drop produces a key longer than 10 characters - **THEN** all tasks in today box get fresh evenly distributed keys - **AND** display order is preserved -- **AND** all rebalanced tasks are marked needsSync=true +- **AND** all rebalanced tasks are marked syncStatus="pending" +- **AND** `updated_at` of every task except the dragged one is unchanged #### Scenario: Rebalancing not triggered for short keys - **GIVEN** today box has tasks with short sort_order keys - **WHEN** a drag-drop produces a key of 4 characters - **THEN** only the dragged task is updated + +#### Scenario: Rebalance on a stale device does not clobber newer content +- **GIVEN** device B holds task X last synced at t2, while the server has X edited at t5 (t5 > t2) +- **WHEN** device B rebalances the box (X gets a new sort key, `updated_at` stays t2) and pushes +- **THEN** the server responds `conflict` for X and device B applies the server version with the newest content