feat(sync): conflict journal + disjoint-field auto-merge + review UI (#8874)

* feat(sync): conflict-journal foundation (observe-only)

Add a device-local IndexedDB conflict journal that records every sync-
conflict auto-resolution so the discarded ("losing") side is preserved
and reviewable later. Foundation subtask for the conflict-review epic;
no UI here, verifiable purely by unit tests.

- New SUP_CONFLICT_JOURNAL IndexedDB store (own DB; never touches the
  op-log SUP_OPS schema) + ConflictJournalService with record/query API
  (unreviewedCount$, list, markKept, markFlipped, getEntry) and 14-day /
  200-entry retention pruning, wired to run on app start via APP_INITIALIZER.
- Pure classifier buildConflictJournalEntry maps each resolution to the
  agreed taxonomy (newer/tie/delete-wins/noise/clock-corruption-
  suspected; disjoint-merge reserved for the next subtask), capturing the
  loser's field values verbatim. NOISE_FIELDS is limited to metadata
  timestamps (modified/lastModified/created): the list-ordering arrays
  carry membership as well as order, so an overlap on them is surfaced as
  a reviewable conflict rather than silently classified as noise.
- Emission is strictly observe-only: journaling runs after the LWW plan is
  built, wrapped in try/catch (record() swallows its own errors); clock-
  corruption attribution uses a WeakSet side-channel tagged at detection.
  The existing conflict-resolution suite stays 138/138 green, proving LWW
  picks are unchanged. One-sided / sequential / EQUAL updates never become
  conflicts and produce zero journal entries.

SPAP-13

* feat(sync): disjoint-field auto-merge for concurrent edits

When two clients concurrently edit the same entity but different fields
(A changes title, B changes notes), whole-entity LWW previously discarded
one side. Keep both when the non-noise changed-field sets are disjoint.

In _resolveConflictsWithLWW, before LWW picks a winner, each CONCURRENT
conflict is tested for merge eligibility (neither side deleting/archiving;
both changed >=1 real field; non-noise field sets disjoint). If eligible,
synthesize a single merged UPDATE op — the current entity overlaid with
the other side's non-noise fields, noise fields resolved by the greater
(timestamp, clientId) — carrying a vector clock that dominates both sides,
so it propagates through normal sync. The resolution is winner 'merged'
and is journaled reason 'disjoint-merge' / status 'info' (not counted as
unreviewed). Any overlap on a non-noise field, or a delete/archive on
either side, falls through to the existing LWW path unchanged.

Convergence: both clients compute the byte-identical merged entity
(disjoint real fields each owned by one side; noise resolved by the same
global tiebreak) with clocks dominating both originals, so the two
independently-synthesized merged ops carry identical full-entity payloads
and re-resolve to the same state via ordinary LWW without re-merging.

No sync-core/protocol change. Existing conflict-resolution suite stays
138/138; SPAP-13 journal specs stay green.

SPAP-14

* feat(sync): sync conflicts review UI (banner, badge, page, flip)

Builds the conflict-review UI on top of the device-local conflict journal.

- Post-sync summary banner "N conflicts auto-resolved (X remote, Y local
  won)" with REVIEW / DISMISS, replacing the bare LWW_CONFLICTS_AUTO_
  RESOLVED snack at its emission sites; a persistent badge on the sync
  icon bound to unreviewedCount$ (survives banner dismiss).
- New /sync-conflicts page: Unreviewed | History tabs, rows grouped by
  entity type with winner + reason chips, expandable per-field diff
  (LOCAL vs REMOTE, device name + wall-clock time, winner marked),
  per-row KEEP / FLIP and bulk KEEP ALL / FLIP ALL → LOCAL / → REMOTE.
  History renders merged auto-merges as per-field chips.
- Flip dispatches a normal entity update with the loser's journaled field
  values (syncs like a user edit, no history rewind) and marks the entry
  flipped; a stale-flip confirm appears (with the current value shown)
  when the entity changed since resolution.
- i18n under F.SYNC.CONFLICT_REVIEW.

Delete-restore and archived-entity flip are surfaced as unsupported for
now (an update op can't recreate an absent entity) — follow-up.

SPAP-15

* fix(sync): count disjoint-merge ops in localWinOpsCreated

autoResolveConflictsLWW returned only newLocalWinOps.length, excluding the
synthesized merged ops appended in STEP 3b. A sync whose only conflicts were
disjoint-field merges returned 0, so the caller (immediate-upload.service.ts)
skipped the immediate re-upload and reported IN_SYNC while the merged op sat
unsynced until a later cycle.

Count mergedResolutions.length too — each merge appends exactly one pending
local op. Mirrors the rejection-handler path (operation-log-sync.service.ts).
Add a regression spec asserting a merge-only conflict returns
localWinOpsCreated: 1 (fails on the old return, passes now).

Also harden the _corruptionSuspectedConflicts WeakSet doc against a future
refactor that clones EntityConflict between detect and resolve.

Addresses review feedback on PR #8874.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): address second-pass review (delete-lost, archive guard test, polish)

Second-pass review follow-ups (johannesjo). The MEDIUM localWinOpsCreated
item was already fixed in 23e9a56.

Important:
- delete-lost classification: when a delete LOSES to a concurrent newer edit,
  the loser side is a pure Delete op (no field changes), so it fell through to
  `noise`/`info` and never surfaced. Add a `delete-lost` reason classified as
  `unreviewed` (inverse of `delete-wins`), checked before the noise fallthrough.
  + regression spec.
- archive-vs-disjoint-edit test (d2): an archive is an UPDATE, so eligibility
  does NOT reject it — only the `_isArchivePlan` guard does. New test forces
  eligibility TRUE and asserts no merged op is synthesized, so a guard
  regression now fails a test (previously nothing covered it).

Minor:
- pruneOnStart: wrap in try/catch (log + return 0, matching record()'s
  observe-only contract); reset the poisoned `_initPromise` in `_ensureDb` on
  open failure so a transient IndexedDB error can't wedge the service.
- sync-conflicts-page.scss: use `--color-success` token + color-mix tint
  instead of hardcoded #4caf50 / rgba(76,175,80,…); drop the dead
  `--color-warning` fallback (#e6a817 didn't match the real #ff9800 token).
- main-header badge: matBadgeColor warn -> accent (a resolved count is not an
  error); move the count announcement to `matBadgeDescription` and give the
  button a stable sync-action aria-label (it was null at count 0, leaving the
  icon-only button unnamed).
- remove dead i18n key LWW_CONFLICTS_AUTO_RESOLVED (t.const.ts + en.json).
- add direct unit spec for noiseTiebreakSide (incl. equal-timestamp clientId
  determinism) and buildMergedFieldDiffs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): address third-pass review (flip capability, field presence, opaque ops, profile isolation)

Blocking #3 — delete-lost/delete-wins offered a false-success Flip:
canFlip() is now reason/field-aware (refuses delete-lost/delete-wins,
empty loser changes, and relationship-bearing fields whose bare adapter
update would bypass meta-reducer invariants); flip() guards on it and
only marks an entry flipped when an op was actually dispatched.

Blocking #2 — field absent vs side-set-undefined: fieldDiffs now record
per-side presence (localChanged/remoteChanged); loserChangesFor/
winnerChangesFor only emit fields the side actually changed, so Flip no
longer clears winner-only fields and the stale guard no longer compares
undefined. Legacy entries without flags fall back to value-presence
(exact, since op payloads are pure JSON).

Blocking #1 — non-adapter action payloads: per-op extraction now falls
back to capture-time entityChanges (TIME_TRACKING/syncTimeSpent), and
ops with no readable delta (convertToSubTask & co) are "opaque": never
classified noise/info, preserved verbatim as kind:'action' diffs for
review, excluded from flip/stale computations, and never disjoint-merge
eligible (merging would drop the mutation and diverge the two clients).

Profile isolation — switchProfile clears the conflict journal
(ConflictJournalService.clearAll) so a new profile cannot see the
previous profile's entity data or Flip against the wrong dataset.

Also: replace the as-any selector cast with a typed union-member
narrowing, and add the required docs (sync-and-op-log/
conflict-journal-and-review.md incl. the atomicity/no-re-merge
contract, wiki 3.06 + 4.23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): address fourth-pass review (flip short-syntax, selector throws, restore isolation)

HIGH — a title-only flip matched shortSyntax$'s exact trigger shape, so
#tag/+project/@schedule tokens in the discarded title re-parsed into
cross-entity mutations: the TASK flip now dispatches with
isIgnoreShortSyntax: true (journaled literal value, not user input).

MEDIUM — selectTagById/selectNoteById THROW on a missing entity, so
expanding (or flipping) a TAG/NOTE row whose entity is gone rejected
unhandled before the !current guard: _readCurrentEntity now catches and
returns undefined, and getStaleState/flip degrade gracefully.

MEDIUM — the journal survived backup restores: clearAll() moved to the
BackupService.importCompleteBackup chokepoint, covering every full
dataset replacement (profile switch, JSON import, local-backup restore,
SuperSync restore) instead of only the profile switch.

LOW — ISSUE_PROVIDER's factory selectById is now special-cased (was
rendering the inner selector function as the "current" entity);
schedule/reminder fields (dueDay/dueWithTime/deadline*/reminderId) join
the flip blocklist (renamed FLIP_UNSAFE_FIELDS) since their invariants
live in dedicated flows; loser/winnerChangesFor early-return for merged
entries (their tiebreak diffs carry pickedSide, so the per-diff check
alone did not exclude them).

Docs updated accordingly (dev doc + wiki 3.06/4.23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): refuse flip for remindAt/deadlineRemindAt (reminder-lifecycle)

FLIP_UNSAFE_FIELDS is a deny-list: any Task field not listed is flippable
via a bare updateTask. reminderId was covered, but the sibling reminder-
lifecycle timestamps remindAt and deadlineRemindAt were not — a conflict on
either alone (e.g. concurrent deadline-reminder-lead edits) would pass
canFlip and, when flipped, write the field without scheduling/cancelling the
actual reminder in ReminderService, leaving a dangling/missing notification.

Add both to FLIP_UNSAFE_FIELDS, document the deny-list drift risk, and pin
the reminder/schedule members with per-field canFlip=false spec cases.

* fix(sync): make disjoint-merge convergent + close flip stale-guard blind spot

Two confirmed cross-device data defects found in the SPAP-14 whole-PR review:

