Commit graph

21429 commits

Author SHA1 Message Date
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
Johannes Millan
8aa0b8ca50 fix(sync): gate incomplete rebuild recovery 2026-07-10 22:35:17 +02:00
Johannes Millan
aa6e74bd34 fix(supersync): preserve occupied ids during cleanup 2026-07-10 22:35:05 +02:00
Johannes Millan
62e9e34b29 fix(sync): preserve incomplete recovery work 2026-07-10 22:11:11 +02:00
Johannes Millan
e3093a416c fix(sync-core): validate remote apply results 2026-07-10 22:11:00 +02:00
Johannes Millan
1edcb1bfac fix(supersync): isolate invalid upload operations 2026-07-10 22:10:52 +02:00
Johannes Millan
7866e16b56 docs(sync): document recovery checkpoints 2026-07-10 19:38:03 +02:00
Johannes Millan
5d568dcf8c test(sync): verify non-task conflict convergence 2026-07-10 19:37:55 +02:00
Johannes Millan
0939f5af69 fix(sync): enforce upload completion contracts 2026-07-10 19:35:37 +02:00
Johannes Millan
3480d749d7 fix(sync): block on incomplete local persistence 2026-07-10 19:29:23 +02:00
Johannes Millan
87353d4b0a fix(sync): preserve edits across rebuild resume 2026-07-10 19:23:07 +02:00
Johannes Millan
de8a2cb7b0 fix(sync): preserve fresh-client recovery state 2026-07-10 19:18:20 +02:00
Johannes Millan
ab9e1d38c9 fix(sync): exempt fresh-client startup ops from conflict gate
Fresh clients generate startup config and genesis operations before their first sync. Treat those operations as setup state until a prior sync exists, while continuing to protect later config changes and all ordinary user work from authoritative snapshot replacement.
2026-07-10 19:08:38 +02:00
Johannes Millan
c6480d1cae
fix(sync): harden SuperSync E2EE against metadata tampering (GHSA-8pxh-mgc7-gp3g) (#8904)
* fix(sync): defense-in-depth vs entityId retarget on encrypted ops

SuperSync E2EE (AES-256-GCM) covers only op.payload; metadata fields
(entityId, opType, actionType, vectorClock, isPayloadEncrypted, ...)
travel as plaintext and are not bound by the auth tag. A malicious/
compromised sync server or MITM could retag an encrypted LWW-update op
with a different entityId, redirecting the authenticated changes onto an
attacker-chosen entity — convertOpToAction() previously trusted the
tampered entityId over the authenticated payload.id (coercing even a
missing payload.id) and only warned.

At the decrypt boundary (where encryption origin is known) verify that
an in-scope LWW op's authenticated payload carries a string id equal to
op.entityId; otherwise fail closed via a new OperationIntegrityError,
distinct from DecryptError so it does not trigger the enter-password
dialog. The gate mirrors convertOpToAction's predicate (alias resolution
+ singleton exclusion) so the two boundaries cannot drift.

Scoped defense-in-depth for GHSA-8pxh-mgc7-gp3g, NOT full integrity.
Still open (durable AAD-envelope fix): plaintext-injection downgrade via
isPayloadEncrypted=false (needs a download-side mandatory-encryption
guard), opType promotion, entityType swap, vectorClock replay. Correct
the overstated integrity claim in the encryption architecture doc.

* fix(sync): reject plaintext ops when SuperSync encryption is mandatory

The isPayloadEncrypted flag is unauthenticated plaintext metadata, so a
compromised SuperSync server or MITM could set it to false and inject a
fully attacker-authored plaintext op — it would skip decryption AND the
payload/metadata integrity check and be applied verbatim (arbitrary op
forgery). This is a strictly more powerful bypass than the ciphertext
entityId retarget closed previously.

assertOpsEncryptedWhenExpected rejects any inbound plaintext op (download
+ piggyback paths) when encryption is enabled. It gates on config INTENT
(isEncryptionMandatory && isEncryptionEnabled()), not key presence, so it
also fails closed in the dropped-credential state (a !!encryptKey gate
would fail open there). Safe with no legacy-data loss: enabling
encryption deletes the server copy and re-uploads everything encrypted,
so no legitimate plaintext op remains; a never-encrypted account
(isEncryptionEnabled()===false) still accepts plaintext. The SuperSync
op-level twin of the file-based GHSA-vrc7 download guard and the
GHSA-9544 upload guard.

Also give OperationIntegrityError a dedicated sync-wrapper branch: fail
closed with a calm translated message instead of the raw GHSA/technical
string.

Follows up the review of GHSA-8pxh-mgc7-gp3g.
2026-07-10 19:06:27 +02:00
Johannes Millan
be0d6e7dd0 fix(sync): surface Undo when an interrupted USE_REMOTE resume can't finish
Multi-review remediation for the USE_REMOTE rebuild path.

