Commit graph

21455 commits

Author SHA1 Message Date
Johannes Millan
bd67174863
fix(sync): make task and time replay deterministic (#8979)
* fix(sync): make task replay values deterministic

Capture logical dates and timestamps in task actions and backfill legacy operations. Carry per-day task totals so own-operation replay is idempotent while foreign time remains additive.

Fixes #8957

* fix(sync): make time snapshots replay-safe

Exclude pending task-time batches from op-log and file-sync snapshots so their later additive operations cannot overlap snapshot state. Preserve concurrent direct credits and normalize legacy replay dates deterministically.

Fixes #8957

* fix(sync): align snapshots with queued task time

Exclude accumulator and in-flight task-time deltas from operation-log snapshots so later delta operations cannot double-count them. Capture file and direct-upload snapshots at the same operation-log boundary, and flush or clear queued time around destructive state replacement.

* fix(tasks): flush queued time at task boundaries

Persist queued timer deltas before absolute short-syntax edits, and clear them whenever project, schedule, or repeat cleanup deletes the owning task. This prevents stale batches from recreating or overwriting removed task time.

* fix(sync): preserve concurrent task time deltas

Treat concurrent task-time batches as commuting updates on both client and server, while retaining causal stale-operation checks. Reject malformed identities, dates, durations, and unsafe timestamps before replay or persistence.

* test(sync): cover task time snapshot replay

Exercise seeded snapshot and restart invariants plus a real three-client SuperSync convergence path with the initial time inside the snapshot.

* fix(sync): select action type for legacy conflicts
2026-07-13 21:53:46 +02:00
Johannes Millan
5e754d3552
fix(sync): prevent multi-entity conflict corruption (#8980)
* fix(sync): reject disjoint merges for multi-entity ops

Scope conflict field extraction and journal titles to the actual entity. Fall back to whole-op LWW whenever either side is multi-entity so sibling updates are never falsely reported as preserved.\n\nFixes #8944.

* fix(sync): harden multi-entity conflict resolution
2026-07-13 21:53:30 +02:00
Johannes Millan
6ac06df6c2
fix(sync): guard full-state apply against late local ops (#8976)
* fix(sync): guard full-state apply against late local ops

Recheck pending work inside the upload and operation-log locks before destructive imports. Defer same-tab actions through the cutoff and keep cursors and acknowledgements unchanged when dialog resolution is required.

Closes #8310

* test(sync): cover late multi-tab full-state race

Use the real Web Lock boundary and shared IndexedDB to prove a sibling-tab operation blocks destructive full-state apply.
2026-07-13 20:04:00 +02:00
Johannes Millan
5624f6891d
fix(sync): harden op-log replay and recovery (#8978)
* fix(sync): harden operation-log failure recovery

Keep compaction, archive replacement, legacy recovery, and reducer replay checkpointing deterministic across crashes and concurrent clients.

Refs #8958

* fix(sync): harden remote operation recovery

Persist reducer failures across hydration and fail closed for full-state operations. Serialize recovery and archive mutations, and report skipped emergency compaction accurately.

* fix(sync): preserve operations across replay failures
2026-07-13 20:03:42 +02:00
Johannes Millan
aa75e6f7ee
fix(plugins): prevent automation rule data loss (#8972)
Keep initialization read-only, preserve unsupported synced entries across explicit edits, and reject malformed runtime payloads. Stage mutations transactionally so failed persistence cannot leave executable rules that disappear after restart.
2026-07-13 19:12:13 +02:00
Johannes Millan
9a38397da5
fix(supersync-server): op data-loss on retention/cleanup & auth-endpoint hardening (#8971)
* fix(sync): preserve offline operations and harden auth

Accept operations from long-offline clients without rewriting their timestamps, and retain a replayable full-state base during cleanup. Neutralize registration account discovery and suppress repeated login and recovery token emails with atomic claims.

Fixes #8961

* fix(sync): harden auth and retention edge cases

Make unauthenticated auth responses neutral across delivery and resend failures, isolate WebAuthn ceremonies, and consume login and recovery tokens atomically. Add monitored handling for histories without a replay base and strengthen regression coverage.

Refs #8961

* perf(sync): reduce cleanup replay-base queries

Reuse the maintained full-state sequence marker during retention cleanup while preserving a query fallback for legacy and stale-marker rows. Clarify no-base warnings and strengthen exact auth-response and recovery transaction tests.

Refs #8961

* fix(sync): reject non-integer op timestamps before BigInt persistence

A non-integer or non-finite client timestamp passed the schema
(timestamp is z.number(), not .int()) and threw at BigInt() during
upload, aborting the whole batch as an unstructured 500. Reject it in
validateOp as a per-op INVALID_TIMESTAMP instead. Op age stays
unbounded so long-offline backlogs are still accepted.

Refs #8961
2026-07-13 14:28:45 +02:00
Johannes Millan
a9ed0b2df2
Merge pull request #8519 from archit-goyal/codex/caldav-empty-dropdown-guidance
Clarify empty CalDAV option loads
2026-07-13 12:46:55 +02:00
Johannes Millan
c1564c316d docs(integrations): clarify CalDAV server URLs 2026-07-13 12:02:28 +02:00
Johannes Millan
be19f847fd
Merge pull request #8967 from super-productivity/dependabot/github_actions/github-actions-minor-63037b5edd
chore(deps)(deps): bump the github-actions-minor group with 7 updates
2026-07-13 11:09:25 +02:00
Johannes Millan
29a59757ab chore: update npm for release-age policy 2026-07-13 10:31:37 +02:00
Johannes Millan
3c57999173 chore: add project-scoped Angular MCP 2026-07-13 10:31:37 +02:00
Johannes Millan
79e6a48ba6
Merge pull request #8949 from aakhter/SPAP-40-review-hardening
fix(sync): suppress the "Project updated" snack on conflict-review flips
2026-07-13 10:31:06 +02:00
Johannes Millan
88c62567cc
Merge pull request #8945 from aakhter/SPAP-37-unreviewed-count-signal
refactor(sync): migrate conflict-journal unreviewedCount to a Signal
2026-07-13 10:24:23 +02:00
Johannes Millan
8888739b87
Merge pull request #8947 from aakhter/SPAP-38-badge-a11y
fix(sync): bind empty string, not null, for the sync badge description at 0
2026-07-13 10:22:07 +02:00
dependabot[bot]
b047f5926f
chore(deps)(deps): bump the github-actions-minor group with 7 updates
Bumps the github-actions-minor group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.19.4` | `2.20.0` |
| [actions/setup-java](https://github.com/actions/setup-java) | `5.4.0` | `5.5.0` |
| [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) | `1.0.165` | `1.0.170` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.36.3` | `4.37.0` |
| [github/codeql-action/autobuild](https://github.com/github/codeql-action) | `4.36.3` | `4.37.0` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.36.3` | `4.37.0` |
| [actions/stale](https://github.com/actions/stale) | `10.3.0` | `10.4.0` |


Updates `step-security/harden-runner` from 2.19.4 to 2.20.0
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](9af89fc715...bf7454d06d)

Updates `actions/setup-java` from 5.4.0 to 5.5.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](1bcf9fb12c...0f481fcb61)

Updates `anthropics/claude-code-action` from 1.0.165 to 1.0.170
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](558b1d6cab...536f2c32a3)

Updates `github/codeql-action/init` from 4.36.3 to 4.37.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](54f647b7e1...99df26d4f1)

Updates `github/codeql-action/autobuild` from 4.36.3 to 4.37.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](54f647b7e1...99df26d4f1)

Updates `github/codeql-action/analyze` from 4.36.3 to 4.37.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](54f647b7e1...99df26d4f1)

Updates `actions/stale` from 10.3.0 to 10.4.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](eb5cf3af3a...1e223db275)

---
updated-dependencies:
- dependency-name: step-security/harden-runner
  dependency-version: 2.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: actions/setup-java
  dependency-version: 5.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.170
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: actions/stale
  dependency-version: 10.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 07:15:14 +00:00
Aamer Akhter
9e924e6e6e fix(sync): suppress the "Project updated" snack on conflict-review flips
PROJECT flips dispatch updateProject, whose snackUpdateBaseSettings$
effect popped an unconditional "Project updated" snack on top of the
flip flow's own outcome reporting. Add an optional isSkipSnack flag to
updateProject (mirroring updateTask's isIgnoreShortSyntax precedent)
and set it from the flip path.

Also adds a MockStore.resetSelectors() afterEach to the conflict-ui
spec: overrideSelector mutates the shared selector references, and the
new selectProjectById override otherwise leaks into project.service
specs in the same karma bundle.

SPAP-40 item 1 (from the PR #8874 independent review). Item 2 (clearAll
outside the op-log lock) is already fixed on master; item 3 (local-won
post-resolution stale baseline) is a documented follow-up and stays in
the ticket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:48:25 -04:00
Aamer Akhter
f7318b1395 fix(sync): bind empty string, not null, for the sync badge description at 0
matBadgeDescription is a string input; binding null relied on
AriaDescriber.describe() silently no-op'ing on falsy values. Verified
hidden-state semantics: matBadgeHidden hides the badge via CSS only and
MatBadge keeps an aria description active regardless, so at count 0 the
description must be empty — '' is the type-correct value MatBadge
explicitly removes the aria-describedby for.

SPAP-38 (deferred LOW from PR #8874 review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:45:00 -04:00
Johannes Millan
8193625c0d
Merge pull request #8900 from super-productivity/feat/sync-55222e
fix(sync): harden replay, conflict recovery and archive durability
2026-07-11 21:34:45 +02:00
Aamer Akhter
9958e33b89 refactor(sync): migrate conflict-journal unreviewedCount to a Signal
The repo prefers Signals; unreviewedCount$ was a BehaviorSubject-backed
Observable whose only consumer immediately converted it with toSignal().
Expose a readonly signal directly instead.

SPAP-37

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:16:26 -04:00
Johannes Millan
ed0861be79 docs(sync): correct two overclaiming comments from the fix review
- The archive-mutex comment claimed every archive mutation is locked;
  compressArchive, the remote loadAllData import and the time-tracking
  cleanups still write outside it (tracked in #8941). Name them.
- The merge-journal comment described an impossible skipped-duplicate
  path (merged ids are fresh per run); describe the real accepted
  window instead: durable merge, crash before journaling, entry stays
  absent (observe-only log).
2026-07-11 21:05:56 +02:00
Johannes Millan
02c84df821 refactor(sync): apply multi-review cleanups
- Delete the unreachable stage-2 intra-batch duplicate pass: stage 1
  (validateAndClampBatch) reserves every op id including invalid first
  siblings, so validated candidates are unique by construction.
- Delete clearRawRebuildIncomplete: superseded by completeRawRebuild,
  which retires the marker atomically with the recovery token; only
  specs still called it.
- Clear the conflict journal when a USE_REMOTE rebuild completes — the
  documented "cleared whenever the full dataset is replaced" contract
  previously had a single caller (backup import), leaving stale badge
  counts and review entries describing replaced history.
- _notifyResolutionOutcome: drop the win-count parameters left over
  from the removed count snack and gate on resolutions.length.
- Snapshot handler: keep the clean-slate opId invariant local with a
  defense-in-depth 400 instead of relying on the contract superRefine
  in another package.
- Document that the legacy misc->tasks conflict alias only covers the
  per-entity path (GLOBAL_CONFIG writes are single-entity today).
2026-07-11 20:25:49 +02:00
Johannes Millan
867d84a3d1 fix(sync): serialize remote archive side effects behind the archive mutex
isIgnoreDBLock historically meant "sync already holds the op-log DB
lock", but _runTaskArchiveMutation also treated it as permission to skip
the new TASK_ARCHIVE mutex — so every remote archive side effect except
moveToArchive ran unserialized against locked local mutations, and a
concurrent read-modify-write could silently drop one side's archive
write (the exact race the mutex was added to close).

TASK_ARCHIVE is deliberately separate from OPERATION_LOG, so acquiring
it while sync holds the op-log lock is safe — the remote moveToArchive
path has always done exactly that. The mutex is now unconditional, and
the remote flushYoungToOld handler wraps its two-archive read-modify-
write in the same lock instead of writing through the adapter bare.
The isIgnoreDBLock option is retained as inert API surface; removing
the threading is tracked as a follow-up.
2026-07-11 20:21:00 +02:00
Johannes Millan
f572b6d077 fix(sync): persist disjoint merges atomically and exempt them from checkpoint
Review of the #8874 x #8900 merge seam found two defects in STEP 3b:

- The synthesized merged op rode in the apply batch, so the reducer
  checkpoint's pending-only assertion threw on it: every real disjoint
  auto-merge aborted the checkpoint transaction and wedged sync behind
  IncompleteRemoteOperationsError until app restart. Checkpoint now
  covers only pending-appended remote rows; synthetic local ops are
  exempt (their durability contract is the append + upload path).
- appendWithVectorClockUpdate replaced the durable vector clock with a
  clock computed only from the conflict's own ops, regressing the
  client counter and enabling silent cross-device op drops. Merge
  writes now go through appendMixedSourceBatchSkipDuplicates, which
  rebases the merged op on the durable clock in the same transaction —
  also closing the crash window between the remote originals and their
  superseding merged op, and turning duplicate re-appends into skips
  instead of ConstraintErrors.

Two regression tests enforce the coordinator's whole-batch reducer-
commit contract and the pending-only checkpoint against the resolution
flow; the journal keeps recording merges only after a durable append.
2026-07-11 20:14:35 +02:00
Johannes Millan
a026b2017f test(sync): mock the mixed-source batch port in the journal-hook spec
Same #8874-vintage store mock as the disjoint-merge spec: without the
atomic mixed-source batch method every local-wins flow through
autoResolveConflictsLWW threw before journaling.
2026-07-11 18:59:27 +02:00
Johannes Millan
fe544b1f79 test(sync): mock the mixed-source batch port in the disjoint-merge spec
The #8874 spec predates the atomic mixed-source batch and reducer
checkpoint on the store port; its two createSpyObj sites lacked the
methods, so every flow through the local-wins write path threw.
2026-07-11 18:55:51 +02:00
Johannes Millan
291995f343 Merge remote-tracking branch 'origin/master' into feat/sync-55222e
* origin/master: (25 commits)
  refactor(tasks): extract shared task ordering helpers (#8926)
  feat(sync): conflict journal + disjoint-field auto-merge + review UI (#8874)
  refactor(config): reduce duplicated form and selector boilerplate (#8928)
  feat(work-view): show break time today (#8909)
  feat(tasks): navigate from empty add-subtask input (#8916)
  docs(development): add instructions for setting up local tests with Chromium (#8887)
  chore(lint): remove unused eslint-disable directives (#8913)
  fix(accessibility): add ARIA roles, live regions, and alt attributes to banner component (#8888)
  chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A] (#8892)
  fix(app): hide donation page on macOS (#8915)
  chore(scripts): remove one-off codemod scripts [#8260 - Tier A] (#8893)
  fix(sync): harden SuperSync E2EE against metadata tampering (GHSA-8pxh-mgc7-gp3g) (#8904)
  feat: add Home Assistant Bridge to community plugins (#8891)
  docs(sync): note local-file rev-check/write is non-atomic (#8898) (#8902)
  18.14.0
  fix(tasks): remove postal-mime dep and harden eml import (#8901)
  style(tasks): elevate add-subtask input
  fix(config): restore day-start offset after operation replay (#8899)
  fix(task-repeat): allow selecting day-of-month recurrence (#8896)
  feat(plugins): add Todoist import plugin with Import/Export launcher (#8882)
  ...

# Conflicts:
#	docs/wiki/3.06-User-Data.md
#	src/app/op-log/backup/backup.service.spec.ts
#	src/app/op-log/backup/backup.service.ts
#	src/app/op-log/sync/conflict-resolution.service.ts
2026-07-11 18:50:32 +02:00
Johannes Millan
9d18d02f96 test(sync): spy the locked-internal archive paths, not the public wrappers
The lock-serialization refactor routes internal archive calls through
_updateTasks/_deleteTasks so a held sp_task_archive lock is never
re-acquired; five assertions still spied the public wrappers and never
fired.
2026-07-11 18:14:27 +02:00
Johannes Millan
582929e375 docs(sync): document atomic checkpoint, db v8 barrier and rebuild undo
Update the archive-operation lifecycle docs to the atomic
reducer-checkpoint + clock-merge design, record the IndexedDB v8
downgrade barrier, drop the removed PENDING_OPERATION_EXPIRY_MS
constant, and describe the durable Use-Server-Data Undo across reloads.
2026-07-11 18:06:20 +02:00
Johannes Millan
307fda3584 fix(shared-schema): let existing tasks settings win in v1-to-v2 merge
The misc-to-tasks migration spread the transformed legacy misc values
over an already-populated tasks section, so a stale v1 misc copy could
overwrite settings a v2 client had since changed. Flip the merge order
(existing tasks section wins) and drop the already-migrated early
return, which made the outcome depend on which duplicate arrived first.
2026-07-11 18:04:54 +02:00
Johannes Millan
12145ed6b2 fix(supersync): isolate duplicate upload ids and make clean-slate idempotent
- A request repeating one operation id no longer double-charges quota or
  wedges the batch: the first occurrence reserves the id and later
  siblings are terminally rejected (DUPLICATE_OPERATION for an exact
  retry, INVALID_OP_ID otherwise), in both batch and serial paths.
- Clean-slate snapshot uploads become durably idempotent: the request
  cache is process-local and expiring, so the client-supplied opId is
  now required (contract superRefine) and checked against the stored
  operation inside the per-user lock before any data is deleted. An
  exact retry returns the original serverSeq; a colliding opId is
  rejected without touching existing data.
- Audit logging validates id/entityType/opType/clientId against the
  known-safe charsets before embedding them in log lines, and the six
  duplicated reject-audit blocks collapse into one rejectedUploadResult
  helper; the ops handler logs rejected error codes instead of whole
  operation objects.
2026-07-11 18:04:23 +02:00
Johannes Millan
e019ef0b71 fix(supersync): widen conflict lookups to aliased and unioned entity ids
Two conflict-detection blind spots let concurrent edits slip through
without a conflict:

- An op carrying both a scalar entityId and an entityIds array only
  checked the array; the scalar and array sets are now unioned for
  detection while getStoredEntityIds keeps the historical storage
  normalization (scalar in entity_id, arrays in entity_ids).
- Histories written before schema v2 keep migrated task settings under
  GLOBAL_CONFIG:misc. Incoming GLOBAL_CONFIG:tasks writes now consult
  the legacy misc row as an alias (newest of the two wins, compared by
  serverSeq), and legacy misc ops check the tasks key per-entity. Works
  for encrypted payloads since only row metadata is consulted.
2026-07-11 18:01:14 +02:00
Johannes Millan
982f2916ca fix(sync): serialize archive mutations and persist before dispatch
Remote archive side effects must be idempotent and immune to concurrent
read-modify-write races, or a retried operation can duplicate or drop
archive rows.

- TaskArchiveService serializes every archive mutation behind a
  dedicated sp_task_archive web lock (separate from OPERATION_LOG,
  which remote archive handlers already hold non-reentrantly).
- ArchiveService.moveTasksToArchiveAndFlushArchiveIfDue writes tasks
  with setMany over a deduplicated sorted id set, so a retry over a
  partially written archive converges instead of double-adding.
- TaskService._moveToArchive coalesces concurrent archive calls for the
  same ids and persists archive data before dispatching moveToArchive,
  so a full-state snapshot cannot acknowledge the op while its archive
  write is still in flight.
2026-07-11 18:00:55 +02:00
Johannes Millan
ffca0f1397 fix(sync): guard rebuild backup identity and keep undo across reload
The pre-replace import backup and the USE_REMOTE rebuild recovery
marker were matched by timestamp only, so a concurrent capture or a
reload between rebuild completion and snack dismissal could clear or
restore the wrong backup.

- Import backups carry an opaque backupId (uuidv7); clearImportBackup,
  replaceAllForRawRebuild and applyRemoteReplaceWithSnapshot verify the
  expected identity inside their transactions before acting.
- Completing a raw rebuild atomically replaces the incomplete marker
  with a durable raw_rebuild_recovery entry, so the "restore previous
  data" Undo survives a reload; StartupService re-offers it at boot and
  dismissal retires exactly the offered backup.
- SnackService treats a sticky recovery/update action as a single
  persistent slot: unrelated transient snacks no longer destroy a
  visible Undo, and dismissal awaits the snack's dismissFn.
2026-07-11 18:00:30 +02:00
Rushi
f9d65debe9
refactor(tasks): extract shared task ordering helpers (#8926)
* refactor(tasks): extract shared task ordering helpers

Extract getReorderedSubTaskIds (membership check + move shared by
moveSubTaskUp/Down/ToTop/ToBottom) and moveValidIdsToFront (shared by
removeTasksFromTodayTag and localRemoveOverdueFromToday) into pure,
tested utils. No behavior change.

Closes #7912

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

* refactor(tasks): drop getReorderedSubTaskIds, inline check in reorderSubTask

Review feedback (#8926): the moveSubTask* actions already funnel through
the single reorderSubTask helper, so the extraction added no dedup value.
Keep moveValidIdsToFront, which removes real duplication.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:59:50 +02:00
Johannes Millan
bdb0fef9a9 fix(sync): make remote reducer checkpoint atomic with its clock merge
A committed reducer must never be durable without its vector clock, and
a merged clock must never be durable without its reducer checkpoint —
either mismatch lets the next local operation be causally older than
state already visible in NgRx after a crash.

- markArchivePending + separate mergeRemoteOpClocks are replaced by one
  markReducersCommittedAndMergeClocks transaction (ops + vector_clock);
  the clock math is extracted into a pure calculateRemoteClockMerge so
  the standalone merge path keeps identical full-state-reset semantics.
- The sync-core RemoteOperationApplyStorePort gains an
  onRemoteClocksDurable hook so deferred local actions drain exactly
  when clocks are durable, not merely when ops were applied; a
  checkpoint rejection can no longer mask the primary apply error.
- Conflict resolution's local-wins path writes remote losers and rebased
  local compensations in one appendMixedSourceBatchSkipDuplicates
  transaction, so synthetic ops cannot reuse or regress the client
  counter.
- DB_VERSION 7 -> 8 as a deliberate downgrade barrier: released v7
  readers only understand 'failed' and would silently overlook
  outstanding 'archive_pending' work.

op-log suite and sync-core suite cover the checkpoint rollback, atomic
clock merge, mixed-source ordering and drain failure matrix.
2026-07-11 17:59:42 +02:00
aakhter
962c5bbeb1
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>
2026-07-11 17:48:46 +02:00
Rushi
830b7acefa
refactor(config): reduce duplicated form and selector boilerplate (#8928)
- sync-form: shared rootSyncProvider/isSyncProvider/isNotSyncProvider
  helpers replace ~25 repeated parent-depth syncProvider predicates
- keyboard-form: drop two dead commented-out field blocks
- global-config.reducer.spec: itBehavesLikeConfigSectionSelector helper
  replaces 10 duplicated describe blocks (same assertions)
- config-page: shared tab-label ng-template + isSectionExpanded()
  replace 6 duplicated template blocks

No behavior change.

Closes #7922

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:11:09 +02:00
J. Loops
7733c4a7e5
feat(work-view): show break time today (#8909) 2026-07-11 15:37:57 +02:00
Johannes Millan
f9610530c9 fix(sync): converge rebuild capture races, unwedge archive recovery
Remediate the findings of the 5-agent necessity review:

- USE_REMOTE: a local capture racing the locked rebuild now throws a
  typed CaptureRacedRebuildError, and forceDownloadRemoteState retries
  phase 2 in-call (bounded, 3 attempts) through the existing
  crash-resume branch — raced ops fold into preservedLocalOps and the
  already-downloaded history is reused (WS downloads and immediate
  uploads stay gated by the marker, so no re-download is needed).
  Previously every attempt aborted while e.g. time tracking dispatched
  continuously, re-downloading the full history per sync trigger and
  churning the Undo snack (now shown on final failure only).
- Hydrator archive retry: pass skipDeferredLocalActions and drain
  explicitly with a caught error. A drain throw from the coordinator's
  finally used to mask the archive result and escalate out of
  hydrateStore() into attemptRecovery(), which can import stale legacy
  data over a correctly hydrated store.
- Incomplete-remote gate: run one in-session archive-only retry when
  the only blockers are quarantined failed/archive_pending ops, so a
  transient archive failure self-heals on the next sync instead of
  wedging until app restart. Never attempted while the rebuild marker
  is set or reducer-uncommitted pending rows exist.
- Boot recovery: StartupService surfaces the pre-replace backup's
  persistent restore snack when a stranded raw_rebuild_incomplete
  marker is found, covering users who boot offline or disable sync
  after finding the app "emptied" by a mid-rebuild crash.
- Snack correctness: latch the USE_REMOTE newer-schema warning once per
  session; guard _notifyBlockedOp and the LWW apply-failure snack with
  hasPendingPersistentAction() so they cannot destroy a visible Undo;
  drop the useless "Update app" action for below-minimum data.
- Strings: MIGRATION_FAILED / VERSION_UNSUPPORTED now describe the
  blocking semantics instead of the removed skip behavior.
- Store: getPendingRemoteOps excludes rejected rows (parity with
  getFailedRemoteOps) so a rejected-but-pending row cannot trip the
  sync gate for a session.
- Server: compute the upload request fingerprint eagerly after the
  rate-limit gate — identical cost to the lazy closure in every path,
  minus the memoization machinery; laziness remains in the snapshot
  handler where it skips hashing multi-MB states.

op-log suite 3004/3004, super-sync-server 831/831, checkFile clean on
all touched files. Nine regression tests pin the new behavior.
2026-07-11 14:49:08 +02:00
Johannes Millan
b52087338e
feat(tasks): navigate from empty add-subtask input (#8916) 2026-07-11 13:33:35 +02:00
Lane Sawyer
ae158f8cd5
docs(development): add instructions for setting up local tests with Chromium (#8887)
* docs(wiki): document CHROME_BIN setup for unit tests

The pre-push hook runs the Karma unit tests, which need a resolvable
Chrome binary. Without a system Chrome or CHROME_BIN set, git push
fails with "No binary for Chrome browser". Add a section covering both
a system Chrome install and installing Chrome for Testing via
@puppeteer/browsers with CHROME_BIN wired into the shell profile.

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

* docs(wiki): note snap/flatpak caveats for the test browser

Snap Chromium works but isn't auto-detected (set CHROME_BIN=/snap/bin/chromium);
Flatpak is not recommended because karma-chrome-launcher execs the binary
directly and the sandbox blocks Karma's temp profile dir.

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

* Improve documentation around Chromium

* add bash to codeblock

* docs(wiki): fix Chrome-for-Testing install path and Chromium claims

Address review feedback:
- Option A: only real Google Chrome is auto-detected on Linux
  (karma-chrome-launcher probes google-chrome/-stable, not chromium);
  drop the overstated "finds it automatically" for Chromium.
- Option B: pin `--path "$HOME/.cache/puppeteer"` — the standalone
  @puppeteer/browsers CLI defaults to the cwd, so the bare command
  downloaded chrome into ./chrome and left CHROME_BIN empty.
- Note macOS differs (binary is "Google Chrome for Testing" in a .app,
  so `find -name chrome` is Linux-only).

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

* Human polish of instructions

* final PR feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:25:02 +02:00
Johannes Millan
01d12cacb7 test(sync): target transient rejection assertion 2026-07-11 13:18:03 +02:00
Lane Sawyer
5f79e0f7fe
chore(lint): remove unused eslint-disable directives (#8913)
These eslint-disable directives no longer suppress any reported
problems, so ESLint flags them as unused directive warnings. Remove
them to clear the 11 lint warnings.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:13:06 +02:00
Salma Abdelrhman Mostafa Mahmoud
c760b35dca
fix(accessibility): add ARIA roles, live regions, and alt attributes to banner component (#8888)
Co-authored-by: SalmaAbdelrhmanMostafaMahmoud <salmaabdelrhmanmostafamahmoud@my.uopeople.com>
2026-07-11 13:06:50 +02:00
Lane Sawyer
c437dfc35a
chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A] (#8892)
* chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A]

* update readme and remove-unused-log-imports.ts

* restore benchmark test and make runnable
2026-07-11 13:05:34 +02:00
Johannes Millan
5220684b7e
fix(app): hide donation page on macOS (#8915)
* fix(app): hide donation page on macOS

Use the stable macOS bridge instead of the MAS runtime flag and redirect direct donation-page navigation on restricted Apple builds.

* fix(app): harden donation platform gating

Give the restriction a behavior-specific contract, cover platform classification and real router redirects, and correct the platform documentation.
2026-07-11 13:02:19 +02:00
Johannes Millan
eaa15fe575 fix(sync): handle initial provider setup safely 2026-07-11 11:25:57 +02:00
Lane Sawyer
6f1cacc553
chore(scripts): remove one-off codemod scripts [#8260 - Tier A] (#8893)
* chore(scripts): remove one-off codemod scripts [#8260 - Tier A]

* Address PR feedback
2026-07-11 10:36:24 +02:00
Johannes Millan
bfdc63e0c6 fix(sync): preserve actionable recovery state 2026-07-10 22:50:33 +02:00
Johannes Millan
eabfd84436 fix(sync): migrate legacy remote failures once 2026-07-10 22:35:30 +02:00