1. Full-entity merged op diverges (CRITICAL). The merged op was a full-entity
   snapshot of each client's CURRENT state, so any field NEITHER conflicting
   side touched rode along. Under ordinary staggered sync (one client already
   applied a third device's edit to that field, the other not yet), the two
   clients synthesize different snapshots that tie under LWW at the identical
   max(timestamp) and diverge PERMANENTLY. Fix: synthesize a PARTIAL delta
   (union of the two sides' changed fields only), derived purely from the ops
   so both clients compute the byte-identical map; lwwUpdateMetaReducer applies
   it via updateOne (shallow merge), leaving untouched fields alone.
   synthesizeMergedEntity -> synthesizeMergedChanges.

   Also: detectConflicts emits one conflict per remote op with no per-entity
   aggregation, so an entity with >=2 concurrent remote ops synthesized multiple
   merged ops whose clocks dominate one another -> a dominated sibling's field
   is silently dropped, falsely journaled as 'kept both'. Fix: refuse merge for
   any entity with >1 conflict this batch and fall back to whole-entity LWW.

2. Flip stale-guard blind to loser-only fields (HIGH). getStaleState only
   compared WINNER-changed fields, but flip writes LOSER-changed fields. A
   post-resolution edit to a loser-only field was undetected and silently
   overwritten. Fix: also flag stale when a loser-only field flip will write
   diverged from the value flip would write; bulk flipAllToSide now SKIPS stale
   entries instead of silently applying them.

Adds regression specs for the un-conflicted-field ride-along, multi-remote-op
refusal, and loser-only stale detection (per-entry + bulk). Full conflict suite
green (disjoint-merge 12, ui 24, conflict-resolution 138, journal 20, hook 6,
util 14, review-util 13, superseded 42, banner 3, page 6).

* fix(sync): scope flip stale-guard loser-only check to remote-won entries

Re-review of efe66d7 found the new loser-only stale check false-positives on
LOCAL-won conflicts: there the loser is the REMOTE side, whose value was never
applied (current holds the un-recorded base), so current != flipVal is the
NORMAL post-resolution state, not an edit. That made flipAllToSide silently skip
legitimate local-won entries and single flip nag with a spurious confirm.

Scope loserOnlyStale to winner==='remote' (the only case where the loser's
optimistically-applied local value persists, giving a valid unedited baseline).
Remote-won silent-loss detection (the originally-reported defect) is unchanged.
Loser-only fields on LOCAL-won entries remain undetectable without a journaled
post-resolution baseline — documented as a follow-up. +2 regression specs
(local-won no-false-positive: getStaleState + bulk flip).

* fix(sync): restrict disjoint-merge to types with a RECREATE_FALLBACK

The merged op is a partial delta. If it wins over a concurrent DELETE on a
passive-observer client (one that already applied that delete), it reaches
lwwUpdateMetaReducer's addOne recreate branch WITHOUT passing through the
full-entity reconstruction in _convertToLWWUpdatesIfNeeded (that runs only for
conflict winners, not non-conflicting remote ops). For a type without a
RECREATE_FALLBACK (NOTE/METRIC/TASK_REPEAT_CFG/ISSUE_PROVIDER) the bare partial
addOne yields a Typia-invalid entity -> 'Repair failed' dead-end; the parent's
full-snapshot merged op recreated validly, so the partial delta is a regression
there.

Refuse disjoint-merge for fallback-less types (fall back to whole-entity LWW,
whose local-win op carries a full snapshot). Residual: fallback types can still
recreate with DEFAULT_* backfill diverging in that rare 3-device race — the same
bounded limitation already documented in recreate-fallback.const.ts. +regression
spec (NOTE disjoint conflict -> LWW, not merged).

* fix(sync): harden conflict-journal failure and lifecycle paths

Three hardening improvements from the final review pass:

1. Journal disjoint merges only AFTER the merged op is durably appended
   (STEP 3b), not at plan time. A 'merged' entry claims both sides were
   kept, which is only true once the op is persisted — an append failure
   could previously leave a phantom 'kept both' journal entry. +regression
   spec (a6): append failure -> no merged journal entry.

2. Extend the never-throw contract to ALL ConflictJournalService methods.
   list() is awaited (via maybeShowSummaryBanner) inside
   autoResolveConflictsLWW's notification step — i.e. after ops were
   already applied — so a transient IndexedDB failure there failed an
   otherwise-completed sync. list -> [], getEntry -> undefined,
   markKept/markFlipped swallowed (entry stays unreviewed, user retries).
   +spec.

3. Recover from abnormal IndexedDB closure: idb's terminated hook now
   drops the memoized _db/_initPromise handles so the next call reopens
   instead of failing on a dead connection for the rest of the session
   (deferred fourth-pass item). +spec.

Plus: reciprocal note in recreate-fallback.const.ts that membership also
opts a type into disjoint-merge, and doc updates for the new contracts.

* test(sync): pin the terminated-hook wiring in the journal recovery spec

The recovery spec called _resetDbHandles() directly, so removing the
terminated: callback from openDB (functionally reverting the force-close
recovery) kept all tests green. Fire the real 'close' event idb listens
for instead (duck-typed FakeEvent for fake-indexeddb) and assert the
handles were cleared. Mutation-verified: deleting the terminated line
now fails this spec.

* fix(sync): clear conflict journal inside the op-log lock on import

Author-review finding: importCompleteBackup released the OPERATION_LOG
lock before clearAll(), leaving a narrow cross-tab window where a
concurrent conflict resolution's fresh post-import journal entry gets
wiped. Clearing inside the lock serializes the clear strictly before any
post-import journaling. (_resetAllLastServerSeqs is journal-independent
local bookkeeping and keeps its persist-first ordering.)

Also documents the merged-op composition residual from the same review:
a merged partial op is not closed under later whole-op LWW composition
in the no-pending-local concurrent-apply path — verified pre-existing
(branch and behavior identical at the pre-SPAP-14 merge-base with plain
user ops), so recorded as a class-level op-log residual rather than
patched here.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
This commit is contained in:
aakhter 2026-07-11 11:48:46 -04:00 committed by GitHub
parent 830b7acefa
commit 962c5bbeb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 6007 additions and 66 deletions

View file

@ -38,6 +38,7 @@ vector clocks detect concurrent edits.
| [contributor-sync-model.md](./contributor-sync-model.md) | The single sync invariant for contributors (one intent = one op; replayed/remote ops must not re-trigger effects) |
| [operation-rules.md](./operation-rules.md) | Design rules and guidelines for operations |
| [package-boundaries.md](./package-boundaries.md) | Dependency/ownership boundaries for `@sp/sync-core`, `@sp/sync-providers`, app wiring |
| [conflict-journal-and-review.md](./conflict-journal-and-review.md) | Conflict journal (device-local record of LWW auto-resolutions), disjoint-field auto-merge, `/sync-conflicts` review UI |
| [vector-clocks.md](./vector-clocks.md) | Vector clock implementation, pruning, history |
| [supersync-encryption-architecture.md](./supersync-encryption-architecture.md) | End-to-end encryption (AES-256-GCM + Argon2id) |
| [diagrams/](./diagrams/) | Mermaid diagrams split by topic |

View file

@ -0,0 +1,221 @@
# Conflict Journal, Disjoint-Field Auto-Merge & Review UI
How LWW conflict auto-resolutions are recorded (conflict journal), when two
concurrent edits are kept instead of one discarded (disjoint-field auto-merge),
and how the user reviews what happened (`/sync-conflicts` page, banner, badge).
Code lives in `src/app/op-log/sync/`:
| Concern | Files |
| ------------------------------ | ------------------------------------------------------------------- |
| Journal data model + store | `conflict-journal.model.ts`, `conflict-journal.service.ts` |
| Classification (taxonomy) | `conflict-journal-emission.util.ts` |
| Disjoint-field auto-merge | `conflict-disjoint-merge.util.ts`, `conflict-resolution.service.ts` |
| Review UI derivation + actions | `sync-conflict-review.util.ts`, `sync-conflict-ui.service.ts` |
| Banner / badge | `sync-conflict-banner.service.ts` |
| Page | `src/app/pages/sync-conflicts-page/` |
## Conflict journal
Every LWW conflict auto-resolution is recorded as a `ConflictJournalEntry` in a
**standalone IndexedDB database `SUP_CONFLICT_JOURNAL`** — deliberately separate
from `SUP_OPS` so journaling can never touch op-log schema/versioning or risk
its data.
Contracts:
- **Observe-only.** Recording an entry never influences which op LWW picked,
and every journal write swallows its own errors — a journal failure must
never throw back into conflict resolution. Corollary: the op-log write and
the journal write are **not atomic**. The op log is the source of truth; the
journal is a best-effort record, and a crash between the two can lose a
journal entry but never an operation. The never-throw contract covers reads
and status writes too (`list``[]`, `getEntry``undefined`, mark
kept/flipped swallowed): `list()` is awaited inside the post-resolution
notification step, so a journal failure degrades the badge/review surface,
never the sync. One asymmetry: a `merged` entry claims "both sides kept", so
it is journaled only AFTER the merged op is durably appended — the journal
can under-report a merge, but never report one that didn't happen.
- **Device-local, never synced.** Entries capture the discarded (losing) side
of a conflict verbatim — exactly the data the op log intentionally dropped.
Uploading them would resurrect discarded data; they are also excluded from
backups/exports (see wiki `3.06-User-Data`).
- **Cleared on full dataset replacement.** Journal entries describe conflicts
in the op history; when that history is replaced wholesale the entries are
stale (and, across user profiles, a privacy leak).
`BackupService.importCompleteBackup` — the chokepoint every replacement path
funnels through (profile switch, JSON import, local-backup restore, SuperSync
restore) — calls `ConflictJournalService.clearAll()`.
- **Retention.** Pruned on app start to whichever bound binds first: entries
older than 14 days (`JOURNAL_RETENTION_DAYS`) or beyond the newest 200
(`JOURNAL_MAX_ENTRIES`).
### Classification taxonomy
`buildConflictJournalEntry` classifies each resolved conflict
(precedence order): `clock-corruption-suspected``delete-wins`
`delete-lost``noise``newer`/`tie`. `noise` (status `info`) fires only
when the DISCARDED side changed nothing but NOISE_FIELDS (`modified`,
`lastModified`, `created`) — i.e. no real content was lost. Everything else is
status `unreviewed` and counts toward the badge.
### Field diffs and per-side presence
`fieldDiffs` is the union of both sides' changed fields, each value captured
verbatim, plus `localChanged`/`remoteChanged` flags recording whether each side
actually touched the field. The flags distinguish "this side never changed the
field" from "changed it to some value" — without them, a union diff stores the
untouched side as `undefined`, and Flip would dispatch `{ field: undefined }`,
clearing a winner-only field. Entries persisted before the flags existed lack
them; readers (`loserChangesFor`/`winnerChangesFor`) fall back to
value-presence, which is exact for that data because op payloads are pure JSON
and cannot encode a real `undefined`.
### Non-adapter ("opaque") ops
Not every persistent action is adapter-shaped (`{ [payloadKey]: { id,
changes } }` or a flat entity). `convertToSubTask` persists
`{ taskId, targetParentId, afterTaskId }`; scheduling/ordering/advanced-config
actions have similar domain-specific shapes. Extraction resolves each op's
delta from two sources in order:
1. the adapter-shaped action payload (`extractUpdateChanges`);
2. the capture-time `entityChanges` computed by `OperationCaptureService`
(covers TIME_TRACKING and `syncTimeSpent`).
An op with neither is **opaque** (`hasOpaqueChanges`). Opaque ops still
represent real state changes, so:
- a loser side with opaque ops is **never** classified `noise` — the loss
surfaces as `unreviewed`;
- the raw action payload is preserved in the entry as a `kind: 'action'`
field diff (field = action type), so the discarded change stays reviewable
after the op itself is gone;
- `kind: 'action'` diffs are excluded from flip/stale computations — they are
not entity fields;
- a side with opaque ops is **never disjoint-merge eligible** (see below).
## Disjoint-field auto-merge
When two clients concurrently edit the SAME entity but DIFFERENT (non-noise)
fields, whole-entity LWW would discard one side's real edit. Instead, both are
kept by synthesizing a single merged UPDATE op. Eligibility
(`isDisjointMergeEligible` + the archive-plan guard in
`conflict-resolution.service.ts`):
- neither side has a DELETE op, and the plan is not an archive plan;
- neither side has opaque ops (their changes could not be carried into the
synthesized delta — merging would silently drop them and the two clients
would synthesize DIFFERENT results);
- both sides changed at least one real (non-noise) field;
- the two sides' non-noise changed-field sets are disjoint;
- the entity has only ONE conflict in this batch. `detectConflicts` emits one
conflict per remote op with no per-entity aggregation, so an entity with ≥2
concurrent remote ops would synthesize multiple merged ops whose clocks
dominate one another — a dominated sibling can be superseded and its field
silently dropped. Such entities fall back to whole-entity LWW (honest refusal;
per-entity aggregation into one op is a possible future improvement);
- the entity type has a `RECREATE_FALLBACK` (`TASK` / `PROJECT` / `TAG` /
`SIMPLE_COUNTER`). The merged op is a partial delta, so if it wins over a
concurrent DELETE on a client that already applied that delete (a passive
observer, which does NOT pass through the full-entity reconstruction in
`_convertToLWWUpdatesIfNeeded`), `lwwUpdateMetaReducer`'s `addOne` recreate
branch must backfill it to a schema-valid entity. Types without a fallback
(`NOTE` / `METRIC` / `TASK_REPEAT_CFG` / `ISSUE_PROVIDER`) would recreate an
invalid entity, so they fall back to whole-entity LWW (whose local-win op
carries a full snapshot). Residual: fallback types can still recreate with
`DEFAULT_*` backfill diverging from holders in that rare race — the same
bounded limitation documented in `recreate-fallback.const.ts`.
**Convergence contract:** both clients must synthesize the byte-identical
merged **changes delta** regardless of which one performs the merge. The delta
is the union of both sides' non-noise fields (disjoint, so nothing is clobbered)
plus the noise fields either side changed, resolved via a deterministic
`(timestamp, clientId)` tiebreak. Crucially the delta is derived ONLY from the
two sides' ops — **not** from either client's current entity snapshot. A
full-entity snapshot would drag along fields NEITHER side touched; if such an
untouched field momentarily differs between the two clients (an ordinary
staggered-sync race — e.g. one client already applied a third device's edit the
other has not), the two snapshots would differ, tie under LWW at the identical
`max(timestamp)`, and diverge PERMANENTLY. See `synthesizeMergedChanges`.
**Atomicity / no-re-merge contract:** the merged resolution is exactly ONE new
UPDATE op carrying a **flat PARTIAL delta** (only the changed fields), layered
on top of both sides' history like a normal edit — there is no history rewind.
`lwwUpdateMetaReducer` applies it via `updateOne` (a shallow merge), so fields
outside the delta keep their own values on each client. Because the payload is
flat (not `{ changes }`-shaped), `extractUpdateChanges` yields `{}` for it, so
a merged op can never itself become disjoint-merge eligible: merges do not
cascade or re-merge on later syncs. Merged resolutions are journaled with
`winner: 'merged'`, status `info` (nothing was discarded), recording per-field
which side supplied each value.
**Composition residual (pre-existing class):** the merged op is an ordinary
partial UPDATE, so it is NOT closed under later whole-op LWW composition. When
a concurrent third-device op overlapping a merged field crosses paths with the
merged op after both are synced, `_checkEntityForConflict`'s no-pending-local
fast path applies whichever op each client receives last, with no reconciling
snapshot op — clients can permanently diverge on the overlapped field. This
hole predates the merge feature: two plain concurrent user ops crossing after
both are synced hit the identical branch (present at the pre-SPAP-14 baseline);
the merged op is simply one more op subject to it, neither widening nor fixing
it. Class-level fix ideas — per-field timestamps, a reconciling op on
concurrent-apply, or carrying parent-op identity so a later conflict can
decompose a merge — belong to a follow-up at the op-log level.
## Review UI (`/sync-conflicts`)
Entry points: a banner after a sync that auto-resolved conflicts, an
unreviewed-count badge, and a link in Settings → Sync. Two views: unreviewed
and history (everything, newest first).
Per-entry actions (`SyncConflictUiService`):
- **KEEP** confirms the auto-resolution (`status: 'kept'`). Bulk keep-all
exists.
- **FLIP** re-applies the discarded side by dispatching a NORMAL entity update
action — the same action a manual edit dispatches — so the operation-capture
meta-reducer turns it into a synced op that propagates everywhere. No history
rewind; a flip is a brand-new edit on top of current state. Before applying,
a **stale guard** asks for confirmation if the entity was edited after the
conflict resolved. It checks a winner-changed field whose current value
diverged from the journaled winner value, plus — for **remote-won** entries
only — a **loser-only** field (one the flip writes but the winner never
changed, so it is invisible to the winner values) whose current value is not
already what the flip would write. The loser-only check is scoped to
`winner === 'remote'` because only then did the loser's (local, optimistically
applied) value persist in current state, giving a valid "unedited" baseline;
for a local win the loser (remote) value was never applied and no base is
journaled, so `current !== flipVal` there is the normal post-resolution state,
not an edit. The bulk flip path shows no dialog, so it **skips** stale entries
rather than overwriting them.
KNOWN GAP: a post-resolution edit to a **loser-only field on a LOCAL-won
entry** is not yet detectable (no journaled base), so a flip there can still
overwrite it silently — a follow-up needs a per-field post-resolution baseline
in the journal.
**Flip capability is deliberately narrow** (`canFlip`); everything else
returns `unsupported`, keeps the entry `unreviewed`, and shows an error snack —
an entry is only ever marked `flipped` when an op was actually dispatched:
- only TASK / PROJECT / NOTE / TAG (types whose flip is expressible as a
normal `{ id, changes }` update);
- not for `delete-lost` / `delete-wins` — re-applying a delete or resurrecting
a deleted entity needs delete/restore semantics a plain update cannot
express (deferred);
- not when the loser has no re-appliable field values (empty diffs, opaque
`kind: 'action'` diffs);
- not when the loser's changes touch unsafe fields (`FLIP_UNSAFE_FIELDS`):
relationship-bearing fields (`projectId`, `parentId`, `subTaskIds`,
`tagIds`, `taskIds`, `backlogTaskIds`, `noteIds`) are kept consistent across
entities by meta-reducers, and re-applying one side of the pair via a bare
adapter update would corrupt the other entity's membership lists;
schedule/reminder fields (`dueDay`, `dueWithTime`, `deadlineDay`,
`deadlineWithTime`, `reminderId`, `remindAt`, `deadlineRemindAt`) have
invariants (mutual exclusivity, TODAY_TAG membership, reminder create/cancel)
that live in dedicated flows.
A flipped TASK title is dispatched with `isIgnoreShortSyntax: true` — it is a
journaled literal value, not user input, so `#tag`/`+project`/`@schedule`
tokens in the discarded title must NOT re-parse into cross-entity mutations.

View file

@ -80,6 +80,7 @@ The Windows Store build uses a different path that includes the Windows Store pa
- **SUP_OPS** (current, version 4): Main database. Object stores: `ops`, `state_cache`, `import_backup`, `vector_clock`, `archive_young`, `archive_old`. Holds operations log, state snapshots, vector clocks, and archives.
- **pf:** Legacy database used for migration and recovery.
- **SUPPluginCache:** Plugin cache.
- **SUP_CONFLICT_JOURNAL** (version 1): Sync **conflict journal** — a device-local record of automatic sync-conflict resolutions, reviewable under `/sync-conflicts`. One object store (`conflicts`). Deliberately separate from `SUP_OPS`, **never synced or uploaded** (entries capture the discarded side of conflicts verbatim), pruned on start to 14 days / newest 200 entries, and cleared whenever the full dataset is replaced (backup import/restore, profile switch).
### localStorage Keys
@ -107,7 +108,7 @@ On mobile (Android/iOS), if the app launches with no local data but a usable on-
**Manual backups:** (1) **Create manual backup** in Settings → Sync & Export creates a backup using the same mechanism as automatic backups (platform-dependent location). (2) **Safety backups** are created automatically before certain sync operations; the app keeps a limited number of slots (e.g. two most recent, one first from today, one first from day before). (3) **Export data** downloads a complete backup JSON file to a path you choose (or the browser download folder on web).
**What is included:** All application data is included in backups and exports; nothing is excluded. When archives are included (e.g. when using "Export data" with full backup), both `archive_young` and `archive_old` are part of the file. A separate **import backup** (pre-import state) is stored in IndexedDB (`import_backup` store) only for recovery if an import fails; it is not part of the user-visible backup set.
**What is included:** All application data is included in backups and exports. The only exclusion is the device-local sync **conflict journal** (`SUP_CONFLICT_JOURNAL`): it is a review log of past conflict resolutions, not application data, and is neither exported nor restored. When archives are included (e.g. when using "Export data" with full backup), both `archive_young` and `archive_old` are part of the file. A separate **import backup** (pre-import state) is stored in IndexedDB (`import_backup` store) only for recovery if an import fails; it is not part of the user-visible backup set.
## Import and Export

View file

@ -48,9 +48,11 @@ The app uses **local-first, operation-based sync**: your device is the primary c
**Conflicts** — When the same data is changed in two places (e.g. on two devices) before they sync, the app detects a conflict. It resolves most conflicts automatically using **last-write-wins**: the change with the **newer timestamp** is kept; if timestamps tie, the **remote** (server) side wins. When your local change wins, the app creates a new operation so your version is sent to the remote. In some cases (e.g. first sync when both local and remote already have data), the app may ask you to choose **use local** or **use remote** instead of applying automatic resolution. After resolution, the app validates and can repair state so references (e.g. tasks to projects) stay consistent.
Because last-write-wins keeps the whole newer change, an older edit to a **different field** of the same task can be dropped. Routine automatic churn (rescheduling, repeat-task and archive updates) is resolved quietly, but when resolution discards a genuine **content** edit — a task's title, notes, or subtasks changed on both devices — the app shows a dismissible banner naming the affected task(s) so you can double-check that nothing important was lost.
**Disjoint-field auto-merge** — When two devices edited the SAME task (or project/tag/note) but touched DIFFERENT fields — e.g. you renamed a task on one device and edited its notes on the other — the app keeps **both** edits instead of discarding one: it merges the two changes into a single update. This only happens when it is unambiguously safe (different real fields, no deletion or archiving involved); anything else falls back to last-write-wins.
So in practice: **newer wins**; you only choose when the app prompts you (e.g. first-sync).
**Reviewing what was auto-resolved** — Every automatic conflict resolution is recorded in a device-local **conflict journal** you can inspect on the **Sync Conflicts** page (`/sync-conflicts`, also linked from Settings → Sync). When a resolution discarded a genuine edit, the app shows a dismissible banner after sync (content edits like a changed title or notes are still called out by task name, as before) and a badge with the number of entries awaiting review. For each entry you can see which side won, which fields differed and both values, and then either **Keep** (confirm the automatic resolution) or **Flip** (re-apply the discarded change as a new edit that syncs to all devices — nothing is rewound). Flip is disabled where re-applying isn't safely possible (e.g. overridden deletions or structural moves); such entries remain visible for review. The journal is informational only: it lives on the device, is never uploaded by sync, is not part of backups/exports, is cleared whenever you import a backup or switch user profiles, and old entries are pruned automatically (after 14 days or beyond the newest 200).
So in practice: **newer wins** (both kept when fields don't overlap); you only choose when the app prompts you (e.g. first-sync), and you can always review or flip automatic resolutions afterwards on the Sync Conflicts page.
## Summary
@ -58,7 +60,7 @@ So in practice: **newer wins**; you only choose when the app prompts you (e.g. f
- **Import** = replace current data with a backup file; data is validated and repaired when possible; encryption change is warned.
- **Export** = snapshot at export time; relationships preserved by IDs; use for backup or transfer.
- **Backups** = automatic (when enabled, platform-dependent location) and manual (manual button, safety backups, export); no data excluded.
- **Sync** = local-first, operation-based; conflicts resolved by newer-wins (or your choice when prompted).
- **Sync** = local-first, operation-based; conflicts resolved by newer-wins, non-overlapping field edits auto-merged; every auto-resolution reviewable (Keep/Flip) on the Sync Conflicts page.
## Related

View file

@ -126,6 +126,7 @@ export type {
ConflictResolutionSuggestion,
EntityConflictLike,
LwwConflictResolutionPlan,
LwwConflictResolutionReason,
LwwResolvedConflict,
} from './conflict-resolution';

View file

@ -44,6 +44,13 @@ export const APP_ROUTES: Routes = [
data: { page: 'config' },
canActivate: [FocusOverlayOpenGuard],
},
{
path: 'sync-conflicts',
loadComponent: () =>
import('./routes/pages.routes').then((m) => m.SyncConflictsPageComponent),
data: { page: 'sync-conflicts' },
canActivate: [FocusOverlayOpenGuard],
},
{
path: 'search',
loadComponent: () =>

View file

@ -50,6 +50,18 @@
@if (isSyncIconEnabled()) {
<button
class="sync-btn"
[matBadge]="unreviewedConflictCount()"
[matBadgeHidden]="unreviewedConflictCount() === 0"
matBadgeColor="accent"
matBadgeSize="small"
matBadgeOverlap="true"
[matBadgeDescription]="
unreviewedConflictCount() > 0
? (T.F.SYNC.CONFLICT_REVIEW.BADGE_TOOLTIP
| translate: { count: unreviewedConflictCount() })
: null
"
[attr.aria-label]="syncTooltip() | translate"
matTooltip="{{ syncTooltip() | translate }}"
(click)="onSyncButtonClick()"
(longPress)="setupSync()"

View file

@ -26,6 +26,7 @@ import { GlobalConfigService } from '../../features/config/global-config.service
import { KeyboardConfig, keyboardConfigOrEmpty } from '@sp/keyboard-config';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatBadge } from '@angular/material/badge';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { SimpleCounterButtonComponent } from '../../features/simple-counter/simple-counter-button/simple-counter-button.component';
@ -49,6 +50,7 @@ import { UserProfileButtonComponent } from '../../features/user-profile/user-pro
import { FocusButtonComponent } from './focus-button/focus-button.component';
import { UserProfileService } from '../../features/user-profile/user-profile.service';
import { EmlDropDirective } from '../../core/drop-paste-input/eml-drop.directive';
import { ConflictJournalService } from '../../op-log/sync/conflict-journal.service';
@Component({
selector: 'main-header',
@ -59,6 +61,7 @@ import { EmlDropDirective } from '../../core/drop-paste-input/eml-drop.directive
imports: [
MatIconButton,
MatIcon,
MatBadge,
MatTooltip,
TranslatePipe,
SimpleCounterButtonComponent,
@ -93,11 +96,18 @@ export class MainHeaderComponent implements OnDestroy {
private readonly _metricService = inject(MetricService);
private readonly _dateService = inject(DateService);
private readonly _dataInitStateService = inject(DataInitStateService);
private readonly _conflictJournal = inject(ConflictJournalService);
readonly isDataLoaded = toSignal(this._dataInitStateService.isAllDataLoadedInitially$, {
initialValue: false,
});
// SPAP-15: persistent badge on the sync icon — count of unreviewed
// auto-resolved sync conflicts awaiting review.
readonly unreviewedConflictCount = toSignal(this._conflictJournal.unreviewedCount$, {
initialValue: 0,
});
T: typeof T = T;
isShowSimpleCounterBtnsDropdown = signal(false);

View file

@ -17,6 +17,7 @@ export enum BannerId {
SuperSyncEncryptionMigration = 'SuperSyncEncryptionMigration',
RatePrompt = 'RatePrompt',
SyncConflictContentResolved = 'SyncConflictContentResolved',
SyncConflictsAutoResolved = 'SyncConflictsAutoResolved',
UpdateAvailable = 'UpdateAvailable',
}
@ -37,6 +38,7 @@ export const BANNER_SORT_PRIO_MAP = {
[BannerId.SuperSyncEncryptionMigration]: 0,
[BannerId.RatePrompt]: 0,
[BannerId.SyncConflictContentResolved]: 1,
[BannerId.SyncConflictsAutoResolved]: 0,
[BannerId.UpdateAvailable]: 0,
} as const;

View file

@ -387,6 +387,11 @@ export class UserProfileService {
);
}
// NOTE: the device-local conflict journal is cleared by
// BackupService.importCompleteBackup (both branches above call it) — the
// shared "clear on full dataset replacement" chokepoint, so the new
// profile cannot see the previous profile's entity titles/values.
// Reload the app to ensure all services and state are fully re-initialized
// with the new profile's data. A reload is required because some parts of the
// app (e.g. WorkContextService) only initialize once at startup via allDataWasLoaded,

View file

@ -9,6 +9,7 @@ import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { OpType } from '../core/operation.types';
import { OperationWriteFlushService } from '../sync/operation-write-flush.service';
import { LockService } from '../sync/lock.service';
import { ConflictJournalService } from '../sync/conflict-journal.service';
import { LOCK_NAMES } from '../core/operation-log.const';
describe('BackupService', () => {
@ -19,6 +20,7 @@ describe('BackupService', () => {
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockOperationWriteFlushService: jasmine.SpyObj<OperationWriteFlushService>;
let mockLockService: jasmine.SpyObj<LockService>;
let mockConflictJournal: jasmine.SpyObj<ConflictJournalService>;
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const createMinimalValidBackup = () => ({
@ -111,6 +113,8 @@ describe('BackupService', () => {
'flushPendingWrites',
]);
mockLockService = jasmine.createSpyObj('LockService', ['request']);
mockConflictJournal = jasmine.createSpyObj('ConflictJournalService', ['clearAll']);
mockConflictJournal.clearAll.and.resolveTo();
// Default mock returns
mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(
@ -135,6 +139,7 @@ describe('BackupService', () => {
useValue: mockOperationWriteFlushService,
},
{ provide: LockService, useValue: mockLockService },
{ provide: ConflictJournalService, useValue: mockConflictJournal },
],
});
@ -244,6 +249,17 @@ describe('BackupService', () => {
);
});
it('should clear the conflict journal (full dataset replacement)', async () => {
// Journal entries reference entities of the REPLACED dataset. Every
// import path (profile switch, JSON import, local-backup restore,
// SuperSync restore) funnels through here — without the clear, the badge
// keeps its pre-restore count and the review page lists conflicts from
// the old dataset.
await service.importCompleteBackup(createMinimalValidBackup() as any, true, true);
expect(mockConflictJournal.clearAll).toHaveBeenCalledTimes(1);
});
it('should persist import to operation log', async () => {
const backupData = createMinimalValidBackup();

View file

@ -21,6 +21,7 @@ import { normalizeGlobalConfigStartOfNextDay } from '../../features/config/norma
import { extractEntityKeysFromState } from '../persistence/extract-entity-keys';
import { OperationWriteFlushService } from '../sync/operation-write-flush.service';
import { LockService } from '../sync/lock.service';
import { ConflictJournalService } from '../sync/conflict-journal.service';
import { LOCK_NAMES } from '../core/operation-log.const';
/**
@ -42,6 +43,7 @@ export class BackupService {
private _opLogStore = inject(OperationLogStoreService);
private _operationWriteFlushService = inject(OperationWriteFlushService);
private _lockService = inject(LockService);
private _conflictJournalService = inject(ConflictJournalService);
/**
* Loads a complete backup of all application data.
@ -148,9 +150,20 @@ export class BackupService {
await this._operationWriteFlushService.flushPendingWrites();
await this._lockService.request(LOCK_NAMES.OPERATION_LOG, async () => {
await this._persistImportToOperationLog(validatedData);
// 4b. The conflict journal is a device-local side store describing
// conflicts in the op history that was JUST replaced — every import
// path (profile switch, JSON import, local-backup restore, SuperSync
// restore) funnels through here, and without this the badge keeps its
// pre-restore count and the review page lists entries from the
// replaced dataset. Cleared INSIDE the op-log lock: a concurrent
// (cross-tab) conflict resolution serializes on this lock, so its
// fresh post-import journal entries cannot land before the clear and
// be wiped. clearAll swallows its own errors (must not fail the import).
await this._conflictJournalService.clearAll();
});
// 4b. Reset all sync providers' lastServerSeq to 0.
// 4c. Reset all sync providers' lastServerSeq to 0.
// After a backup import, the client must re-sync from the beginning to ensure
// that any ops on the server (which may conflict with the backup) are properly
// filtered by the local BACKUP_IMPORT operation.

View file

@ -29,6 +29,11 @@ import { EMPTY_SIMPLE_COUNTER } from '../../features/simple-counter/simple-count
* IMPORTANT: adding a new type here gives you the generic recreate backfill,
* but the on-disk DEFENSE-IN-DEPTH heal stays absent until you ALSO add a
* matching branch in `auto-fix-typia-errors.ts` (or generalize that file).
* Membership here ALSO opts the type into SPAP-14 disjoint-field auto-merge:
* `ConflictResolutionService._tryCreateDisjointMergeOp` refuses fallback-less
* types because its partial merged op must survive this recreate path. That is
* safe by construction (recreate-safe merge-recreate-safe), but know that an
* entry here enables merging for the type too.
*
* TASK is the type the original report hit; PROJECT and TAG are defense in
* depth because they share the same recreate code path. SIMPLE_COUNTER was

View file

@ -0,0 +1,231 @@
import {
buildMergedFieldDiffs,
hasOpaqueChanges,
isDisjointMergeEligible,
mergeChangedFields,
MergeSideMeta,
noiseTiebreakSide,
} from './conflict-disjoint-merge.util';
import { ActionType, EntityType, OpType, Operation } from '../core/operation.types';
const op = (over: Partial<Operation> = {}): Operation => ({
id: 'op-1',
actionType: '[Task] Update' as ActionType,
opType: OpType.Update,
entityType: 'TASK' as EntityType,
entityId: 'task-1',
payload: { task: { id: 'task-1' } },
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 1000,
schemaVersion: 1,
...over,
});
/** Production-shaped convertToSubTask op: non-adapter payload, empty entityChanges. */
const convertToSubTaskOp = (over: Partial<Operation> = {}): Operation =>
op({
actionType: '[Task] Convert to sub task' as ActionType,
payload: {
actionPayload: {
taskId: 'task-1',
targetParentId: 'parent-1',
afterTaskId: null,
},
entityChanges: [],
},
...over,
});
describe('conflict-disjoint-merge.util', () => {
describe('mergeChangedFields (non-adapter payloads)', () => {
it('falls back to capture-time entityChanges when the action payload is not adapter-shaped', () => {
const timeSyncOp = op({
actionType: '[TimeTracking] Sync time spent' as ActionType,
payload: {
actionPayload: { taskId: 'task-1', date: '2026-07-10', duration: 100 },
entityChanges: [
{
entityType: 'TASK' as EntityType,
entityId: 'task-1',
opType: OpType.Update,
changes: { taskId: 'task-1', date: '2026-07-10', duration: 100 },
},
],
},
});
expect(mergeChangedFields([timeSyncOp], 'task')).toEqual({
taskId: 'task-1',
date: '2026-07-10',
duration: 100,
});
});
});
describe('hasOpaqueChanges', () => {
it('is true for a non-adapter payload with no entityChanges (convertToSubTask)', () => {
expect(hasOpaqueChanges([convertToSubTaskOp()], 'task')).toBe(true);
});
it('is false for adapter-shaped updates and for DELETE ops', () => {
expect(
hasOpaqueChanges(
[op({ payload: { task: { id: 'task-1', title: 'T' } } })],
'task',
),
).toBe(false);
expect(
hasOpaqueChanges(
[op({ opType: OpType.Delete, payload: { task: { id: 'task-1' } } })],
'task',
),
).toBe(false);
});
});
describe('isDisjointMergeEligible (opaque ops)', () => {
it('refuses the merge when one side also has an opaque op, even if extracted fields are disjoint', () => {
// local: adapter title edit + opaque structural move; remote: notes edit.
// Extracted fields (title vs notes) are disjoint, but merging would
// silently drop the structural move and the two clients would diverge.
const eligible = isDisjointMergeEligible({
localOps: [
op({ payload: { task: { id: 'task-1', title: 'Local' } } }),
convertToSubTaskOp(),
],
remoteOps: [
op({ payload: { task: { id: 'task-1', notes: 'Remote' } }, clientId: 'B' }),
],
payloadKey: 'task',
});
expect(eligible).toBe(false);
});
});
describe('noiseTiebreakSide', () => {
it('picks the greater-timestamp side (local newer)', () => {
expect(
noiseTiebreakSide(
{ timestamp: 2000, clientId: 'A' },
{ timestamp: 1000, clientId: 'Z' },
),
).toBe('local');
});
it('picks the greater-timestamp side (remote newer)', () => {
expect(
noiseTiebreakSide(
{ timestamp: 1000, clientId: 'Z' },
{ timestamp: 2000, clientId: 'A' },
),
).toBe('remote');
});
// The equal-timestamp branch: falls back to the greater clientId. This is the
// cross-client determinism guarantee — without it two clients could pick
// different noise values and diverge.
it('breaks an equal-timestamp tie by the greater clientId (local wins)', () => {
expect(
noiseTiebreakSide(
{ timestamp: 1000, clientId: 'B' },
{ timestamp: 1000, clientId: 'A' },
),
).toBe('local');
});
it('breaks an equal-timestamp tie by the greater clientId (remote wins)', () => {
expect(
noiseTiebreakSide(
{ timestamp: 1000, clientId: 'A' },
{ timestamp: 1000, clientId: 'B' },
),
).toBe('remote');
});
it('is fully symmetric on equal timestamps: both clients pick the SAME physical side', () => {
// X and Y differ only by clientId. Whichever side X is passed as, the result
// must always point at the SAME side (the greater clientId, Y here).
const x: MergeSideMeta = { timestamp: 1000, clientId: 'A' };
const y: MergeSideMeta = { timestamp: 1000, clientId: 'B' };
// Client 1 sees X local / Y remote → picks remote (= Y).
expect(noiseTiebreakSide(x, y)).toBe('remote');
// Client 2 sees Y local / X remote → picks local (= Y).
expect(noiseTiebreakSide(y, x)).toBe('local');
});
it('defaults to local when both identity components are equal', () => {
expect(
noiseTiebreakSide(
{ timestamp: 1000, clientId: 'A' },
{ timestamp: 1000, clientId: 'A' },
),
).toBe('local');
});
});
describe('buildMergedFieldDiffs', () => {
const localMeta: MergeSideMeta = { timestamp: 1000, clientId: 'A' };
const remoteMeta: MergeSideMeta = { timestamp: 2000, clientId: 'B' };
it('captures each side verbatim and attributes disjoint real fields to their side', () => {
const diffs = buildMergedFieldDiffs(
{ title: 'Local' },
{ notes: 'Remote' },
localMeta,
remoteMeta,
);
expect(diffs).toContain({
field: 'title',
localVal: 'Local',
remoteVal: undefined,
localChanged: true,
remoteChanged: false,
pickedSide: 'local',
});
expect(diffs).toContain({
field: 'notes',
localVal: undefined,
remoteVal: 'Remote',
localChanged: false,
remoteChanged: true,
pickedSide: 'remote',
});
expect(diffs.length).toBe(2);
});
it('resolves a field changed on BOTH sides (only noise can be) via the timestamp tiebreak', () => {
const diffs = buildMergedFieldDiffs(
{ modified: 1111 },
{ modified: 2222 },
localMeta, // ts 1000
remoteMeta, // ts 2000 → newer wins
);
expect(diffs.length).toBe(1);
expect(diffs[0]).toEqual({
field: 'modified',
localVal: 1111,
remoteVal: 2222,
localChanged: true,
remoteChanged: true,
pickedSide: 'remote',
});
});
it('resolves a both-sides field by clientId when timestamps are equal', () => {
const diffs = buildMergedFieldDiffs(
{ modified: 1111 },
{ modified: 2222 },
{ timestamp: 1000, clientId: 'B' }, // greater clientId → local wins
{ timestamp: 1000, clientId: 'A' },
);
expect(diffs[0].pickedSide).toBe('local');
});
it('returns an empty array when neither side changed anything', () => {
expect(buildMergedFieldDiffs({}, {}, localMeta, remoteMeta)).toEqual([]);
});
});
});

View file

@ -0,0 +1,262 @@
/**
* SPAP-14 Pure disjoint-field auto-merge logic.
*
* When two clients concurrently edit the SAME entity but DIFFERENT (non-noise)
* fields, whole-entity LWW would discard one side's real edit. SPAP-14 instead
* KEEPS BOTH by synthesizing a single merged UPDATE whose delta is the union of
* both sides' changed fields.
*
* No Angular, no I/O deterministic, so the merge decision and the synthesized
* changes delta are unit-testable in isolation. Determinism is the whole point:
* both clients must arrive at the byte-identical merged delta regardless of
* which one performs the merge (see `synthesizeMergedChanges`).
*/
import { OpType } from '../core/operation.types';
import type { Operation } from '../core/operation.types';
import { extractUpdateChanges, isMultiEntityPayload } from '@sp/sync-core';
import { ConflictJournalFieldDiff, NOISE_FIELDS } from './conflict-journal.model';
/** Identity of one side of the conflict for the deterministic noise tiebreak. */
export interface MergeSideMeta {
/** Max timestamp across that side's ops. */
timestamp: number;
/** The client that authored that side. */
clientId: string;
}
/**
* The changed fields of ONE op, from either of the two delta sources:
*
* 1. the adapter-shaped action payload (`{ [payloadKey]: { id, changes } }`
* or a flat entity) via `extractUpdateChanges`;
* 2. falling back to the capture-time `entityChanges` computed by
* `OperationCaptureService` for reducers that don't follow the adapter
* pattern (e.g. TIME_TRACKING, syncTimeSpent).
*
* Returns `{}` when neither source has anything the op's mutation is encoded
* in a domain-specific payload shape (e.g. `convertToSubTask`'s
* `{ taskId, targetParentId, afterTaskId }`) that CANNOT be read as field
* values. Such "opaque" ops still represent a real state change; callers must
* treat empty-with-payload as unknown, not as "nothing changed" see
* `hasOpaqueChanges`.
*/
const extractOpChanges = (op: Operation, payloadKey: string): Record<string, unknown> => {
const adapterChanges = extractUpdateChanges(op.payload, payloadKey);
if (Object.keys(adapterChanges).length > 0) {
return adapterChanges;
}
if (isMultiEntityPayload(op.payload)) {
const merged: Record<string, unknown> = {};
for (const change of op.payload.entityChanges) {
if (
change.entityId === op.entityId &&
change.changes &&
typeof change.changes === 'object'
) {
Object.assign(merged, change.changes as Record<string, unknown>);
}
}
return merged;
}
return {};
};
/**
* Union of the changed-field maps across a set of ops on one side.
*
* Mirrors the SPAP-13 `mergeChangedFields` pattern (was private to the journal
* emission util). DELETE ops carry no meaningful field changes and are skipped
* though disjoint-merge eligibility already excludes any side with a DELETE.
*/
export const mergeChangedFields = (
ops: Operation[],
payloadKey: string,
): Record<string, unknown> => {
const merged: Record<string, unknown> = {};
for (const op of ops) {
if (op.opType === OpType.Delete) {
continue;
}
Object.assign(merged, extractOpChanges(op, payloadKey));
}
return merged;
};
/** True when this non-DELETE op's field-level delta cannot be extracted. */
export const isOpaqueChangeOp = (op: Operation, payloadKey: string): boolean =>
op.opType !== OpType.Delete &&
Object.keys(extractOpChanges(op, payloadKey)).length === 0;
/**
* True when the side contains at least one op whose mutation is real but not
* expressible as field values (see `extractOpChanges`). A side with opaque
* changes must never be classified as "changed nothing real" (journal `noise`)
* nor auto-merged (the synthesized entity would silently drop the opaque
* mutation and the two clients would diverge).
*/
export const hasOpaqueChanges = (ops: Operation[], payloadKey: string): boolean =>
ops.some((op) => isOpaqueChangeOp(op, payloadKey));
/** The non-NOISE keys of a changed-field map. */
const nonNoiseKeys = (changes: Record<string, unknown>): string[] =>
Object.keys(changes).filter((field) => !NOISE_FIELDS.has(field));
/**
* Deterministic tiebreak for a field both sides changed: the side with the
* greater `(timestamp, clientId)`. Both clients compute the SAME global winner
* because the comparison is over the two sides' identities, independent of which
* side happens to be "local" on a given client.
*/
export const noiseTiebreakSide = (
local: MergeSideMeta,
remote: MergeSideMeta,
): 'local' | 'remote' => {
if (local.timestamp !== remote.timestamp) {
return local.timestamp > remote.timestamp ? 'local' : 'remote';
}
if (local.clientId !== remote.clientId) {
return local.clientId > remote.clientId ? 'local' : 'remote';
}
// Same identity on both — value is identical either way; pick 'local'.
return 'local';
};
/**
* True iff this conflict is safe to resolve by a disjoint-field merge.
*
* Field-level conditions only (the caller separately excludes archive plans):
* - neither side has a DELETE op;
* - BOTH sides changed at least one real (non-noise) field if one side only
* bumped noise, nothing real is lost by LWW, so leave it to SPAP-13's `noise`
* classification;
* - the two sides' non-noise changed-field sets are DISJOINT.
*/
export const isDisjointMergeEligible = (params: {
localOps: Operation[];
remoteOps: Operation[];
payloadKey: string;
}): boolean => {
const { localOps, remoteOps, payloadKey } = params;
if (localOps.some((op) => op.opType === OpType.Delete)) return false;
if (remoteOps.some((op) => op.opType === OpType.Delete)) return false;
// A side with opaque ops has real changes the merge could not carry over —
// synthesizing from the extracted fields alone would drop them (and the two
// clients would synthesize DIFFERENT entities). Fall back to LWW instead.
if (hasOpaqueChanges(localOps, payloadKey)) return false;
if (hasOpaqueChanges(remoteOps, payloadKey)) return false;
const localNonNoise = nonNoiseKeys(mergeChangedFields(localOps, payloadKey));
const remoteNonNoise = nonNoiseKeys(mergeChangedFields(remoteOps, payloadKey));
if (localNonNoise.length === 0 || remoteNonNoise.length === 0) return false;
const remoteSet = new Set(remoteNonNoise);
return !localNonNoise.some((field) => remoteSet.has(field));
};
/**
* Synthesizes the merged CHANGES DELTA the union of both sides' changed
* fields, applied on top of each client's current entity by `updateOne` (a
* shallow MERGE, not a replace). This is the SINGLE source of truth both clients
* must converge on.
*
* IMPORTANT why a delta and NOT a full-entity snapshot: the delta is derived
* purely from the two conflicting sides' ops, so both clients compute the
* byte-identical map regardless of the rest of their entity state. A full-entity
* snapshot (`{...currentEntity}`) would drag along fields NEITHER side touched;
* if such an untouched field momentarily differs between the two clients (an
* ordinary staggered-sync race e.g. one client already applied a third
* device's edit the other has not), the two synthesized snapshots differ, tie
* under LWW at the identical `max(timestamp)`, and diverge PERMANENTLY. Carrying
* only the changed fields makes the merged ops identical and leaves every
* untouched field to its own op/LWW.
*
* Convergence: for every non-noise field the value is the same (disjoint sets
* each field owned by exactly one side); for every noise field both pick the
* same global `(timestamp, clientId)` tiebreak winner. Therefore the delta is
* identical on both clients.
*/
export const synthesizeMergedChanges = (
localChanges: Record<string, unknown>,
remoteChanges: Record<string, unknown>,
localMeta: MergeSideMeta,
remoteMeta: MergeSideMeta,
): Record<string, unknown> => {
const changes: Record<string, unknown> = {};
// Union of both sides' real (non-noise) fields. The two sets are guaranteed
// disjoint (isDisjointMergeEligible), so neither overwrites the other.
for (const [key, value] of Object.entries(localChanges)) {
if (!NOISE_FIELDS.has(key)) {
changes[key] = value;
}
}
for (const [key, value] of Object.entries(remoteChanges)) {
if (!NOISE_FIELDS.has(key)) {
changes[key] = value;
}
}
// Resolve every noise field either side changed, deterministically, so both
// clients write the identical value (not each their own).
const winner = noiseTiebreakSide(localMeta, remoteMeta);
const noiseFields = new Set<string>(
[...Object.keys(localChanges), ...Object.keys(remoteChanges)].filter((field) =>
NOISE_FIELDS.has(field),
),
);
for (const field of noiseFields) {
const localHas = field in localChanges;
const remoteHas = field in remoteChanges;
if (localHas && remoteHas) {
changes[field] = winner === 'local' ? localChanges[field] : remoteChanges[field];
} else if (localHas) {
changes[field] = localChanges[field];
} else {
changes[field] = remoteChanges[field];
}
}
return changes;
};
/**
* Per-field journal diffs for a merged resolution. Each field records which
* side's value the merge kept: `local` for local-changed fields, `remote` for
* remote-changed fields, and the deterministic tiebreak winner for a noise field
* both sides changed.
*/
export const buildMergedFieldDiffs = (
localChanges: Record<string, unknown>,
remoteChanges: Record<string, unknown>,
localMeta: MergeSideMeta,
remoteMeta: MergeSideMeta,
): ConflictJournalFieldDiff[] => {
const winner = noiseTiebreakSide(localMeta, remoteMeta);
const fieldNames = Array.from(
new Set([...Object.keys(localChanges), ...Object.keys(remoteChanges)]),
);
return fieldNames.map((field) => {
const localHas = field in localChanges;
const remoteHas = field in remoteChanges;
let pickedSide: 'local' | 'remote';
if (localHas && remoteHas) {
// Only NOISE fields can be on both sides (real fields are disjoint).
pickedSide = winner;
} else if (localHas) {
pickedSide = 'local';
} else {
pickedSide = 'remote';
}
return {
field,
localVal: localChanges[field],
remoteVal: remoteChanges[field],
localChanged: localHas,
remoteChanged: remoteHas,
pickedSide,
};
});
};

View file

@ -0,0 +1,290 @@
/**
* SPAP-13 Pure classification of an LWW resolution into a conflict-journal
* entry (SPAP-12 taxonomy). No Angular, no I/O deterministic apart from `id`
* (uuidv7) and `resolvedAt` (Date.now), so the taxonomy is unit-testable in
* isolation.
*
* OBSERVE-ONLY: this only READS the already-decided resolution plan; it never
* changes which op LWW picked.
*/
import { OpType } from '../core/operation.types';
import type { EntityType, Operation } from '../core/operation.types';
import { extractActionPayload, extractEntityFromPayload } from '@sp/sync-core';
import type { LwwConflictResolutionReason } from '@sp/sync-core';
import { uuidv7 } from '../../util/uuid-v7';
import {
ConflictJournalEntry,
ConflictJournalFieldDiff,
ConflictJournalReason,
ConflictJournalStatus,
ConflictJournalWinner,
NOISE_FIELDS,
} from './conflict-journal.model';
import {
buildMergedFieldDiffs,
hasOpaqueChanges,
isOpaqueChangeOp,
mergeChangedFields,
} from './conflict-disjoint-merge.util';
/** Everything the classifier needs about one resolved conflict. */
export interface ConflictJournalClassificationInput {
entityType: EntityType;
entityId: string;
winner: ConflictJournalWinner;
/** The plan reason from `planLwwConflictResolutions` (archive detection etc.). */
planReason: LwwConflictResolutionReason;
localOps: Operation[];
remoteOps: Operation[];
/**
* True when this conflict only exists because `_adjustForClockCorruption`
* escalated a non-CONCURRENT comparison to CONCURRENT.
*/
isCorruptionSuspected: boolean;
/** Resolves the payload key (e.g. 'task') for an entity type. */
resolvePayloadKey: (entityType: EntityType) => string;
}
const ARCHIVE_PLAN_REASONS: ReadonlySet<LwwConflictResolutionReason> = new Set([
'remote-archive',
'local-archive',
'local-archive-sibling',
]);
const firstString = (...vals: unknown[]): string | undefined => {
for (const v of vals) {
if (typeof v === 'string' && v.trim().length > 0) {
return v;
}
}
return undefined;
};
/** Best-effort human title for the entity, from the op payloads only. */
const extractEntityTitle = (
ops: Operation[],
changes: Record<string, unknown>,
payloadKey: string,
): string => {
const fromChanges = firstString(changes['title'], changes['name']);
if (fromChanges) {
return fromChanges;
}
for (const op of ops) {
const entity = extractEntityFromPayload(op.payload, payloadKey) as
| Record<string, unknown>
| undefined;
const title = firstString(entity?.['title'], entity?.['name']);
if (title) {
return title;
}
// Fallback: some payloads nest the fields directly under the action payload.
const action = extractActionPayload(op.payload);
const nested = firstString(action?.['title'], action?.['name']);
if (nested) {
return nested;
}
}
return '';
};
const maxTimestamp = (ops: Operation[]): number =>
ops.length ? Math.max(...ops.map((op) => op.timestamp)) : 0;
/**
* `kind: 'action'` diffs for every opaque op on either side: the op's mutation
* is real but not readable as field values (see `extractOpChanges`), so the
* raw action payload is preserved verbatim under the action type instead. This
* keeps the discarded change REVIEWABLE (the journal entry outlives the op),
* while `loserChangesFor`/`winnerChangesFor` skip these diffs so flip and the
* stale guard never treat an action payload as entity fields.
*/
const buildOpaqueActionDiffs = (
localOps: Operation[],
remoteOps: Operation[],
payloadKey: string,
pickedSide: 'local' | 'remote',
): ConflictJournalFieldDiff[] => {
const byActionType = new Map<string, ConflictJournalFieldDiff>();
const add = (op: Operation, side: 'local' | 'remote'): void => {
if (!isOpaqueChangeOp(op, payloadKey)) {
return;
}
const diff = byActionType.get(op.actionType) ?? {
field: op.actionType,
localVal: undefined,
remoteVal: undefined,
localChanged: false,
remoteChanged: false,
pickedSide,
kind: 'action' as const,
};
if (side === 'local') {
diff.localVal = extractActionPayload(op.payload);
diff.localChanged = true;
} else {
diff.remoteVal = extractActionPayload(op.payload);
diff.remoteChanged = true;
}
byActionType.set(op.actionType, diff);
};
for (const op of localOps) {
add(op, 'local');
}
for (const op of remoteOps) {
add(op, 'remote');
}
return Array.from(byActionType.values());
};
/**
* Classifies one already-resolved LWW conflict into a journal entry.
*
* Precedence: clock-corruption delete-wins delete-lost noise newer/tie.
*
* `noise` fires when the DISCARDED (losing) side changed only NOISE_FIELDS i.e.
* nothing real was lost. This is the data-safety-correct reading of "only NOISE
* fields overlap": a real edit is only lost if the loser touched a non-noise field.
*/
export const buildConflictJournalEntry = (
input: ConflictJournalClassificationInput,
): ConflictJournalEntry => {
const {
entityType,
entityId,
winner,
planReason,
localOps,
remoteOps,
isCorruptionSuspected,
resolvePayloadKey,
} = input;
const payloadKey = resolvePayloadKey(entityType);
const localChanges = mergeChangedFields(localOps, payloadKey);
const remoteChanges = mergeChangedFields(remoteOps, payloadKey);
const localTs = maxTimestamp(localOps);
const remoteTs = maxTimestamp(remoteOps);
// SPAP-14: disjoint-field auto-merge. Nothing was discarded — BOTH sides'
// changes survive in the synthesized merged op — so this is informational,
// never counts toward the unreviewed count, and records per-field which side
// supplied each value. Early-return keeps the LWW classification below (which
// narrows `winner` to 'local' | 'remote') completely unchanged.
if (winner === 'merged') {
const localClientId = localOps[0]?.clientId ?? '';
const remoteClientId = remoteOps[0]?.clientId ?? '';
const mergedTitle =
extractEntityTitle(localOps, localChanges, payloadKey) ||
extractEntityTitle(remoteOps, remoteChanges, payloadKey);
return {
id: uuidv7(),
entityType,
entityId,
entityTitle: mergedTitle,
resolvedAt: Date.now(),
winner: 'merged',
reason: 'disjoint-merge',
fieldDiffs: buildMergedFieldDiffs(
localChanges,
remoteChanges,
{ timestamp: localTs, clientId: localClientId },
{ timestamp: remoteTs, clientId: remoteClientId },
),
localClientId,
remoteClientId,
localTs,
remoteTs,
status: 'info',
};
}
// fieldDiffs: union of changed fields on both sides, capturing each side's
// value VERBATIM so the loser's discarded values are preserved, plus per-side
// presence flags so readers can tell "this side never touched the field"
// apart from the union's `undefined` placeholder.
const fieldNames = Array.from(
new Set([...Object.keys(localChanges), ...Object.keys(remoteChanges)]),
);
const fieldDiffs: ConflictJournalFieldDiff[] = fieldNames.map((field) => ({
field,
localVal: localChanges[field],
remoteVal: remoteChanges[field],
localChanged: field in localChanges,
remoteChanged: field in remoteChanges,
pickedSide: winner,
}));
fieldDiffs.push(...buildOpaqueActionDiffs(localOps, remoteOps, payloadKey, winner));
const winnerOps = winner === 'local' ? localOps : remoteOps;
const loserOps = winner === 'local' ? remoteOps : localOps;
const loserChanges = winner === 'local' ? remoteChanges : localChanges;
const loserRealFields = Object.keys(loserChanges).filter(
(field) => !NOISE_FIELDS.has(field),
);
// Opaque loser ops (mutation not readable as fields — e.g. convertToSubTask)
// are REAL losses: without this, `loserChanges` is empty and the discarded
// structural change would be misclassified as `noise`/`info` and hidden.
const loserHasOpaqueChanges = hasOpaqueChanges(loserOps, payloadKey);
const isDeleteWin =
ARCHIVE_PLAN_REASONS.has(planReason) ||
winnerOps.some((op) => op.opType === OpType.Delete);
// Inverse of delete-wins: the LOSER side is a pure DELETE — a delete that lost
// to a concurrent newer edit, so LWW resurrected the entity and the user's
// delete was silently overridden. Because a DELETE op carries no field changes,
// `loserChanges` is empty, which would otherwise misclassify this as `noise`.
// Must be checked BEFORE the noise fallthrough. (delete-wins takes precedence
// when the winner is also a delete, e.g. delete-vs-delete.)
const isDeleteLost = loserOps.some((op) => op.opType === OpType.Delete);
let reason: ConflictJournalReason;
let status: ConflictJournalStatus;
if (isCorruptionSuspected) {
reason = 'clock-corruption-suspected';
status = 'unreviewed';
} else if (isDeleteWin) {
reason = 'delete-wins';
status = 'unreviewed';
} else if (isDeleteLost) {
reason = 'delete-lost';
status = 'unreviewed';
} else if (loserRealFields.length === 0 && !loserHasOpaqueChanges) {
reason = 'noise';
status = 'info';
} else {
reason = localTs === remoteTs ? 'tie' : 'newer';
status = 'unreviewed';
}
const title =
extractEntityTitle(
winnerOps,
winner === 'local' ? localChanges : remoteChanges,
payloadKey,
) ||
extractEntityTitle(
winner === 'local' ? remoteOps : localOps,
loserChanges,
payloadKey,
);
return {
id: uuidv7(),
entityType,
entityId,
entityTitle: title,
resolvedAt: Date.now(),
winner,
reason,
fieldDiffs,
localClientId: localOps[0]?.clientId ?? '',
remoteClientId: remoteOps[0]?.clientId ?? '',
localTs,
remoteTs,
status,
};
};

View file

@ -0,0 +1,278 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { ConflictResolutionService } from './conflict-resolution.service';
import { ConflictJournalService } from './conflict-journal.service';
import { OperationApplierService } from '../apply/operation-applier.service';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import { SnackService } from '../../core/snack/snack.service';
import { ValidateStateService } from '../validation/validate-state.service';
import { OperationLogEffects } from '../capture/operation-log.effects';
import { CLIENT_ID_PROVIDER } from '../util/client-id.provider';
import { buildEntityRegistry, ENTITY_REGISTRY } from '../core/entity-registry';
import { toEntityKey } from '../util/entity-key.util';
import {
ActionType,
EntityConflict,
EntityType,
OpType,
Operation,
VectorClock,
} from '../core/operation.types';
/**
* SPAP-13 Verifies the observe-only journal hook end-to-end through the real
* ConflictResolutionService:
* - a genuine CONCURRENT conflict is journaled exactly once;
* - one-sided / sequential (GREATER_THAN + LESS_THAN) / EQUAL scenarios are
* NEVER even detected as conflicts, so ZERO entries are written;
* - journaling does not change which op LWW picks (observe-only).
*/
describe('ConflictResolution → ConflictJournal hook (integration)', () => {
let service: ConflictResolutionService;
let journal: ConflictJournalService;
let mockStore: jasmine.SpyObj<Store>;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockOperationApplier: jasmine.SpyObj<OperationApplierService>;
const ENTITY_TYPE = 'TASK' as EntityType;
const ENTITY_ID = 'task-1';
const KEY = toEntityKey(ENTITY_TYPE, ENTITY_ID);
const op = (over: Partial<Operation> = {}): Operation => ({
id: `op-${Math.random().toString(36).slice(2)}`,
actionType: '[Task] Update' as ActionType,
opType: OpType.Update,
entityType: ENTITY_TYPE,
entityId: ENTITY_ID,
payload: { task: { id: ENTITY_ID, title: 'x' } },
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 1000,
schemaVersion: 1,
...over,
});
interface Ctx {
localPendingOpsByEntity: Map<string, Operation[]>;
appliedFrontierByEntity: Map<string, VectorClock>;
snapshotVectorClock: VectorClock | undefined;
snapshotEntityKeys: Set<string> | undefined;
hasNoSnapshotClock: boolean;
}
const ctx = (over: Partial<Ctx> = {}): Ctx => ({
localPendingOpsByEntity: new Map(),
appliedFrontierByEntity: new Map(),
snapshotVectorClock: undefined,
snapshotEntityKeys: undefined,
hasNoSnapshotClock: true,
...over,
});
beforeEach(() => {
mockStore = jasmine.createSpyObj('Store', ['select']);
mockStore.select.and.returnValue(of(undefined));
mockOperationApplier = jasmine.createSpyObj('OperationApplierService', [
'applyOperations',
]);
mockOperationApplier.applyOperations.and.resolveTo({ appliedOps: [] });
mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'appendBatchSkipDuplicates',
'appendWithVectorClockUpdate',
'markApplied',
'markRejected',
'markFailed',
'getUnsyncedByEntity',
'mergeRemoteOpClocks',
]);
mockOpLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
mockOpLogStore.getUnsyncedByEntity.and.resolveTo(new Map());
mockOpLogStore.appendWithVectorClockUpdate.and.resolveTo(undefined);
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOpLogStore.appendBatchSkipDuplicates.and.callFake((ops: Operation[]) =>
Promise.resolve({
seqs: ops.map((_, i) => i + 1),
writtenOps: ops,
skippedCount: 0,
}),
);
const mockValidate = jasmine.createSpyObj('ValidateStateService', [
'validateAndRepairCurrentState',
]);
mockValidate.validateAndRepairCurrentState.and.resolveTo(true);
const mockEffects = jasmine.createSpyObj('OperationLogEffects', [
'processDeferredActions',
]);
mockEffects.processDeferredActions.and.resolveTo();
TestBed.configureTestingModule({
providers: [
ConflictResolutionService,
{ provide: Store, useValue: mockStore },
{ provide: OperationApplierService, useValue: mockOperationApplier },
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{
provide: SnackService,
useValue: jasmine.createSpyObj('SnackService', ['open']),
},
{ provide: ValidateStateService, useValue: mockValidate },
{ provide: OperationLogEffects, useValue: mockEffects },
{
provide: CLIENT_ID_PROVIDER,
useValue: { loadClientId: () => Promise.resolve('A') },
},
{ provide: ENTITY_REGISTRY, useValue: buildEntityRegistry() },
],
});
service = TestBed.inject(ConflictResolutionService);
journal = TestBed.inject(ConflictJournalService);
});
it('journals exactly ONE entry for a genuine CONCURRENT conflict', async () => {
// local frontier {A:1}, remote {B:1} → CONCURRENT.
const remoteOp = op({ clientId: 'B', vectorClock: { B: 1 }, timestamp: 1000 });
const localOp = op({
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { task: { id: ENTITY_ID, title: 'Local' } },
});
const detection = await service.checkOpForConflicts(
remoteOp,
ctx({
localPendingOpsByEntity: new Map([[KEY, [localOp]]]),
appliedFrontierByEntity: new Map([[KEY, { A: 1 }]]),
hasNoSnapshotClock: false,
snapshotVectorClock: { A: 1 },
snapshotEntityKeys: new Set([KEY]),
}),
);
expect(detection.conflict).toBeTruthy();
await service.autoResolveConflictsLWW([detection.conflict as EntityConflict]);
const entries = await journal.list('history');
expect(entries.length).toBe(1);
expect(entries[0].entityId).toBe(ENTITY_ID);
expect(entries[0].reason).toBe('newer'); // local ts newer, same field
});
describe('regression guard: NON-conflicts are never detected → ZERO journal entries', () => {
const nonConflictCases: Array<{ name: string; run: () => Promise<unknown> }> = [
{
name: 'sequential GREATER_THAN (local dominates)',
run: () =>
service.checkOpForConflicts(
op({ clientId: 'B', vectorClock: { A: 1 } }),
ctx({
localPendingOpsByEntity: new Map([[KEY, [op({ vectorClock: { A: 2 } })]]]),
appliedFrontierByEntity: new Map([[KEY, { A: 2 }]]),
hasNoSnapshotClock: false,
snapshotVectorClock: { A: 2 },
snapshotEntityKeys: new Set([KEY]),
}),
),
},
{
name: 'sequential LESS_THAN (remote dominates)',
run: () =>
service.checkOpForConflicts(
op({ clientId: 'B', vectorClock: { A: 2 } }),
ctx({
localPendingOpsByEntity: new Map([[KEY, [op({ vectorClock: { A: 1 } })]]]),
appliedFrontierByEntity: new Map([[KEY, { A: 1 }]]),
hasNoSnapshotClock: false,
snapshotVectorClock: { A: 1 },
snapshotEntityKeys: new Set([KEY]),
}),
),
},
{
name: 'EQUAL duplicate',
run: () =>
service.checkOpForConflicts(
op({ clientId: 'B', vectorClock: { A: 1 } }),
ctx({
localPendingOpsByEntity: new Map([[KEY, [op({ vectorClock: { A: 1 } })]]]),
appliedFrontierByEntity: new Map([[KEY, { A: 1 }]]),
hasNoSnapshotClock: false,
snapshotVectorClock: { A: 1 },
snapshotEntityKeys: new Set([KEY]),
}),
),
},
{
name: 'one-sided edit (no pending local ops, no local state)',
run: () =>
service.checkOpForConflicts(
op({ clientId: 'B', vectorClock: { B: 1 } }),
ctx({ snapshotEntityKeys: new Set() }),
),
},
];
nonConflictCases.forEach(({ name, run }) => {
it(`${name} → no conflict, zero entries`, async () => {
const result = (await run()) as { conflict: EntityConflict | null };
expect(result.conflict).toBeNull();
expect((await journal.list('history')).length).toBe(0);
});
});
});
it('observe-only: winner ops are identical whether journaling succeeds or throws', async () => {
const buildConflict = (): EntityConflict => ({
entityType: ENTITY_TYPE,
entityId: ENTITY_ID,
localOps: [
op({
id: 'local-1',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { task: { id: ENTITY_ID, title: 'Local' } },
}),
],
remoteOps: [
op({
id: 'remote-1',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 1000,
payload: { task: { id: ENTITY_ID, title: 'Remote' } },
}),
],
suggestedResolution: 'manual',
});
// Control run: journaling works normally.
await service.autoResolveConflictsLWW([buildConflict()]);
const controlRejected = mockOpLogStore.markRejected.calls.allArgs();
const controlAppended = mockOpLogStore.appendWithVectorClockUpdate.calls
.allArgs()
.map(([o]) => (o as Operation).entityId);
mockOpLogStore.markRejected.calls.reset();
mockOpLogStore.appendWithVectorClockUpdate.calls.reset();
// Sabotaged run: force record() to reject — resolution must be unaffected.
spyOn(journal, 'record').and.rejectWith(new Error('journal boom'));
await service.autoResolveConflictsLWW([buildConflict()]);
const sabotagedRejected = mockOpLogStore.markRejected.calls.allArgs();
const sabotagedAppended = mockOpLogStore.appendWithVectorClockUpdate.calls
.allArgs()
.map(([o]) => (o as Operation).entityId);
expect(sabotagedRejected).toEqual(controlRejected);
expect(sabotagedAppended).toEqual(controlAppended);
});
});

View file

@ -0,0 +1,164 @@
/**
* SPAP-13 Conflict Journal data model (SPAP-12 schema).
*
* A device-local record of every sync-conflict auto-resolution, so a losing
* edit is preserved and reviewable later. Purely observational: writing a
* journal entry never influences which operation LWW conflict resolution picks.
*
* Journal entries are DEVICE-LOCAL and MUST NEVER be synced they capture the
* discarded (losing) side of a conflict verbatim, which is exactly the data the
* op-log intentionally dropped. See `conflict-journal.service.ts`.
*/
import { EntityType } from '../core/operation.types';
/**
* Which side of the conflict LWW kept.
* - `local` / `remote`: whole-entity LWW winner (SPAP-13).
* - `merged`: disjoint-field auto-merge (SPAP-14 reserved, not emitted here).
*/
export type ConflictJournalWinner = 'local' | 'remote' | 'merged';
/**
* Why the conflict resolved the way it did (SPAP-12 taxonomy).
* - `newer`: both edited the same real field, newer timestamp won.
* - `tie`: both edited the same real field, timestamps equal remote won.
* - `delete-wins`: an edit lost to a delete/archive of the same entity.
* - `delete-lost`: the inverse a delete lost to a concurrent newer edit, so the
* entity was resurrected and the user's delete was silently overridden. The
* loser side is a pure DELETE op (no field changes), so without this reason it
* would fall through to `noise`/`info` and never surface for review.
* - `disjoint-merge`: disjoint-field auto-merge (SPAP-14 reserved, NOT emitted
* by SPAP-13; the value exists so downstream code can already switch on it).
* - `noise`: the discarded side only touched NOISE_FIELDS (no real content lost).
* - `clock-corruption-suspected`: the conflict only arose because per-entity
* clock corruption forced a CONCURRENT comparison (see `_adjustForClockCorruption`).
*/
export type ConflictJournalReason =
| 'newer'
| 'tie'
| 'delete-wins'
| 'delete-lost'
| 'disjoint-merge'
| 'noise'
| 'clock-corruption-suspected';
/**
* Review lifecycle of an entry.
* - `unreviewed`: a real edit was discarded; awaiting user review.
* - `kept`: user confirmed the auto-resolution.
* - `flipped`: user chose the discarded side instead (SPAP-15+ applies it).
* - `info`: informational only (e.g. `noise`) no real data lost.
* - `expired`: retention window elapsed (reserved).
*/
export type ConflictJournalStatus =
| 'unreviewed'
| 'kept'
| 'flipped'
| 'info'
| 'expired';
/**
* One field-level diff between the two conflicting sides. Stores only the field
* values (not whole entities). `localVal`/`remoteVal` are captured VERBATIM from
* the respective op payloads so the loser's discarded values are preserved.
*/
export interface ConflictJournalFieldDiff {
field: string;
localVal: unknown;
remoteVal: unknown;
/**
* Whether each side's ops actually changed this field. Distinguishes "this
* side never touched the field" from "this side set it to a value" without
* the flags a union diff stores the untouched side as `undefined`, and a flip
* would then dispatch `{ field: undefined }`, clearing a winner-only field.
* Optional because entries persisted before the flags existed lack them;
* readers fall back to value-presence (`val !== undefined`), which is exact
* for legacy data since op payloads are pure JSON and cannot encode a real
* `undefined`.
*/
localChanged?: boolean;
remoteChanged?: boolean;
/** Which side's value LWW kept for this field. Absent for `merged`. */
pickedSide?: 'local' | 'remote';
/**
* `action`: not an entity field `field` is the ACTION TYPE and the side
* values are the raw action payload of a non-adapter op whose field-level
* delta could not be extracted (e.g. `convertToSubTask`'s
* `{ taskId, targetParentId, afterTaskId }`). Preserved verbatim so the
* discarded change stays reviewable; excluded from flip / stale-guard
* computations, which only operate on real entity fields. Absent = `field`.
*/
kind?: 'action';
}
/**
* A single conflict-journal record (SPAP-12 data model). Device-local, never synced.
*/
export interface ConflictJournalEntry {
id: string;
entityType: EntityType;
entityId: string;
entityTitle: string;
resolvedAt: number;
winner: ConflictJournalWinner;
reason: ConflictJournalReason;
fieldDiffs: ConflictJournalFieldDiff[];
localClientId: string;
remoteClientId: string;
localTs: number;
remoteTs: number;
status: ConflictJournalStatus;
}
/** The two views the service can list. */
export type ConflictJournalView = 'unreviewed' | 'history';
// ─────────────────────────────────────────────────────────────────────────────
// Retention (pruned on app-start; whichever bound binds first)
// ─────────────────────────────────────────────────────────────────────────────
/** Entries older than this many days are pruned on start. */
export const JOURNAL_RETENTION_DAYS = 14;
/** At most this many entries are kept (newest wins) — pruned on start. */
export const JOURNAL_MAX_ENTRIES = 200;
// ─────────────────────────────────────────────────────────────────────────────
// NOISE_FIELDS
//
// Fields whose divergence is NOT a real user content edit: last-modified /
// metadata timestamps. When the discarded (losing) side of a conflict changed
// ONLY these fields, no real content was lost, so the entry is journaled as
// `noise`/`info` rather than a content loss.
//
// NOTE (SPAP-13): the list-ordering arrays (`taskIds`, `subTaskIds`,
// `backlogTaskIds`, `noteIds`) are DELIBERATELY NOT noise. They carry
// MEMBERSHIP as well as order — a task added/removed on one device rewrites the
// parent's `taskIds`, and blanket-classifying that as noise would silently hide
// a membership loss (the added item dropped by LWW) as `info`. Erring toward
// surfacing, an overlap on these fields is journaled as a reviewable conflict.
// A future, set-aware refinement (noise only when membership is unchanged and
// just the order differs) can revisit this — see SPAP-14.
// Timestamp field is `modified` on TASK (task.model.ts); `lastModified` /
// `created` are included defensively as conventional metadata names.
// ─────────────────────────────────────────────────────────────────────────────
export const NOISE_FIELDS: ReadonlySet<string> = new Set<string>([
'modified',
'lastModified',
'created',
]);
// ─────────────────────────────────────────────────────────────────────────────
// IndexedDB store identity
//
// A NEW, standalone database — completely separate from the op-log `SUP_OPS`
// DB so the journal cannot affect op-log schema/versioning or risk its data.
// ─────────────────────────────────────────────────────────────────────────────
export const CONFLICT_JOURNAL_DB_NAME = 'SUP_CONFLICT_JOURNAL';
export const CONFLICT_JOURNAL_DB_VERSION = 1;
export const CONFLICT_JOURNAL_STORE = 'conflicts';
export const CONFLICT_JOURNAL_INDEX_STATUS = 'by-status';
export const CONFLICT_JOURNAL_INDEX_RESOLVED_AT = 'by-resolvedAt';

View file

@ -0,0 +1,596 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { ConflictJournalService } from './conflict-journal.service';
import {
ConflictJournalEntry,
JOURNAL_MAX_ENTRIES,
JOURNAL_RETENTION_DAYS,
} from './conflict-journal.model';
import { buildConflictJournalEntry } from './conflict-journal-emission.util';
import { ActionType, EntityType, OpType, Operation } from '../core/operation.types';
import { uuidv7 } from '../../util/uuid-v7';
const DAY_MS = 24 * 60 * 60 * 1000;
const staleOffsetMs = (days: number): number => days * DAY_MS;
const makeEntry = (over: Partial<ConflictJournalEntry> = {}): ConflictJournalEntry => ({
id: uuidv7(),
entityType: 'TASK' as EntityType,
entityId: 'task-1',
entityTitle: 'Test Task',
resolvedAt: Date.now(),
winner: 'remote',
reason: 'newer',
fieldDiffs: [],
localClientId: 'A',
remoteClientId: 'B',
localTs: 1000,
remoteTs: 2000,
status: 'unreviewed',
...over,
});
describe('ConflictJournalService (store)', () => {
let service: ConflictJournalService;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [ConflictJournalService] });
service = TestBed.inject(ConflictJournalService);
});
it('records and reads back an entry', async () => {
const entry = makeEntry({ id: 'e1' });
await service.record(entry);
const read = await service.getEntry('e1');
expect(read).toBeTruthy();
expect(read?.id).toBe('e1');
expect(read?.reason).toBe('newer');
});
it('list("history") returns everything newest-first; list("unreviewed") filters', async () => {
await service.record(
makeEntry({ id: 'old', resolvedAt: 1000, status: 'unreviewed' }),
);
await service.record(makeEntry({ id: 'mid', resolvedAt: 2000, status: 'info' }));
await service.record(
makeEntry({ id: 'new', resolvedAt: 3000, status: 'unreviewed' }),
);
const history = await service.list('history');
expect(history.map((e) => e.id)).toEqual(['new', 'mid', 'old']);
const unreviewed = await service.list('unreviewed');
expect(unreviewed.map((e) => e.id)).toEqual(['new', 'old']);
});
it('unreviewedCount$ reflects the number of unreviewed entries', async () => {
expect(await firstValueFrom(service.unreviewedCount$)).toBe(0);
await service.record(makeEntry({ id: 'a', status: 'unreviewed' }));
await service.record(makeEntry({ id: 'b', status: 'unreviewed' }));
await service.record(makeEntry({ id: 'c', status: 'info' }));
expect(await firstValueFrom(service.unreviewedCount$)).toBe(2);
});
it('markKept / markFlipped update status and the unreviewed count', async () => {
await service.record(makeEntry({ id: 'a', status: 'unreviewed' }));
await service.record(makeEntry({ id: 'b', status: 'unreviewed' }));
await service.markKept('a');
await service.markFlipped('b');
expect((await service.getEntry('a'))?.status).toBe('kept');
expect((await service.getEntry('b'))?.status).toBe('flipped');
expect(await firstValueFrom(service.unreviewedCount$)).toBe(0);
});
it('clearAll() removes every entry and resets the unreviewed count (profile switch)', async () => {
await service.record(makeEntry({ id: 'a', status: 'unreviewed' }));
await service.record(makeEntry({ id: 'b', status: 'kept' }));
expect(await firstValueFrom(service.unreviewedCount$)).toBe(1);
await service.clearAll();
expect(await service.getEntry('a')).toBeUndefined();
expect(await service.getEntry('b')).toBeUndefined();
expect((await service.list('history')).length).toBe(0);
expect(await firstValueFrom(service.unreviewedCount$)).toBe(0);
});
describe('retention (pruneOnStart)', () => {
it('prunes an entry older than JOURNAL_RETENTION_DAYS and keeps a fresh one', async () => {
const now = Date.now();
await service.record(
makeEntry({
id: 'stale',
resolvedAt: now - staleOffsetMs(JOURNAL_RETENTION_DAYS + 1),
}),
);
await service.record(makeEntry({ id: 'fresh', resolvedAt: now }));
const deleted = await service.pruneOnStart(now);
expect(deleted).toBe(1);
expect(await service.getEntry('stale')).toBeUndefined();
expect(await service.getEntry('fresh')).toBeTruthy();
});
it('prunes stale kept/flipped entries exactly like others', async () => {
const now = Date.now();
const oldTs = now - staleOffsetMs(JOURNAL_RETENTION_DAYS + 5);
await service.record(
makeEntry({ id: 'kept-old', resolvedAt: oldTs, status: 'kept' }),
);
await service.record(
makeEntry({ id: 'flipped-old', resolvedAt: oldTs, status: 'flipped' }),
);
const deleted = await service.pruneOnStart(now);
expect(deleted).toBe(2);
expect(await service.getEntry('kept-old')).toBeUndefined();
expect(await service.getEntry('flipped-old')).toBeUndefined();
});
it('prunes the oldest overflow beyond JOURNAL_MAX_ENTRIES (the 201st entry)', async () => {
const now = Date.now();
// JOURNAL_MAX_ENTRIES + 1 fresh entries, oldest = index 0.
for (let i = 0; i <= JOURNAL_MAX_ENTRIES; i++) {
await service.record(
makeEntry({ id: `entry-${i}`, resolvedAt: now - (JOURNAL_MAX_ENTRIES - i) }),
);
}
const deleted = await service.pruneOnStart(now);
expect(deleted).toBe(1);
// The single oldest entry is gone; exactly JOURNAL_MAX_ENTRIES remain.
expect(await service.getEntry('entry-0')).toBeUndefined();
expect((await service.list('history')).length).toBe(JOURNAL_MAX_ENTRIES);
expect(await service.getEntry(`entry-${JOURNAL_MAX_ENTRIES}`)).toBeTruthy();
});
});
describe('failure hardening (never-throw contract)', () => {
it('list/getEntry/markKept/markFlipped degrade to safe defaults when the DB cannot open', async () => {
// list() is awaited inside conflict resolution's notification step — a
// DB failure must degrade to "no entries", never reject into the sync.
spyOn(globalThis.indexedDB, 'open').and.throwError('idb down');
await expectAsync(service.list('history')).toBeResolvedTo([]);
await expectAsync(service.getEntry('nope')).toBeResolvedTo(undefined);
await expectAsync(service.markKept('nope')).toBeResolved();
await expectAsync(service.markFlipped('nope')).toBeResolved();
});
it('recovers after an abnormal DB termination (handles reset, next call reopens)', async () => {
await service.record(makeEntry({ id: 'before-term' }));
// Fire the `close` event idb's `terminated` option listens for (what the
// browser dispatches on abnormal termination). This pins the wiring
// itself: if the `terminated` callback were removed, the handles would
// survive and the expectation below fails. fake-indexeddb's dispatchEvent
// rejects DOM Events (it checks its own FakeEvent flags), so this is a
// minimal duck-typed FakeEvent; the distinct phase constants matter —
// all-undefined phases make its `stopped()` check skip the listener.
const db = await service['_ensureDb']();
db.dispatchEvent({
type: 'close',
initialized: true,
dispatched: false,
eventPath: [],
bubbles: false,
CAPTURING_PHASE: 1,
AT_TARGET: 2,
BUBBLING_PHASE: 3,
} as unknown as Event);
expect(service['_db']).toBeUndefined();
// Next call reopens instead of failing on a dead connection.
await service.record(makeEntry({ id: 'after-term' }));
const ids = (await service.list('history')).map((e) => e.id);
expect(ids).toContain('before-term');
expect(ids).toContain('after-term');
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Taxonomy classification (pure) — one entry per SPAP-12 taxonomy row.
// ─────────────────────────────────────────────────────────────────────────────
describe('buildConflictJournalEntry (taxonomy)', () => {
const resolvePayloadKey = (): string => 'task';
const op = (over: Partial<Operation> = {}): Operation => ({
id: uuidv7(),
actionType: '[Task] Update' as ActionType,
opType: OpType.Update,
entityType: 'TASK' as EntityType,
entityId: 'task-1',
payload: { task: { id: 'task-1' } },
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 1000,
schemaVersion: 1,
...over,
});
it('same-field edit, local newer → reason "newer", status "unreviewed"', () => {
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'local',
planReason: 'local-timestamp',
localOps: [
op({ payload: { task: { id: 'task-1', title: 'Local' } }, timestamp: 2000 }),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', title: 'Remote' } },
timestamp: 1000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
expect(entry.reason).toBe('newer');
expect(entry.status).toBe('unreviewed');
expect(entry.winner).toBe('local');
const titleDiff = entry.fieldDiffs.find((d) => d.field === 'title');
expect(titleDiff).toEqual({
field: 'title',
localVal: 'Local',
remoteVal: 'Remote',
localChanged: true,
remoteChanged: true,
pickedSide: 'local',
});
});
it('classifies a lost non-adapter structural action as a real loss, not noise', () => {
// Production shape of convertToSubTask: the mutation lives in
// { taskId, targetParentId, afterTaskId } (no adapter { task: { changes } })
// and OperationCaptureService emits entityChanges: []. The discarded
// structural move must surface as unreviewed and keep its payload visible.
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote',
planReason: 'remote-timestamp-or-tie',
localOps: [
op({
actionType: '[Task] Convert to sub task' as ActionType,
payload: {
actionPayload: {
taskId: 'task-1',
targetParentId: 'parent-1',
afterTaskId: null,
},
entityChanges: [],
},
timestamp: 1000,
}),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', title: 'Remote' } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
expect(entry.reason).not.toBe('noise');
expect(entry.status).toBe('unreviewed');
// The discarded action payload is preserved verbatim for review.
const actionDiff = entry.fieldDiffs.find((d) => d.kind === 'action');
expect(actionDiff?.field).toBe('[Task] Convert to sub task');
expect(actionDiff?.localVal).toEqual({
taskId: 'task-1',
targetParentId: 'parent-1',
afterTaskId: null,
});
expect(actionDiff?.localChanged).toBe(true);
expect(actionDiff?.remoteChanged).toBe(false);
});
it('uses capture-time entityChanges as the delta source for non-adapter payloads', () => {
// syncTimeSpent: reducer-relevant fields live in entityChanges, not in an
// adapter-shaped actionPayload. The loser's real fields must be read from
// there instead of misclassifying the loss as noise.
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote',
planReason: 'remote-timestamp-or-tie',
localOps: [
op({
actionType: '[TimeTracking] Sync time spent' as ActionType,
payload: {
actionPayload: { taskId: 'task-1', date: '2026-07-10', duration: 100 },
entityChanges: [
{
entityType: 'TASK' as EntityType,
entityId: 'task-1',
opType: OpType.Update,
changes: { taskId: 'task-1', date: '2026-07-10', duration: 100 },
},
],
},
timestamp: 1000,
}),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', title: 'Remote' } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
expect(entry.reason).toBe('newer');
expect(entry.status).toBe('unreviewed');
const durationDiff = entry.fieldDiffs.find((d) => d.field === 'duration');
expect(durationDiff?.localVal).toBe(100);
expect(durationDiff?.localChanged).toBe(true);
});
it('records per-side presence so winner-only fields are not attributed to the loser', () => {
// local (loser) changed only title; remote (winner) changed title + notes.
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote',
planReason: 'remote-timestamp-or-tie',
localOps: [
op({ payload: { task: { id: 'task-1', title: 'Local' } }, timestamp: 1000 }),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', title: 'Remote', notes: 'Remote notes' } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
const titleDiff = entry.fieldDiffs.find((d) => d.field === 'title');
expect(titleDiff?.localChanged).toBe(true);
expect(titleDiff?.remoteChanged).toBe(true);
const notesDiff = entry.fieldDiffs.find((d) => d.field === 'notes');
expect(notesDiff?.localChanged).toBe(false);
expect(notesDiff?.remoteChanged).toBe(true);
});
it('same-field edit, equal timestamps, remote wins → reason "tie"', () => {
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote',
planReason: 'remote-timestamp-or-tie',
localOps: [
op({ payload: { task: { id: 'task-1', title: 'Local' } }, timestamp: 1000 }),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', title: 'Remote' } },
timestamp: 1000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
expect(entry.reason).toBe('tie');
expect(entry.status).toBe('unreviewed');
});
it('edit vs delete (delete wins) → reason "delete-wins", status "unreviewed"', () => {
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote',
planReason: 'remote-timestamp-or-tie',
localOps: [
op({ payload: { task: { id: 'task-1', title: 'Local' } }, timestamp: 1000 }),
],
remoteOps: [
op({
opType: OpType.Delete,
actionType: '[Task] Delete' as ActionType,
payload: { task: { id: 'task-1' } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
expect(entry.reason).toBe('delete-wins');
expect(entry.status).toBe('unreviewed');
});
it('archive plan reason → reason "delete-wins"', () => {
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'local',
planReason: 'local-archive',
localOps: [
op({
actionType: '[Task] Move to archive' as ActionType,
payload: { task: { id: 'task-1', title: 'Local' } },
timestamp: 1000,
}),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', title: 'Remote' } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
expect(entry.reason).toBe('delete-wins');
});
it('delete vs edit (edit wins, delete lost) → reason "delete-lost", status "unreviewed"', () => {
// Local deleted the task; remote edited it concurrently and newer, so LWW
// resurrects the entity and the local DELETE is discarded. The loser side is
// a pure Delete op (no field changes) — without the delete-lost branch this
// would fall through to `noise`/`info` and never surface for review.
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote', // the edit side won
planReason: 'remote-timestamp-or-tie',
localOps: [
op({
opType: OpType.Delete,
actionType: '[Task] Delete' as ActionType,
payload: { task: { id: 'task-1' } },
timestamp: 1000,
}),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', title: 'Remote' } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
expect(entry.reason).toBe('delete-lost');
expect(entry.status).toBe('unreviewed');
expect(entry.winner).toBe('remote');
});
it('loser changed only NOISE fields → reason "noise", status "info"', () => {
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote',
planReason: 'remote-timestamp-or-tie',
// loser (local) only bumped `modified` — a NOISE field: nothing real lost.
localOps: [
op({ payload: { task: { id: 'task-1', modified: 111 } }, timestamp: 1000 }),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', modified: 222 } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
expect(entry.reason).toBe('noise');
expect(entry.status).toBe('info');
});
it('loser changed an ordering/membership array (subTaskIds) → reviewable, NOT noise (SPAP-13 safety)', () => {
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote',
planReason: 'remote-timestamp-or-tie',
// loser (local) changed subTaskIds — membership-bearing, so deliberately
// NOT a NOISE field: a dropped member must surface as reviewable, not info.
localOps: [
op({
payload: { task: { id: 'task-1', subTaskIds: ['s1', 's2'] } },
timestamp: 1000,
}),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', subTaskIds: ['s1'] } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
expect(entry.reason).toBe('newer');
expect(entry.status).toBe('unreviewed');
});
it('clock-corruption escalation → reason "clock-corruption-suspected", status "unreviewed"', () => {
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote',
planReason: 'remote-timestamp-or-tie',
localOps: [
op({ payload: { task: { id: 'task-1', title: 'Local' } }, timestamp: 1000 }),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', title: 'Remote' } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: true,
resolvePayloadKey,
});
expect(entry.reason).toBe('clock-corruption-suspected');
expect(entry.status).toBe('unreviewed');
});
it('preserves the LOSER values verbatim (incl. nested objects)', () => {
const discarded = { steps: ['a', 'b'], nested: { x: 1 } };
const entry = buildConflictJournalEntry({
entityType: 'TASK' as EntityType,
entityId: 'task-1',
winner: 'remote', // local is the loser
planReason: 'remote-timestamp-or-tie',
localOps: [
op({
payload: { task: { id: 'task-1', title: 'Loser', notes: discarded } },
timestamp: 1000,
}),
],
remoteOps: [
op({
payload: { task: { id: 'task-1', title: 'Winner' } },
timestamp: 2000,
clientId: 'B',
}),
],
isCorruptionSuspected: false,
resolvePayloadKey,
});
const notesDiff = entry.fieldDiffs.find((d) => d.field === 'notes');
expect(notesDiff?.localVal).toEqual(discarded);
const titleDiff = entry.fieldDiffs.find((d) => d.field === 'title');
expect(titleDiff?.localVal).toBe('Loser');
expect(titleDiff?.remoteVal).toBe('Winner');
});
});

View file

@ -0,0 +1,275 @@
/**
* SPAP-13 Conflict Journal service.
*
* Owns a NEW, standalone IndexedDB database (`SUP_CONFLICT_JOURNAL`) that is
* completely separate from the op-log `SUP_OPS` DB, so journaling can never
* touch op-log schema/versioning or risk its data.
*
* OBSERVE-ONLY: `record()` is called from the LWW resolution hook purely to log
* what happened; it NEVER throws back into resolution (any DB failure is logged
* and swallowed) and never influences which op was picked.
*
* The never-throw contract covers EVERY public method, not just `record()`:
* `list()` is awaited (via the summary banner) inside
* `autoResolveConflictsLWW`'s notification step i.e. AFTER ops were applied
* so a journal read failure must degrade to "no entries", never fail the sync.
* Reads fall back to empty results, status writes are swallowed; only the
* badge/review surface degrades.
*
* Journal entries are DEVICE-LOCAL and are NEVER uploaded to the sync server.
*/
import { Injectable } from '@angular/core';
import { DBSchema, IDBPDatabase, openDB } from 'idb';
import { BehaviorSubject, Observable } from 'rxjs';
import { OpLog } from '../../core/log';
import {
CONFLICT_JOURNAL_DB_NAME,
CONFLICT_JOURNAL_DB_VERSION,
CONFLICT_JOURNAL_INDEX_RESOLVED_AT,
CONFLICT_JOURNAL_INDEX_STATUS,
CONFLICT_JOURNAL_STORE,
ConflictJournalEntry,
ConflictJournalStatus,
ConflictJournalView,
JOURNAL_MAX_ENTRIES,
JOURNAL_RETENTION_DAYS,
} from './conflict-journal.model';
interface ConflictJournalDB extends DBSchema {
[CONFLICT_JOURNAL_STORE]: {
key: string;
value: ConflictJournalEntry;
indexes: {
[CONFLICT_JOURNAL_INDEX_STATUS]: ConflictJournalStatus;
[CONFLICT_JOURNAL_INDEX_RESOLVED_AT]: number;
};
};
}
const DAY_MS = 24 * 60 * 60 * 1000;
@Injectable({
providedIn: 'root',
})
export class ConflictJournalService {
private _db?: IDBPDatabase<ConflictJournalDB>;
private _initPromise?: Promise<IDBPDatabase<ConflictJournalDB>>;
private readonly _unreviewedCount$ = new BehaviorSubject<number>(0);
/** Number of entries still awaiting review (status === 'unreviewed'). */
readonly unreviewedCount$: Observable<number> = this._unreviewedCount$.asObservable();
private _openDb(): Promise<IDBPDatabase<ConflictJournalDB>> {
return openDB<ConflictJournalDB>(
CONFLICT_JOURNAL_DB_NAME,
CONFLICT_JOURNAL_DB_VERSION,
{
upgrade: (db) => {
if (!db.objectStoreNames.contains(CONFLICT_JOURNAL_STORE)) {
const store = db.createObjectStore(CONFLICT_JOURNAL_STORE, {
keyPath: 'id',
});
store.createIndex(CONFLICT_JOURNAL_INDEX_STATUS, 'status');
store.createIndex(CONFLICT_JOURNAL_INDEX_RESOLVED_AT, 'resolvedAt');
}
},
// Abnormal closure (browser force-closes the connection, e.g. storage
// pressure): without this the memoized `_db` handle stays dead and every
// later call fails for the rest of the session. Dropping the handles
// lets the next call reopen.
terminated: () => this._resetDbHandles(),
},
);
}
private _resetDbHandles(): void {
this._db = undefined;
this._initPromise = undefined;
}
private async _ensureDb(): Promise<IDBPDatabase<ConflictJournalDB>> {
if (this._db) {
return this._db;
}
if (!this._initPromise) {
this._initPromise = this._openDb().then(
(db) => {
this._db = db;
return db;
},
(err) => {
// Don't poison the service: a transient open failure must not leave a
// permanently-rejected `_initPromise` cached (every later call would
// then reject). Clear it so the next `_ensureDb` retries the open.
this._initPromise = undefined;
throw err;
},
);
}
return this._initPromise;
}
/**
* Records one conflict-journal entry. OBSERVE-ONLY contract: on ANY failure it
* logs and returns normally it must never throw back into LWW resolution.
*/
async record(entry: ConflictJournalEntry): Promise<void> {
try {
const db = await this._ensureDb();
await db.put(CONFLICT_JOURNAL_STORE, entry);
await this._refreshUnreviewedCount(db);
} catch (err) {
OpLog.err('ConflictJournalService: failed to record entry (ignored)', err);
}
}
/**
* Lists entries newest-first.
* - `unreviewed`: only entries still awaiting review.
* - `history`: everything.
*
* Never throws: the banner path awaits this inside conflict resolution's
* notification step, so a transient DB failure degrades to an empty list
* (badge/banner miss a beat) instead of failing an otherwise-completed sync.
*/
async list(view: ConflictJournalView): Promise<ConflictJournalEntry[]> {
try {
const db = await this._ensureDb();
const ascending = await db.getAllFromIndex(
CONFLICT_JOURNAL_STORE,
CONFLICT_JOURNAL_INDEX_RESOLVED_AT,
);
const newestFirst = ascending.reverse();
if (view === 'unreviewed') {
return newestFirst.filter((entry) => entry.status === 'unreviewed');
}
return newestFirst;
} catch (err) {
OpLog.err('ConflictJournalService: list failed (returning empty)', err);
return [];
}
}
/** Never throws — a failed lookup reads as "no such entry". */
async getEntry(id: string): Promise<ConflictJournalEntry | undefined> {
try {
const db = await this._ensureDb();
return await db.get(CONFLICT_JOURNAL_STORE, id);
} catch (err) {
OpLog.err('ConflictJournalService: getEntry failed (ignored)', err);
return undefined;
}
}
/** User confirmed the auto-resolution. */
async markKept(id: string): Promise<void> {
await this._setStatus(id, 'kept');
}
/** User wants the discarded side instead (application is a later subtask). */
async markFlipped(id: string): Promise<void> {
await this._setStatus(id, 'flipped');
}
/**
* Never throws (same contract as `record()`): a failed status write leaves
* the entry unreviewed the user can simply Keep/Flip it again which beats
* an unhandled rejection in the review page's action handlers.
*/
private async _setStatus(id: string, status: ConflictJournalStatus): Promise<void> {
try {
const db = await this._ensureDb();
const entry = await db.get(CONFLICT_JOURNAL_STORE, id);
if (!entry) {
return;
}
await db.put(CONFLICT_JOURNAL_STORE, { ...entry, status });
await this._refreshUnreviewedCount(db);
} catch (err) {
OpLog.err(`ConflictJournalService: failed to mark entry ${status} (ignored)`, err);
}
}
/**
* Prunes on app-start to whichever bound binds first: entries older than
* {@link JOURNAL_RETENTION_DAYS} days, OR everything beyond the newest
* {@link JOURNAL_MAX_ENTRIES}. kept/flipped entries prune exactly like any
* other. Returns the number of entries deleted.
*/
async pruneOnStart(now: number = Date.now()): Promise<number> {
// Observe-only, like record(): pruneOnStart is the sole app-start seeder of
// the badge count (its _refreshUnreviewedCount), and its main.ts caller
// relies on it swallowing its own errors. A transient IndexedDB failure must
// return 0, not reject — and _ensureDb already resets its poisoned promise.
try {
const db = await this._ensureDb();
// Ascending by resolvedAt (oldest first).
const ascending = await db.getAllFromIndex(
CONFLICT_JOURNAL_STORE,
CONFLICT_JOURNAL_INDEX_RESOLVED_AT,
);
const retentionWindowMs = JOURNAL_RETENTION_DAYS * DAY_MS;
const cutoff = now - retentionWindowMs;
const idsToDelete = new Set<string>();
for (const entry of ascending) {
if (entry.resolvedAt < cutoff) {
idsToDelete.add(entry.id);
}
}
// Count bound applies to the survivors of the age prune; drop the oldest
// overflow so only the newest JOURNAL_MAX_ENTRIES remain.
const survivors = ascending.filter((entry) => !idsToDelete.has(entry.id));
const overflow = survivors.length - JOURNAL_MAX_ENTRIES;
for (let i = 0; i < overflow; i++) {
idsToDelete.add(survivors[i].id);
}
if (idsToDelete.size > 0) {
const tx = db.transaction(CONFLICT_JOURNAL_STORE, 'readwrite');
await Promise.all(Array.from(idsToDelete, (id) => tx.store.delete(id)));
await tx.done;
}
await this._refreshUnreviewedCount(db);
return idsToDelete.size;
} catch (err) {
OpLog.err('ConflictJournalService: pruneOnStart failed (ignored)', err);
return 0;
}
}
/**
* Deletes EVERY journal entry. Called on user-profile transitions: profiles
* are "complete, isolated instances", and the journal is a device-local
* side-store the profile switch's backup/import cycle does not otherwise
* touch without this, the next profile would see the previous profile's
* entity titles/values and could Flip against the wrong dataset.
*
* Same swallow-errors contract as `record()`: the caller (profile switch)
* must not fail after the dataset has already been replaced.
*/
async clearAll(): Promise<void> {
try {
const db = await this._ensureDb();
await db.clear(CONFLICT_JOURNAL_STORE);
await this._refreshUnreviewedCount(db);
} catch (err) {
OpLog.err('ConflictJournalService: clearAll failed (ignored)', err);
}
}
private async _refreshUnreviewedCount(
db: IDBPDatabase<ConflictJournalDB>,
): Promise<void> {
const count = await db.countFromIndex(
CONFLICT_JOURNAL_STORE,
CONFLICT_JOURNAL_INDEX_STATUS,
IDBKeyRange.only('unreviewed'),
);
this._unreviewedCount$.next(count);
}
}

View file

@ -0,0 +1,745 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ConflictResolutionService } from './conflict-resolution.service';
import { ConflictJournalService } from './conflict-journal.service';
import { Store } from '@ngrx/store';
import { OperationApplierService } from '../apply/operation-applier.service';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import { SnackService } from '../../core/snack/snack.service';
import { ValidateStateService } from '../validation/validate-state.service';
import { OperationLogEffects } from '../capture/operation-log.effects';
import { CLIENT_ID_PROVIDER } from '../util/client-id.provider';
import { buildEntityRegistry, ENTITY_REGISTRY } from '../core/entity-registry';
import { ActionType, EntityConflict, OpType, Operation } from '../core/operation.types';
import {
compareVectorClocks,
incrementVectorClock,
mergeVectorClocks,
VectorClockComparison,
} from '../../core/util/vector-clock';
import {
synthesizeMergedChanges,
isDisjointMergeEligible,
} from './conflict-disjoint-merge.util';
/**
* SPAP-14 disjoint-field auto-merge acceptance tests.
*
* (a) title-vs-notes concurrent edit merged entity keeps BOTH; journal
* merged/disjoint-merge/info; not in unreviewed.
* (b) title-vs-title (same field) LWW unchanged; journal unreviewed.
* (c) disjoint real fields + both bumped a NOISE field still merges; noise
* field resolved deterministically.
* (d) edit-vs-delete delete wins, NO merge.
* (e) two-client convergence: both orderings yield identical entity + dominating
* clocks.
*/
describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
let service: ConflictResolutionService;
let journal: ConflictJournalService;
let mockStore: jasmine.SpyObj<Store>;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockOperationApplier: jasmine.SpyObj<OperationApplierService>;
const CLIENT_ID = 'client-local';
const op = (over: Partial<Operation> = {}): Operation => ({
id: `op-${Math.random().toString(36).slice(2)}`,
clientId: 'A',
actionType: '[Task] Update' as ActionType,
opType: OpType.Update,
entityType: 'TASK',
entityId: 'task-1',
payload: { task: { id: 'task-1', changes: {} } },
vectorClock: { A: 1 },
timestamp: 1000,
schemaVersion: 1,
...over,
});
const conflictOf = (localOps: Operation[], remoteOps: Operation[]): EntityConflict => ({
entityType: 'TASK',
entityId: 'task-1',
localOps,
remoteOps,
suggestedResolution: 'manual',
});
const mergedOpArgs = (): Operation | undefined =>
mockOpLogStore.appendWithVectorClockUpdate.calls
.allArgs()
.map(([o]) => o as Operation)
.find((o) => o.entityId === 'task-1' && o.opType === OpType.Update);
beforeEach(() => {
mockStore = jasmine.createSpyObj('Store', ['select']);
mockStore.select.and.returnValue(of(undefined));
mockOperationApplier = jasmine.createSpyObj('OperationApplierService', [
'applyOperations',
]);
mockOperationApplier.applyOperations.and.resolveTo({ appliedOps: [] });
mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'appendBatchSkipDuplicates',
'appendWithVectorClockUpdate',
'markApplied',
'markRejected',
'markFailed',
'getUnsyncedByEntity',
'mergeRemoteOpClocks',
]);
mockOpLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
mockOpLogStore.getUnsyncedByEntity.and.resolveTo(new Map());
mockOpLogStore.markRejected.and.resolveTo(undefined);
mockOpLogStore.markApplied.and.resolveTo(undefined);
mockOpLogStore.appendWithVectorClockUpdate.and.resolveTo(1);
mockOpLogStore.appendBatchSkipDuplicates.and.callFake((ops: Operation[]) =>
Promise.resolve({
seqs: ops.map((_, i) => i + 1),
writtenOps: ops,
skippedCount: 0,
}),
);
const mockValidate = jasmine.createSpyObj('ValidateStateService', [
'validateAndRepairCurrentState',
]);
mockValidate.validateAndRepairCurrentState.and.resolveTo(true);
const mockEffects = jasmine.createSpyObj('OperationLogEffects', [
'processDeferredActions',
]);
mockEffects.processDeferredActions.and.resolveTo();
TestBed.configureTestingModule({
providers: [
ConflictResolutionService,
{ provide: Store, useValue: mockStore },
{ provide: OperationApplierService, useValue: mockOperationApplier },
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{
provide: SnackService,
useValue: jasmine.createSpyObj('SnackService', ['open']),
},
{ provide: ValidateStateService, useValue: mockValidate },
{ provide: OperationLogEffects, useValue: mockEffects },
{
provide: CLIENT_ID_PROVIDER,
useValue: { loadClientId: () => Promise.resolve(CLIENT_ID) },
},
{ provide: ENTITY_REGISTRY, useValue: buildEntityRegistry() },
],
});
service = TestBed.inject(ConflictResolutionService);
journal = TestBed.inject(ConflictJournalService);
});
// ── (a) title vs notes → merge both ────────────────────────────────────────
it('(a) merges concurrent title-vs-notes edits into one op keeping BOTH', async () => {
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'Local title', notes: 'base notes' }),
);
const localOp = op({
id: 'local-1',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'Local title' } } },
});
const remoteOp = op({
id: 'remote-1',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 1000,
payload: { task: { id: 'task-1', changes: { notes: 'Remote notes' } } },
});
await service.autoResolveConflictsLWW([conflictOf([localOp], [remoteOp])]);
// A single synthesized merged op carries BOTH changes.
const merged = mergedOpArgs();
expect(merged).toBeDefined();
const payload = merged!.payload as Record<string, unknown>;
expect(payload['title']).toBe('Local title');
expect(payload['notes']).toBe('Remote notes');
// BOTH original ops are superseded (rejected).
const rejected = mockOpLogStore.markRejected.calls.allArgs().flat(2);
expect(rejected).toContain('local-1');
expect(rejected).toContain('remote-1');
// Merged clock dominates both original ops.
expect(compareVectorClocks(merged!.vectorClock, { A: 1 })).toBe(
VectorClockComparison.GREATER_THAN,
);
expect(compareVectorClocks(merged!.vectorClock, { B: 1 })).toBe(
VectorClockComparison.GREATER_THAN,
);
// Journal: merged / disjoint-merge / info, and NOT counted as unreviewed.
const entries = await journal.list('history');
expect(entries.length).toBe(1);
expect(entries[0].winner).toBe('merged');
expect(entries[0].reason).toBe('disjoint-merge');
expect(entries[0].status).toBe('info');
expect((await journal.list('unreviewed')).length).toBe(0);
});
// ── (a2) merge-only sync counts the synthesized op for re-upload ────────────
it('(a2) counts the synthesized merged op in localWinOpsCreated (drives re-upload)', async () => {
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'Local title', notes: 'base notes' }),
);
const localOp = op({
id: 'local-1',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'Local title' } } },
});
const remoteOp = op({
id: 'remote-1',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 1000,
payload: { task: { id: 'task-1', changes: { notes: 'Remote notes' } } },
});
const result = await service.autoResolveConflictsLWW([
conflictOf([localOp], [remoteOp]),
]);
// No LWW local-win ops here — the single synthesized merged op is the sole
// pending-local op. It MUST be counted or the caller's immediate re-upload
// is skipped and the sync falsely reports IN_SYNC while the merge is unsynced.
expect(mergedOpArgs()).toBeDefined();
expect(result.localWinOpsCreated).toBe(1);
});
// ── (a3) SPAP-14 fix: partial-delta merged op, no un-conflicted ride-along ──
it('(a3) synthesizes a partial-delta merged op that excludes un-conflicted fields', async () => {
// Current entity carries a field NEITHER side touched (timeSpentOnDay). A
// full-entity snapshot would embed it and diverge across clients whose
// current state differs (staggered third-device sync); the delta must carry
// ONLY the two sides' changed fields.
mockStore.select.and.returnValue(
of({
id: 'task-1',
title: 'Local title',
notes: 'base notes',
// An un-conflicted field present in current state (value shape is
// irrelevant — the delta must not read current state at all).
timeSpentOnDay: {},
}),
);
const localOp = op({
id: 'local-1',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'Local title' } } },
});
const remoteOp = op({
id: 'remote-1',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 1000,
payload: { task: { id: 'task-1', changes: { notes: 'Remote notes' } } },
});
await service.autoResolveConflictsLWW([conflictOf([localOp], [remoteOp])]);
const merged = mergedOpArgs();
expect(merged).toBeDefined();
const payload = merged!.payload as Record<string, unknown>;
expect(payload['title']).toBe('Local title');
expect(payload['notes']).toBe('Remote notes');
// The un-conflicted field must NOT ride along in the synthesized op.
expect('timeSpentOnDay' in payload).toBe(false);
});
// ── (a4) SPAP-14 fix: refuse merge when the entity has >1 conflict this batch
it('(a4) refuses disjoint-merge for an entity with multiple conflicts (falls back to LWW)', async () => {
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'base', notes: 'base', timeEstimate: 5 }),
);
const localEst = op({
id: 'local-est',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 3000,
payload: { task: { id: 'task-1', changes: { timeEstimate: 9 } } },
});
const remoteTitle = op({
id: 'remote-title',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 3100,
payload: { task: { id: 'task-1', changes: { title: 'B title' } } },
});
const remoteNotes = op({
id: 'remote-notes',
clientId: 'B',
vectorClock: { B: 2 },
timestamp: 3200,
payload: { task: { id: 'task-1', changes: { notes: 'B notes' } } },
});
// detectConflicts emits one conflict per remote op → two conflicts, same
// entity. Merging each independently would let the clock-dominating sibling
// silently drop the other's field, falsely journaled as "kept both".
await service.autoResolveConflictsLWW([
conflictOf([localEst], [remoteTitle]),
conflictOf([localEst], [remoteNotes]),
]);
// No merged op was synthesized; both conflicts fell back to whole-entity LWW.
const entries = await journal.list('history');
expect(entries.length).toBeGreaterThan(0);
expect(entries.every((e) => e.winner !== 'merged')).toBe(true);
expect((await journal.list('unreviewed')).length).toBeGreaterThan(0);
});
// ── (a5) SPAP-14 fix: refuse merge for fallback-less entity types ───────────
it('(a5) refuses disjoint-merge for a type without a RECREATE_FALLBACK (NOTE → LWW)', async () => {
// A partial-delta merged op that later wins over a concurrent delete would
// recreate a schema-INVALID NOTE (no RECREATE_FALLBACK). So NOTE disjoint
// conflicts must fall back to whole-entity LWW, not merge.
mockStore.select.and.returnValue(
of({ id: 'note-1', content: 'base', backgroundColor: 'base' }),
);
const localOp = op({
id: 'local-note',
clientId: 'A',
entityType: 'NOTE',
entityId: 'note-1',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { note: { id: 'note-1', changes: { content: 'Local content' } } },
});
const remoteOp = op({
id: 'remote-note',
clientId: 'B',
entityType: 'NOTE',
entityId: 'note-1',
vectorClock: { B: 1 },
timestamp: 1000,
payload: { note: { id: 'note-1', changes: { backgroundColor: 'Remote color' } } },
});
await service.autoResolveConflictsLWW([
{
entityType: 'NOTE',
entityId: 'note-1',
localOps: [localOp],
remoteOps: [remoteOp],
suggestedResolution: 'manual',
},
]);
const entries = await journal.list('history');
expect(entries.length).toBeGreaterThan(0);
expect(entries.every((e) => e.winner !== 'merged')).toBe(true);
});
// ── (a6) merge journaled only AFTER the merged op is durably appended ──────
it('(a6) does not journal a merge when appending the merged op fails', async () => {
// A `merged` entry claims "both sides kept" — that is only true once the
// merged op is persisted. If the append throws, the journal must not
// contain a phantom merge (STEP 3b journals post-append, not at plan time).
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'Local title', notes: 'base' }),
);
mockOpLogStore.appendWithVectorClockUpdate.and.rejectWith(new Error('append failed'));
const localOp = op({
id: 'local-1',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'Local title' } } },
});
const remoteOp = op({
id: 'remote-1',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 1000,
payload: { task: { id: 'task-1', changes: { notes: 'Remote notes' } } },
});
await expectAsync(
service.autoResolveConflictsLWW([conflictOf([localOp], [remoteOp])]),
).toBeRejected();
const history = await journal.list('history');
expect(history.filter((e) => e.winner === 'merged')).toEqual([]);
});
// ── (b) title vs title → LWW unchanged ─────────────────────────────────────
it('(b) leaves same-field (title-vs-title) conflicts to LWW (journal unreviewed)', async () => {
mockStore.select.and.returnValue(of({ id: 'task-1', title: 'Local title' }));
const localOp = op({
id: 'local-1',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'Local title' } } },
});
const remoteOp = op({
id: 'remote-1',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 1000,
payload: { task: { id: 'task-1', changes: { title: 'Remote title' } } },
});
await service.autoResolveConflictsLWW([conflictOf([localOp], [remoteOp])]);
const entries = await journal.list('history');
expect(entries.length).toBe(1);
expect(entries[0].reason).toBe('newer'); // local ts newer, same field
expect(entries[0].winner).toBe('local');
expect(entries[0].status).toBe('unreviewed');
expect((await journal.list('unreviewed')).length).toBe(1);
});
// ── (c) disjoint real fields + both bumped a noise field → still merges ─────
it('(c) merges when disjoint real fields also both bump a NOISE field (deterministic tiebreak)', async () => {
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'Local title', notes: 'base', modified: 1111 }),
);
const localOp = op({
id: 'local-1',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 1000, // older → loses the noise tiebreak
payload: {
task: { id: 'task-1', changes: { title: 'Local title', modified: 1111 } },
},
});
const remoteOp = op({
id: 'remote-1',
clientId: 'B',
vectorClock: { B: 1 },
timestamp: 2000, // newer → wins the noise tiebreak
payload: {
task: { id: 'task-1', changes: { notes: 'Remote notes', modified: 2222 } },
},
});
await service.autoResolveConflictsLWW([conflictOf([localOp], [remoteOp])]);
const merged = mergedOpArgs();
expect(merged).toBeDefined();
const payload = merged!.payload as Record<string, unknown>;
expect(payload['title']).toBe('Local title');
expect(payload['notes']).toBe('Remote notes');
// The noise field resolves to the greater-(timestamp) side, NOT simply the
// local current-state value.
expect(payload['modified']).toBe(2222);
const entries = await journal.list('history');
expect(entries[0].reason).toBe('disjoint-merge');
expect(entries[0].status).toBe('info');
});
// ── (d) edit vs delete → delete wins, NO merge ─────────────────────────────
it('(d) never merges an edit-vs-delete conflict (delete-wins path unchanged)', async () => {
const localOp = op({
id: 'local-1',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 1000,
payload: { task: { id: 'task-1', changes: { title: 'Local title' } } },
});
const remoteDelete = op({
id: 'remote-1',
clientId: 'B',
opType: OpType.Delete,
vectorClock: { B: 1 },
timestamp: 2000, // delete newer → wins
payload: { task: { id: 'task-1' } },
});
// Sanity: eligibility must reject a delete-containing conflict outright.
expect(
isDisjointMergeEligible({
localOps: [localOp],
remoteOps: [remoteDelete],
payloadKey: 'task',
}),
).toBe(false);
await service.autoResolveConflictsLWW([conflictOf([localOp], [remoteDelete])]);
const entries = await journal.list('history');
expect(entries.length).toBe(1);
expect(entries[0].reason).toBe('delete-wins');
expect(entries[0].reason).not.toBe('disjoint-merge');
// No synthesized merged UPDATE op was created for this entity.
expect(mergedOpArgs()).toBeUndefined();
});
// ── (d2) archive vs disjoint edit → archive wins whole entity, NO merge ─────
it('(d2) never merges an archive-vs-disjoint-edit conflict (archive-plan guard, not eligibility, blocks it)', async () => {
// An archive is an UPDATE op (not a Delete), so `isDisjointMergeEligible`
// does NOT reject it: an archive that carries its own disjoint non-noise
// field alongside a concurrent disjoint edit is field-level merge-eligible.
// The ONLY thing preventing a partial-resurrection merge is the
// `_isArchivePlan` guard in `_tryCreateDisjointMergeOp`. This asserts
// eligibility is TRUE yet no merged op is synthesized — so a regression that
// dropped the guard would fail here (and nowhere else).
const localEdit = op({
id: 'local-1',
clientId: 'A',
vectorClock: { A: 1 },
timestamp: 1000,
payload: { task: { id: 'task-1', changes: { title: 'Local title' } } },
});
const remoteArchive = op({
id: 'remote-1',
clientId: 'B',
actionType: '[Task Shared] moveToArchive' as ActionType,
vectorClock: { B: 1 },
timestamp: 2000, // archive newer → wins
payload: { task: { id: 'task-1', changes: { isDone: true } } },
});
// Field-level eligibility PASSES (disjoint non-noise fields, no Delete op):
// the archive-plan guard is the sole reason the merge must not happen.
expect(
isDisjointMergeEligible({
localOps: [localEdit],
remoteOps: [remoteArchive],
payloadKey: 'task',
}),
).toBe(true);
await service.autoResolveConflictsLWW([conflictOf([localEdit], [remoteArchive])]);
// No synthesized merged UPDATE op — the archive wins the WHOLE entity.
expect(mergedOpArgs()).toBeUndefined();
const entries = await journal.list('history');
expect(entries.length).toBe(1);
expect(entries[0].reason).toBe('delete-wins');
expect(entries[0].reason).not.toBe('disjoint-merge');
expect(entries[0].winner).toBe('remote');
});
// ── (e) two-client convergence ─────────────────────────────────────────────
describe('(e) two-client convergence', () => {
// side1 authored by client A; side2 authored by client B.
const side1Changes = { title: 'A-title', modified: 1500 };
const side2Changes = { notes: 'B-notes', modified: 1600 };
const side1Meta = { timestamp: 1500, clientId: 'A' };
const side2Meta = { timestamp: 1600, clientId: 'B' };
it('both clients synthesize the byte-identical merged DELTA (either ordering)', () => {
// Client A: local = side1, remote = side2.
const mergedA = synthesizeMergedChanges(
side1Changes,
side2Changes,
side1Meta,
side2Meta,
);
// Client B: local = side2, remote = side1 (mirror).
const mergedB = synthesizeMergedChanges(
side2Changes,
side1Changes,
side2Meta,
side1Meta,
);
expect(mergedA).toEqual(mergedB);
// Explicit expected delta: both real fields kept; noise → newer (side2).
// No `id` and NO untouched fields — the delta carries ONLY changed fields.
expect(mergedA).toEqual({
title: 'A-title',
notes: 'B-notes',
modified: 1600,
});
});
it("the merged DELTA is independent of each client's divergent current state (SPAP-14 divergence fix)", () => {
// The two clients' current entities differ on an UN-conflicted field
// (timeSpentOnDay) — e.g. one already applied a third device's edit the
// other has not. A full-entity snapshot would drag that field along and
// diverge forever; the delta is derived only from the two sides' ops, so
// it is identical regardless. Neither delta may contain timeSpentOnDay.
const mergedA = synthesizeMergedChanges(
side1Changes,
side2Changes,
side1Meta,
side2Meta,
);
const mergedB = synthesizeMergedChanges(
side2Changes,
side1Changes,
side2Meta,
side1Meta,
);
expect(mergedA).toEqual(mergedB);
expect('timeSpentOnDay' in mergedA).toBe(false);
});
it('both merged clocks dominate BOTH original ops', () => {
const clockSide1 = { clientA: 2 };
const clockSide2 = { clientB: 2 };
const merge = (...cs: Array<Record<string, number>>): Record<string, number> =>
cs.reduce((acc, c) => mergeVectorClocks(acc, c), {});
const clockA = incrementVectorClock(merge(clockSide1, clockSide2), 'clientA');
const clockB = incrementVectorClock(merge(clockSide1, clockSide2), 'clientB');
for (const clk of [clockA, clockB]) {
expect(compareVectorClocks(clk, clockSide1)).toBe(
VectorClockComparison.GREATER_THAN,
);
expect(compareVectorClocks(clk, clockSide2)).toBe(
VectorClockComparison.GREATER_THAN,
);
}
// The two independently-synthesized merged ops are concurrent by clock,
// but carry identical payloads (previous test) → resolve by ordinary LWW,
// never re-merging, so entity state converges.
expect(compareVectorClocks(clockA, clockB)).toBe(VectorClockComparison.CONCURRENT);
});
});
// ── (e2e) full two-client round-trip: both clients merge independently to the
// IDENTICAL entity, then the two merged ops meet and are NOT re-merge-
// eligible (→ ordinary LWW on identical payloads → convergence, no ping-pong).
describe('(e2e) two-client sync round-trip convergence', () => {
const resolveAsClient = async (
clientId: string,
currentState: Record<string, unknown>,
conflict: EntityConflict,
): Promise<{ synthesized?: Operation }> => {
TestBed.resetTestingModule();
const store = jasmine.createSpyObj('Store', ['select']);
store.select.and.returnValue(of(currentState));
const applier = jasmine.createSpyObj('OperationApplierService', [
'applyOperations',
]);
applier.applyOperations.and.resolveTo({ appliedOps: [] });
const opLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'appendBatchSkipDuplicates',
'appendWithVectorClockUpdate',
'markApplied',
'markRejected',
'markFailed',
'getUnsyncedByEntity',
'mergeRemoteOpClocks',
]);
opLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
opLogStore.getUnsyncedByEntity.and.resolveTo(new Map());
opLogStore.markRejected.and.resolveTo(undefined);
opLogStore.markApplied.and.resolveTo(undefined);
opLogStore.markFailed.and.resolveTo(undefined);
opLogStore.appendWithVectorClockUpdate.and.resolveTo(1);
opLogStore.appendBatchSkipDuplicates.and.callFake((ops: Operation[]) =>
Promise.resolve({
seqs: ops.map((_, i) => i + 1),
writtenOps: ops,
skippedCount: 0,
}),
);
const validate = jasmine.createSpyObj('ValidateStateService', [
'validateAndRepairCurrentState',
]);
validate.validateAndRepairCurrentState.and.resolveTo(true);
const effects = jasmine.createSpyObj('OperationLogEffects', [
'processDeferredActions',
]);
effects.processDeferredActions.and.resolveTo();
TestBed.configureTestingModule({
providers: [
ConflictResolutionService,
{ provide: Store, useValue: store },
{ provide: OperationApplierService, useValue: applier },
{ provide: OperationLogStoreService, useValue: opLogStore },
{
provide: SnackService,
useValue: jasmine.createSpyObj('SnackService', ['open']),
},
{ provide: ValidateStateService, useValue: validate },
{ provide: OperationLogEffects, useValue: effects },
{
provide: CLIENT_ID_PROVIDER,
useValue: { loadClientId: () => Promise.resolve(clientId) },
},
{ provide: ENTITY_REGISTRY, useValue: buildEntityRegistry() },
],
});
const svc = TestBed.inject(ConflictResolutionService);
await svc.autoResolveConflictsLWW([conflict]);
const synthesized = opLogStore.appendWithVectorClockUpdate.calls
.allArgs()
.map(([o]) => o as Operation)
.find((o) => o.entityId === 'task-1' && o.opType === OpType.Update);
return { synthesized };
};
const entityOf = (o: Operation): Record<string, unknown> => {
const p = o.payload as Record<string, unknown>;
return { title: p['title'], notes: p['notes'] };
};
it('both clients synthesize the identical merged entity, and the two merged ops do not re-merge (converge)', async () => {
const titleOp = op({
id: 'op-A',
clientId: 'clientA',
vectorClock: { clientA: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'A-title' } } },
});
const notesOp = op({
id: 'op-B',
clientId: 'clientB',
vectorClock: { clientB: 1 },
timestamp: 3000,
payload: { task: { id: 'task-1', changes: { notes: 'B-notes' } } },
});
const a1 = await resolveAsClient(
'clientA',
{ id: 'task-1', title: 'A-title', notes: 'base' },
conflictOf([titleOp], [notesOp]),
);
const b1 = await resolveAsClient(
'clientB',
{ id: 'task-1', title: 'base', notes: 'B-notes' },
conflictOf([notesOp], [titleOp]),
);
expect(a1.synthesized).toBeDefined();
expect(b1.synthesized).toBeDefined();
expect(entityOf(a1.synthesized!)).toEqual({ title: 'A-title', notes: 'B-notes' });
expect(entityOf(a1.synthesized!)).toEqual(entityOf(b1.synthesized!));
const mA = a1.synthesized!;
const mB = b1.synthesized!;
expect(
isDisjointMergeEligible({ localOps: [mA], remoteOps: [mB], payloadKey: 'task' }),
).toBe(false);
expect(
isDisjointMergeEligible({ localOps: [mB], remoteOps: [mA], payloadKey: 'task' }),
).toBe(false);
});
});
});