- Crash-resume recovery gap (confirmed by 2 independent reviewers): when an
  interrupted-rebuild resume aborts in its download/validate phase (empty or
  newer-schema remote, or a persistent download failure), forceDownloadRemoteState
  threw before it could offer Undo. The prior attempt had already committed the
  destructive baseline, so the pre-replace backup was stranded with no restore
  affordance — reading as total data loss. downloadRemoteOps now surfaces the
  recovery Undo on a resume abort (deduped via hasPendingPersistentAction so
  repeated auto/WS syncs don't respawn it). Covered by two new unit tests.

- SnackService: collapse the redundant dual write of the persistent-action flag
  (set in open() AND recomputed in _openSnack) to one authoritative write in
  open(); _openSnack keeps only the on-dismiss clear (3-reviewer consensus).

- Document the archive-retry idempotency invariant on ARCHIVE_AFFECTING_ACTION_TYPES:
  the hydrator retry re-runs archive side effects with skipReducerDispatch even
  after a fully-successful run, so they must stay idempotent (overwrite, never
  additive) to avoid double-counting time-tracking/counter deltas.
2026-07-10 18:41:09 +02:00
J. Loops
e972de039e
feat: add Home Assistant Bridge to community plugins (#8891)
* feat: add Home Assistant Bridge to community plugins

* feat: add Home Assistant Bridge to community plugins
2026-07-10 18:10:01 +02:00
Johannes Millan
a3272ed138 fix(sync): keep USE_REMOTE recovery Undo visible after routine sync
After a "Use Server Data" replace shows the persistent Undo recovery
snack (#8107), a follow-up routine sync-success snack must not silently
replace it. SnackService now tracks a pending persistent action
(actionStr + duration 0); the header sync() skips its success feedback
while one is showing. Unblocks the committed USE_REMOTE crash-resume e2e
(supersync-use-remote-crash-resume.spec.ts), whose Undo assertion
depends on the recovery snack surviving the sync that resumed the rebuild.
2026-07-10 17:38:47 +02:00
Johannes Millan
0aa2941947
docs(sync): note local-file rev-check/write is non-atomic (#8898) (#8902)
Record the LocalFile check-then-write TOCTOU race as an accepted limitation
and correct stale references in the reliability doc:

- §5 documents the non-atomic rev-check + write as an accepted limitation,
  honestly scoping each mitigation: the upload lock only serializes a single
  client's own uploads (not across machines); the mismatch-fallback catches only
  a concurrent write visible at check time (never force-overwriting), so a write
  landing inside the check→write window can still be lost; .bak recovery is
  best-effort and only covers a corrupt/interrupted primary, not a valid
  concurrent overwrite or a fully-missing one.
- Distinguishes torn writes (prevented on Electron via temp-file + renameSync;
  still in-place on Android SAF) from the CAS race (not closed by atomic rename;
  needs OS-level CAS, left accepted).
- §1 corrected: _uploadWithRetry()/:474/"retries once" → current
  _uploadWithMismatchFallback with _MAX_UPLOAD_RETRIES=2 (3 attempts), and clarify
  that genuine concurrency throws immediately rather than exhausting retries.
2026-07-10 17:31:24 +02:00
Johannes Millan
2bc3641cfa 18.14.0 2026-07-10 17:23:35 +02:00
Johannes Millan
19b6fed044 test(e2e): cover USE_REMOTE interrupted rebuild crash resume
Reloads client B at the exact atomic-baseline-commit cutoff, then
verifies the next sync detects the interrupted rebuild, redoes the raw
download, keeps the first attempt's pre-replace backup, finishes on the
remote state, and offers an Undo that restores B's original import.
2026-07-10 17:15:51 +02:00
Johannes Millan
360f95ed7a fix(sync): preserve device-local sync settings in rebuild baseline
USE_REMOTE's atomic rebuild synthesizes a globalConfig shell from
DEFAULT_GLOBAL_CONFIG (getDefaultMainModelData omits globalConfig), then
re-applies the device's local-only sync settings (provider, isEnabled,
isEncryptionEnabled, syncInterval, isManualSyncOnly) onto the baseline.

Without this, an interrupted rebuild committed a baseline whose sync
config was pure defaults, so the resumed client could lose the provider
and schedule it needs to sync again. Adds a unit test asserting the
device-local settings survive into runRemoteStateReplacement's baseline.
2026-07-10 17:15:13 +02:00
Johannes Millan
d5db11f329
fix(tasks): remove postal-mime dep and harden eml import (#8901)
* style(tasks): elevate add-subtask input

* fix(tasks): parse eml files without external dependency

Replace postal-mime with a bounded parser for sender, subject, and unencoded plain-text bodies. Ignore unsupported MIME body formats and document the root dependency policy.

* fix(tasks): isolate and harden eml import

Lazy-load the local parser, reject lossy or unsupported bodies, and store accepted text as literal notes so imported content cannot trigger remote resource loads. Document the title-only fallback and expand regression coverage.

* fix(tasks): decode eml headers and harden import parsing

- Decode RFC 2047 encoded-words in the subject and sender name so
  international titles show "Grüße" instead of raw "=?UTF-8?...?=".
- Replace the charset regex with a quote-aware Content-Type parser so a
  charset inside another quoted parameter can't be mistaken for the real
  one, and ignore RFC 822 header comments (e.g. "7bit (comment)").
- Cap the synced title (300) and body (100k) so a crafted .eml can't
  amplify into an oversized op (the literal fence can double the body).
- Document parseEml's intentional MIME omissions to prevent a later
  "fix" that reopens the untrusted-body attack surface.
2026-07-10 17:05:50 +02:00
Johannes Millan
17ab963c8f Merge remote-tracking branch 'origin/feat/sync-55222e' into feat/sync-55222e
* origin/feat/sync-55222e:
  test(e2e): identify counter without the flaky hover tooltip
2026-07-10 17:03:57 +02:00
Johannes Millan
16af839641 test(sync): align example-task gate spec with widened conflict gate
The integration spec still pinned the pre-branch semantics where a
pending GLOBAL_CONFIG op counted as non-meaningful. The widened gate
(79f91e36fe) deliberately treats config changes as meaningful user work
(entity-wide exemptions are unsafe — GLOBAL_CONFIG carries synced
preferences), pinned by the gate unit spec but missed here, so the full
suite failed since that commit. Split the case: example-only pending
stays silent + discardable; config pending now expects the dialog.
2026-07-10 16:56:50 +02:00