* fix(sync): authenticate the project-move footprint on encrypted LWW ops
SuperSync E2EE (AES-256-GCM) covers only `op.payload`; `op.entityId`/`entityIds`
travel as plaintext beside the ciphertext. The LWW project-repair reducer trusts
`meta.entityIds` (copied verbatim from that envelope) to move EVERY declared task
out of its project, so a compromised sync server could append victim task ids to
the envelope of an otherwise-valid encrypted move and orphan those tasks — without
touching the authenticated payload.
Bind the envelope footprint to the authenticated one: on the decrypt path, require
exact-set equality between `op.entityIds` and `{op.entityId} ∪
payload.projectMoveSubTaskIds`.
Interim hardening: enforced only when the authenticated payload carries a
`projectMoveSubTaskIds` array. Synthetic conflict-resolution LWW ops legitimately
carry `entityIds` with no such payload field (footprint lives only in the
envelope), so they are left untouched to avoid rejecting valid ops. Fully closing
the envelope-injection vector needs the durable fix (bind the footprint as GCM
AAD behind an envelope-versioned migration), which remains open. GHSA-8pxh-mgc7-gp3g.
* fix(sync): two data-loss fixes on the snapshot-hydration path
Both bugs surface when a remote snapshot replaces live state, and both live in
sync-hydration.service.ts — kept together as one change.
(1) Atomic rejection of superseded local ops. The file-based bootstrap rejected
pending local ops via a standalone markRejected() that commits in its own
transaction BEFORE commitFileSnapshotBaseline(). If that baseline commit then
threw (e.g. the op-log tail changed), the old state survived but the ops were
already durably rejected — permanently non-uploadable local edits. Fold the
rejection into the baseline transaction via a new `rejectOpIds` param so it is
atomic with the state replacement, and notify the user only after it commits.
(2) Flush the tracked-time accumulator before replacing NgRx. Time tracking
buffers a delta in memory for up to five minutes; a hydration in that window
replaced live state without flushing it, so the later flush dispatched a LOCAL
syncTimeSpent the reducer ignores — silently dropping the tracked time. Flush the
accumulator into a durable op before loadAllData (mirroring how clean-slate and
backup handle their own replacement paths), placed after the baseline commit so
the appended op can't move the tail past the asserted seq.
* test(sync): cover the #3 tracked-time flush-across-hydration path
Adds a deterministic scenario to the task-time replay state machine: a delta
tracked but not yet flushed, flush()ed before a snapshot replaces the store,
becomes a durable syncTimeSpent op that re-applies additively on replay onto the
delta-less remote baseline (and, without the flush, the baseline stays
under-counted). Together with the sync-hydration unit test (flush runs before
loadAllData) this covers the flush → durable-op → replay chain the #3 fix relies
on.
* fix(sync): fail-safe the conflict-journal clear on profile switch (privacy)
ConflictJournalService.clearAll() swallows IndexedDB clear() failures — by
contract, it must not throw after the dataset was already replaced during a
profile switch. But if the bulk clear failed, the next profile could still see
the previous dataset's task titles / field values in the conflict-review UI: a
cross-profile privacy leak.
Persist a durable "cleared before" timestamp in localStorage (which does not
depend on the journal's own IndexedDB being healthy) before the bulk clear. The
read paths — list(), getEntry(), and the badge count — hide every entry resolved
at or before that boundary, so a swallowed clear failure can never surface stale
content. The marker is dropped on a successful clear, and pruneOnStart physically
reclaims any hidden survivors and drops the marker on the next app start.
The boundary favors hiding (entries resolved at the exact clear instant are
treated as pre-clear), matching the privacy-fail-safe intent. Known edge-case
limitations (timestamp vs monotonic clock, localStorage durability, a future
localStorage.clear()) are documented on the marker constant.
Held from public issue tracking (privacy). 4 tests added; 31/31 green.
Hardened per a 3-agent review (correctness / privacy-completeness / lean).
* test(sync): round-trip the encrypted project-move footprint through real crypto (#2)
Exercises the #2 integrity gate through the actual encrypt→decrypt flow (real
AES-GCM), not just the isolated integrity function: a legitimate move round-trips
(entityIds ride OUTSIDE the ciphertext — no false positive), a server-tampered
entityIds footprint is rejected on both the single and batch decrypt paths, and a
synthetic envelope-only op (no authenticated projectMoveSubTaskIds) is accepted.
Closes the one gap flagged in the confidence review: proves the attack model and
the fail-closed behaviour in the real pipeline.
* fix(sync): don't block on repair dialogs while holding sp_op_log (#9026)
Post-remote-apply validation failure ran the data-repair flow — a native
confirm() before repair AND a native alert() after — from inside the
sp_op_log lock. During unattended background sync the dialog sat open for
~10 min, freezing the event loop and starving every sp_op_log contender
(snapshot compaction, write-flush, upload, backup) until the 30s
LockAcquisitionTimeout, and widening the file-based recovery-snapshot
clobber window (#9023).
Make data-repair non-interactive by default (fail-safe), so no automatic
or in-lock caller can block on a native dialog:
- ValidateStateService.validateAndRepair gains `interactive` (default
false): the confirm-before-repair and the repair-failed alert only run
for interactive callers.
- RepairOperationService.createRepairOperation / _notifyUser gain the
same flag: the post-repair "data repaired" acknowledge alert (the
common-case blocker the first cut missed) is skipped when
non-interactive, and the record is logged non-blockingly instead of via
devError (which itself pops a dialog in dev builds).
- Since essentially every caller runs automatically and/or inside the
lock, the safe behavior is the default and cannot be forgotten. Only the
user-initiated, pre-lock USE_REMOTE recovery opts into interactive:true.
Automatic in-lock callers (validateAfterSync / conflict-resolution via
validateAndRepairCurrentState, snapshot hydration, server migration) all
inherit the non-interactive default.
The REPAIR op is built identically (dataRepair unchanged), so replay
determinism is preserved; repair still re-validates and refuses to
dispatch a still-invalid state. Failures stay surfaced via the
session-validation latch (#7330) and error snack.
Note: silent auto-repair no longer signals the user that data was
modified — a gated non-blocking "data repaired" notification is a
possible follow-up.
* feat(sync): surface a non-blocking snack when background repair changes data (#9026)
Making automatic repair non-interactive removed the only signal that the
user's data was auto-modified. Restore awareness without reintroducing a
blocking dialog: when a non-interactive repair actually changes something
(totalFixes > 0), RepairOperationService._notifyUser opens a single
non-blocking WARNING snack per session (reusing T.F.SYNC.D_DATA_REPAIRED),
so a silent, cross-device-propagating data change isn't wholly invisible.
The once-per-session latch mirrors the version-block snack guard so a
repeat-repair loop can't spam it. Interactive (foreground) callers keep the
existing blocking acknowledge alert.
* test(sync): stitched proof the background-sync repair path is dialog-free (#9026)
Adds an integration test that drives the full path with BOTH ValidateState-
Service and RepairOperationService real — validateAndRepairCurrentState
('sync', { callerHoldsLock: true }) → real dataRepair → real
createRepairOperation → real _notifyUser — and asserts it never reaches a
blocking window.confirm/window.alert, while still creating the REPAIR op and
surfacing the non-blocking "data repaired" snack. Closes the coverage seam
the unit specs (which mock RepairOperationService) left open.
* test(sync): reproduce file-based compaction stranded ops pointer (#9040)
Concurrent split-file compactions can leave the committed sync-ops.json
referencing a snapshot present in neither sync-state.json nor its .bak: the
snapshot is force-written unconditionally while only the ops file is gated, so
the loser of the ops race can clobber the winner's snapshot after backing up an
older generation.
Adds a failing integration test that drives two interleaved compactions against
the stateful MockFileProvider and asserts a fresh client can still hydrate the
committed generation. Fails today (unrecoverable gap); passes once the snapshot
is written under a generation-unique, immutable name.
* fix(sync): write split-compaction snapshots to immutable files (#9040)
Concurrent split-file compactions could strand the winning ops pointer: the
snapshot was force-written to the fixed sync-state.json, so the loser of the
conditional ops-commit race could clobber the winner's snapshot, leaving the
committed sync-ops.json referencing a snapshot absent from both sync-state.json
and its .bak — an unrecoverable gap for a fresh client.
Write the compaction snapshot to a generation+client-unique immutable file
(sync-state__<syncVersion>__<clientId>.json) recorded in snapshotRef.file. A
concurrent compactor writes a DIFFERENT file, so the winning pointer can never
be clobbered. Readers prefer snapshotRef.file and fall back to sync-state.json /
.bak (kept dual-written for pre-#9040 clients). The superseded snapshot is
GC'd O(1) after each commit; a losing compactor's same-generation orphan is a
rare, bounded leak (documented, with a listFiles-prune upgrade path).
Also fixes MockFileProvider to model create-if-absent (If-None-Match: *) so the
fresh-folder concurrent-compaction path is faithfully gated in tests.
Tests: 3 concurrent-compaction integration scenarios (harmful/benign interleave,
fresh-folder race) + 2 unit tests pinning the snapshot resolution order. Full
op-log suite (3427) and sync-providers package (404) green.
* fix(sync): reclaim orphaned snapshot when compaction loses the commit race (#9040)
A concurrent compactor that writes its immutable snapshot but then loses the
conditional ops-commit left that snapshot orphaned — no committed ops file ever
referenced it, and the O(1) predecessor-GC never reclaimed it (same generation).
The losing compactor now deletes the snapshot it just wrote when its commit
throws, eliminating the leak at its only realistic source. Only a crash between
the snapshot write and the commit/cleanup can still orphan a file (rare crash
window; listFiles-prune remains the documented backstop).
Refactors the GC helper into a single-file _removeGenStateFile primitive used by
both the post-commit predecessor delete and the new failure-path cleanup.
Tests assert the loser's orphan is gone after both the near-cap and fresh-folder
concurrent-loss scenarios. Full op-log suite (3427) green.
* fix(sync): only reclaim orphaned snapshot on confirmed rev-mismatch (#9040)
The loser-orphan cleanup deleted the just-written immutable snapshot on ANY
commit failure. A rev-mismatch is a confirmed server rejection (ops did not
commit), but a network/5xx error is ambiguous — the ops PUT may have landed and
committed, in which case the snapshot is still referenced and deleting it would
strand a reader. Restrict the cleanup to UploadRevToMatchMismatchAPIError.
Adds a test asserting the immutable snapshot survives a non-mismatch commit
failure. Full op-log suite (3428) green.
* fix(sync): use a random suffix, not clientId, in immutable snapshot names (#9040)
Filenames are not encrypted, so embedding the clientId in
sync-state__<syncVersion>__<clientId>.json leaked device count/platform and a
per-device counter to the remote — a metadata downgrade for E2EE file-based
users. Replace the clientId with an opaque random suffix, which still gives two
concurrent compactors distinct files. A collision is astronomically unlikely and
self-heals (the reader validates loaded content against snapshotRef, so a wrong
file fails validation and falls back to sync-state.json/.bak). syncVersion stays
in the name for legible ordering and a future listFiles-prune.
Tests now assert on the count of immutable snapshot files (and a captured name)
rather than hardcoding the now-random filenames. Full op-log suite (3428) green.
* fix(sync): recover project notes/sections/repeat-cfgs on losing delete
When a legacy (pre-schema-v4) deleteProject loses an LWW race to a
concurrent project UPDATE, the recovery path previously recreated only
the project entity and its active/backlog tasks. Notes, sections, and
task-repeat-cfgs stayed deleted, so every client converged to a lossy
shape on hydration replay of the durable loser row — data loss against
the winning "keep the project" intent (#9037).
Recreate those three adapter entities at resolution time on the winner
(which still holds them) via the generic lwwUpdateMetaReducer recreate
path. Sections/repeat-cfgs are enumerated from the store (not carried in
the delete payload) through the non-throwing selectEntities selectors;
notes come from the delete payload's noteIds. Guard against resurrecting
an entity another device is concurrently deleting, and strip dead task
refs from recreated sections.
Deferred (own snapshot design): time-tracking and menu-tree (singletons,
no safe single-project restore) and archived tasks (separate persistence).
Documented, converging limitations: section/repeat-cfg have no modified
field so a concurrent content edit can be clobbered; a note's todayOrder
slot is not restored.
Tests: producer recreation + concurrency guard (conflict-resolution spec)
and a SECTION recreate round-trip (lww-update meta-reducer spec).
* test(sync): converge losing deleteProject cascade recovery (#9037)
End-to-end convergence test through the real cascade reducers, op-log
persistence, and conflict-resolution service: a losing deleteProject's
cascade wipes the project's note, section, and repeat-cfg, and replaying
the full durable log (delete + compensations) restores all three on both
the restarted local client and the originating delete client.
Closes the round-trip coverage gap left by the unit-level producer and
meta-reducer specs.
* test(sync): cover cross-batch deleteProject cascade recovery (#9037)
Extract the losing-deleteProject convergence fixture into a shared helper
and add a cross-batch case: the note/section/cfg recreations are replayed
in a separate page from the durable loser and the project/task
compensations. Proves the recreation ops carry no cross-batch dependency —
they stay wiped after batch 1 (delete + project/task comps) and converge
once the later batch arrives.
* fix(gitlab): avoid request storm on connection test and search (#9034)
searchIssueInProject$ paginated through every matching issue and then
fired a paginated /notes request per issue via forkJoin. With an empty
search term (used by testConnection) or a broad one, a project with
thousands of issues produced thousands of rapid requests, which GitLab
rate-limited with a 429 and surfaced as "connection failed".
The fetched comments were never used: search results only need id +
title, the comment count already rides along via user_notes_count, and
full notes are re-fetched fresh in the detail view via getById$.
Fetch only the first page for search (results are already ordered by
updated_at) and drop the per-issue comment fan-out. This turns the
connection test and each search from thousands of requests into one.
Add gitlab-api.service.spec.ts asserting search issues a single
first-page request, never follows x-next-page, and never requests notes.
* fix(gitlab): make project pattern valid as a native input attribute (#9034)
Formly writes templateOptions.pattern verbatim to the native
<input pattern> attribute, and Chromium 146 compiles that attribute with
the RegExp `v` flag. The field passed a RegExp object, so its `/…/i`
stringification became the attribute value, and the unescaped `/` and `-`
in the `[\w.%/-]` character class are reserved under `v` — Chromium
logged "Invalid regular expression: Invalid character in character class"
on every change-detection cycle (hundreds of times during the request
storm from the connection test).
Pass the regex `.source` string instead, escape `/` and `-` in the class
(`[\w.%\/\-]`), and make the pattern flag-independent by writing the only
case-sensitive literal as `%2[Ff]` so dropping the `i` flag preserves the
lowercase `%2f` separator. Matching behaviour is unchanged (verified
against all existing valid/invalid cases). Angular's Validators.pattern
still enforces the same rule via the source string.
Add a regression test that the source compiles under the `v` flag.
On the LWW conflict-apply path, an invalid projectId destination was
handled inconsistently with the local handleUpdateTask strip. Unknown or
non-string ids fell back to the task's current project, but an explicit
null orphaned the task from every list (projectId = undefined). So a null
destination replayed via a disjoint-merge patch diverged: a passive client
kept the task in its project, while a conflict client orphaned it.
Sanitize any invalid destination — null/undefined, non-string, or unknown
project — to the task's current project, in every mode, mirroring the local
strip. Tasks use '' for "no project", so a null is a malformed value to
sanitize (not a signal to clear); this also matches how #9001 already
handled unknown/non-string ids. '' remains a valid no-project assignment.
The same synthetic LWW-TASK action is processed by a twin handler in
section-shared.reducer, which had the same null bug and would otherwise
strip the task from its current-project section while the task slice kept
it. Both handlers now leave the task in its current project for any invalid
destination.
Tests:
- lww-update / section-shared specs: null and unknown destinations retain
the current project and section, in patch and replace modes; the "current
project itself deleted -> undefined" fallback is covered.
- New integration spec composes section -> crud -> lww in registry order and
asserts the normal-replay and LWW-patch paths converge on the same
projectId, project.taskIds and section membership. Verified to fail on the
pre-fix code (LWW path orphaned the task from both project and section).
* fix(planner): don't erase day-scheduled subtasks on parent plan (#9019)
Planning a parent task ran removeTasksFromPlannerDays(task.subTaskIds),
which stripped its subtasks from EVERY planner day. As a result, any
day-scheduling the user had given a subtask was silently wiped the moment
its parent was planned anywhere, so those subtasks disappeared from the
planner and schedule (timed subtasks survived via a separate, plannerDays-
independent path).
handlePlanTaskForDay now collapses the parent's subtasks on the target day
only, matching the target-day-only behaviour handleTransferTask already
had. Subtasks scheduled for other days keep their scheduling and stay
visible. Add removeTasksFromSinglePlannerDay helper (reuses removeTasksFromList)
for this; the explicit "SinglePlannerDay" name keeps it from being confused
with the all-days removeTasksFromPlannerDays (the mix-up that caused the bug).
Same-day subtasks still fold into the parent by design: showing them as
siblings would double-count their time (the parent estimate rolls up its
subtasks) in the day total and on the schedule timeline.
No schema barrier: the op payload and planner.days/dueDay shapes are
unchanged, so this is a pure reducer behaviour fix, not a new payload
semantic (op-log rules 2.5). In a mixed-version fleet a not-yet-upgraded
client still applies the old all-days removal for these ops, but the task's
dueDay is preserved either way and state re-converges once clients upgrade.
* test(planner): e2e for day-scheduled subtask visibility (#9019)
Drives the real UI: add a task + subtask, schedule the subtask for a
day (day-only, via the 'S' shortcut + quick-access Tomorrow), plan the
parent for a different day, then assert the subtask is still visible on
its own day in the planner. Fails against the pre-fix all-days removal,
passes with the target-day-only fix (both verified locally).
* fix(sync): guard file-based recovery snapshot vs concurrent ops (#9023)
An automatic REPAIR recovery snapshot on a file-based provider (Dropbox/
WebDAV/local) force-overwrote the whole shared sync file unconditionally.
If another device had uploaded a concurrent op this client had not yet
merged, that op was silently dropped. SuperSync guards this via a
server-side repairBaseServerSeq check; the file-based path had none.
Add a rev-based guard: for a REPAIR (not BACKUP_IMPORT/initial/migration),
write the primary sync file CONDITIONALLY on the rev this client last
downloaded+applied (_lastSeenRevs). On a rev mismatch the adapter returns
REPAIR_STALE, which routes into the existing provider-agnostic rebase path
(RejectedOpsHandlerService -> rebaseStaleRepair): it downloads the
concurrent ops, rebuilds the repair, and retries. That download promotes
_lastSeenRevs, so the retry converges.
A rev (not a vector clock) is used deliberately: two independently-pruned
clocks (MAX_VECTOR_CLOCK_SIZE) can compare CONCURRENT even when one
dominates, which would wedge the repair forever. The split path applies
the same guard via a getFileRev pre-check (metadata only, so a torn remote
does not block). _conditionalUploadRepairSnapshot writes primary-before-
.bak so a lost race leaves the stale repair out of .bak.
Tests: adapter unit specs for the guard (conditional-on-base-rev, stale ->
REPAIR_STALE with .bak untouched, null-rev expect-absent, BACKUP_IMPORT
still forces, split stale pre-check) and an end-to-end two-client
convergence integration test (no clobber -> rebase -> converge, both
clients' data preserved).
* refactor(sync): centralize REPAIR_STALE error code (#9023)
The file-based adapter (producer) and RejectedOpsHandlerService (consumer,
routes it to a rebase) both matched the bare literal 'REPAIR_STALE', so a
rename on one side could silently break the rebase without any test failing.
Extract REPAIR_STALE_ERROR_CODE so the client-internal producer/consumer
contract is a single shared symbol. Behavior-identical; the value still
mirrors the server's SYNC_ERROR_CODES.REPAIR_STALE (the wire contract),
pinned by the existing handler spec (feeds the literal) and adapter specs.
* fix(supersync): exclude legacy REPAIR from retention cleanup pruning
The daily-cleanup fallback (used when `latestFullStateSeq` is absent, i.e.
legacy/pre-marker installs) selected the pruning boundary with a raw
`opType: { in: [SYNC_IMPORT, BACKUP_IMPORT, REPAIR] }` filter that includes
legacy REPAIR rows (`repairBaseServerSeq` NULL). Such a repair carries no
causal base cursor proving its state is current as of its seq, so pruning
history behind it can permanently drop ops committed between its logical
base and its seq for any device replaying from before it.
#8971 migrated five full-state queries to `CAUSAL_FULL_STATE_OPERATION_WHERE`
but missed this one — the single query whose result directly authorizes a
DELETE. Route it through the same causal-only predicate; a legacy-repair-only
user then yields `protectedFromSeq === null` and is skipped (no deletion),
the safe default already implemented below.
Adds a regression test whose mock `findFirst` honours the causal
`repairBaseServerSeq` predicate.
* fix(supersync): gate misc→tasks conflict alias on the split boundary
`detectConflictForEntity` and `prefetchLatestEntityOpsForBatch` looked up
the legacy `GLOBAL_CONFIG:misc` alias for an incoming `tasks` write with
`schemaVersion < CURRENT_SCHEMA_VERSION`. Once v4 shipped, that aliases
post-split v2/v3 misc writes — disjoint from `tasks` — and fabricates false
`CONFLICT_CONCURRENT` rejections of tasks-settings writes for multi-device
users.
Gate both read-side lookups on the fixed `MISC_TASKS_SPLIT_SCHEMA_VERSION`
(2), matching the `isLegacyMiscConfigOperation` incoming gate and the
warning comment it already carries. Drops the now-unused
`CURRENT_SCHEMA_VERSION` import.
Adds real-PostgreSQL integration coverage for both changed lookups
(`detectConflictForEntity` and the batch `prefetchLatestEntityOpsForBatch`):
a post-split v2/v3 misc write must not alias, a legacy v1 one still must.
* fix(sync): re-upload LWW local-win ops after a WS-triggered download
A WS-triggered download that resolves a conflict against pending local ops
appends LWW local-win replacement ops straight to the op-log, bypassing the
capture effect. Unlike `ImmediateUploadService` and the main sync loop, this
path had no follow-up upload, so the preserved edit sat unsynced until the
next user edit or periodic sync — an unbounded window for manual-sync-only
users.
Re-upload when `localWinOpsCreated > 0`, mirroring the other two paths, and
surface the same terminal outcomes `ImmediateUploadService` does — permanent
rejection / rejected-full-state baseline → ERROR, mandatory-but-missing key →
UNKNOWN_OR_CHANGED — so a preserved edit that fails to converge is not left
silent (the WS path never claims IN_SYNC). Single follow-up only, matching the
sibling side channel.
* fix(supersync): validate the primary latestFullStateSeq marker as causal before pruning
The scheduled retention cleanup trusted `state.latestFullStateSeq` as a pruning
boundary whenever it was `<= lastSnapshotSeq`, with no check that the marked op is
a causal full-state operation. The earlier fix (37bf818) hardened only the
fallback query used when the marker is absent.
Installs upgraded from before #8973 can carry a `latestFullStateSeq` set from a
legacy REPAIR (repairBaseServerSeq NULL) through the old isFullStateOpType gate,
and that migration shipped no backfill to clear stale markers. Trusting such a
marker prunes history behind a repair the replay path deliberately refuses as a
boundary (LegacyRepairReplayUnsupportedError) — permanent, cross-device loss.
Validate the marked op against CAUSAL_FULL_STATE_OPERATION_WHERE before it can
authorize a DELETE; a stale marker drops to the (now causal-only) fallback query
or the user is skipped. Updates the happy-path test to a causal boundary, adds a
stale-marker regression test, and teaches the mock findFirst to honour an exact
serverSeq predicate.
* test(e2e): de-flake USE_REMOTE crash-resume reload
`page.reload()` defaulted to waiting for the `load` event, which can never
fire while an active SuperSync WebSocket/sync connection keeps the page
"loading" — so the reload timed out at 30s (observed flake:
`page.reload: Timeout 30000ms exceeded`). Three sibling sync specs already
document this hang and work around it with close()+newPage(), but that drops
sessionStorage, which this test asserts on across the reload.
Wait only for `domcontentloaded` (with 60s headroom) instead; `waitForAppReady`
— which itself only needs `domcontentloaded` — remains the real readiness gate,
and the reload still preserves sessionStorage.
* fix(sync-core): break whole-entity LWW timestamp ties by clientId
An exact-millisecond timestamp tie on the same field of one entity fell
to remote-wins with no tiebreak. Because local/remote are swapped between
devices, each kept the other's value and diverged permanently.
Fall back to a deterministic larger-clientId compare on the tie (mirrors
noiseTiebreakSide), routing a local tie-win through the existing
localWinOperationKind: 'update' path so the compensating op preserves the
local value and dominates the loser's clock.
* refactor(sync-core): tidy LWW tiebreak helper + strengthen tie tests
Multi-review follow-up (no behavior change):
- drop unreachable `?? ops[0]` fallback and the unused generic in
winningClientId; correct the doc comment's "mirrors" overstatement.
- rewrite the mislabeled service-level tie test that never exercised the
tiebreak and add the local-clientId-larger direction (compensating op).
* test(sync): cover LWW client-ID tie-win over a concurrent remote DELETE
The tiebreak reaches _createLocalWinUpdateOp's delete-recreation branch via
a tie (not just a newer timestamp); assert the entity is still recreated.
* docs(plugins): add microsoft 365 calendar provider plan
* docs(plugins): add microsoft 365 calendar provider plan
* docs(ios): plan internal testflight builds
* fix(electron): assert renderer IPC boundary at window creation
Every IPC trust boundary (Jira one-shot capability, plugin node-exec
consent, the window.ea preload bridge) rests on the renderer main world
not having require/ipcRenderer, which is guaranteed solely by
contextIsolation: true + nodeIntegration: false and sub-frames not
getting node integration. If that webPreferences ever silently regressed
(a refactor spreading a shared options object, a bad merge), every gate
would collapse at once while still looking correct in review.
Add web-preferences-guard.ts (assertSecureWebPreferences) and fail closed
before creating a window if the boundary is not intact. It rejects a
non-true contextIsolation, a non-false nodeIntegration, and (fail-closed)
a nodeIntegrationInSubFrames that is not explicitly false; it also
directionally rejects an explicit sandbox: false, nodeIntegrationInWorker:
true, and webviewTag: true (each off by default, so no call site is
forced to set it). Wire it at all three new BrowserWindow sites (main
window, task widget, full-screen blocker); the full-screen blocker
previously relied on Electron defaults, so set its webPreferences
explicitly. A *.test.cjs backs it with behavioral coverage plus a wiring
guard that counts constructor sites vs guard calls per file, so a future
window cannot silently ship without the check.
Closes#9015
* fix(electron): extend webPreferences guard to webSecurity
Follow-up hardening from the multi-agent review of #9018:
- Reject an explicit `webSecurity: false` (directional, like the sandbox
/worker/webviewTag trio). With the app's blanket
Access-Control-Allow-Origin: *, disabling the same-origin policy in a
node-bridged renderer would widen cross-origin reach — and no call site
currently guards against it.
- Broaden the wiring-guard test to also require the assert for
`new BrowserView` / `new WebContentsView`, closing the tripwire's blind
spot for future non-BrowserWindow renderers (none exist today).
- Correct the fail() comment: the `throw` narrows the type regardless of
return-vs-throw; fail() returns an Error only to DRY the message.
230/230 electron tests pass; checkFile + prettier clean.
* fix(tasks): decode multipart and transfer-encoded eml bodies #8975
Dropping a real-world .eml (e.g. saved from Outlook) onto the add-task
button created a title-only task: the parser only accepted a single,
unencoded text/plain body, but most clients send multipart/alternative
(plain + HTML) with quoted-printable or base64 transfer encoding on the
plain part.
eml-parser.ts now walks multipart/* structures (bounded recursion depth)
for the first supported text/plain leaf, and decodes quoted-printable/
base64 transfer encodings on it. HTML bodies and non-UTF-8/ASCII
charsets are still never decoded, matching the existing threat model
for this untrusted, inert note content.
* fix(tasks): keep unsupported-encoding eml test accurate after decode support
The service-level test for "unsupported body encoding" used a base64
fixture, which the eml-parser fix now legitimately decodes. Switch it
to a genuinely unsupported transfer-encoding token, and add a
dedicated test asserting base64 bodies decode and wrap in the notes
code fence as expected.
* fix(tasks): skip Content-Disposition: attachment parts in eml import
_extractPlainText() picked the first supported text/plain leaf in wire
order regardless of Content-Disposition, so a text/plain attachment
could be imported as the note when the real body was HTML, or shadow
the real body if it appeared first in the multipart structure. Skip
any part (leaf or multipart container) marked as an attachment before
inspecting it further.
Addresses review feedback from @johannesjo on PR #8999.
* fix(tasks): treat non-inline disposition and legacy name= as attachment
Content-Disposition is optional (legacy mail marks a filename via
Content-Type's name= parameter instead), and RFC 2183 §2.8 requires
any disposition type other than inline — recognized or not — to be
treated as attachment. The previous fix only matched the literal
token "attachment", so a text/plain part identified solely by a
legacy name= parameter, or one with an unrecognized disposition type
(e.g. x-download), still shadowed the real body.
_isAttachmentPart() now treats any present disposition other than
inline as an attachment, and falls back to the Content-Type name=
hint only when Content-Disposition is absent entirely.
Addresses further review feedback from @johannesjo on PR #8999.
* fix(tasks): recognize RFC 2231 name*/name*0 attachment filename hints
The legacy Content-Type name= fallback (used when Content-Disposition
is absent) only matched the literal key "name", missing RFC 2231's
encoded (name*) and continuation (name*0, name*0*, name*1, ...)
spellings of the same parameter. A part identified solely by a
continued name*0/name*1 filename hint slipped through undetected and
could be imported as the note instead of producing a title-only task.
_parseContentType() now matches any RFC 2231 spelling of name via a
presence-only regex; the value is never decoded or reassembled since
only the filename hint's existence matters.
Addresses further review feedback from @johannesjo on PR #8999.
* fix(tasks): recognize RFC 2231 filename*/filename*0 disposition hints
Content-Disposition's hasFilename fallback (used when the disposition
type token fails to parse, e.g. a type-less "; filename=...") only
matched the literal key "filename=", missing RFC 2231's encoded
(filename*) and continuation (filename*0, filename*0*, ...) spellings
of the same parameter -- the mirror of the name*/name*0 gap fixed for
Content-Type.
_parseContentDisposition() now matches any RFC 2231 spelling of
filename via a presence-only regex, symmetric to _NAME_PARAM_KEY_RE.
Addresses further review feedback from @johannesjo on PR #8999.
* docs(plugins): add microsoft 365 calendar provider plan
* docs(plugins): add microsoft 365 calendar provider plan
* docs(ios): plan internal testflight builds
* fix(sync): recreate cascaded tasks when a deleteProject loses LWW (#8997)
When a remote deleteProject loses an LWW conflict, its reducer cascade
(tasks removed via removeMany(allTaskIds)) is not compensated, so the
project resurfaces empty and its tasks are lost on every client that
applied the delete and on this client's own hydration replay.
Mirror the TASK-parent recovery from #8990 for PROJECT cascades:
_createTaskRecreationOpsForWinningProject emits recreate-after-delete
TASK snapshots for every still-present task in the delete payload's
allTaskIds, plus relationship/membership patch ops so the exact
regular/backlog lists and subtask links are restored after the entities
exist. enrichDeleteProjectAction expands a stale delete through current
project relationships on replay; the lww meta-reducer validates recreate
rows against present parents/projects.
Covers part (a) of #8997. Parts (b) local-delete-loses and (c)
notes/archived-tasks remain open.
* fix(sync): make deleteProject-loser recovery interruption-safe (#8997)
Hardens the base #8997 recovery for cases where a recreate-after-delete
row is itself caught in a later conflict or delivered out of order.
- createTaskRecreationFollowUpOps re-emits parent/subtask relationships
and PROJECT membership when a recovery row is rejected and replaced, so
independent server acceptance cannot drop parent/child links or append
a backlog task to the regular list.
- _createRemoteWinCompensationForRejectedTaskRecreation reconstructs a
local snapshot when a remote TASK winner (move-to-project or a
field-safe update) beats a local recovery row, then restores its
dependents; opaque/relationship-changing remote winners bail out.
- The superseded-op resolver re-emits the same follow-ups and persists
each replacement group atomically before retiring the stale rows.
- Preserve the recreate guard when a recovery row wins a later conflict.
Load-bearing, not gold-plating: with this layer stubbed, 8 unit tests
plus the move-winner half of the persistence convergence test fail.
* fix(sync): don't resurrect concurrently-deleted tasks in project recovery (#8997)
Adversarial review of the #8997 recovery found two cross-device residuals
in _createTaskRecreationOpsForWinningProject:
1. (HIGH) The recovery reads task presence from the pre-batch store, so it
was blind to a delete piggybacked as a non-conflicting op in the same
sync batch. Device C deletes task t2 while device A wins a project rename
vs B's deleteProject; A recreated t2, resurrecting it (via a borrowed
newer timestamp) on every client that applied C's delete while A's own
delete won locally — a silent split-brain. _collectDeletedTaskIds now
gathers the batch's deleted TASK ids (single + multi-entity) and recovery
skips them.
2. (MEDIUM) Recreations borrowed the project's timestamp as their LWW proxy,
which is unrelated to task content and could clobber a CONCURRENT edit on
another device. They now use each task's own `modified` (fallback: the
project timestamp). Clock domination over the delete is unaffected.
Both are proven by new failing-first specs. Note: the sibling subtask path
(_createSubtaskRecreationOpsForWinningParent, #8956) shares the same
same-batch-delete blindness and is a candidate follow-up.
* fix(sync): keep bulk-deleted tasks deleted in project recovery (#8997)
The deleteProject-loser recovery skips tasks a concurrent non-conflicting
op is deleting in the same batch, so it doesn't resurrect them on clients
that applied the delete. Its `_collectDeletedTaskIds` guard only read
`op.entityId`, but a bulk `deleteTasks` (TASK_SHARED_DELETE_MULTIPLE) op
carries every id in `entityIds` and mirrors only the first to `entityId`,
with an empty `entityChanges`. So every id after the first was missed and
recreated with a borrowed newer timestamp — the same split-brain the
single-delete guard closes, via the bulk path.
Union both id sources via `getOpEntityIds` (already used throughout this
file for the identical reason). Adds a failing-first regression test
mirroring the single-delete case with an entityIds-carrying bulk delete.
* fix(sync): exclude conflict-won deletes from project recovery (#8997)
The deleteProject-loser recovery excludes tasks a non-conflicting op is
deleting in the same batch, but a task can also be deleted by its OWN LWW
conflict — this client held a competing edit that lost, and the remote
delete won. Such a delete is applied this batch yet never reaches
`nonConflictingOps`, so recovery read the task from the pre-batch store
and re-emitted a recreation for a deletion that had just won.
Fold the remote-delete winners of resolved conflicts into the same
`_collectDeletedTaskIds` guard so recovery skips them too. Reusing that
helper means bulk deleteTasks winners are covered as well. Failing-first
regression test added.
* test(sync): add replay-convergence tests for project-recovery delete guards (#8997)
The concurrent-delete guards were covered only by emission assertions (was
the recovery row emitted/skipped) — which cannot catch a resurrection that
only manifests after replay. These two real-store integration tests apply
the recovery ops on a fresh client that also applied the concurrent delete
and assert the deleted task stays deleted:
- bulk deleteTasks: every trailing entityIds entry stays deleted
- a task whose own LWW conflict the remote delete won stays deleted
Both fail (task resurrected) when the respective guard is reverted,
confirming they exercise the divergence, not just op emission. The second
also disproves the "borrowed modified timestamp self-corrects" theory: a
recreation dominates the delete by clock/seq, so the task is resurrected
without the guard.
* docs(plugins): add microsoft 365 calendar provider plan
* docs(plugins): add microsoft 365 calendar provider plan
* docs(ios): plan internal testflight builds
* fix(sync): prevent file-based sync data loss
Preserve post-snapshot operations and stage remote baselines until durable apply.
Use conditional writes and a resumable marker for provider concurrency and legacy migration. Report trustworthy remote timestamps and document transport limits.
Refs #8960
* fix(sync): avoid biased conflict recommendations
Highlight a side only when its timestamp or known change count is strictly greater, leaving unknown and tied metadata neutral.
* fix(sync): preserve local edits during snapshot hydration
Extracts the one reachable-data-loss fix from the #9005 hardening branch
(feat/sync-recovery-hardening). On the default single-file path, a local
action dispatched while an async snapshot hydrate is in flight was
persisted against the old state and then clobbered by loadAllData (or
permanently dropped by markRejected) — a silent edit loss multi-device
users can hit on gap-detected re-sync.
Runs hydration inside writeFlushService.flushThenRunExclusive so capture
stays in deferred mode across the snapshot dispatch, commits the snapshot
baseline atomically (commitFileSnapshotBaseline), then replays the
buffered local intents and their archive side effects onto the new
baseline.
Deliberately excludes #9005's snapshot two-phase-commit, structural
validation, split-migration crash-resume, and gap persistence: reachability
analysis found those defend non-reachable (structural validation),
already-fail-safe (crash-mid-write), or default-off (split sync) failure
modes, at the cost of ~1k lines and two permanent on-disk formats.
* test(sync): add commitFileSnapshotBaseline to hydration spy
* docs(plugins): add microsoft 365 calendar provider plan
* docs(plugins): add microsoft 365 calendar provider plan
* docs(ios): plan internal testflight builds
* fix(locale): localize ISO calendar weekday/month names (#8987)
PR #8991 localized ISO 8601 weekday labels only in the custom
Schedule/Habits/Planner components. Material <mat-calendar> (schedule-task
dialog, deadline dialog, date-picker inputs, repeat-task heatmap) still
rendered month + weekday names via the global adapter locale 'sv' (the ISO
sentinel), so they showed Swedish and ignored the app language.
Override getDayOfWeekNames, getMonthNames and the spelled-out branch of
format() in CustomDateAdapter to run under a temporary swap to
isoTextLocale() (the UI language) when the ISO option is active. Numeric
dateInput and time-only formats keep the configured locale, so ISO stays
YYYY-MM-DD and the 24h clock is preserved. The swap assigns this.locale
directly (not setLocale) so it fires no spurious localeChanges.
Add unit coverage for the adapter and a real <mat-calendar> integration
spec asserting UI-language headers, a live language switch, and the
non-ISO fallback.
* docs(plugins): add microsoft 365 calendar provider plan
* fix(jira): gate Electron requests behind one-shot capability
Claim privileged Jira IPC before plugin startup and return responses through invoke instead of a broadcast event. Keep arbitrary HTTP(S) Jira hosts supported while rejecting redirects and bounding request resources.
* fix(jira): enforce Electron request capability
Bind privileged Jira IPC to a main-issued renderer-document token and strip raw Electron events from renderer callbacks. Scope image authentication by origin, base path, and resource type while preserving safe redirects and legacy configurations.
* fix(electron): handle payload-only IPC lifecycle
Clear Jira image authentication before replacement and when a new renderer document claims the capability. Parse before-close IDs from payload-only events so pending sync and finish-day hooks can complete.
* fix(electron): address Jira IPC capability review findings
- electron.effects: read ANY_FILE_DOWNLOADED payload at [0] after the
payload-only IPC refactor (was [1], now undefined -> TypeError on every
download); guard against a malformed payload
- jira-capability: rotate the token on re-register so a renderer reload
that reuses the WebFrameMain object is not permanently locked out of
Jira; invalidates any stale token
- document that the one-shot consumption order, not the bypassable
main-frame check, is the real capability boundary
- jira-electron-bridge: skip the no-op clearImgHeaders IPC round-trip
when image auth was never set up (non-Jira detail-panel open/close)
- jira-api: route a synchronous _toElectronRequestInit throw through
_handleResponse instead of leaking a dangling request-log entry
* test(electron): cover ANY_FILE_DOWNLOADED payload parsing
Extract parseDownloadedFilePayload from ElectronEffects and add a
regression spec pinning the payload-only shape ([file], not [event,
file]) that caused a TypeError on every download. Hardened against
non-array input surfaced by the new test.
* fix(electron): restore Node ambient globals for frontend build
* docs(plugins): add microsoft 365 calendar provider plan
* docs(plugins): add microsoft 365 calendar provider plan
* docs(ios): plan internal testflight builds
* fix(sync): serialize archive replacements
* test(sync): scope archive-race spec to op-log replacement (#8941)
The integration spec's first case drove SyncHydrationService.hydrateFromRemoteSync
and expected it to serialize against archive compression via the TASK_ARCHIVE
lock. But the hydration-side lock is not part of this PR — it lands with the
snapshot-hydration ordering change in PR #9010 (fix/8960-hydration-race), which
also ships its own sync-hydration.service.spec coverage.
Because hydrateFromRemoteSync never requested the lock here, that test hung
forever on `await hydrationLockRequested` while compressArchive still held the
real navigator.locks `sp_task_archive` lock. The leaked global lock then
cascaded into every downstream spec that acquires it (26 failing tests,
LockAcquisitionTimeoutError).
Drop the hydration-path case (it belongs with #9010) and keep the
runRemoteStateReplacement case, which is exactly the writer this PR serializes.
Trim the now-unused hydration providers/mocks from the setup.
deleteProject cascade-deletes a project's tasks, notes, sections, repeat
config, and archive data in one reducer pass. When that op lost an LWW
conflict to a concurrent project edit, only the PROJECT entity was
reversed: every client resurrected an empty project and the winning
client's status-blind hydration replay cascaded its tasks away after a
restart (live state != post-restart replay).
Rather than recreate every cascaded entity (payload scales with project
size and cannot restore every side effect safely), give schema-v4
deleteProject operations explicit delete-wins precedence:
- new deleteProject actions carry a shared PROJECT_DELETE_WINS_MARKER; the
shared LWW planner accepts a host-supplied delete-wins classifier. A
marked remote delete is applied regardless of timestamps; a marked local
delete is replaced with one op whose vector clock dominates both sides.
- historical unmarked (schema-v3) deletions keep timestamp-based LWW; the
absence of the marker (never added by the no-op v3->v4 migration) is the
real discriminator, and a schema v3->v4 barrier (mirroring v2->v3) makes
older clients block on the newer-schema gate instead of mis-resolving.
Delete-wins plans reuse the archive-win resolution pipeline, so they
inherit its atomic persistence and losing-op rejection, and disjoint
merge leaves them untouched (the delete must win the whole entity).
Hardening from multi-agent review:
- union allTaskIds/noteIds across multiple concurrent marked deletes for
the same project, so a single replacement cannot leave orphan tasks on
clients that only receive it (the task reducer removes by allTaskIds).
- gate the classifier on the AUTHENTICATED payload projectId matching the
plaintext entityId, so a tampered/replayed delete retargeted onto a live
entity cannot silently drop a concurrent edit.
- guard a null/undefined delete payload in the classifier instead of
throwing and wedging the conflict pass.
- pin the server's legacy-misc conflict alias to the fixed v1->v2 split
boundary, not CURRENT_SCHEMA_VERSION, so this bump does not fabricate
false GLOBAL_CONFIG:misc/tasks conflicts during rollout.
- bind the marker with a shared const (compiler-checked on producer and
consumer) and rename _isArchivePlan -> _isWholeEntityWinPlan.
Documents the policy as ARCHITECTURE-DECISIONS.md #7.
Addresses #8997.
* docs(plugins): add microsoft 365 calendar provider plan
* docs(plugins): add microsoft 365 calendar provider plan
* docs(ios): plan internal testflight builds
* fix(sync): prevent file-based sync data loss
Preserve post-snapshot operations and stage remote baselines until durable apply.
Use conditional writes and a resumable marker for provider concurrency and legacy migration. Report trustworthy remote timestamps and document transport limits.
Refs #8960
* fix(sync): avoid biased conflict recommendations
Highlight a side only when its timestamp or known change count is strictly greater, leaving unknown and tied metadata neutral.
* test(sync): assert options arg on split-file processRemoteOps
The split-file snapshot path now routes post-snapshot ops through
_processRemoteOpsWithStartupCleanup, which calls processRemoteOps with an
(empty) options object. Update the assertion to match the 2-arg call so the
unit suite passes.
* refactor(sync): dedupe strong-ETag regex, document snapshot-op boundary
- Extract the duplicated RFC 7232 strong-entity-tag pattern into a single
STRONG_ETAG_RE constant with a comment noting why the char class is safe to
interpolate into an If-Match header (no CR/LF header splitting).
- Explain why sv === undefined ops are classified as snapshot-included: they are
legacy migration ops fully contained in the snapshot, and _validateSnapshotRef
enforces a clock-EQUAL boundary. No behavior change.
* fix(tasks): make REST project moves atomic
Capture affected subtasks in the persisted update action so project moves replay consistently, and repair stale project and section references during archive and restore.
Fixes#8983
* fix(tasks): harden project move replay
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* @
feat: suppress idle dialog during focus mode sessions
Add a new setting (default: ON) to suppress the idle time dialog when a
focus mode session is actively running. The idle popup no longer
interrupts Pomodoro, Flowtime, or Countdown sessions.
Changes:
- Add isSuppressIdleDuringFocusMode field to IdleConfig
- Add checkbox to Settings → Time Tracking → Idle Handling
- Skip idle trigger in idle.effects.ts when focus session is active
- Add translations for all 28 languages
Closes: #1834
References: #1676
@
* feat: add opt-in setting to suppress idle dialog during work sessions
Address review feedback: narrow scope, default OFF, fix existing-user config merge.
Changes:
- default: isSuppressIdleDuringFocusMode changed from true to false (opt-in)
- reducer: deep-merge idle section on loadAllData so the new field defaults
properly for users with persisted idle configs
- i18n: revert all non-English translation files, keep only en.json
- effects: fix Prettier/ESLint indentation
- tests: add reducer test for idle section merging + idle-effects spec
- docs: add setting to Settings-and-Preferences.md
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: repair idle-effects test harness for CI
- Use ReplaySubject<void>(1) so onReady$ replay survives late subscription
- Provide LOCAL_ACTIONS token + provideMockActions
- Fix TS2740 type mismatch on chromeInterfaceMock.onReady$
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(tasks): avoid double-offsetting add-task bar on ios
Use the overlay-only keyboard offset on iOS because Capacitor's native resize mode already shrinks the WebView. Preserve the measured keyboard-height path for Android and other touch builds, with regression coverage for both selectors.
* fix(tasks): scope ios keyboard offset to touch devices
Keep hybrid iOS devices on the existing top-positioned layout while applying the overlay-only keyboard offset to touch-only iOS builds. Replace the stylesheet inspection with rendered layout coverage for resized, overlay, non-iOS, and hybrid cases.
* test(planner): reset mocked selectors after each test
Prevent the mocked task selector from leaking into later Jasmine specs and causing nondeterministic sync convergence failures.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* chore: add project-scoped Angular MCP
* chore: update npm for release-age policy
* fix(sync): preserve LWW outcomes across clients
Distinguish replacement snapshots from partial merge operations, protect device-local sync configuration, recreate winning deletes, and resolve every conflicted entity in bulk operations.
Fixes#8956
* fix(sync): harden mixed LWW conflict replay
Preserve unaffected remote and local bulk intents, keep device-local sync settings during replacement replay, and gate replacement semantics behind schema v3.
* fix(sync): recover subtask subtree and harden LWW replay follow-ups
Follow-up hardening for #8956 after multi-agent review:
- Recreate a locally-winning parent's subtasks when a remote bulk delete
is a mixed winner. The full remote delete is applied (cascade-deleting
the parent's subtasks via handleDeleteTasks) but only the parent had a
compensation op, so the subtree was silently lost across devices.
- extractUpdateChanges: scan array-valued payload props instead of guessing
`${payloadKey}s`, so irregular bulk keys (e.g. taskUpdates) no longer
return {} and drop a remote winner's changes.
- Degrade gracefully instead of throwing when a remote update wins over a
local delete with no reconstructable base entity, matching the
single-entity path (a permanent sync wedge is worse than the bounded
divergence it already accepts).
- Remove dead code (unused `deleting` set + zero-caller wrapper), use the
Set-based scoped-bulk-delete filter, type meta via LwwUpdateMode, and
restore the withLocalOnlySyncSettings rationale comment.
Adds regression tests for the subtree-recovery and irregular-bulk-key paths.
* fix(sync): preserve conflict outcomes during replay
Persist replacement LWW operations and bulk-delete snapshots so reconstructed state matches the result applied live. Bump the op-log DB version to prevent older clients from opening the incompatible schema.
* fix(sync): persist conflict outcomes atomically
Write remote losers, local compensations, and final remote winners in one IndexedDB transaction so crashes cannot expose a partial replay order. Add real-store coverage for live/replay equivalence and transaction rollback.
* fix(sync): preserve multi-entity conflict recovery
* fix(sync): close review gaps in multi-entity conflict recovery
- recreate a winning parent's subtasks when a remote DELETE loses
outright (single-entity or all-local-win bulk), so clients that
applied the delete and status-blind hydration replay converge
- apply the combined resolution batch in durable seq order so a
pending row reused from a prior failed attempt replays identically
live and after a crash
- restamp converted remote updates carrying the v3 replacement
envelope to the current schema version
- strip the virtual TODAY tag from LWW task payloads and shallow-merge
patch-mode singleton payloads instead of replacing feature state
- pin the server snapshot fast-path spec to CURRENT_SCHEMA_VERSION
(fixes the CI failure from the v2-to-v3 bump)
* test(sync): pin outright-losing delete convergence across clients
Three-way real-reducer/real-store convergence for the pure-loser path
(live == restart replay == originating client), covering both the
same-batch recreate exemption and the cross-batch recreate path.
Verified to fail against the pre-fix service.
* fix(ui): open time picker for Electron touch input
Open the native time picker for touch activation in Electron while preserving mouse, keyboard, pen, and non-Electron behavior.
Closes#8986
* test(planner): reset mocked selectors after scheduling specs
Counter-scale fixed-size font icons by the Android WebView text zoom while preserving accessible text and inline icon scaling. Covers Angular icons, raw Material Symbols, and shared pseudo-element icons.
Fixes#5694
* fix(sync): keep the conflict summary banner counts live during review
The banner captured its counts when it opened; the sync-icon badge
updates live but an OPEN banner went stale while the user reviewed
entries on the sync-conflicts page (and lingered with a nonzero count
after everything was reviewed).
Refresh (or dismiss at zero) the banner on every unreviewed-count
change — but only while it is actually still shown, so a banner the
user dismissed is never resurrected by reviewing activity. Adds
BannerService.isShown(id) for that check.
SPAP-35 (deferred LOW from PR #8874 review)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: retrigger CI (supersync crash-resume e2e flake)
* fix(sync): guard live-refresh banner against dismiss/reorder/phantom-zero races
Follow-up hardening to the SPAP-35 live-refresh banner, from an
independent review of the change. The refresh does an async journal
read between the "is the banner shown?" check and the open/dismiss that
follows, and that gap could misbehave three ways:
- Resurrection: the banner's own DISMISS button bypasses this service,
so a dismiss landing mid-read left the post-read open() to resurrect
the banner the user just closed. Re-check isShown() AFTER the read.
- Stale overwrite: bursts of count changes (e.g. "Keep All" marking
every entry) start concurrent refreshes with no ordering guarantee,
so a slow older read could reopen after the zero-count dismiss or
show a stale count. Add a shared monotonic sequence guard across the
open and refresh paths — last write wins.
- Phantom zero: ConflictJournalService.list() degrades to [] on a
transient DB error, so a zero read while the count stream still
reports >0 must not dismiss a valid banner.
Each guard has a regression test that fails if the guard is removed.
No change to the open-on-sync behavior.
SPAP-35
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): make the live conflict banner mutation-aware and coalesced
Addresses maintainer review on #8946:
- Trigger the open-banner refresh on a journal REVISION (a new monotonic signal
bumped on every mutation), not distinctUntilChanged on the unreviewed count, so
an equal-total composition change (one remote-win reviewed while one local-win
is recorded, total unchanged) still refreshes the 'X remote, Y local' breakdown.
- Coalesce mutation bursts (e.g. Keep All over the whole journal) into a single
trailing refresh via auditTime, so a bulk review no longer fires one full
journal scan per entry.
- Phantom-zero guard now reads the authoritative unreviewedCount signal.
Also fixes the rebase onto current master: #8945 migrated unreviewedCount$ to a
signal, so the banner's old Observable subscription no longer compiled.
Adds regression tests: an equal-total (1 remote -> 1 local) composition change
refreshes the breakdown (fails on the old count-keyed trigger), and a burst of
reviews coalesces to a single journal scan.
SPAP-35
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): enforce conflict-journal retention mid-session, not only at start
pruneOnStart (14 days / newest 200) ran only at startup, so a long-lived
session could grow SUP_CONFLICT_JOURNAL unboundedly. record() now checks
the store count (cheap) and, when it exceeds JOURNAL_MAX_ENTRIES plus a
slack of JOURNAL_PRUNE_SLACK, runs the same age+count prune — amortized
to once every JOURNAL_PRUNE_SLACK records. The prune core is extracted
and shared with pruneOnStart; the observe-only never-throw contract of
record() is unchanged.
SPAP-36 (deferred LOW from PR #8874 review)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): await tx.done with the deletes in the conflict-journal prune
An aborted prune delete transaction rejects both the delete requests and
tx.done. Awaiting them separately (Promise.all(deletes), then await tx.done)
leaves tx.done's rejection unhandled once the delete aggregate rejects first, so
it escapes as a global unhandled rejection during active conflict resolution.
Await both in one Promise.all so tx.done always has a handler. Adds an
aborted-transaction regression test (fails on the old sequencing) and updates
the retention model comment + doc to reflect mid-session pruning.
Addresses maintainer review on #8948.
SPAP-36
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): prevent destructive full-state sync races
Use causal ordering and server-sequence preconditions for full-state operations, surface snapshot rejection, deduplicate migrations, and retain queued WebSocket downloads.\n\nFixes #8959
* fix(sync): block uploads after rejected imports
Keep incremental operations behind a durable barrier when a local import or restore is rejected. Release the barrier only after a newer full-state snapshot is accepted, and surface the blocked state instead of reporting sync success.
* fix(sync): harden causal repair recovery
Persist repair base cursors across the client, provider, and server so stale snapshots are rejected before quota or history mutation and legacy repairs cannot poison restore state.
Atomically rebase stale repairs after downloading the missing suffix, preserve rejected-import barriers and WebSocket watermarks, and cover PostgreSQL serialization.
* fix(sync): harden repair recovery edge cases
Block dependent uploads after any rejected full-state operation, reset negotiated capabilities across provider config changes, and bound WebSocket download retries.
Update sync test doubles for causal repairs and the expanded duplicate-operation identity.
* fix(sync): reject forged encrypted full-state operations
Validate decrypted import and repair payloads against the full-state schema so authenticated ordinary operations cannot be promoted into destructive full-state operations.
Closes#8905
* fix(sync): preserve compatible encrypted full-state payloads
Normalize only known wire and legacy omissions on the validation copy so stripped device-local settings and pre-section backups are not mistaken for metadata tampering.
* test(sync): cover encrypted full-state round trips
* fix(sync): honor sync import conflict outcomes
Propagate nested conflict cancellation through rejected-op handling so it cannot trigger automatic merges or consume the retry budget.
Report force-local resolution failures when the clean-slate upload is blocked, rejected, or accepts no operations.
* fix(sync): harden force overwrite recovery
Verify the exact force-upload operation, preserve clean-slate retries, and roll back rejected replacements. Keep unresolved work retryable without reporting a successful sync.
* test(sync): cover clean-slate rollback in postgres
* 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
* 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
* 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.
* 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