View file

@ -1,4 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { SyncConflictBannerService } from './sync-conflict-banner.service';
import { ConflictResolutionService } from './conflict-resolution.service';
import { Store } from '@ngrx/store';
import { OperationApplierService } from '../apply/operation-applier.service';
@ -433,8 +434,10 @@ describe('ConflictResolutionService', () => {
);
// Local ops should be rejected
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-1']);
// Snack should be shown
expect(mockSnackService.open).toHaveBeenCalled();
// SPAP-15: the generic count snack was replaced by the journal-driven
// summary banner. These ops carry no real field changes (noise), so
// nothing unreviewed is journaled and no snack fires.
expect(mockSnackService.open).not.toHaveBeenCalled();
});
it('should auto-resolve conflict as local when local timestamp is newer', async () => {
@ -458,8 +461,9 @@ describe('ConflictResolutionService', () => {
undefined,
);
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-1']);
// Snack should show local wins
expect(mockSnackService.open).toHaveBeenCalled();
// SPAP-15: count snack replaced by the journal-driven summary banner;
// noise-only resolutions journal nothing unreviewed, so no snack fires.
expect(mockSnackService.open).not.toHaveBeenCalled();
});
it('shows a content-conflict banner (not the generic snack) when a discarded edit touched task content (#8694)', async () => {
@ -642,7 +646,7 @@ describe('ConflictResolutionService', () => {
expect(taskList).toContain('&lt;img');
});
it('keeps the quiet count snack (no banner) for routine-only resolutions (#8694)', async () => {
it('surfaces the journal-driven summary banner (not the content banner or count snack) for routine field resolutions (SPAP-15)', async () => {
const bannerService = TestBed.inject(BannerService);
const openBannerSpy = spyOn(bannerService, 'open');
const now = Date.now();
@ -679,8 +683,13 @@ describe('ConflictResolutionService', () => {
await service.autoResolveConflictsLWW(conflicts);
expect(mockSnackService.open).toHaveBeenCalled();
expect(openBannerSpy).not.toHaveBeenCalled();
// A dueDay reschedule is a real (non-noise) discarded edit → journaled
// unreviewed → the summary banner (NOT the named content banner) surfaces
// it, and the old count snack is gone.
expect(mockSnackService.open).not.toHaveBeenCalled();
expect(openBannerSpy).toHaveBeenCalledWith(
jasmine.objectContaining({ id: BannerId.SyncConflictsAutoResolved }),
);
});
it('should auto-resolve as remote when timestamps are equal (tie-breaker)', async () => {
@ -787,15 +796,10 @@ describe('ConflictResolutionService', () => {
undefined,
);
// Snack notification should reflect both outcomes
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
translateParams: {
localWins: 1,
remoteWins: 1,
},
}),
);
// SPAP-15: the generic count snack was removed. Both conflicts here carry
// no real field changes (noise), so nothing unreviewed is journaled and
// neither the snack nor the summary banner fires.
expect(mockSnackService.open).not.toHaveBeenCalled();
});
it('should piggyback non-conflicting ops with conflict resolution', async () => {
@ -1507,6 +1511,11 @@ describe('ConflictResolutionService', () => {
appliedOps: [conflicts[0].remoteOps[0], conflicts[2].remoteOps[0]],
});
const bannerSpy = spyOn(
TestBed.inject(SyncConflictBannerService),
'maybeShowSummaryBanner',
).and.resolveTo();
await service.autoResolveConflictsLWW(conflicts);
// Task: remote wins (newer), Tag: remote wins (tie goes to remote)
@ -1530,15 +1539,10 @@ describe('ConflictResolutionService', () => {
// Project: local wins - remote op rejected separately
expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['remote-project']);
// Notification should show mixed results
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
translateParams: {
localWins: 1,
remoteWins: 2,
},
}),
);
// SPAP-15: the mixed-result count notification is now the journal-driven
// summary banner (win-count VALUES are covered by
// sync-conflict-banner.service.spec).
expect(bannerSpy).toHaveBeenCalled();
});
});

View file

@ -16,6 +16,7 @@ import {
partitionLwwResolutions,
planLwwConflictResolutions,
suggestConflictResolution,
type LwwConflictResolutionPlan,
type LwwResolvedConflict,
} from '@sp/sync-core';
import {
@ -63,12 +64,39 @@ import { uuidv7 } from '../../util/uuid-v7';
import { CURRENT_SCHEMA_VERSION } from '../persistence/schema-migration.service';
import { SYNC_LOGGER } from '../core/sync-logger.adapter';
import { processDeferredActionsAfterRemoteApply } from './process-deferred-actions-flush.util';
import { ConflictJournalService } from './conflict-journal.service';
import { SyncConflictBannerService } from './sync-conflict-banner.service';
import { buildConflictJournalEntry } from './conflict-journal-emission.util';
import {
isDisjointMergeEligible,
mergeChangedFields,
synthesizeMergedChanges,
} from './conflict-disjoint-merge.util';
import { RECREATE_FALLBACK } from '../core/recreate-fallback.const';
/**
* Represents the result of LWW (Last-Write-Wins) conflict resolution.
*/
type LWWResolution = LwwResolvedConflict<Operation, EntityConflict>;
/**
* SPAP-14: one conflict resolved by a disjoint-field auto-merge. `mergedOp` is a
* synthetic LWW Update carrying the UNION of both sides' changes; it is applied
* locally AND uploaded, and both original sides are rejected (superseded).
*/
interface MergedResolution {
conflict: EntityConflict;
mergedOp: Operation;
/** Kept so STEP 3b can journal the merge AFTER the merged op is durably appended. */
plan: LwwConflictResolutionPlan<EntityConflict>;
}
/** Result of `_resolveConflictsWithLWW`: LWW winners plus disjoint merges. */
interface ResolvedConflicts {
lwwResolutions: LWWResolution[];
mergedResolutions: MergedResolution[];
}
interface AutoResolveConflictsLwwOptions {
callerHoldsOperationLogLock?: boolean;
}
@ -115,6 +143,25 @@ export class ConflictResolutionService {
private syncLogger = inject(SYNC_LOGGER);
private entityRegistry = inject(ENTITY_REGISTRY);
private injector = inject(Injector);
private conflictJournal = inject(ConflictJournalService);
private syncConflictBanner = inject(SyncConflictBannerService);
/**
* SPAP-13 (observe-only): conflicts whose CONCURRENT status was FORCED by
* `_adjustForClockCorruption` escalation. Tagged here at detection time and
* read at resolution time so the journal can attribute those resolutions to
* `clock-corruption-suspected`. Keyed by the live EntityConflict object (the
* same reference flows detection autoResolveConflictsLWW), so a WeakSet
* both avoids mutating the shared type and cannot leak across sync cycles.
* Purely a side-channel: it never changes which op resolution picks.
*
* FRAGILE: attribution depends on the SAME EntityConflict reference surviving
* from detection (`.add`) to resolution (`.has`). A future refactor that
* clones or rebuilds the conflict object between those points would silently
* drop the `clock-corruption-suspected` classification (no error, just wrong
* journal reason). Keep the reference stable or switch to an explicit flag.
*/
private readonly _corruptionSuspectedConflicts = new WeakSet<EntityConflict>();
// ═══════════════════════════════════════════════════════════════════════════
// LWW OPERATION FACTORY METHODS
@ -292,7 +339,8 @@ export class ConflictResolutionService {
// ─────────────────────────────────────────────────────────────────────────
// STEP 1: Resolve each conflict using LWW
// ─────────────────────────────────────────────────────────────────────────
const resolutions = await this._resolveConflictsWithLWW(conflicts);
const { lwwResolutions: resolutions, mergedResolutions } =
await this._resolveConflictsWithLWW(conflicts);
const allOpsToApply: Operation[] = [];
const allStoredOps: Array<{ id: string; seq: number }> = [];
@ -397,6 +445,45 @@ export class ConflictResolutionService {
}
}
// ─────────────────────────────────────────────────────────────────────────
// STEP 3b (SPAP-14): Process disjoint-field merges.
//
// For each merge we: (1) reject BOTH original sides (the merged op
// supersedes them); (2) persist the original remote ops as rejected so they
// are recorded-as-seen but not applied (mirrors the local-wins remote-op
// bookkeeping); (3) append the synthesized merged op as a PENDING LOCAL op
// (so it uploads on next sync) AND queue it into the apply batch (so THIS
// client's state picks up the remote side's fields — local's are already
// optimistically applied). The op stays unsynced+not-rejected → it uploads.
// ─────────────────────────────────────────────────────────────────────────
for (const merged of mergedResolutions) {
for (const op of merged.conflict.localOps) {
if (!localOpsToRejectSet.has(op.id)) {
localOpsToReject.push(op.id);
localOpsToRejectSet.add(op.id);
}
}
if (merged.conflict.remoteOps.length > 0) {
await this._filterAndAppendOpsWithRetry(merged.conflict.remoteOps, 'remote');
remoteOpsToReject.push(...merged.conflict.remoteOps.map((op) => op.id));
}
const seq = await this.opLogStore.appendWithVectorClockUpdate(
merged.mergedOp,
'local',
);
allStoredOps.push({ id: merged.mergedOp.id, seq });
allOpsToApply.push(merged.mergedOp);
// Journal ONLY after the append: once persisted as a pending local op the
// merge is durable (it applies/uploads even across a crash), so a `merged`
// ("kept both") entry can never describe a merge that didn't happen.
await this._journalMergedResolution(merged.plan);
OpLog.normal(
`ConflictResolutionService: Appended disjoint-merge op ${merged.mergedOp.id} for ` +
`${merged.mergedOp.entityType}:${merged.mergedOp.entityId}`,
);
}
// ─────────────────────────────────────────────────────────────────────────
// STEP 4: Mark rejected operations BEFORE applying (crash safety)
// ─────────────────────────────────────────────────────────────────────────
@ -523,7 +610,15 @@ export class ConflictResolutionService {
const isValid = await this._validateAndRepairAfterResolution();
if (!isValid) this.sessionValidation.setFailed();
return { localWinOpsCreated: newLocalWinOps.length };
// Count both LWW local-win ops AND disjoint-merge ops (STEP 3b): each merge
// appended a synthesized pending-local op that still needs uploading. The
// caller uses this count to trigger the immediate re-upload
// (immediate-upload.service.ts) — omitting merges lets a merge-only sync
// report IN_SYNC while its merged op sits unsynced until a later cycle.
// Mirrors the rejection-handler path (operation-log-sync.service.ts:361).
return {
localWinOpsCreated: newLocalWinOps.length + mergedResolutions.length,
};
}
/**
@ -547,13 +642,10 @@ export class ConflictResolutionService {
);
if (contentConflicts.length === 0) {
this.snackService.open({
msg: T.F.SYNC.S.LWW_CONFLICTS_AUTO_RESOLVED,
translateParams: {
localWins: localWinsCount,
remoteWins: remoteWinsCount,
},
});
// SPAP-15: no named content loss to surface here. If the sync journaled
// any (non-content) unreviewed conflicts, the summary banner names the
// count + REVIEW link; otherwise it stays silent (replaces the old snack).
await this.syncConflictBanner.maybeShowSummaryBanner();
return;
}
@ -588,6 +680,11 @@ export class ConflictResolutionService {
ico: 'sync_problem',
msg: T.F.SYNC.B.CONTENT_CONFLICT_RESOLVED,
translateParams: { taskList },
// SPAP-15: REVIEW opens the conflicts page; DISMISS auto-renders (no action2).
action: {
label: T.F.SYNC.CONFLICT_REVIEW.BANNER_REVIEW,
fn: () => this.syncConflictBanner.navigateToReview(),
},
});
}
@ -648,8 +745,9 @@ export class ConflictResolutionService {
*/
private async _resolveConflictsWithLWW(
conflicts: EntityConflict[],
): Promise<LWWResolution[]> {
): Promise<ResolvedConflicts> {
const resolutions: LWWResolution[] = [];
const mergedResolutions: MergedResolution[] = [];
const plans = planLwwConflictResolutions(conflicts, {
isArchiveAction: (op) => op.actionType === ActionType.TASK_SHARED_MOVE_TO_ARCHIVE,
@ -657,7 +755,53 @@ export class ConflictResolutionService {
toEntityKey(entityType as EntityType, entityId),
});
// SPAP-14 hardening: disjoint-merge is only safe for a SINGLE remote op per
// entity per batch. detectConflicts emits one conflict per remote op with no
// per-entity aggregation, so an entity with ≥2 concurrent remote ops (e.g.
// one device edited title then notes offline) would synthesize multiple
// merged ops for the same entity; their clocks dominate one another, so a
// dominated sibling can be superseded and its field silently dropped —
// falsely journaled as a successful "kept both" merge. Refuse the merge for
// any entity with >1 conflict this batch and fall back to whole-entity LWW
// (baseline behaviour, no false merge). Per-entity aggregation into one op is
// a possible future improvement; refusal is the safe floor.
const conflictCountByEntity = new Map<string, number>();
for (const plan of plans) {
const key = toEntityKey(
plan.conflict.entityType as EntityType,
plan.conflict.entityId,
);
conflictCountByEntity.set(key, (conflictCountByEntity.get(key) ?? 0) + 1);
}
for (const plan of plans) {
// SPAP-14: BEFORE the whole-entity LWW plan, try a disjoint-field merge —
// when both sides edited the same entity but DIFFERENT real fields, keep
// BOTH instead of discarding the loser. Delete/archive, same-field
// (overlapping), and multi-remote-op-per-entity conflicts are NOT eligible
// and fall through to the exact LWW + SPAP-13 path below, byte-unchanged.
const entityKey = toEntityKey(
plan.conflict.entityType as EntityType,
plan.conflict.entityId,
);
const mergedOp =
(conflictCountByEntity.get(entityKey) ?? 0) > 1
? undefined
: await this._tryCreateDisjointMergeOp(plan);
if (mergedOp) {
// NOT journaled here: a `merged` entry claims "both sides kept", which
// is only true once the merged op is durably appended — STEP 3b journals
// it right after the append, so a failure in between cannot leave a
// phantom "kept both" entry. (LWW entries below journal at plan time:
// they describe a pick, not a new op, so there is nothing to wait for.)
mergedResolutions.push({ conflict: plan.conflict, mergedOp, plan });
OpLog.normal(
`ConflictResolutionService: Disjoint-field merge for ` +
`${plan.conflict.entityType}:${plan.conflict.entityId} (kept both sides)`,
);
continue;
}
let localWinOp: Operation | undefined;
if (plan.localWinOperationKind === 'archive-win') {
@ -672,6 +816,12 @@ export class ConflictResolutionService {
localWinOp,
});
// SPAP-13 (observe-only): journal this auto-resolution so the discarded
// side is preserved and reviewable. Reads `plan`/`conflict` only — never
// mutates the resolution. Journal failures are swallowed inside
// `_journalResolution`, so they cannot affect what LWW picked.
await this._journalResolution(plan);
if (
plan.reason === 'remote-archive' ||
plan.reason === 'local-archive' ||
@ -695,7 +845,196 @@ export class ConflictResolutionService {
}
}
return resolutions;
return { lwwResolutions: resolutions, mergedResolutions };
}
/**
* SPAP-13 (observe-only): builds and records one conflict-journal entry for an
* already-decided LWW plan. Classification is pure (see
* `buildConflictJournalEntry`); `conflictJournal.record` swallows its own
* errors. This method therefore cannot alter which op resolution picks it
* only logs the outcome (and preserves the discarded side's field values).
*/
private async _journalResolution(
plan: LwwConflictResolutionPlan<EntityConflict>,
): Promise<void> {
// Belt-and-suspenders observe-only guard: neither classification nor the
// DB write may ever throw back into resolution and change what LWW picked.
try {
const entry = buildConflictJournalEntry({
entityType: plan.conflict.entityType,
entityId: plan.conflict.entityId,
winner: plan.winner,
planReason: plan.reason,
localOps: plan.conflict.localOps,
remoteOps: plan.conflict.remoteOps,
isCorruptionSuspected: this._corruptionSuspectedConflicts.has(plan.conflict),
resolvePayloadKey: (entityType) => this._resolvePayloadKey(entityType),
});
await this.conflictJournal.record(entry);
} catch (err) {
OpLog.err('ConflictResolutionService: conflict-journal hook failed (ignored)', err);
}
}
/**
* SPAP-14: whether this plan is an archive plan. Archive/delete-wins semantics
* are left 100% untouched by disjoint-merge the archive must win the whole
* entity, never be partially merged with a concurrent edit.
*/
private _isArchivePlan(plan: LwwConflictResolutionPlan<EntityConflict>): boolean {
return (
plan.reason === 'remote-archive' ||
plan.reason === 'local-archive' ||
plan.reason === 'local-archive-sibling' ||
plan.localWinOperationKind === 'archive-win'
);
}
/**
* SPAP-14: if this conflict is a disjoint-field merge, synthesize the merged
* UPDATE op; otherwise return undefined so the caller uses the whole-entity LWW
* path unchanged.
*
* The merged op is deterministic and CONVERGENT: both clients synthesize the
* byte-identical merged CHANGES DELTA (union of both sides' disjoint real
* fields, with noise fields resolved by a deterministic `(timestamp, clientId)`
* tiebreak see `synthesizeMergedChanges`) and a vector clock that DOMINATES
* both sides (via `mergeAndIncrementClocks`, mirroring `_createLocalWinUpdateOp`).
* The op carries a PARTIAL delta (not a full-entity snapshot), so untouched
* fields that momentarily differ between the two clients can't ride along and
* diverge; `lwwUpdateMetaReducer` applies it via `updateOne` (a shallow merge).
* It uses the standard LWW Update action type and the max timestamp across both
* sides, so when two independently-synthesized merged ops meet they carry
* identical payloads and resolve by ordinary LWW never re-merging.
*
* Returns undefined ( fall back to LWW) if the conflict is not merge-eligible,
* the current entity state is unavailable, or there is no client id.
*/
private async _tryCreateDisjointMergeOp(
plan: LwwConflictResolutionPlan<EntityConflict>,
): Promise<Operation | undefined> {
if (this._isArchivePlan(plan)) {
return undefined;
}
const { conflict } = plan;
const payloadKey = this._resolvePayloadKey(conflict.entityType);
// The merged op carries a PARTIAL delta. If it later has to RECREATE a
// concurrently-deleted entity (lwwUpdateMetaReducer's addOne branch — reached
// by a passive observer that applied a remote delete before this op, which
// does NOT pass through the full-entity reconstruction in
// `_convertToLWWUpdatesIfNeeded`), the entity must be backfillable to a
// schema-valid shape. Only types with a RECREATE_FALLBACK are; for others a
// bare partial `addOne` yields a Typia-invalid entity ("Repair failed"
// dead-end). Refuse the merge for fallback-less types and fall back to
// whole-entity LWW, whose local-win op carries a full snapshot that recreates
// losslessly. See recreate-fallback.const.ts.
if (!RECREATE_FALLBACK[conflict.entityType]) {
return undefined;
}
if (
!isDisjointMergeEligible({
localOps: conflict.localOps,
remoteOps: conflict.remoteOps,
payloadKey,
})
) {
return undefined;
}
// The merged entity is built on THIS client's current state (= base + local
// changes). If it is unavailable, we cannot merge safely → fall back to LWW.
const currentEntityState = await this.getCurrentEntityState(
conflict.entityType,
conflict.entityId,
);
if (currentEntityState === undefined || currentEntityState === null) {
OpLog.warn(
`ConflictResolutionService: Cannot disjoint-merge - entity state unavailable: ` +
`${conflict.entityType}:${conflict.entityId}. Falling back to LWW.`,
);
return undefined;
}
const clientId = await this.clientIdProvider.loadClientId();
if (!clientId) {
OpLog.err('ConflictResolutionService: Cannot disjoint-merge - no client ID');
return undefined;
}
const localChanges = mergeChangedFields(conflict.localOps, payloadKey);
const remoteChanges = mergeChangedFields(conflict.remoteOps, payloadKey);
const localTs = Math.max(...conflict.localOps.map((op) => op.timestamp));
const remoteTs = Math.max(...conflict.remoteOps.map((op) => op.timestamp));
// The merged op carries ONLY the union of both sides' changed fields (a
// partial delta), NOT a full-entity snapshot of `currentEntityState`. The
// delta is derived purely from the two sides' ops, so both clients compute
// the byte-identical map — a full snapshot would drag along untouched fields
// that can differ between clients under staggered sync and diverge forever.
// The lwwUpdateMetaReducer applies it via `updateOne` (a shallow merge), so
// fields outside the delta keep their own values. See `synthesizeMergedChanges`.
const mergedChanges = synthesizeMergedChanges(
localChanges,
remoteChanges,
{ timestamp: localTs, clientId: conflict.localOps[0]?.clientId ?? clientId },
{ timestamp: remoteTs, clientId: conflict.remoteOps[0]?.clientId ?? '' },
);
// Clock dominates BOTH sides so the merged op supersedes them and propagates
// through normal sync. No client-side pruning (mirrors _createLocalWinUpdateOp).
const allClocks = [
...conflict.localOps.map((op) => op.vectorClock),
...conflict.remoteOps.map((op) => op.vectorClock),
];
const newClock = this.mergeAndIncrementClocks(allClocks, clientId);
// Deterministic timestamp both clients agree on (max across both sides), so
// two independently-synthesized merged ops tie under LWW and converge.
const mergedTimestamp = Math.max(localTs, remoteTs);
return this.createLWWUpdateOp(
conflict.entityType,
conflict.entityId,
mergedChanges,
clientId,
newClock,
mergedTimestamp,
);
}
/**
* SPAP-14 (observe-only): journal a disjoint-field merge as `merged` /
* `disjoint-merge` / `info`. Nothing was discarded, so it must NOT count toward
* the unreviewed count. Like `_journalResolution`, any failure is swallowed and
* can never affect resolution. Called from STEP 3b AFTER the merged op is
* appended not at plan time so the entry never describes a merge that was
* never persisted.
*/
private async _journalMergedResolution(
plan: LwwConflictResolutionPlan<EntityConflict>,
): Promise<void> {
try {
const entry = buildConflictJournalEntry({
entityType: plan.conflict.entityType,
entityId: plan.conflict.entityId,
winner: 'merged',
planReason: plan.reason,
localOps: plan.conflict.localOps,
remoteOps: plan.conflict.remoteOps,
isCorruptionSuspected: this._corruptionSuspectedConflicts.has(plan.conflict),
resolvePayloadKey: (entityType) => this._resolvePayloadKey(entityType),
});
await this.conflictJournal.record(entry);
} catch (err) {
OpLog.err(
'ConflictResolutionService: disjoint-merge journal hook failed (ignored)',
err,
);
}
}
/**
@ -1070,15 +1409,22 @@ export class ConflictResolutionService {
return { isSupersededOrDuplicate: false, conflict: null };
}
let vcComparison = compareVectorClocks(localFrontier, remoteOp.vectorClock);
const rawComparison = compareVectorClocks(localFrontier, remoteOp.vectorClock);
// Handle potential per-entity clock corruption
vcComparison = this._adjustForClockCorruption(vcComparison, entityKey, {
const vcComparison = this._adjustForClockCorruption(rawComparison, entityKey, {
localOpsForEntity: ctx.localOpsForEntity,
hasNoSnapshotClock: ctx.hasNoSnapshotClock,
localFrontierIsEmpty,
});
// SPAP-13 (observe-only): remember when the ONLY reason this became a
// conflict is that clock-corruption escalation flipped a non-CONCURRENT
// comparison to CONCURRENT. Does not affect the returned comparison.
const corruptionEscalated =
rawComparison !== VectorClockComparison.CONCURRENT &&
vcComparison === VectorClockComparison.CONCURRENT;
// Skip superseded operations (local already has newer state)
if (vcComparison === VectorClockComparison.GREATER_THAN) {
OpLog.verbose(
@ -1118,16 +1464,17 @@ export class ConflictResolutionService {
// CONCURRENT = true conflict
if (vcComparison === VectorClockComparison.CONCURRENT) {
return {
isSupersededOrDuplicate: false,
conflict: {
entityType: remoteOp.entityType,
entityId,
localOps: ctx.localOpsForEntity,
remoteOps: [remoteOp],
suggestedResolution: this._suggestResolution(ctx.localOpsForEntity, [remoteOp]),
},
const conflict: EntityConflict = {
entityType: remoteOp.entityType,
entityId,
localOps: ctx.localOpsForEntity,
remoteOps: [remoteOp],
suggestedResolution: this._suggestResolution(ctx.localOpsForEntity, [remoteOp]),
};
if (corruptionEscalated) {
this._corruptionSuspectedConflicts.add(conflict);
}
return { isSupersededOrDuplicate: false, conflict };
}
return { isSupersededOrDuplicate: false, conflict: null };

View file

@ -470,7 +470,7 @@ describe('SupersededOperationResolverService', () => {
expect(appendedOp.actionType).toBe('[PROJECT] LWW Update');
});
it('should show snack notification when ops are created', async () => {
it('no longer fires the bare count snack when merge ops are created (SPAP-15)', async () => {
const supersededOp = createMockOperation('op-1', 'TASK', 'task-1', { clientA: 1 });
const entityState = { id: 'task-1' };
@ -481,7 +481,10 @@ describe('SupersededOperationResolverService', () => {
await service.resolveSupersededLocalOps([{ opId: 'op-1', op: supersededOp }]);
expect(mockSnackService.open).toHaveBeenCalledWith(
// SPAP-15: the LWW_CONFLICTS_AUTO_RESOLVED snack was replaced by the
// journal-driven summary banner. Superseded-merge ops are self-healing
// local wins and are not journaled, so no count snack fires here.
expect(mockSnackService.open).not.toHaveBeenCalledWith(
jasmine.objectContaining({
translateParams: { localWins: 1, remoteWins: 0 },
}),
@ -1064,7 +1067,7 @@ describe('SupersededOperationResolverService', () => {
expect(updateOp?.opType).toBe(OpType.Update);
});
it('should show conflict resolution snack for DELETE ops', async () => {
it('no longer emits a bare count snack for DELETE ops (SPAP-15 journal-driven banner)', async () => {
const supersededDeleteOp = createMockDeleteOperation('op-1', 'TASK', 'task-1', {
clientA: 1,
});
@ -1075,7 +1078,10 @@ describe('SupersededOperationResolverService', () => {
{ opId: 'op-1', op: supersededDeleteOp },
]);
expect(mockSnackService.open).toHaveBeenCalledWith(
// SPAP-15: the bare count snack was replaced by the journal-driven summary
// banner. Superseded self-heals are not journaled, so no count notification
// fires for them (behavior change — flagged for review).
expect(mockSnackService.open).not.toHaveBeenCalledWith(
jasmine.objectContaining({
translateParams: { localWins: 1, remoteWins: 0 },
}),

View file

@ -9,6 +9,7 @@ import { LockService } from './lock.service';
import { toEntityKey } from '../util/entity-key.util';
import { LOCK_NAMES } from '../core/operation-log.const';
import { SnackService } from '../../core/snack/snack.service';
import { SyncConflictBannerService } from './sync-conflict-banner.service';
import { T } from '../../t.const';
import { CLIENT_ID_PROVIDER } from '../util/client-id.provider';
import { uuidv7 } from '../../util/uuid-v7';
@ -40,6 +41,7 @@ export class SupersededOperationResolverService {
private conflictResolutionService = inject(ConflictResolutionService);
private lockService = inject(LockService);
private snackService = inject(SnackService);
private syncConflictBanner = inject(SyncConflictBannerService);
private clientIdProvider = inject(CLIENT_ID_PROVIDER);
/**
@ -281,13 +283,9 @@ export class SupersededOperationResolverService {
}
if (newOpsCreated.length > 0) {
this.snackService.open({
msg: T.F.SYNC.S.LWW_CONFLICTS_AUTO_RESOLVED,
translateParams: {
localWins: newOpsCreated.length,
remoteWins: 0,
},
});
// SPAP-15: surface via the journal-driven summary banner (with REVIEW)
// instead of a bare snack.
await this.syncConflictBanner.maybeShowSummaryBanner();
}
// Notify user if local changes were discarded because entities no longer exist

View file

@ -0,0 +1,91 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import {
SyncConflictBannerService,
SYNC_CONFLICTS_ROUTE,
} from './sync-conflict-banner.service';
import { ConflictJournalService } from './conflict-journal.service';
import { ConflictJournalEntry } from './conflict-journal.model';
import { BannerService } from '../../core/banner/banner.service';
import { BannerId } from '../../core/banner/banner.model';
import { EntityType } from '../core/operation.types';
import { T } from '../../t.const';
const makeEntry = (over: Partial<ConflictJournalEntry> = {}): ConflictJournalEntry => ({
id: Math.random().toString(36).slice(2),
entityType: 'TASK' as EntityType,
entityId: 'task-1',
entityTitle: 'Test Task',
resolvedAt: Date.now(),
winner: 'remote',
reason: 'newer',
fieldDiffs: [],
localClientId: 'A',
remoteClientId: 'B',
localTs: 1000,
remoteTs: 2000,
status: 'unreviewed',
...over,
});
describe('SyncConflictBannerService', () => {
let service: SyncConflictBannerService;
let journal: ConflictJournalService;
let bannerService: jasmine.SpyObj<BannerService>;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
bannerService = jasmine.createSpyObj('BannerService', ['open', 'dismiss']);
router = jasmine.createSpyObj('Router', ['navigate']);
TestBed.configureTestingModule({
providers: [
SyncConflictBannerService,
ConflictJournalService,
{ provide: BannerService, useValue: bannerService },
{ provide: Router, useValue: router },
],
});
service = TestBed.inject(SyncConflictBannerService);
journal = TestBed.inject(ConflictJournalService);
});
it('opens the banner with correct win counts when there are unreviewed conflicts', async () => {
await journal.record(makeEntry({ winner: 'remote' }));
await journal.record(makeEntry({ winner: 'remote' }));
await journal.record(makeEntry({ winner: 'local' }));
await service.maybeShowSummaryBanner();
expect(bannerService.open).toHaveBeenCalledTimes(1);
const banner = bannerService.open.calls.mostRecent().args[0];
expect(banner.id).toBe(BannerId.SyncConflictsAutoResolved);
expect(banner.msg).toBe(T.F.SYNC.CONFLICT_REVIEW.BANNER_MSG);
expect(banner.translateParams).toEqual({ count: 3, remoteWins: 2, localWins: 1 });
expect(banner.action?.label).toBe(T.F.SYNC.CONFLICT_REVIEW.BANNER_REVIEW);
});
it('does NOT open the banner when there are no unreviewed conflicts', async () => {
// Only reviewed/info entries — nothing to surface.
await journal.record(makeEntry({ status: 'kept' }));
await journal.record(makeEntry({ status: 'info', winner: 'merged' }));
await service.maybeShowSummaryBanner();
expect(bannerService.open).not.toHaveBeenCalled();
expect(bannerService.dismiss).toHaveBeenCalledWith(
BannerId.SyncConflictsAutoResolved,
);
});
it('REVIEW action navigates to the conflicts page', async () => {
await journal.record(makeEntry());
await service.maybeShowSummaryBanner();
const banner = bannerService.open.calls.mostRecent().args[0];
banner.action?.fn();
expect(router.navigate).toHaveBeenCalledWith([SYNC_CONFLICTS_ROUTE]);
});
});

View file

@ -0,0 +1,62 @@
/**
* SPAP-15 Summary banner for auto-resolved sync conflicts.
*
* Replaces the bare `LWW_CONFLICTS_AUTO_RESOLVED` snacks. After a sync it reads
* the journal's UNREVIEWED entries and, if any exist, shows one dismissible
* banner: "N sync conflicts auto-resolved (X remote, Y local won)" with a REVIEW
* action that opens the review page. DISMISS (the banner's built-in button) only
* hides the banner the persistent sync-icon badge keeps surfacing the count.
*/
import { inject, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { BannerService } from '../../core/banner/banner.service';
import { BannerId } from '../../core/banner/banner.model';
import { ConflictJournalService } from './conflict-journal.service';
import { computeWinCounts } from './sync-conflict-review.util';
import { T } from '../../t.const';
/** Route path of the Sync Conflicts review page. */
export const SYNC_CONFLICTS_ROUTE = '/sync-conflicts';
const CR = T.F.SYNC.CONFLICT_REVIEW;
@Injectable({ providedIn: 'root' })
export class SyncConflictBannerService {
private readonly _bannerService = inject(BannerService);
// Optional so the many sync specs that construct the resolver services (which
// now depend on this) don't all have to provide a Router.
private readonly _router = inject(Router, { optional: true });
private readonly _journal = inject(ConflictJournalService);
/** Navigate to the review page (shared by the banner action and elsewhere). */
navigateToReview(): void {
void this._router?.navigate([SYNC_CONFLICTS_ROUTE]);
}
/**
* Opens the summary banner iff there are unreviewed conflicts. No-ops (and
* dismisses any stale banner) when the unreviewed count is zero, so routine
* self-healing syncs stay silent.
*/
async maybeShowSummaryBanner(): Promise<void> {
const unreviewed = await this._journal.list('unreviewed');
const { total, remoteWins, localWins } = computeWinCounts(unreviewed);
if (total === 0) {
this._bannerService.dismiss(BannerId.SyncConflictsAutoResolved);
return;
}
this._bannerService.open({
id: BannerId.SyncConflictsAutoResolved,
ico: 'sync_problem',
msg: CR.BANNER_MSG,
translateParams: { count: total, remoteWins, localWins },
action: {
label: CR.BANNER_REVIEW,
fn: () => this.navigateToReview(),
},
});
}
}

View file

@ -0,0 +1,232 @@
import {
computeWinCounts,
groupByEntityType,
loserChangesFor,
reasonI18nKey,
statusI18nKey,
winnerChangesFor,
winnerI18nKey,
} from './sync-conflict-review.util';
import { ConflictJournalEntry } from './conflict-journal.model';
import { EntityType } from '../core/operation.types';
import { T } from '../../t.const';
const makeEntry = (over: Partial<ConflictJournalEntry> = {}): ConflictJournalEntry => ({
id: 'e',
entityType: 'TASK' as EntityType,
entityId: 'task-1',
entityTitle: 'Test Task',
resolvedAt: 1000,
winner: 'remote',
reason: 'newer',
fieldDiffs: [],
localClientId: 'A',
remoteClientId: 'B',
localTs: 1000,
remoteTs: 2000,
status: 'unreviewed',
...over,
});
describe('sync-conflict-review.util', () => {
describe('computeWinCounts', () => {
it('tallies remote/local winners and excludes merged from the breakdown', () => {
const counts = computeWinCounts([
makeEntry({ winner: 'remote' }),
makeEntry({ winner: 'remote' }),
makeEntry({ winner: 'local' }),
makeEntry({ winner: 'merged' }),
]);
expect(counts).toEqual({ total: 4, remoteWins: 2, localWins: 1 });
});
it('is all-zero for an empty list', () => {
expect(computeWinCounts([])).toEqual({ total: 0, remoteWins: 0, localWins: 0 });
});
});
describe('loserChangesFor / winnerChangesFor', () => {
const entry = makeEntry({
winner: 'remote',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
pickedSide: 'remote',
},
{
field: 'notes',
localVal: 'Local notes',
remoteVal: 'Remote notes',
pickedSide: 'remote',
},
],
});
it('loserChangesFor returns the discarded (losing) side values', () => {
// remote won, so the loser is local
expect(loserChangesFor(entry)).toEqual({
title: 'Local title',
notes: 'Local notes',
});
});
it('winnerChangesFor returns the kept (winning) side values', () => {
expect(winnerChangesFor(entry)).toEqual({
title: 'Remote title',
notes: 'Remote notes',
});
});
it('skips diffs with no pickedSide (merged fields)', () => {
const merged = makeEntry({
winner: 'merged',
fieldDiffs: [
// pickedSide 'local' but remote never changed the field → no loser value
{ field: 'title', localVal: 'L', remoteVal: undefined, pickedSide: 'local' },
{ field: 'x', localVal: 1, remoteVal: 2 }, // no pickedSide
],
});
expect(loserChangesFor(merged)).toEqual({});
});
it('loserChangesFor omits fields the losing side never changed', () => {
// local (loser) changed only title; remote (winner) changed title + notes.
// Emitting notes: undefined would CLEAR the winner-only field on flip.
const e = makeEntry({
winner: 'remote',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
localChanged: true,
remoteChanged: true,
pickedSide: 'remote',
},
{
field: 'notes',
localVal: undefined,
remoteVal: 'Remote notes',
localChanged: false,
remoteChanged: true,
pickedSide: 'remote',
},
],
});
const changes = loserChangesFor(e);
expect(changes).toEqual({ title: 'Local title' });
expect(Object.prototype.hasOwnProperty.call(changes, 'notes')).toBe(false);
});
it('loserChangesFor falls back to value-presence for legacy diffs without flags', () => {
const e = makeEntry({
winner: 'remote',
fieldDiffs: [
{
field: 'notes',
localVal: undefined,
remoteVal: 'Remote notes',
pickedSide: 'remote',
},
],
});
expect(Object.prototype.hasOwnProperty.call(loserChangesFor(e), 'notes')).toBe(
false,
);
});
it('returns nothing for merged entries even when a tiebroken noise diff has a pickedSide', () => {
// buildMergedFieldDiffs sets pickedSide on EVERY diff (incl. the noise
// tiebreak), so per-diff pickedSide checks alone don't exclude merged
// entries — nothing was discarded, so there is no loser/winner side.
const merged = makeEntry({
winner: 'merged',
fieldDiffs: [
{
field: 'modified',
localVal: 1111,
remoteVal: 2222,
localChanged: true,
remoteChanged: true,
pickedSide: 'remote',
},
],
});
expect(loserChangesFor(merged)).toEqual({});
expect(winnerChangesFor(merged)).toEqual({});
});
it('excludes action-payload diffs from both loser and winner changes', () => {
// kind: 'action' diffs carry a raw action payload, not an entity field —
// dispatching or stale-comparing them would corrupt the entity.
const e = makeEntry({
winner: 'remote',
fieldDiffs: [
{
field: '[Task] Convert to sub task',
localVal: { taskId: 'task-1', targetParentId: 'parent-1' },
remoteVal: undefined,
localChanged: true,
remoteChanged: false,
pickedSide: 'remote',
kind: 'action',
},
],
});
expect(loserChangesFor(e)).toEqual({});
expect(winnerChangesFor(e)).toEqual({});
});
it('winnerChangesFor omits fields the winning side never changed', () => {
// Loser-only field: leaking winner-side undefined into the stale compare
// would flag EVERY such entry stale (undefined never equals current value).
const e = makeEntry({
winner: 'local',
fieldDiffs: [
{
field: 'notes',
localVal: undefined,
remoteVal: 'Remote notes',
localChanged: false,
remoteChanged: true,
pickedSide: 'local',
},
],
});
expect(Object.prototype.hasOwnProperty.call(winnerChangesFor(e), 'notes')).toBe(
false,
);
});
});
describe('i18n key mappers', () => {
it('maps reasons', () => {
expect(reasonI18nKey('delete-wins')).toBe(
T.F.SYNC.CONFLICT_REVIEW.REASON_DELETE_WINS,
);
expect(reasonI18nKey('disjoint-merge')).toBe(
T.F.SYNC.CONFLICT_REVIEW.REASON_DISJOINT_MERGE,
);
});
it('maps winners and statuses', () => {
expect(winnerI18nKey('local')).toBe(T.F.SYNC.CONFLICT_REVIEW.WINNER_LOCAL);
expect(statusI18nKey('flipped')).toBe(T.F.SYNC.CONFLICT_REVIEW.STATUS_FLIPPED);
});
});
describe('groupByEntityType', () => {
it('groups by entity type preserving order', () => {
const groups = groupByEntityType([
makeEntry({ id: 't1', entityType: 'TASK' as EntityType }),
makeEntry({ id: 'p1', entityType: 'PROJECT' as EntityType }),
makeEntry({ id: 't2', entityType: 'TASK' as EntityType }),
]);
expect(groups.map((g) => g.entityType)).toEqual(['TASK', 'PROJECT']);
expect(groups[0].entries.map((e) => e.id)).toEqual(['t1', 't2']);
expect(groups[0].labelKey).toBe(T.F.SYNC.CONFLICT_REVIEW.GROUP_TASK);
});
});
});

View file

@ -0,0 +1,202 @@
/**
* SPAP-15 Pure presentation/derivation helpers for the Sync Conflicts review
* UI. No Angular / NgRx / IndexedDB dependencies so these are trivially unit
* testable and can be reused by the page component, the flip service and the
* banner service.
*/
import { EntityType } from '../core/operation.types';
import { T } from '../../t.const';
import {
ConflictJournalEntry,
ConflictJournalFieldDiff,
ConflictJournalReason,
ConflictJournalStatus,
ConflictJournalWinner,
} from './conflict-journal.model';
const CR = T.F.SYNC.CONFLICT_REVIEW;
export interface ConflictWinCounts {
total: number;
/** Entries where the remote side won (a local edit was discarded). */
remoteWins: number;
/** Entries where the local side won (a remote edit was discarded). */
localWins: number;
}
/**
* Counts how the given entries resolved. Only `local`/`remote` winners are
* tallied into the breakdown; `merged` entries have no losing side and are
* excluded from both win buckets (but still counted in `total`).
*/
export const computeWinCounts = (
entries: readonly ConflictJournalEntry[],
): ConflictWinCounts => {
let remoteWins = 0;
let localWins = 0;
for (const e of entries) {
if (e.winner === 'remote') {
remoteWins++;
} else if (e.winner === 'local') {
localWins++;
}
}
return { total: entries.length, remoteWins, localWins };
};
/**
* Whether the given side actually changed the diffed field. Falls back to
* value-presence for entries persisted before the `localChanged`/`remoteChanged`
* flags existed exact for that data, since op payloads are pure JSON and can
* never carry a real `undefined`.
*/
const sideChanged = (
diff: ConflictJournalFieldDiff,
side: 'local' | 'remote',
): boolean =>
side === 'local'
? (diff.localChanged ?? diff.localVal !== undefined)
: (diff.remoteChanged ?? diff.remoteVal !== undefined);
/**
* For each diffed field, the value of the side that LWW *discarded* (the loser).
* Applying this map re-instates the losing edit this is exactly what "flip"
* dispatches. Skipped: `merged` entries as a whole (nothing was discarded and
* their noise-tiebreak diffs DO carry a `pickedSide`, so the per-diff check
* alone would not exclude them), `kind: 'action'` diffs, and fields the losing
* side never changed (a union diff records those as `undefined`, and
* dispatching them would CLEAR winner-only fields instead of layering the
* discarded edit on top).
*/
export const loserChangesFor = (entry: ConflictJournalEntry): Record<string, unknown> => {
const changes: Record<string, unknown> = {};
if (entry.winner === 'merged') {
return changes;
}
for (const diff of entry.fieldDiffs) {
if (diff.kind === 'action') {
// Raw action payload of an opaque op — not an entity field.
continue;
}
if (diff.pickedSide === 'local' && sideChanged(diff, 'remote')) {
changes[diff.field] = diff.remoteVal;
} else if (diff.pickedSide === 'remote' && sideChanged(diff, 'local')) {
changes[diff.field] = diff.localVal;
}
}
return changes;
};
/**
* For each diffed field the winner actually changed, the value LWW *kept*.
* The stale-flip guard compares the entity's CURRENT field values to these: if
* any differ, the entity was edited since the conflict resolved and flipping
* would overwrite that newer edit. Loser-only fields are omitted the winner
* recorded no value for them, and comparing `undefined` against the live entity
* would flag every such entry stale.
*/
export const winnerChangesFor = (
entry: ConflictJournalEntry,
): Record<string, unknown> => {
const changes: Record<string, unknown> = {};
// Merged entries have no winner/loser side — see loserChangesFor.
if (entry.winner === 'merged') {
return changes;
}
for (const diff of entry.fieldDiffs) {
if (diff.kind === 'action') {
// Raw action payload of an opaque op — not an entity field.
continue;
}
if (diff.pickedSide === 'local' && sideChanged(diff, 'local')) {
changes[diff.field] = diff.localVal;
} else if (diff.pickedSide === 'remote' && sideChanged(diff, 'remote')) {
changes[diff.field] = diff.remoteVal;
}
}
return changes;
};
/** Which side supplied a given field's value in a merged entry. */
export const mergedFieldSideKey = (diff: ConflictJournalFieldDiff): string =>
diff.pickedSide === 'remote' ? CR.SIDE_REMOTE : CR.SIDE_LOCAL;
// Kebab-case reason values can't be object-literal keys (lint naming rule), so
// a Map keeps the mapping explicit and typed.
const REASON_KEYS: ReadonlyMap<ConflictJournalReason, string> = new Map([
['newer', CR.REASON_NEWER],
['tie', CR.REASON_TIE],
['delete-wins', CR.REASON_DELETE_WINS],
['delete-lost', CR.REASON_DELETE_LOST],
['disjoint-merge', CR.REASON_DISJOINT_MERGE],
['noise', CR.REASON_NOISE],
['clock-corruption-suspected', CR.REASON_CLOCK_CORRUPTION],
]);
export const reasonI18nKey = (reason: ConflictJournalReason): string =>
REASON_KEYS.get(reason) ?? reason;
const WINNER_KEYS: Record<ConflictJournalWinner, string> = {
local: CR.WINNER_LOCAL,
remote: CR.WINNER_REMOTE,
merged: CR.WINNER_MERGED,
};
export const winnerI18nKey = (winner: ConflictJournalWinner): string =>
WINNER_KEYS[winner] ?? winner;
const STATUS_KEYS: Record<ConflictJournalStatus, string> = {
unreviewed: CR.STATUS_UNREVIEWED,
kept: CR.STATUS_KEPT,
flipped: CR.STATUS_FLIPPED,
info: CR.STATUS_INFO,
expired: CR.STATUS_INFO,
};
export const statusI18nKey = (status: ConflictJournalStatus): string =>
STATUS_KEYS[status] ?? status;
const GROUP_KEYS: Partial<Record<EntityType, string>> = {
TASK: CR.GROUP_TASK,
PROJECT: CR.GROUP_PROJECT,
TAG: CR.GROUP_TAG,
NOTE: CR.GROUP_NOTE,
};
export const groupLabelKey = (entityType: EntityType): string =>
GROUP_KEYS[entityType] ?? CR.GROUP_OTHER;
/** Short, opaque device label — no friendly-name mechanism exists in the app. */
export const shortClientId = (clientId: string): string =>
clientId ? clientId.slice(0, 8) : '?';
/** A group of entries that share one entity type, for grouped rendering. */
export interface ConflictEntryGroup {
entityType: EntityType;
labelKey: string;
entries: ConflictJournalEntry[];
}
/**
* Groups entries by entity type, preserving the incoming (newest-first) order
* both across and within groups. Group order follows first appearance.
*/
export const groupByEntityType = (
entries: readonly ConflictJournalEntry[],
): ConflictEntryGroup[] => {
const byType = new Map<EntityType, ConflictJournalEntry[]>();
for (const e of entries) {
const list = byType.get(e.entityType);
if (list) {
list.push(e);
} else {
byType.set(e.entityType, [e]);
}
}
return Array.from(byType, ([entityType, groupEntries]) => ({
entityType,
labelKey: groupLabelKey(entityType),
entries: groupEntries,
}));
};

View file

@ -0,0 +1,577 @@
import { TestBed } from '@angular/core/testing';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { Store } from '@ngrx/store';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { SyncConflictUiService } from './sync-conflict-ui.service';
import { ConflictJournalService } from './conflict-journal.service';
import { ConflictJournalEntry } from './conflict-journal.model';
import { SnackService } from '../../core/snack/snack.service';
import { EntityType } from '../core/operation.types';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { selectTaskById } from '../../features/tasks/store/task.selectors';
import { Task } from '../../features/tasks/task.model';
const makeEntry = (over: Partial<ConflictJournalEntry> = {}): ConflictJournalEntry => ({
id: 'e1',
entityType: 'TASK' as EntityType,
entityId: 'task-1',
entityTitle: 'Test Task',
resolvedAt: 1000,
winner: 'remote',
reason: 'newer',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
pickedSide: 'remote',
},
],
localClientId: 'A',
remoteClientId: 'B',
localTs: 1000,
remoteTs: 2000,
status: 'unreviewed',
...over,
});
describe('SyncConflictUiService', () => {
let service: SyncConflictUiService;
let journal: ConflictJournalService;
let store: MockStore;
let matDialog: jasmine.SpyObj<MatDialog>;
let dispatchSpy: jasmine.Spy;
const setDialogResult = (res: boolean): void => {
matDialog.open.and.returnValue({ afterClosed: () => of(res) } as never);
};
beforeEach(() => {
matDialog = jasmine.createSpyObj('MatDialog', ['open']);
setDialogResult(true);
TestBed.configureTestingModule({
providers: [
SyncConflictUiService,
ConflictJournalService,
{
provide: SnackService,
useValue: jasmine.createSpyObj('SnackService', ['open']),
},
{ provide: MatDialog, useValue: matDialog },
provideMockStore({ initialState: {} }),
],
});
service = TestBed.inject(SyncConflictUiService);
journal = TestBed.inject(ConflictJournalService);
store = TestBed.inject(Store) as MockStore;
// Not stale by default: current title equals the journaled winner value.
store.overrideSelector(selectTaskById, {
id: 'task-1',
title: 'Remote title',
} as Task);
dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
});
it('keep() marks the entry kept', async () => {
const entry = makeEntry();
await journal.record(entry);
await service.keep(entry);
expect((await journal.getEntry('e1'))?.status).toBe('kept');
});
it('flip() dispatches a normal update op with the LOSER values and marks flipped', async () => {
const entry = makeEntry();
await journal.record(entry);
const result = await service.flip(entry);
expect(result).toBe('applied');
expect(dispatchSpy).toHaveBeenCalledWith(
TaskSharedActions.updateTask({
task: { id: 'task-1', changes: { title: 'Local title' } },
isIgnoreShortSyntax: true,
}),
);
expect((await journal.getEntry('e1'))?.status).toBe('flipped');
});
it('flip() shows the stale confirm when the entity changed since resolution', async () => {
const entry = makeEntry();
await journal.record(entry);
// Current title differs from the journaled winner ("Remote title") → stale.
store.overrideSelector(selectTaskById, {
id: 'task-1',
title: 'Edited later',
} as Task);
store.refreshState();
setDialogResult(true);
const result = await service.flip(entry);
expect(matDialog.open).toHaveBeenCalled();
expect(result).toBe('applied');
expect(dispatchSpy).toHaveBeenCalled();
});
it('flip() aborts (no dispatch) when the stale confirm is cancelled', async () => {
const entry = makeEntry();
await journal.record(entry);
store.overrideSelector(selectTaskById, {
id: 'task-1',
title: 'Edited later',
} as Task);
store.refreshState();
setDialogResult(false);
const result = await service.flip(entry);
expect(result).toBe('cancelled');
expect(dispatchSpy).not.toHaveBeenCalled();
expect((await journal.getEntry('e1'))?.status).toBe('unreviewed');
});
it('getStaleState() flags stale when a LOSER-ONLY field was edited after resolution', async () => {
// Overlapping-field LWW conflict: loser changed title+notes, winner changed
// only title (remote won). `notes` is a loser-only field — winnerChangesFor
// cannot see it, so the guard was blind to a post-resolution notes edit and
// flip would silently overwrite it. getStaleState must now detect it.
const entry = makeEntry({
id: 'lo1',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
localChanged: true,
remoteChanged: true,
pickedSide: 'remote',
},
{
field: 'notes',
localVal: 'Loser notes',
remoteVal: undefined,
localChanged: true,
remoteChanged: false,
pickedSide: 'remote',
},
],
});
// Winner field (title) unchanged, but notes was edited to a value that is
// neither the kept value nor what flip would write.
store.overrideSelector(selectTaskById, {
id: 'task-1',
title: 'Remote title',
notes: 'USER-EDIT',
} as Task);
store.refreshState();
const stale = await service.getStaleState(entry);
expect(stale.isStale).toBe(true);
});
it('flip() shows the stale confirm for a post-resolution loser-only edit (previously silent)', async () => {
const entry = makeEntry({
id: 'lo2',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
localChanged: true,
remoteChanged: true,
pickedSide: 'remote',
},
{
field: 'notes',
localVal: 'Loser notes',
remoteVal: undefined,
localChanged: true,
remoteChanged: false,
pickedSide: 'remote',
},
],
});
await journal.record(entry);
store.overrideSelector(selectTaskById, {
id: 'task-1',
title: 'Remote title',
notes: 'USER-EDIT',
} as Task);
store.refreshState();
setDialogResult(false); // user cancels → flip must NOT silently overwrite
const result = await service.flip(entry);
expect(matDialog.open).toHaveBeenCalled();
expect(result).toBe('cancelled');
expect(dispatchSpy).not.toHaveBeenCalled();
});
it('flipAllToSide() skips stale entries (never silently overwrites in bulk)', async () => {
// Remote-won entry whose loser-only notes field diverged after resolution.
// Bulk flip-to-local must SKIP it (leave unreviewed), not silently overwrite
// the newer notes — the bulk path shows no per-entry confirm.
const entry = makeEntry({
id: 'bulk1',
winner: 'remote',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
localChanged: true,
remoteChanged: true,
pickedSide: 'remote',
},
{
field: 'notes',
localVal: 'Loser notes',
remoteVal: undefined,
localChanged: true,
remoteChanged: false,
pickedSide: 'remote',
},
],
});
await journal.record(entry);
store.overrideSelector(selectTaskById, {
id: 'task-1',
title: 'Remote title',
notes: 'USER-EDIT',
} as Task);
store.refreshState();
await service.flipAllToSide([entry], 'local');
expect(dispatchSpy).not.toHaveBeenCalled();
expect((await journal.getEntry('bulk1'))?.status).toBe('unreviewed');
});
it('getStaleState() does NOT false-positive on a LOCAL-won loser-only field (no post-edit)', async () => {
// winner='local': the loser (remote) changed `notes`, which was rejected and
// never applied, so current.notes sits at base — which legitimately differs
// from the remote value flip would write. That is NOT a post-resolution edit,
// so it must NOT be flagged stale (the loser-only check only applies when the
// loser is LOCAL, i.e. winner='remote').
const entry = makeEntry({
id: 'lw1',
winner: 'local',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
localChanged: true,
remoteChanged: true,
pickedSide: 'local',
},
{
field: 'notes',
localVal: undefined,
remoteVal: 'Remote notes',
localChanged: false,
remoteChanged: true,
pickedSide: 'local',
},
],
});
store.overrideSelector(selectTaskById, {
id: 'task-1',
title: 'Local title',
notes: 'base notes',
} as Task);
store.refreshState();
const stale = await service.getStaleState(entry);
expect(stale.isStale).toBe(false);
});
it('flipAllToSide() flips a clean LOCAL-won entry (not falsely skipped as stale)', async () => {
const entry = makeEntry({
id: 'lw2',
winner: 'local',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
localChanged: true,
remoteChanged: true,
pickedSide: 'local',
},
{
field: 'notes',
localVal: undefined,
remoteVal: 'Remote notes',
localChanged: false,
remoteChanged: true,
pickedSide: 'local',
},
],
});
await journal.record(entry);
store.overrideSelector(selectTaskById, {
id: 'task-1',
title: 'Local title',
notes: 'base notes',
} as Task);
store.refreshState();
// flip-to-remote targets local-won entries; a clean one must actually flip.
await service.flipAllToSide([entry], 'remote');
expect(dispatchSpy).toHaveBeenCalled();
expect((await journal.getEntry('lw2'))?.status).toBe('flipped');
});
it('flip() suppresses short-syntax parsing on the re-applied title', async () => {
// The canonical flip is a rename conflict → changes is exactly { title },
// which is precisely the shape shortSyntax$ re-parses in replace mode. A
// journaled title like "Fix bug #urgent" must be re-applied LITERALLY, not
// re-parsed into tag/project/schedule mutations.
const entry = makeEntry({ id: 'ss1' });
await journal.record(entry);
const result = await service.flip(entry);
expect(result).toBe('applied');
const dispatched = dispatchSpy.calls.mostRecent().args[0] as ReturnType<
typeof TaskSharedActions.updateTask
>;
expect(dispatched.isIgnoreShortSyntax).toBe(true);
});
it('flip() only dispatches fields the losing side actually changed', async () => {
// local (loser) changed only title; remote (winner) also changed notes.
// The dispatched changes must NOT contain notes: undefined — that would
// clear the winner-only field instead of layering the discarded edit.
const entry = makeEntry({
id: 'presence1',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
localChanged: true,
remoteChanged: true,
pickedSide: 'remote',
},
{
field: 'notes',
localVal: undefined,
remoteVal: 'Remote notes',
localChanged: false,
remoteChanged: true,
pickedSide: 'remote',
},
],
});
await journal.record(entry);
const result = await service.flip(entry);
expect(result).toBe('applied');
const dispatched = dispatchSpy.calls.mostRecent().args[0] as ReturnType<
typeof TaskSharedActions.updateTask
>;
const changes = dispatched.task.changes as Record<string, unknown>;
expect(changes).toEqual({ title: 'Local title' });
expect(Object.prototype.hasOwnProperty.call(changes, 'notes')).toBe(false);
});
it('canFlip() is false for delete-lost and delete-wins entries', () => {
expect(service.canFlip(makeEntry({ reason: 'delete-lost', fieldDiffs: [] }))).toBe(
false,
);
expect(service.canFlip(makeEntry({ reason: 'delete-wins' }))).toBe(false);
});
it('flip() reports unsupported for delete-lost (no false success)', async () => {
// delete-lost: the entity was resurrected, fieldDiffs is empty. A normal
// update op cannot re-apply the delete, so flip must NOT mark the entry
// flipped / return applied while dispatching nothing.
const entry = makeEntry({ id: 'dl1', reason: 'delete-lost', fieldDiffs: [] });
await journal.record(entry);
const result = await service.flip(entry);
expect(result).toBe('unsupported');
expect(dispatchSpy).not.toHaveBeenCalled();
expect((await journal.getEntry('dl1'))?.status).toBe('unreviewed');
});
it('flip() reports unsupported when the entry has no discarded field values', async () => {
const entry = makeEntry({ id: 'empty1', fieldDiffs: [] });
await journal.record(entry);
const result = await service.flip(entry);
expect(result).toBe('unsupported');
expect(dispatchSpy).not.toHaveBeenCalled();
expect((await journal.getEntry('empty1'))?.status).toBe('unreviewed');
});
it('canFlip() is false when the discarded side touched relationship fields', () => {
const entry = makeEntry({
fieldDiffs: [
{
field: 'projectId',
localVal: 'project-A',
remoteVal: 'project-B',
pickedSide: 'remote',
},
],
});
expect(service.canFlip(entry)).toBe(false);
});
it('flip() reports unsupported when loser changes include relationship fields', async () => {
// Re-applying projectId/subTaskIds/... via a bare adapter update bypasses
// the multi-entity meta-reducer invariants (membership lists on the other
// entity are not updated), so such flips must be refused, not dispatched.
const entry = makeEntry({
id: 'rel1',
fieldDiffs: [
{
field: 'title',
localVal: 'Local title',
remoteVal: 'Remote title',
pickedSide: 'remote',
},
{
field: 'projectId',
localVal: 'project-A',
remoteVal: 'project-B',
pickedSide: 'remote',
},
],
});
await journal.record(entry);
const result = await service.flip(entry);
expect(result).toBe('unsupported');
expect(dispatchSpy).not.toHaveBeenCalled();
expect((await journal.getEntry('rel1'))?.status).toBe('unreviewed');
});
it('canFlip() is false when the discarded side touched schedule/reminder fields', () => {
// dueDay/dueWithTime/deadline*/reminderId invariants (mutual exclusivity,
// TODAY_TAG membership, reminder create/cancel) are maintained by dedicated
// flows, not by a bare updateTask — same honest-refusal policy as
// relationship fields.
const entry = makeEntry({
fieldDiffs: [
{
field: 'dueWithTime',
localVal: 111,
remoteVal: 222,
localChanged: true,
remoteChanged: true,
pickedSide: 'remote',
},
],
});
expect(service.canFlip(entry)).toBe(false);
});
// Drift guard: every reminder/schedule field whose invariants live in a
// dedicated flow must be refused by canFlip. Adding such a Task field without
// adding it to FLIP_UNSAFE_FIELDS (a deny-list — unlisted fields are flippable
// by default) would let a bare updateTask flip write the field WITHOUT
// scheduling/cancelling the reminder or enforcing due/deadline exclusivity.
(['remindAt', 'deadlineRemindAt', 'dueDay', 'deadlineWithTime'] as const).forEach(
(field) => {
it(`canFlip() is false when the discarded side touched ${field}`, () => {
const entry = makeEntry({
fieldDiffs: [
{
field,
localVal: 111,
remoteVal: 222,
localChanged: true,
remoteChanged: true,
pickedSide: 'remote',
},
],
});
expect(service.canFlip(entry)).toBe(false);
});
},
);
it('getStaleState() returns no current entity for factory-selector types (ISSUE_PROVIDER)', async () => {
// ISSUE_PROVIDER registers a (id, key) => selector FACTORY, not a props
// selector. Calling it as a props selector returns the inner selector
// FUNCTION as the "entity", rendering a bogus current column + stale flag.
const entry = makeEntry({
id: 'ip1',
entityType: 'ISSUE_PROVIDER' as EntityType,
entityId: 'provider-1',
});
const stale = await service.getStaleState(entry);
expect(stale.current).toBeUndefined();
expect(stale.isStale).toBe(false);
});
it('getStaleState() resolves (no rejection) when the entity selector throws', async () => {
// selectTagById / selectNoteById THROW on a missing entity (unlike
// TASK/PROJECT which return undefined). Every delete-wins TAG/NOTE entry
// hits this on row expand — it must resolve to "no current entity", not
// reject through toggleExpand as an unhandled rejection.
const entry = makeEntry({
id: 'throw1',
entityType: 'TAG' as EntityType,
entityId: 'tag-gone',
});
const stale = await service.getStaleState(entry);
expect(stale.current).toBeUndefined();
expect(stale.isStale).toBe(false);
});
it('flip() reports unsupported (no rejection) when the entity selector throws', async () => {
const entry = makeEntry({
id: 'throw2',
entityType: 'TAG' as EntityType,
entityId: 'tag-gone',
});
await journal.record(entry);
const result = await service.flip(entry);
expect(result).toBe('unsupported');
expect(dispatchSpy).not.toHaveBeenCalled();
expect((await journal.getEntry('throw2'))?.status).toBe('unreviewed');
});
it('flip() reports unsupported for a non-adapter entity type', async () => {
const entry = makeEntry({ id: 'e2', entityType: 'GLOBAL_CONFIG' as EntityType });
await journal.record(entry);
const result = await service.flip(entry);
expect(result).toBe('unsupported');
expect(dispatchSpy).not.toHaveBeenCalled();
expect((await journal.getEntry('e2'))?.status).toBe('unreviewed');
});
it('flipAllToSide("local") only flips remote-won rows', async () => {
const remoteWon = makeEntry({ id: 'r1', winner: 'remote' });
const localWon = makeEntry({ id: 'l1', winner: 'local' });
await journal.record(remoteWon);
await journal.record(localWon);
await service.flipAllToSide([remoteWon, localWon], 'local');
// remote-won → flipped (local now wins); local-won → untouched
expect((await journal.getEntry('r1'))?.status).toBe('flipped');
expect((await journal.getEntry('l1'))?.status).toBe('unreviewed');
});
});

View file

@ -0,0 +1,358 @@
/**
* SPAP-15 Orchestration for the Sync Conflicts review UI.
*
* KEEP just confirms the auto-resolution (`markKept`). FLIP re-applies the
* *discarded* (losing) side of the conflict by dispatching a NORMAL entity
* update action the exact same action a manual edit dispatches so the
* `operationCaptureMetaReducer` turns it into a synced op that propagates to
* every device. There is NO history rewind: flipping is a brand-new edit layered
* on top of the current state.
*
* Stale-flip guard: before applying, the entity's CURRENT field values are
* compared to the journaled WINNER values. If they differ, the entity was edited
* after the conflict resolved, so flipping would overwrite that newer edit the
* user is asked to confirm first.
*/
import { inject, Injectable } from '@angular/core';
import { Action, Store } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { MatDialog } from '@angular/material/dialog';
import { firstValueFrom } from 'rxjs';
import { EntityType } from '../core/operation.types';
import { getEntityConfig, isAdapterEntity } from '../core/entity-registry';
import {
PropsStateSelector,
SelectByIdFactory,
} from '../core/entity-registry-host.types';
import { ConflictJournalService } from './conflict-journal.service';
import { ConflictJournalEntry, ConflictJournalReason } from './conflict-journal.model';
import { loserChangesFor, winnerChangesFor } from './sync-conflict-review.util';
import { SnackService } from '../../core/snack/snack.service';
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
import { T } from '../../t.const';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { updateProject } from '../../features/project/store/project.actions';
import { updateNote } from '../../features/note/store/note.actions';
import { updateTag } from '../../features/tag/store/tag.actions';
import { Task } from '../../features/tasks/task.model';
import { Project } from '../../features/project/project.model';
import { Note } from '../../features/note/note.model';
import { Tag } from '../../features/tag/tag.model';
const CR = T.F.SYNC.CONFLICT_REVIEW;
export type FlipResult = 'applied' | 'cancelled' | 'unsupported';
export interface StaleState {
isStale: boolean;
current: Record<string, unknown> | undefined;
}
/** Entity types whose flip is implemented via a normal `{id,changes}` update. */
const FLIP_SUPPORTED_TYPES: ReadonlySet<EntityType> = new Set<EntityType>([
'TASK',
'PROJECT',
'NOTE',
'TAG',
]);
/**
* Reasons whose flip needs delete/restore semantics that a normal update op
* cannot express: `delete-lost` would have to re-apply a delete, `delete-wins`
* would have to resurrect a deleted entity. Both are DEFERRED until then the
* entry stays reviewable but flip is refused instead of falsely succeeding.
*/
const FLIP_UNSUPPORTED_REASONS: ReadonlySet<ConflictJournalReason> =
new Set<ConflictJournalReason>(['delete-lost', 'delete-wins']);
/**
* Fields a bare `{id,changes}` update cannot safely re-apply, in two classes:
*
* - RELATIONSHIP fields kept consistent across entities by meta-reducers
* (e.g. moving a task rewrites BOTH `task.projectId` and the project's
* `taskIds`) re-applying one side of the pair would corrupt the other
* entity's membership list.
* - SCHEDULE/REMINDER fields whose invariants live in dedicated flows, not the
* reducer: `dueDay`/`dueWithTime` mutual exclusivity, `dueDay`TODAY_TAG
* membership, and reminder create/cancel tied to `reminderId`/`remindAt`/
* `deadlineRemindAt` (scheduled via `scheduleTaskWithTime`, torn down via
* `dismissReminderOnly`/`clearDeadlineReminder`) a bare update can produce
* the both-set state or a dangling/missing reminder.
*
* Flips touching any of these are refused until domain-specific handling
* exists (honest refusal over silent cross-entity corruption).
*
* NOTE: this is a deny-list any Task field NOT listed here is flippable by
* default. When adding a Task field with cross-entity or dedicated-flow
* invariants, add it here too; the spec's per-field `canFlip() is false when
* the discarded side touched <field>` cases pin the reminder/schedule members
* against silent drift.
*/
const FLIP_UNSAFE_FIELDS: ReadonlySet<string> = new Set<string>([
'projectId',
'parentId',
'subTaskIds',
'tagIds',
'taskIds',
'backlogTaskIds',
'noteIds',
'dueDay',
'dueWithTime',
'deadlineDay',
'deadlineWithTime',
'reminderId',
// Reminder-lifecycle timestamps: setting them via a bare update writes the
// field without scheduling/cancelling the actual reminder in ReminderService.
'remindAt',
'deadlineRemindAt',
]);
@Injectable({ providedIn: 'root' })
export class SyncConflictUiService {
private readonly _store = inject(Store);
private readonly _journal = inject(ConflictJournalService);
private readonly _snack = inject(SnackService);
private readonly _matDialog = inject(MatDialog);
/**
* Whether FLIP can be applied for this entry. Requires a supported entity
* type, a reason expressible as a normal update op (not delete/restore), and
* at least one discarded field value that is safe to re-apply (relationship
* and schedule/reminder fields are excluded see FLIP_UNSAFE_FIELDS).
*/
canFlip(entry: ConflictJournalEntry): boolean {
if (!FLIP_SUPPORTED_TYPES.has(entry.entityType)) {
return false;
}
if (FLIP_UNSUPPORTED_REASONS.has(entry.reason)) {
return false;
}
const fields = Object.keys(loserChangesFor(entry));
return fields.length > 0 && !fields.some((field) => FLIP_UNSAFE_FIELDS.has(field));
}
/** KEEP — confirm the auto-resolution. */
async keep(entry: ConflictJournalEntry): Promise<void> {
await this._journal.markKept(entry.id);
}
/**
* FLIP dispatch a normal update op that re-applies the loser's journaled
* field values, then mark the entry flipped. Returns what happened so the
* caller can surface it / refresh the list.
*/
async flip(
entry: ConflictJournalEntry,
opts: { skipStaleConfirm?: boolean } = {},
): Promise<FlipResult> {
if (!this.canFlip(entry)) {
this._snack.open({ msg: CR.FLIP_UNSUPPORTED, type: 'ERROR' });
return 'unsupported';
}
const changes = loserChangesFor(entry);
const action = this._buildUpdateAction(entry.entityType, entry.entityId, changes);
if (!action) {
this._snack.open({ msg: CR.FLIP_UNSUPPORTED, type: 'ERROR' });
return 'unsupported';
}
const { isStale, current } = await this.getStaleState(entry);
if (!current) {
// The entity is not in the live store — it was deleted (delete-wins) or
// archived out of the adapter. A normal update op can't recreate it, so
// rather than silently marking the entry "flipped" we report unsupported.
// Deletion-restore / archived-entity flips are DEFERRED (see report).
this._snack.open({ msg: CR.FLIP_UNSUPPORTED, type: 'ERROR' });
return 'unsupported';
}
if (!opts.skipStaleConfirm && isStale) {
const confirmed = await this._confirmStaleFlip(entry);
if (!confirmed) {
return 'cancelled';
}
}
// canFlip guarantees non-empty changes, so the op is always dispatched —
// an entry is only ever marked flipped when something was actually applied.
this._store.dispatch(action);
await this._journal.markFlipped(entry.id);
return 'applied';
}
/** Bulk KEEP — confirm every still-unreviewed entry. */
async keepAll(entries: readonly ConflictJournalEntry[]): Promise<void> {
for (const entry of entries) {
if (entry.status === 'unreviewed') {
await this._journal.markKept(entry.id);
}
}
}
/**
* Bulk FLIP toward one side: applies to rows where that side LOST (so flipping
* makes it win). `side='local'` targets remote-won entries; `side='remote'`
* targets local-won entries. Merged entries are never touched.
*
* Stale entries are SKIPPED (left unreviewed), never silently flipped: a bulk
* action must not overwrite an edit made after the conflict resolved. Because
* the bulk path shows no per-entry dialog, we cannot ask so we refuse the
* risky ones and leave them for per-entry flip (which surfaces the confirm).
*/
async flipAllToSide(
entries: readonly ConflictJournalEntry[],
side: 'local' | 'remote',
): Promise<void> {
const loserIsSide = side === 'local' ? 'remote' : 'local';
for (const entry of entries) {
if (
entry.status === 'unreviewed' &&
entry.winner === loserIsSide &&
this.canFlip(entry)
) {
const { isStale } = await this.getStaleState(entry);
if (isStale) {
continue;
}
await this.flip(entry, { skipStaleConfirm: true });
}
}
}
/**
* Reads the entity's CURRENT state and reports whether it diverged from the
* journaled winner values (i.e. was edited after the conflict resolved). Used
* both by the flip guard and by the page to surface a "current" column.
*/
async getStaleState(entry: ConflictJournalEntry): Promise<StaleState> {
const current = await this._readCurrentEntity(entry.entityType, entry.entityId);
if (!current) {
return { isStale: false, current: undefined };
}
// A field the WINNER changed diverged from its kept value → entity edited
// since resolution.
const winnerVals = winnerChangesFor(entry);
const winnerStale = Object.keys(winnerVals).some(
(field) => !this._valueEquals(current[field], winnerVals[field]),
);
// Loser-only fields: FLIP WILL write these, but the winner never changed
// them, so `winnerChangesFor` cannot see them — leaving getStaleState blind
// to exactly the fields flip overwrites. We can only tell "edited since" from
// "clean" when the loser's value actually PERSISTED in current state, and
// that is ONLY when the loser is the LOCAL side (`winner === 'remote'`): a
// remote win rejects the local op in the log but does NOT roll local state
// back, so the loser's optimistic value stays applied — a clean entry has
// `current === flipVal`, and any divergence is a genuine post-resolution edit
// flip would silently overwrite. For a LOCAL win the loser is REMOTE, whose
// value was never applied (current holds the un-recorded base), so
// `current !== flipVal` is the NORMAL post-resolution state, not an edit — we
// have no baseline to compare against and must NOT flag it stale (doing so
// false-positives on the common two-device shape and silently skips valid
// bulk flips). Detecting that quadrant needs a journaled post-resolution
// baseline (follow-up).
const flipVals = entry.winner === 'remote' ? loserChangesFor(entry) : {};
const loserOnlyStale = Object.keys(flipVals).some(
(field) =>
!(field in winnerVals) && !this._valueEquals(current[field], flipVals[field]),
);
return { isStale: winnerStale || loserOnlyStale, current };
}
private _buildUpdateAction(
entityType: EntityType,
entityId: string,
changes: Record<string, unknown>,
): Action | undefined {
switch (entityType) {
case 'TASK':
return TaskSharedActions.updateTask({
task: { id: entityId, changes: changes as Partial<Task> } as Update<Task>,
// A flipped title is a journaled LITERAL value, not user input: without
// this, a title-only flip matches shortSyntax$'s exact trigger shape and
// any `#tag`/`+project`/`@schedule` tokens in the discarded title would
// re-parse into cross-entity tag/project/schedule mutations.
isIgnoreShortSyntax: true,
});
case 'PROJECT':
return updateProject({
project: {
id: entityId,
changes: changes as Partial<Project>,
} as Update<Project>,
});
case 'NOTE':
return updateNote({
note: { id: entityId, changes: changes as Partial<Note> } as Update<Note>,
});
case 'TAG':
return updateTag({
tag: { id: entityId, changes: changes as Partial<Tag> } as Update<Tag>,
isSkipSnack: true,
});
default:
return undefined;
}
}
private async _readCurrentEntity(
entityType: EntityType,
entityId: string,
): Promise<Record<string, unknown> | undefined> {
const config = getEntityConfig(entityType);
if (!config || !isAdapterEntity(config) || !config.selectById) {
return undefined;
}
try {
// ISSUE_PROVIDER registers a `(id, key) => selector` FACTORY, not a props
// selector (mirrors ConflictResolutionService.getCurrentEntityState).
// Calling it as a props selector would return the inner selector FUNCTION
// as the "entity" and render a bogus current column + stale flag.
if (entityType === 'ISSUE_PROVIDER') {
const factory = config.selectById as SelectByIdFactory<null>;
const entity = await firstValueFrom(this._store.select(factory(entityId, null)));
return (entity as Record<string, unknown> | undefined) ?? undefined;
}
// `SelectById` is a union across the registry's selector shapes; every
// other adapter type registers the standard props-based selector, so
// narrowing to that union member is safe here.
const selectById = config.selectById as PropsStateSelector<{ id: string }>;
const entity = await firstValueFrom(
this._store.select(selectById, { id: entityId }),
);
return (entity as Record<string, unknown> | undefined) ?? undefined;
} catch {
// Some selectors (selectTagById, selectNoteById) THROW on a missing
// entity instead of returning undefined — the app-wide convention. For
// this read-only "current state" lookup both mean the same thing.
return undefined;
}
}
private _valueEquals(a: unknown, b: unknown): boolean {
if (a === b) {
return true;
}
// Structural fallback for arrays/objects captured verbatim from op payloads.
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
private async _confirmStaleFlip(entry: ConflictJournalEntry): Promise<boolean> {
const res = await firstValueFrom(
this._matDialog
.open(DialogConfirmComponent, {
restoreFocus: true,
data: {
message: CR.STALE_CONFIRM,
translateParams: { title: entry.entityTitle },
},
})
.afterClosed(),
);
return res === true;
}
}

View file

@ -236,6 +236,14 @@
<mat-icon>tune</mat-icon>
{{ 'PS.SYNC.CONFIGURE' | translate }}
</button>
<button
routerLink="/sync-conflicts"
mat-stroked-button
type="button"
>
<mat-icon>rule</mat-icon>
{{ 'F.SYNC.CONFLICT_REVIEW.CONFIG_LINK' | translate }}
</button>
</div>
}
</section>

View file

@ -7,7 +7,7 @@ import {
inject,
OnInit,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { TaskWidgetSettingsService } from '../../features/config/task-widget-settings.service';
import { FocusModeLocalSettingsService } from '../../features/config/focus-mode-local-settings.service';
@ -88,6 +88,7 @@ import { LocalBackupService } from '../../imex/local-backup/local-backup.service
MatIcon,
MatTooltip,
MatButton,
RouterLink,
NgTemplateOutlet,
],
})

View file

@ -0,0 +1,239 @@
<div class="page-wrapper sync-conflicts-page">
<header class="page-header">
<h1>{{ T.F.SYNC.CONFLICT_REVIEW.PAGE_TITLE | translate }}</h1>
<p class="subtitle">{{ T.F.SYNC.CONFLICT_REVIEW.PAGE_SUBTITLE | translate }}</p>
</header>
<mat-tab-group
class="conflict-tabs"
animationDuration="200ms"
[selectedIndex]="selectedTabIndex()"
(selectedIndexChange)="onTabIndexChange($event)"
>
<!-- ── Unreviewed ─────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
{{
T.F.SYNC.CONFLICT_REVIEW.TAB_UNREVIEWED
| translate: { count: unreviewedCount() }
}}
</ng-template>
<div class="tab-content">
@if (unreviewed().length === 0) {
<p class="empty-state">
{{ T.F.SYNC.CONFLICT_REVIEW.EMPTY_UNREVIEWED | translate }}
</p>
} @else {
<div class="bulk-actions">
<button
mat-stroked-button
(click)="keepAll()"
>
{{ T.F.SYNC.CONFLICT_REVIEW.BULK_KEEP_ALL | translate }}
</button>
<button
mat-stroked-button
(click)="flipAll('local')"
>
{{ T.F.SYNC.CONFLICT_REVIEW.BULK_FLIP_LOCAL | translate }}
</button>
<button
mat-stroked-button
(click)="flipAll('remote')"
>
{{ T.F.SYNC.CONFLICT_REVIEW.BULK_FLIP_REMOTE | translate }}
</button>
</div>
@for (group of unreviewedGroups(); track group.entityType) {
<section class="entity-group">
<h2 class="group-title">
{{ group.labelKey | translate }} ({{ group.entries.length }})
</h2>
@for (entry of group.entries; track entry.id) {
<ng-container
[ngTemplateOutlet]="rowTpl"
[ngTemplateOutletContext]="{ $implicit: entry, actionable: true }"
></ng-container>
}
</section>
}
}
</div>
</mat-tab>
<!-- ── History ────────────────────────────────────────────────────── -->
<mat-tab [label]="T.F.SYNC.CONFLICT_REVIEW.TAB_HISTORY | translate">
<div class="tab-content">
@if (history().length === 0) {
<p class="empty-state">
{{ T.F.SYNC.CONFLICT_REVIEW.EMPTY_HISTORY | translate }}
</p>
} @else {
@for (group of historyGroups(); track group.entityType) {
<section class="entity-group">
<h2 class="group-title">
{{ group.labelKey | translate }} ({{ group.entries.length }})
</h2>
@for (entry of group.entries; track entry.id) {
<ng-container
[ngTemplateOutlet]="rowTpl"
[ngTemplateOutletContext]="{ $implicit: entry, actionable: false }"
></ng-container>
}
</section>
}
}
</div>
</mat-tab>
</mat-tab-group>
</div>
<!-- ── Shared conflict row ──────────────────────────────────────────────── -->
<ng-template
#rowTpl
let-entry
let-actionable="actionable"
>
<div
class="conflict-row"
[class.is-expanded]="isExpanded(entry)"
>
<div
class="row-head"
role="button"
tabindex="0"
[attr.aria-expanded]="isExpanded(entry)"
(click)="toggleExpand(entry)"
(keydown.enter)="toggleExpand(entry)"
(keydown.space)="$event.preventDefault(); toggleExpand(entry)"
>
<mat-icon class="expand-ico">{{
isExpanded(entry) ? 'expand_less' : 'expand_more'
}}</mat-icon>
<span class="row-title">{{ entry.entityTitle }}</span>
<span class="row-age">{{ entry.resolvedAt | date: 'short' }}</span>
<span
class="chip chip--winner"
[class.chip--merged]="entry.winner === 'merged'"
>{{ winnerKey(entry) | translate }}</span
>
<span class="chip chip--reason">{{ reasonKey(entry) | translate }}</span>
@if (!actionable) {
<span class="chip chip--status">{{ statusKey(entry) | translate }}</span>
}
</div>
@if (isExpanded(entry)) {
<div class="row-body">
@if (entry.winner === 'merged') {
<div class="merged-chips">
@for (diff of entry.fieldDiffs; track diff.field) {
<span class="chip chip--merged-field">{{
T.F.SYNC.CONFLICT_REVIEW.MERGED_FIELD_CHIP
| translate
: { field: diff.field, side: mergedSideKey(diff) | translate }
}}</span>
}
</div>
} @else {
<div class="diff-table-wrap">
<table class="diff-table">
<thead>
<tr>
<th>{{ T.F.SYNC.CONFLICT_REVIEW.COL_FIELD | translate }}</th>
<th>
{{ T.F.SYNC.CONFLICT_REVIEW.COL_LOCAL | translate }}
<small class="device"
>{{
isThisDevice(entry.localClientId)
? (T.F.SYNC.CONFLICT_REVIEW.THIS_DEVICE | translate)
: (T.F.SYNC.CONFLICT_REVIEW.DEVICE_LABEL
| translate: { id: shortId(entry.localClientId) })
}}
· {{ entry.localTs | date: 'short' }}</small
>
</th>
<th>
{{ T.F.SYNC.CONFLICT_REVIEW.COL_REMOTE | translate }}
<small class="device"
>{{
isThisDevice(entry.remoteClientId)
? (T.F.SYNC.CONFLICT_REVIEW.THIS_DEVICE | translate)
: (T.F.SYNC.CONFLICT_REVIEW.DEVICE_LABEL
| translate: { id: shortId(entry.remoteClientId) })
}}
· {{ entry.remoteTs | date: 'short' }}</small
>
</th>
@if (isStale(entry)) {
<th>{{ T.F.SYNC.CONFLICT_REVIEW.COL_CURRENT | translate }}</th>
}
</tr>
</thead>
<tbody>
@for (diff of entry.fieldDiffs; track diff.field) {
<tr>
<td class="field-name">{{ diff.field }}</td>
<td [class.is-won]="isFieldWonBy(diff, 'local')">
<span class="val">{{ display(diff.localVal) }}</span>
@if (isFieldWonBy(diff, 'local')) {
<mat-icon
class="won-ico"
[matTooltip]="T.F.SYNC.CONFLICT_REVIEW.WINNING_SIDE | translate"
>check</mat-icon
>
}
</td>
<td [class.is-won]="isFieldWonBy(diff, 'remote')">
<span class="val">{{ display(diff.remoteVal) }}</span>
@if (isFieldWonBy(diff, 'remote')) {
<mat-icon
class="won-ico"
[matTooltip]="T.F.SYNC.CONFLICT_REVIEW.WINNING_SIDE | translate"
>check</mat-icon
>
}
</td>
@if (isStale(entry)) {
<td class="current-val">
<span class="val">{{
display(currentValue(entry, diff.field))
}}</span>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
@if (actionable) {
<div class="row-actions">
<button
mat-stroked-button
(click)="keep(entry)"
>
{{ T.F.SYNC.CONFLICT_REVIEW.ACTION_KEEP | translate }}
</button>
<button
mat-flat-button
color="primary"
[disabled]="!canFlip(entry)"
[matTooltip]="
canFlip(entry)
? ''
: (T.F.SYNC.CONFLICT_REVIEW.FLIP_UNSUPPORTED | translate)
"
(click)="flip(entry)"
>
{{ T.F.SYNC.CONFLICT_REVIEW.ACTION_FLIP | translate }}
</button>
</div>
}
</div>
}
</div>
</ng-template>

View file

@ -0,0 +1,193 @@
:host {
display: block;
}
.sync-conflicts-page {
max-width: var(--component-max-width, 1000px);
margin: 0 auto;
}
.page-header {
margin-bottom: var(--s2);
h1 {
margin: 0 0 var(--s-half);
}
.subtitle {
margin: 0;
color: var(--text-color-muted);
}
}
.tab-content {
padding: var(--s2) 0;
}
.empty-state {
opacity: 0.6;
font-style: italic;
padding: var(--s2) 0;
}
.bulk-actions {
display: flex;
flex-wrap: wrap;
gap: var(--s);
margin-bottom: var(--s2);
}
.entity-group {
margin-bottom: var(--s3);
.group-title {
font-size: 1rem;
margin: 0 0 var(--s);
color: var(--text-color-muted);
}
}
.conflict-row {
border: 1px solid var(--divider-color);
border-radius: var(--card-border-radius, 4px);
background: var(--card-bg);
margin-bottom: var(--s);
overflow: hidden;
&.is-expanded {
box-shadow: var(--whiteframe-shadow-2dp);
}
}
.row-head {
display: flex;
align-items: center;
gap: var(--s);
padding: var(--s) var(--s2);
cursor: pointer;
user-select: none;
&:hover,
&:focus-visible {
background: var(--state-hover);
outline: none;
}
.expand-ico {
flex: 0 0 auto;
opacity: 0.7;
}
.row-title {
flex: 1 1 auto;
min-width: 0;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-age {
flex: 0 0 auto;
color: var(--text-color-muted);
font-size: 0.8rem;
}
}
.chip {
flex: 0 0 auto;
display: inline-block;
padding: 2px var(--s);
border-radius: 999px;
font-size: 0.75rem;
line-height: 1.4;
background: var(--separator-color, rgba(128, 128, 128, 0.2));
white-space: nowrap;
&--winner {
background: var(--color-warning);
color: #000;
}
&--merged,
&--merged-field {
background: var(--state-hover);
color: var(--text-color);
}
&--status {
background: var(--separator-color, rgba(128, 128, 128, 0.2));
}
}
.row-body {
padding: var(--s) var(--s2) var(--s2);
border-top: 1px solid var(--divider-color);
}
.merged-chips {
display: flex;
flex-wrap: wrap;
gap: var(--s);
}
.diff-table-wrap {
overflow-x: auto;
}
.diff-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
th,
td {
text-align: left;
padding: var(--s-half) var(--s);
border-bottom: 1px solid var(--divider-color);
vertical-align: top;
}
th {
color: var(--text-color-muted);
font-weight: 500;
.device {
display: block;
font-weight: 400;
opacity: 0.7;
}
}
.field-name {
font-family: var(--font-mono-stack, monospace);
white-space: nowrap;
}
.val {
overflow-wrap: anywhere;
}
td.is-won {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
.won-ico {
font-size: 16px;
width: 16px;
height: 16px;
vertical-align: middle;
color: var(--color-success);
}
}
.current-val {
font-style: italic;
}
}
.row-actions {
display: flex;
gap: var(--s);
margin-top: var(--s2);
justify-content: flex-end;
}

View file

@ -0,0 +1,170 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { SyncConflictsPageComponent } from './sync-conflicts-page.component';
import { ConflictJournalService } from '../../op-log/sync/conflict-journal.service';
import { ConflictJournalEntry } from '../../op-log/sync/conflict-journal.model';
import { SyncConflictUiService } from '../../op-log/sync/sync-conflict-ui.service';
import { CLIENT_ID_PROVIDER } from '../../op-log/util/client-id.provider';
import { EntityType } from '../../op-log/core/operation.types';
const makeEntry = (over: Partial<ConflictJournalEntry> = {}): ConflictJournalEntry => ({
id: Math.random().toString(36).slice(2),
entityType: 'TASK' as EntityType,
entityId: 'task-1',
entityTitle: 'Buy milk',
resolvedAt: 1_700_000_000_000,
winner: 'remote',
reason: 'newer',
fieldDiffs: [
{
field: 'title',
localVal: 'Buy oat milk',
remoteVal: 'Buy milk',
pickedSide: 'remote',
},
],
localClientId: 'AAAAAAAAAA',
remoteClientId: 'BBBBBBBBBB',
localTs: 1_700_000_000_000,
remoteTs: 1_700_000_100_000,
status: 'unreviewed',
...over,
});
describe('SyncConflictsPageComponent', () => {
let fixture: ComponentFixture<SyncConflictsPageComponent>;
let component: SyncConflictsPageComponent;
let journal: ConflictJournalService;
let ui: jasmine.SpyObj<SyncConflictUiService>;
const setUp = async (entries: ConflictJournalEntry[]): Promise<void> => {
ui = jasmine.createSpyObj<SyncConflictUiService>('SyncConflictUiService', [
'keep',
'flip',
'keepAll',
'flipAllToSide',
'getStaleState',
'canFlip',
]);
ui.getStaleState.and.resolveTo({ isStale: false, current: undefined });
ui.canFlip.and.returnValue(true);
ui.keep.and.resolveTo();
ui.flip.and.resolveTo('applied');
ui.keepAll.and.resolveTo();
ui.flipAllToSide.and.resolveTo();
await TestBed.configureTestingModule({
imports: [
SyncConflictsPageComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
ConflictJournalService,
{ provide: SyncConflictUiService, useValue: ui },
{
provide: CLIENT_ID_PROVIDER,
useValue: {
loadClientId: () => Promise.resolve('AAAAAAAAAA'),
getOrGenerateClientId: () => Promise.resolve('AAAAAAAAAA'),
clearCache: () => undefined,
},
},
],
}).compileComponents();
journal = TestBed.inject(ConflictJournalService);
for (const e of entries) {
await journal.record(e);
}
fixture = TestBed.createComponent(SyncConflictsPageComponent);
component = fixture.componentInstance;
await component.reload();
fixture.detectChanges();
};
it('shows the empty state when there are no unreviewed conflicts', async () => {
await setUp([]);
expect(fixture.debugElement.query(By.css('.empty-state'))).not.toBeNull();
expect(fixture.debugElement.queryAll(By.css('.conflict-row')).length).toBe(0);
});
it('renders a grouped row per unreviewed entry with winner + reason chips', async () => {
await setUp([
makeEntry({ id: 'a', entityType: 'TASK' as EntityType }),
makeEntry({ id: 'b', entityType: 'PROJECT' as EntityType, entityTitle: 'Work' }),
]);
const rows = fixture.debugElement.queryAll(By.css('.conflict-row'));
expect(rows.length).toBe(2);
expect(fixture.debugElement.queryAll(By.css('.entity-group')).length).toBe(2);
expect(fixture.debugElement.query(By.css('.chip--winner'))).not.toBeNull();
expect(fixture.debugElement.query(By.css('.chip--reason'))).not.toBeNull();
});
it('marks the winning side in the expanded diff table', async () => {
await setUp([makeEntry({ id: 'a' })]);
await component.toggleExpand(component.unreviewed()[0]);
fixture.detectChanges();
const won = fixture.debugElement.queryAll(By.css('td.is-won'));
expect(won.length).toBe(1); // remote won the single 'title' field
expect(fixture.debugElement.query(By.css('.won-ico'))).not.toBeNull();
});
it('KEEP calls the service; FLIP calls the service', async () => {
await setUp([makeEntry({ id: 'a' })]);
const entry = component.unreviewed()[0];
await component.toggleExpand(entry);
fixture.detectChanges();
const [keepBtn, flipBtn] = fixture.debugElement.queryAll(
By.css('.row-actions button'),
);
keepBtn.nativeElement.click();
expect(ui.keep).toHaveBeenCalledWith(entry);
flipBtn.nativeElement.click();
expect(ui.flip).toHaveBeenCalledWith(entry);
});
it('bulk actions delegate to the service', async () => {
await setUp([makeEntry({ id: 'a' })]);
const buttons = fixture.debugElement.queryAll(By.css('.bulk-actions button'));
buttons[0].nativeElement.click();
expect(ui.keepAll).toHaveBeenCalled();
buttons[1].nativeElement.click();
expect(ui.flipAllToSide).toHaveBeenCalledWith(jasmine.any(Array), 'local');
buttons[2].nativeElement.click();
expect(ui.flipAllToSide).toHaveBeenCalledWith(jasmine.any(Array), 'remote');
});
it('History tab renders a merged entry as per-field chips', async () => {
await setUp([
makeEntry({
id: 'm',
winner: 'merged',
reason: 'disjoint-merge',
status: 'info',
fieldDiffs: [
{ field: 'title', localVal: 'L', remoteVal: undefined, pickedSide: 'local' },
{ field: 'notes', localVal: undefined, remoteVal: 'R', pickedSide: 'remote' },
],
}),
]);
// Switch to History tab and expand the merged entry.
component.onTabIndexChange(1);
fixture.detectChanges();
await component.toggleExpand(component.history()[0]);
fixture.detectChanges();
const chips = fixture.debugElement.queryAll(By.css('.chip--merged-field'));
expect(chips.length).toBe(2);
// Read-only: no KEEP/FLIP actions in History.
expect(fixture.debugElement.query(By.css('.row-actions'))).toBeNull();
});
});

View file

@ -0,0 +1,204 @@
/**
* SPAP-15 Sync Conflicts review page (SPAP-12 mockup 2).
*
* Two tabs Unreviewed | History listing auto-resolved sync conflicts from
* the device-local journal. Rows are grouped by entity type; expanding a row
* shows a table of ONLY the differing fields (LOCAL vs REMOTE, winning side
* marked, plus a CURRENT column when the entity changed since resolution).
* Unreviewed rows offer KEEP / FLIP (+ bulk actions); History is read-only and
* renders merged auto-merges as per-field chips.
*/
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { DatePipe, NgTemplateOutlet } from '@angular/common';
import { MatTab, MatTabGroup, MatTabLabel } from '@angular/material/tabs';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from '../../t.const';
import { ConflictJournalService } from '../../op-log/sync/conflict-journal.service';
import {
ConflictJournalEntry,
ConflictJournalFieldDiff,
} from '../../op-log/sync/conflict-journal.model';
import {
SyncConflictUiService,
StaleState,
} from '../../op-log/sync/sync-conflict-ui.service';
import {
groupByEntityType,
mergedFieldSideKey,
reasonI18nKey,
shortClientId,
statusI18nKey,
winnerI18nKey,
} from '../../op-log/sync/sync-conflict-review.util';
import { CLIENT_ID_PROVIDER } from '../../op-log/util/client-id.provider';
@Component({
selector: 'sync-conflicts-page',
templateUrl: './sync-conflicts-page.component.html',
styleUrls: ['./sync-conflicts-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
DatePipe,
NgTemplateOutlet,
MatTabGroup,
MatTab,
MatTabLabel,
MatButton,
MatIconButton,
MatIcon,
MatTooltip,
TranslatePipe,
],
})
export class SyncConflictsPageComponent implements OnInit {
private readonly _journal = inject(ConflictJournalService);
private readonly _ui = inject(SyncConflictUiService);
private readonly _clientIdProvider = inject(CLIENT_ID_PROVIDER);
readonly T = T;
readonly selectedTabIndex = signal(0);
readonly expandedId = signal<string | null>(null);
private readonly _unreviewed = signal<ConflictJournalEntry[]>([]);
private readonly _history = signal<ConflictJournalEntry[]>([]);
private readonly _ownClientId = signal<string | null>(null);
/** Current entity state per expanded entry, for the stale "current" column. */
private readonly _staleByEntryId = signal<Record<string, StaleState>>({});
readonly unreviewed = this._unreviewed.asReadonly();
readonly history = this._history.asReadonly();
readonly unreviewedGroups = computed(() => groupByEntityType(this._unreviewed()));
readonly historyGroups = computed(() => groupByEntityType(this._history()));
readonly unreviewedCount = computed(() => this._unreviewed().length);
async ngOnInit(): Promise<void> {
this._ownClientId.set(await this._clientIdProvider.loadClientId());
await this.reload();
}
async reload(): Promise<void> {
const [unreviewed, history] = await Promise.all([
this._journal.list('unreviewed'),
this._journal.list('history'),
]);
this._unreviewed.set(unreviewed);
this._history.set(history);
}
onTabIndexChange(index: number): void {
this.selectedTabIndex.set(index);
}
async toggleExpand(entry: ConflictJournalEntry): Promise<void> {
if (this.expandedId() === entry.id) {
this.expandedId.set(null);
return;
}
this.expandedId.set(entry.id);
if (!this._staleByEntryId()[entry.id]) {
const stale = await this._ui.getStaleState(entry);
this._staleByEntryId.update((m) => ({ ...m, [entry.id]: stale }));
}
}
isExpanded(entry: ConflictJournalEntry): boolean {
return this.expandedId() === entry.id;
}
// ── Row actions ───────────────────────────────────────────────────────────
async keep(entry: ConflictJournalEntry): Promise<void> {
await this._ui.keep(entry);
await this.reload();
}
async flip(entry: ConflictJournalEntry): Promise<void> {
await this._ui.flip(entry);
await this.reload();
}
async keepAll(): Promise<void> {
await this._ui.keepAll(this._unreviewed());
await this.reload();
}
async flipAll(side: 'local' | 'remote'): Promise<void> {
await this._ui.flipAllToSide(this._unreviewed(), side);
await this.reload();
}
canFlip(entry: ConflictJournalEntry): boolean {
return this._ui.canFlip(entry);
}
// ── Presentation helpers (template can't import free functions) ────────────
reasonKey(entry: ConflictJournalEntry): string {
return reasonI18nKey(entry.reason);
}
winnerKey(entry: ConflictJournalEntry): string {
return winnerI18nKey(entry.winner);
}
statusKey(entry: ConflictJournalEntry): string {
return statusI18nKey(entry.status);
}
mergedSideKey(diff: ConflictJournalFieldDiff): string {
return mergedFieldSideKey(diff);
}
isThisDevice(clientId: string): boolean {
const own = this._ownClientId();
return !!own && own === clientId;
}
shortId(clientId: string): string {
return shortClientId(clientId);
}
/** True once we've loaded state and the entity diverged since resolution. */
isStale(entry: ConflictJournalEntry): boolean {
return this._staleByEntryId()[entry.id]?.isStale ?? false;
}
currentValue(entry: ConflictJournalEntry, field: string): unknown {
return this._staleByEntryId()[entry.id]?.current?.[field];
}
/** Which side won a given field (marks the kept column). */
isFieldWonBy(diff: ConflictJournalFieldDiff, side: 'local' | 'remote'): boolean {
return diff.pickedSide === side;
}
/** Human-readable rendering of a captured field value. */
display(val: unknown): string {
if (val === null || val === undefined) {
return '—';
}
if (typeof val === 'string') {
return val;
}
if (typeof val === 'number' || typeof val === 'boolean') {
return String(val);
}
try {
return JSON.stringify(val);
} catch {
return String(val);
}
}
}

View file

@ -1,6 +1,7 @@
export { ArchivedProjectsPageComponent } from '../pages/archived-projects-page/archived-projects-page.component';
export { ConfigPageComponent } from '../pages/config-page/config-page.component';
export { SearchPageComponent } from '../pages/search-page/search-page.component';
export { SyncConflictsPageComponent } from '../pages/sync-conflicts-page/sync-conflicts-page.component';
export { ScheduledListPageComponent } from '../pages/scheduled-list-page/scheduled-list-page.component';
export { PlannerComponent } from '../features/planner/planner.component';
export { ScheduleComponent } from '../features/schedule/schedule/schedule.component';

View file

@ -1243,6 +1243,55 @@ const T = {
FORCE_UPLOAD: 'F.SYNC.C.FORCE_UPLOAD',
DECRYPT_OVERWRITE: 'F.SYNC.C.DECRYPT_OVERWRITE',
},
CONFLICT_REVIEW: {
PAGE_TITLE: 'F.SYNC.CONFLICT_REVIEW.PAGE_TITLE',
PAGE_SUBTITLE: 'F.SYNC.CONFLICT_REVIEW.PAGE_SUBTITLE',
CONFIG_LINK: 'F.SYNC.CONFLICT_REVIEW.CONFIG_LINK',
BADGE_TOOLTIP: 'F.SYNC.CONFLICT_REVIEW.BADGE_TOOLTIP',
BANNER_MSG: 'F.SYNC.CONFLICT_REVIEW.BANNER_MSG',
BANNER_REVIEW: 'F.SYNC.CONFLICT_REVIEW.BANNER_REVIEW',
TAB_UNREVIEWED: 'F.SYNC.CONFLICT_REVIEW.TAB_UNREVIEWED',
TAB_HISTORY: 'F.SYNC.CONFLICT_REVIEW.TAB_HISTORY',
EMPTY_UNREVIEWED: 'F.SYNC.CONFLICT_REVIEW.EMPTY_UNREVIEWED',
EMPTY_HISTORY: 'F.SYNC.CONFLICT_REVIEW.EMPTY_HISTORY',
GROUP_TASK: 'F.SYNC.CONFLICT_REVIEW.GROUP_TASK',
GROUP_PROJECT: 'F.SYNC.CONFLICT_REVIEW.GROUP_PROJECT',
GROUP_TAG: 'F.SYNC.CONFLICT_REVIEW.GROUP_TAG',
GROUP_NOTE: 'F.SYNC.CONFLICT_REVIEW.GROUP_NOTE',
GROUP_OTHER: 'F.SYNC.CONFLICT_REVIEW.GROUP_OTHER',
COL_FIELD: 'F.SYNC.CONFLICT_REVIEW.COL_FIELD',
COL_LOCAL: 'F.SYNC.CONFLICT_REVIEW.COL_LOCAL',
COL_REMOTE: 'F.SYNC.CONFLICT_REVIEW.COL_REMOTE',
COL_CURRENT: 'F.SYNC.CONFLICT_REVIEW.COL_CURRENT',
WINNER_LOCAL: 'F.SYNC.CONFLICT_REVIEW.WINNER_LOCAL',
WINNER_REMOTE: 'F.SYNC.CONFLICT_REVIEW.WINNER_REMOTE',
WINNER_MERGED: 'F.SYNC.CONFLICT_REVIEW.WINNER_MERGED',
REASON_NEWER: 'F.SYNC.CONFLICT_REVIEW.REASON_NEWER',
REASON_TIE: 'F.SYNC.CONFLICT_REVIEW.REASON_TIE',
REASON_DELETE_WINS: 'F.SYNC.CONFLICT_REVIEW.REASON_DELETE_WINS',
REASON_DELETE_LOST: 'F.SYNC.CONFLICT_REVIEW.REASON_DELETE_LOST',
REASON_DISJOINT_MERGE: 'F.SYNC.CONFLICT_REVIEW.REASON_DISJOINT_MERGE',
REASON_NOISE: 'F.SYNC.CONFLICT_REVIEW.REASON_NOISE',
REASON_CLOCK_CORRUPTION: 'F.SYNC.CONFLICT_REVIEW.REASON_CLOCK_CORRUPTION',
STATUS_UNREVIEWED: 'F.SYNC.CONFLICT_REVIEW.STATUS_UNREVIEWED',
STATUS_KEPT: 'F.SYNC.CONFLICT_REVIEW.STATUS_KEPT',
STATUS_FLIPPED: 'F.SYNC.CONFLICT_REVIEW.STATUS_FLIPPED',
STATUS_INFO: 'F.SYNC.CONFLICT_REVIEW.STATUS_INFO',
THIS_DEVICE: 'F.SYNC.CONFLICT_REVIEW.THIS_DEVICE',
DEVICE_LABEL: 'F.SYNC.CONFLICT_REVIEW.DEVICE_LABEL',
MERGED_FIELD_CHIP: 'F.SYNC.CONFLICT_REVIEW.MERGED_FIELD_CHIP',
SIDE_LOCAL: 'F.SYNC.CONFLICT_REVIEW.SIDE_LOCAL',
SIDE_REMOTE: 'F.SYNC.CONFLICT_REVIEW.SIDE_REMOTE',
ACTION_KEEP: 'F.SYNC.CONFLICT_REVIEW.ACTION_KEEP',
ACTION_FLIP: 'F.SYNC.CONFLICT_REVIEW.ACTION_FLIP',
BULK_KEEP_ALL: 'F.SYNC.CONFLICT_REVIEW.BULK_KEEP_ALL',
BULK_FLIP_LOCAL: 'F.SYNC.CONFLICT_REVIEW.BULK_FLIP_LOCAL',
BULK_FLIP_REMOTE: 'F.SYNC.CONFLICT_REVIEW.BULK_FLIP_REMOTE',
FLIP_UNSUPPORTED: 'F.SYNC.CONFLICT_REVIEW.FLIP_UNSUPPORTED',
FLIP_DONE: 'F.SYNC.CONFLICT_REVIEW.FLIP_DONE',
STALE_CONFIRM: 'F.SYNC.CONFLICT_REVIEW.STALE_CONFIRM',
WINNING_SIDE: 'F.SYNC.CONFLICT_REVIEW.WINNING_SIDE',
},
D_AUTH_CODE: {
FOLLOW_LINK: 'F.SYNC.D_AUTH_CODE.FOLLOW_LINK',
FOLLOW_LINK_MOBILE: 'F.SYNC.D_AUTH_CODE.FOLLOW_LINK_MOBILE',
@ -1631,7 +1680,6 @@ const T = {
LOCAL_DATA_REPLACE_CANCELLED: 'F.SYNC.S.LOCAL_DATA_REPLACE_CANCELLED',
LOCAL_DATA_REPLACE_UNDO: 'F.SYNC.S.LOCAL_DATA_REPLACE_UNDO',
LOCK_TIMEOUT_ERROR: 'F.SYNC.S.LOCK_TIMEOUT_ERROR',
LWW_CONFLICTS_AUTO_RESOLVED: 'F.SYNC.S.LWW_CONFLICTS_AUTO_RESOLVED',
LOCAL_FILE_RESELECT_REQUIRED: 'F.SYNC.S.LOCAL_FILE_RESELECT_REQUIRED',
MIGRATION_FAILED: 'F.SYNC.S.MIGRATION_FAILED',
NETWORK_ERROR: 'F.SYNC.S.NETWORK_ERROR',

View file

@ -1220,6 +1220,55 @@
"FORCE_UPLOAD": "This will REPLACE all data on the server and on every other device with this device's local copy. Any unsynced changes on those devices will be permanently lost. Continue?",
"DECRYPT_OVERWRITE": "This will REPLACE the encrypted data on the server with this device's local copy and re-encrypt it with the new password. All other devices will need to re-enter the password and download the replaced data. Any unsynced changes on those devices will be permanently lost. Continue?"
},
"CONFLICT_REVIEW": {
"PAGE_TITLE": "Sync Conflicts",
"PAGE_SUBTITLE": "Edits that were auto-resolved during sync. Review them and, if needed, switch to the other version.",
"CONFIG_LINK": "Review sync conflicts",
"BADGE_TOOLTIP": "{{count}} unreviewed sync conflict(s)",
"BANNER_MSG": "{{count}} sync conflict(s) auto-resolved ({{remoteWins}} remote, {{localWins}} local won)",
"BANNER_REVIEW": "Review",
"TAB_UNREVIEWED": "Unreviewed ({{count}})",
"TAB_HISTORY": "History",
"EMPTY_UNREVIEWED": "No conflicts to review.",
"EMPTY_HISTORY": "No conflict history yet.",
"GROUP_TASK": "Tasks",
"GROUP_PROJECT": "Projects",
"GROUP_TAG": "Tags",
"GROUP_NOTE": "Notes",
"GROUP_OTHER": "Other",
"COL_FIELD": "Field",
"COL_LOCAL": "Local",
"COL_REMOTE": "Remote",
"COL_CURRENT": "Current (now)",
"WINNER_LOCAL": "Local won",
"WINNER_REMOTE": "Remote won",
"WINNER_MERGED": "Merged",
"REASON_NEWER": "Newer edit won",
"REASON_TIE": "Tie, remote won",
"REASON_DELETE_WINS": "Delete won",
"REASON_DELETE_LOST": "Delete overridden by edit",
"REASON_DISJOINT_MERGE": "Auto-merged",
"REASON_NOISE": "No content change",
"REASON_CLOCK_CORRUPTION": "Clock corruption suspected",
"STATUS_UNREVIEWED": "Unreviewed",
"STATUS_KEPT": "Kept",
"STATUS_FLIPPED": "Flipped",
"STATUS_INFO": "Info",
"THIS_DEVICE": "This device",
"DEVICE_LABEL": "Device {{id}}",
"MERGED_FIELD_CHIP": "{{field}} ← {{side}}",
"SIDE_LOCAL": "local",
"SIDE_REMOTE": "remote",
"ACTION_KEEP": "Keep",
"ACTION_FLIP": "Flip",
"BULK_KEEP_ALL": "Keep all",
"BULK_FLIP_LOCAL": "Flip all → Local",
"BULK_FLIP_REMOTE": "Flip all → Remote",
"FLIP_UNSUPPORTED": "Flipping is not supported for this item type yet.",
"FLIP_DONE": "Applied the other version.",
"STALE_CONFIRM": "\"{{title}}\" changed since this conflict was resolved. Flipping will overwrite those newer edits. Continue?",
"WINNING_SIDE": "Kept"
},
"D_AUTH_CODE": {
"FOLLOW_LINK": "Please open the following link and copy the auth code provided there into the input field below.",
"FOLLOW_LINK_MOBILE": "Please tap the button below to authorize. You will be automatically redirected back to the app.",
@ -1583,7 +1632,6 @@
"LOCAL_DATA_REPLACE_CANCELLED": "Sync cancelled. Your local data has been preserved.",
"LOCAL_DATA_REPLACE_UNDO": "Your local data was replaced with the server's data.",
"LOCK_TIMEOUT_ERROR": "Sync timed out waiting for a previous operation to finish. Please try again.",
"LWW_CONFLICTS_AUTO_RESOLVED": "Sync conflicts auto-resolved: {{localWins}} local win(s), {{remoteWins}} remote win(s)",
"LOCAL_FILE_RESELECT_REQUIRED": "Local file sync needs the sync folder to be selected again after a security update. Open Sync settings and choose the folder once more.",
"MIGRATION_FAILED": "Some sync data could not be migrated and was skipped. This may indicate data corruption or a bug.",
"NETWORK_ERROR": "Sync request failed because of a temporary network problem. Your changes remain local and sync will retry.",

View file

@ -64,6 +64,7 @@ import { StoreModule, Store } from '@ngrx/store';
import { META_REDUCERS } from './app/root-store/meta/meta-reducer-registry';
import { setOperationCaptureService } from './app/root-store/meta/task-shared-meta-reducers';
import { OperationCaptureService } from './app/op-log/capture/operation-capture.service';
import { ConflictJournalService } from './app/op-log/sync/conflict-journal.service';
import { EncryptionPasswordDialogOpenerService } from './app/imex/sync/encryption-password-dialog-opener.service';
import { DataInitService } from './app/core/data-init/data-init.service';
import { EffectsModule } from '@ngrx/effects';
@ -305,6 +306,20 @@ bootstrapApplication(AppComponent, {
deps: [OAuthCallbackHandlerService],
multi: true,
},
// SPAP-13: prune the device-local conflict journal to its retention bound
// (14 days / 200 entries) on app start. Fire-and-forget — pruneOnStart opens
// its own IndexedDB lazily and swallows its own errors, so it can never block
// or fail bootstrap.
{
provide: APP_INITIALIZER,
useFactory: (journal: ConflictJournalService) => {
return () => {
void journal.pruneOnStart();
};
},
deps: [ConflictJournalService],
multi: true,
},
// Note: ImmediateUploadService now initializes itself in constructor
// after DataInitStateService.isAllDataLoadedInitially$ fires to avoid
// race condition where upload attempts happen before sync config is loaded