mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
21499 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
470c700358
|
fix(schedule): keep the layout reference live instead of freezing at first render (#9064)
* fix(schedule): keep the layout reference live instead of freezing _contextNow decides the reference time the Schedule lays events out against. It is a computed that returns Date.now(), but nothing it reads advances with the clock — so the value it returns is cached until _selectedDate changes. In practice that means the layout reference freezes at whatever instant the view was first rendered. Leave the Schedule open from 09:00 to 17:00 and msLeftToday() still budgets 15h, and today's unscheduled tasks still lay out from 09:00 — above a now-line that has since marched down the grid. scheduleDays already refreshes every 2 minutes via scheduleRefreshTick, so the layout re-runs all day while being handed a stale reference. Read scheduleRefreshTick() in _contextNow, the same 2-min cadence currentTimeRow and scheduleDays already ride. No added change-detection work: scheduleDays recomputes on that tick regardless. This also fixes the midnight-rollover case. The view does not move when the clock does, so a day picked as "tomorrow" silently becomes today while _contextNow still reports its 00:00. Rather than comparing day strings to detect that, decide by where the wall clock sits inside the selected day. Comparing daysToShow()[0] against _todayDateStr() would mix two notions of "today": todayStr() applies the start-of-next-day offset, todayStr(date) does not (see date.service.ts). With a custom rollover the guard could then return a now outside day 0, and since create-schedule-days anchors dayDates[0] with `startTime = i == 0 ? now`, every day-0 entry would be pushed past its boundary and the first column would empty. Testing the clock's position in the day makes that structural: the reference can never land outside day 0. It also drops _todayDateStr and daysToShow from _contextNow, leaving _selectedDate as the single source. Tests: the rollover spec now moves only the refresh tick, as production does; a spec pins the reference staying live as time passes. The future-date spec asserted only that contextNow !== realNow, which passes for arbitrary wrong values — it now names both timestamps exactly. * test(schedule): pin that day 0 is never anchored outside itself The invariant the _contextNow rewrite rests on: contextNow anchors dayDates[0] via `startTime = i == 0 ? now`, so a now past that day's end would push every day-0 entry over its boundary and empty the column. It was guaranteed by construction but not by a spec. Reachable with a custom start-of-next-day, where the logical today is still Jan 20 while the wall clock already reads 02:00 on Jan 21. |
||
|
|
542da1eb6c
|
fix(sync): isolate file-provider state across target changes (#9063)
* fix(sync): invalidate file-provider target state on config change Task 2 (sync-simplification plan), core increment. The file adapter keys all per-target state (sync version, revs, vector clocks, seq cursor, within-cycle caches) by provider id only, and nothing cleared it on a configuration save. A provider switch, an account switch behind the same provider id, or an identity-affecting setting change would reuse the previous target's state against the new target — reading or writing one target's data against another. - Extract the delete-all reset into a shared _resetTargetState() over a single _targetScopedMaps source of truth; this also closes the one-field gap where deleteAllData never cleared _lastRecoveredCorruptRev. - Add invalidateAllTargets() (+ a target generation counter for the later in-flight guard) and call it from WrappedProviderService's existing providerConfigChanged$ subscription. Machine-only token refreshes go through the credential store (setComplete), not setProviderConfig, so they do not fire providerConfigChanged$ and correctly do not invalidate. Remaining Task 2 scope (follow-ups): Electron LocalFile picker + Android setupSaf ingresses that bypass providerConfigChanged$, and in-flight generation validation before each remote side effect (incl. the #9023 REPAIR rebase loop). * fix(sync): invalidate file target on LocalFile picker/SAF change Task 2 follow-up. The Electron LocalFile folder picker (persists main-side post-#8228) and Android setupSaf() (writes safFolderUri to the credential store) change the sync target without going through setProviderConfig(), so they never fired providerConfigChanged$ — leaving the file adapter's per-target revs/clocks/ caches (keyed only by the unchanged LocalFile provider id) pointed at the old folder. Route both ingresses through the existing providerConfigChanged$ signal via a module-level bridge, so WrappedProviderService clears its cache and calls invalidateAllTargets() exactly as it does for a config save. Remaining Task 2 scope: in-flight generation validation before each remote side effect (incl. the #9023 REPAIR rebase loop). * fix(sync): abort file upload when the target changes mid-operation Task 2 follow-up (in-flight guard). The file adapter keys per-target state by provider id, and the same provider object reads live config, so a target switch (provider/account/folder/identity-affecting setting) DURING an upload would let the in-flight write commit the previous target's merged data to the new target. Capture the target generation at the _uploadOps boundary and thread a write-guarded provider (Proxy over uploadFile/removeFile) through every write path — single-file, split, REPAIR snapshot, and backups. A generation bump (invalidateAllTargets) between capture and a write throws FileSyncTargetChangedError before the write; reads pass through. SyncWrapper maps it to UNKNOWN_OR_CHANGED (silent self-healing re-sync), like a concurrent-upload rev mismatch. Residual (documented): a check->write TOCTOU window remains (narrowed, not closed), and the REPAIR rebaseStaleRepair loop across RejectedOpsHandlerService/ RepairOperationService is not yet generation-threaded (follow-up #2b). Tests: adapter guard (mid-op abort writes nothing; removeFile guarded; reads pass), sync-wrapper mapping (silent UNKNOWN_OR_CHANGED). * fix(sync): extend in-flight target guard to snapshot uploads Completes the Task 2 in-flight guard. #2a guarded _uploadOps, but the file adapter has a SECOND remote-write entry point — uploadSnapshot/_uploadSnapshot (initial/recovery/migration + the #9023 REPAIR snapshot via _conditionalUploadRepairSnapshot and its backups) — which bypassed _uploadOps and was left unguarded. Apply the same generation-capture-at-boundary + write-guarded-provider shadow to _uploadSnapshot. The REPAIR rebase loop itself needs no guard: rebaseStaleRepair (RepairOperation Service) performs NO remote I/O — it rebuilds the repair op from local state (stateSnapshotService + opLogStore.replaceRejectedRepair) and defers the re-upload to the next sync cycle, which flows through the now-guarded write paths. deleteAllData's removeFile stays unguarded by design: it is a deliberate user-initiated wipe of a chosen target, not an in-flight-sync write race. Test: a target switch during the snapshot's archive-load phase aborts before the write with FileSyncTargetChangedError. * fix(sync): guard split-migration writes on the download path Review follow-up (two independent reviewers). The in-flight target guard covered the two upload entry points, but the split-format DOWNLOAD path also writes: when a remote carries a pending split-migration marker, _downloadOpsSplit -> _resumePendingSplitMigration force-writes the state file, tombstone/.bak, and migration marker via the RAW provider. A target switch during that resume could land the previous target's migration on the new one — the same corruption class the guard prevents. Apply the same generation-capture-at-boundary shadow to _downloadOps so the resume writes are guarded; reads (downloadFile/getFileRev) still pass through, so normal downloads are unaffected. Test (e2c): a target switch during a pending-migration download aborts the resume writes with FileSyncTargetChangedError. * docs(sync): correct Task 2 guard comments; make targetGeneration private Multi-review cleanup (no behavior change): - Fix the _targetGeneration doc comment (the in-flight guard it called a 'follow-up' shipped in the same work; it drives _withTargetGuard now). - Correct the _targetScopedMaps comment, which overstated the guarantee: a mid-download switch can repopulate a cache (read path) and a switch between download and upload is caught by neither per-operation guard. What actually prevents a cross-target write is the generation guard + the conditional-write rev check, which self-heals on the next sync — document that honestly. - Make the targetGeneration getter private (test-only surface; the guard reads the private field directly); specs use bracket access. * fix(sync): abort a download whose target changed before committing its baseline Multi-review (Codex) found a data-loss window the write-guard missed: a download READS target A, and if the target then switches mid-download, staging A's baseline (sync-version/clock/rev) and letting the caller advance the seq cursor under the shared provider id makes the NEXT sync skip the new target's ops from a stale cursor. The write-guard only covers writes; reads pass through. Capture the generation at the download boundary and, before committing the baseline (single-file and split paths), abort + reset the target-scoped state if it changed — SyncWrapper maps the error to a silent self-heal. Closes the dominant switch-during-download window; a switch after a successful download (during op-apply / an upload's own cursor commit) is a narrower residual only per-cycle session capture can fully close (documented). * fix(sync): map FileSyncTargetChangedError on the force-upload paths Multi-review (Codex): the error was only mapped in the normal sync() catch. The forceUpload and USE_LOCAL conflict-resolution catches handled only EncryptNoPasswordError, so a target switch during a force upload aborted safely (guard held — no data loss) but surfaced a scary ERROR snack instead of the silent UNKNOWN_OR_CHANGED self-heal. Add the branch to both force paths. * refactor(sync): compile-enforce the in-flight guard via a branded provider type Multi-review (Architecture) hardening: the guard was threaded by an implicit call-graph convention — a future entry point or write-helper caller that forgot the _withTargetGuard shadow would silently re-open cross-target writes, the worst failure class here. Introduce a phantom-branded GuardedFileSyncProvider (FileSyncProvider & { [unique symbol]: true }) returned by _withTargetGuard and required by every write-path helper param. Passing a raw provider to a write path is now a compile error (verified: TS2345). Only createAdapter and the intentionally-unguarded _deleteAllData keep the raw type. No runtime/behavior change (brand is a phantom type); adapter suite 135/135. * fix(sync): invalidate file-adapter state only on real target moves Task 2's invalidation was wired to "any privateCfg was saved", but its semantics are "the sync target moved". setProviderConfig() fires providerConfigChanged$ unconditionally, so two content-only writes reached invalidateAllTargets(): 1. The sync-settings dialog saves with isForce=true, which bypasses the JSON-equality dedup, and _updatePrivateConfig then rewrites privateCfg unconditionally. So changing the sync interval, toggling compression, or pressing Save with nothing changed wiped _localSeqCounters and persisted it. A cursor back at 0 makes the next download return a snapshotState (isForceFromZero); for a client holding unsynced ops that classifies CONCURRENT, and with AUTO_MERGE_CONCURRENT_SNAPSHOT false it dead-ends in a binary conflict dialog whose either answer discards data. file-based-sync-adapter.service.ts already documented this exact hazard at the latestSeq computation. 2. WrappedProviderService fires the GHSA-9544 isEncryptionEnabled backfill fire-and-forget on adapter creation. Its setProviderConfig() lands within ms (local IO) while the sync it was spawned from is still on the network, so it wiped the cursor mid-cycle and aborted that sync with FileSyncTargetChangedError even though nothing moved. (The abort does not heal the cursor: invalidateAllTargets clears and persists it before the abort throws, so the next cycle still bootstraps from 0.) providerConfigChanged$ now carries isTargetChanged, set from isSyncTargetChanged(). Every subscriber still drops config-derived caches (the adapter closes over the resolved encryption key/intent); only a real move additionally calls invalidateAllTargets(). Both facts ride one emission so a caller cannot raise a move without the cache drop. isSyncTargetChanged treats only encryptKey/isEncryptionEnabled as content-only and everything else as identity-affecting, so an unknown or newly added field errs toward invalidating rather than reusing one target's cursor against another. Both directions can lose data; the false negative is worse only because it is silent. The OneDrive pre-auth cfg write (dialog-sync-cfg) bypasses setProviderConfig, so by the time the save's setProviderConfig runs the diff is a no-op and a folder move would have silently kept the previous folder's cursor. It now asserts the move directly, gated on a real diff since it runs on every save. Covered by a test verified to fail without the fix. Also correct two comments: invalidateAllTargets' trigger, and the download-abort's mechanism (a stale cursor suppresses gap detection so the new target's snapshot is never loaded — it does not skip ops; both download paths return every op and dedup via appliedOpIds). Known residual: OneDrive's PROVIDER_FIELD_DEFAULTS are the only ones not seeded with '', so a config missing one still reports a spurious move on its first save (one-time, self-correcting). Documented at isUnset. |
||
|
|
71a9a4338a
|
fix(sync): freeze the conflict-review producers before the next release (#9061)
* fix(sync): freeze the conflict-review producers before the next release
The conflict-review feature (
|
||
|
|
011a92585d
|
docs(sync): add sync simplification roadmap (#9062)
* docs(sync): add simplification roadmap * docs(sync): strengthen simplification roadmap * docs(sync): simplify sync implementation plan Prioritize deletion, atomic single-tab ownership, and full-sync trigger consolidation while deferring compatibility removals and speculative abstractions. * docs(sync): make simplification roadmap compatibility-safe Narrow deletion work behind persisted-data, provider-eligibility, delivery, and request-cost gates. Keep schema readers and separate unrelated startup and maintenance correctness projects. * docs(sync): fold code-verified review findings into plan * docs(sync): align simplification plan with latest changes * docs(sync): correct trigger-branch claim and add footprint estimate * docs: cap services at 1000 LOC * chore(lint): warn when a service exceeds 1000 lines * chore(lint): raise service size cap to 1200 lines * docs(sync): re-align simplification plan to master |
||
|
|
104043e2d2
|
fix(locale): localize ISO 8601 recurring-task start date & weekday labels (#8987) (#9055)
* fix(locale): localize ISO 8601 recurring-task start date & weekday labels (#8987) Follow-up to #9013. The ISO 8601 option stores dateTimeLocale='sv' (the sentinel for YYYY-MM-DD + 24h). Components that format spelled-out weekday/ month names via toLocaleDateString(currentLocale(), ...) bypass the CustomDateAdapter fix, so they still rendered Swedish (e.g. the recurring- task dialog's Start-date value 'ons 15 juli 2026', and quick-setting labels 'Weekly on onsdag') regardless of the UI language. Add DateTimeFormatService.textLocale (= isoTextLocale() ?? currentLocale()) as the canonical locale for spelled-out names, and route those paths through it while keeping numeric dates and clock times on currentLocale() so ISO stays YYYY-MM-DD and the 24h clock is preserved. - plannedStartDateStr: spelled-out date -> textLocale, time -> currentLocale - buildRepeatQuickSettingOptions: new weekdayLocale param (numeric day/month stay on locale for ISO day-first ordering); dialog + add-task-bar pass it - dialog quick-setting memo key now tracks textLocale * fix(locale): extend ISO 8601 name localization to repeat-info & date chips Multi-review follow-ups on the #8987 fix: - getTaskRepeatInfoText (rendered in the work-view task list, task detail, scheduled list, tag chips, archived-task dialog): spelled-out weekday names now follow textLocale (derived from the already-passed DateTimeFormatService), while numeric day/month stay on locale. Previously leaked Swedish weekday names under the ISO option. - add-task-bar date/deadline chips: month:'short' name now uses textLocale so it isn't shown in Swedish. - plannedStartDateStr: hoist the duplicated toLocaleDateString options out of the if/else (DRY). - Harden DateTimeFormatService mocks in caller specs with textLocale. Adds regression tests for the weekday split and the date-chip localization. * refactor(locale): require weekdayLocale + correct the memo-key rationale Review follow-ups on the #8987 fix: - buildRepeatQuickSettingOptions: drop the `weekdayLocale = locale` default. Both callers already pass it, so the default was dead flexibility whose only effect would be to silently render a Swedish weekday for a future caller under the ISO option. Making it required forces the choice consciously. The spec that pinned the fallback now covers the real sv/sv case instead (an actual Swedish user should get 'onsdag'). - dialog-edit-task-repeat-cfg: the memo-key comment claimed the composite key was mere hygiene because the dialog's UI language can't change mid-life. That premise is wrong: applyLanguageFromState$ applies the UI language from remote sync too, so lng can change while the dialog is open, moving textLocale() with currentLocale() frozen at 'sv'. State the real reason so the key isn't removed later as redundant. Rename lastLocale -> lastLocaleKey. |
||
|
|
7ef7e69e96
|
fix(locale): localize remaining ISO 8601 spelled-out date names (#8987) (#9056)
Under the ISO 8601 option, dateTimeLocale is the 'sv' sync sentinel, so any spelled-out weekday/month name rendered with currentLocale() prints in Swedish regardless of UI language. Follow-up to #9013/#9055 covering the surfaces those PRs didn't: spelled-out names now follow the UI language (isoTextLocale), while numeric dates and clock times keep currentLocale(). - scheduled-list-page: locale signal -> isoTextLocale ?? currentLocale (all 6 localeDate labels are spelled-out; fixes the reproduced 'ons, 15 juli' leak) - dialog-focus-session-edit.formatSelectedDate: weekday+month long - habit-tracker.dateRangeLabel: month short - scheduled-date-group.pipe: work-view group-header tooltip weekday - worklog (formatDayStr via mapArchiveToWorklog): day-header weekday Inlines the isoTextLocale ?? currentLocale expression (not #9055's textLocale) so this PR shares no files with #9055 and is conflict-free / mergeable in any order. Adds an ISO regression test to scheduled-date-group.pipe.spec and updates mocks so the new isoTextLocale() calls resolve. Also adds docs/handover.md. |
||
|
|
6f88775ea2
|
fix(sync): authenticate LWW project-move footprint (#9053) (#9054)
SuperSync E2EE (AES-256-GCM) authenticates only op.payload; envelope fields including op.entityIds travel as plaintext. The two [TASK] LWW Update meta-reducers trusted meta.entityIds (copied from that envelope) to decide which tasks to relocate, so a compromised sync server could inject victim ids and relocate arbitrary tasks. GHSA-8pxh-mgc7-gp3g. Carry the deterministic move footprint (#9001) inside the encrypted, authenticated payload as LwwUpdatePayload.projectMoveFootprint: - createLWWUpdateOp writes the footprint to both op.entityIds (the server still needs plaintext ids for its indexed conflict detection and cannot read the ciphertext) and payload.projectMoveFootprint. - convertOpToAction surfaces the authenticated copy onto meta.moveFootprint (mirrors the recreatesEntityAfterDelete pattern). - Both LWW-TASK reducers (project repair + section membership) read meta.moveFootprint via a shared parseMoveFootprint helper and no longer trust meta.entityIds. Legacy ops without the field fall back to receiving-state repair. - getTaskProjectMoveEntityIds (footprint re-derivation during conflict resolution, fed remote ops) reads the authenticated payload instead of op.entityIds, so a tampered remote envelope cannot be laundered into a freshly-authenticated merged op. Covers the disjoint-merge, local-win, and superseded-op callers via one choke point. Additive optional field: forward/backward compatible, no crypto or envelope-version change. The DELETE/archive (bulk-archive-filter) and conflict-detection (get-op-entity-ids) envelope reads are suppression- only, not relocation, and remain for the durable AAD hardening. |
||
|
|
56ddeafd90
|
fix(tasks): self-heal orphan tasks to Inbox on navigation (#8780) (#9052)
* fix(tasks): self-heal orphan tasks to Inbox on navigation (#8780) Tapping a global-search result did nothing for a task with a falsy projectId (an empty-string projectId survives hydration because it passes typia validation), no tags, and not due today. Such a task's id is in no work context's `taskIds` ordering array, so it renders in no list. The previous fix routed these to the Today list, but the task is not shown there unless it is due/overdue today, so navigation still landed on the wrong view with nothing focused. Re-home the orphan into the Inbox via the existing moveToOtherProject op (which assigns projectId and adds the id to the Inbox list), then navigate there so the task actually renders and can be focused. The move reducer no-ops the source removal for an empty/dangling source and reads canonical subtask data from state, so replay is deterministic and concurrent heals converge (LWW projectId=INBOX + unique() taskIds). Also surface the existing error snack when a same-context task can never be focused, instead of silently leaving the user on the wrong view. * fix(tasks): suppress 'moved to project' snack on orphan self-heal (#8780) The #8780 self-heal dispatches moveToOtherProject to re-home an orphan task (falsy projectId, no tag) into the Inbox on navigation. That fired goToProjectSnack$ — a SUCCESS 'Moved task to project Inbox' snack — even though the user only tapped a search result to view the task and is being navigated to it anyway. Guard the snack on the moved task having a real source project, so the silent data-repair path stays silent while genuine user-initiated moves still announce. Tests: - task-ui.effects.spec: cover goToProjectSnack$ (orphan heal = no snack; real move = snack), previously untested. - layout.service.spec: cover the new onFailure retry-exhaustion branch. - navigate-to-task.service.spec: model the real orphan (empty-string projectId, no due date) with an accurate root-cause comment. * refactor(tasks): address multi-review findings for #8780 orphan heal Applied fixes from a three-way review (correctness, sync-correctness, tests): - Command/query separation: `_getLocation` renamed to `_resolveNavTarget` and made pure (returns {location, orphanToHeal}); `navigate()` now owns the synced moveToOtherProject dispatch, so it's an explicit navigation step, not a hidden side effect of computing a URL. Prevents a future display-only caller from silently syncing a move. - Scope the snack suppression to Inbox filing (`!task.projectId && targetProjectId === INBOX_PROJECT.id`) instead of any falsy-source move, so quick-add default-project assignment to a REAL project (short-syntax.effects) keeps its 'Moved to project' wayfinding snack. - Tests: add reducer coverage for the empty-string-projectId heal into Inbox (the exact repair the fix relies on, previously verified nowhere); add navigate cases for same-context heal and for an orphaned subtask whose parent can't load (routed, not moved); add effect case proving a non-Inbox falsy-source move still announces. Specs: navigate 7/7, task-ui.effects 14/14, project-shared.reducer 19/19, layout 13/13. checkFile clean on all touched files. * docs(tasks): note route-change focus delegates to AppComponent (#8780) |
||
|
|
6fefd741c5
|
fix(sync): sync review follow-up fixes (hydration data-loss + journal privacy) (#9045)
* 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.
|
||
|
|
953d680d34
|
fix(sync): don't block on repair dialogs while holding sp_op_log (#9026) (#9049)
* 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. |
||
|
|
f75346613d
|
fix(sync): write split-compaction snapshots to immutable files (#9040) (#9047)
* 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. |
||
|
|
e3581add2f
|
fix(sync): recover project notes/sections/repeat-cfgs on losing delete (#9048)
* 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. |
||
|
|
0cd069fc74
|
fix(gitlab): stop connection test/search from firing thousands of requests (#9034) (#9051)
* 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. |
||
|
|
30139c7f88
|
fix(i18n): complete Simplified Chinese translations #5362 (#9036) | ||
|
|
cbd0641681
|
fix(sync): sanitize invalid LWW projectId to the current project (#9025) (#9041)
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). |
||
|
|
0923fe66b2
|
fix(planner): don't erase day-scheduled subtasks on parent plan (#9019) (#9027)
* 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). |
||
|
|
a4dee9d5f7
|
fix(sync): isolate provider encryption settings + enforce critical e2e coverage (#9044)
* docs(plugins): add microsoft 365 calendar provider plan * docs(plugins): add microsoft 365 calendar provider plan * docs(ios): plan internal testflight builds * fix(sync): isolate provider encryption settings * test: enforce critical end-to-end coverage |
||
|
|
000d4726ac
|
fix(sync): guard file-based recovery snapshot vs concurrent ops (#9023) (#9043)
* 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. |
||
|
|
18262eb1f3
|
fix(sync): retention pruning, misc→tasks alias boundary & WS local-win re-upload (#9028)
* 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 (
|
||
|
|
9132ab6722
|
fix(sync-core): break whole-entity LWW timestamp ties by clientId (#9035)
* 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. |
||
|
|
463709e2de
|
fix(electron): assert renderer IPC boundary at window creation (#9018)
* 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. |
||
|
|
f665536ef3
|
test(sync): cover project delete-wins conflicts (#9021) | ||
|
|
7fba6f55e2
|
fix(tasks): decode multipart and transfer-encoded eml bodies #8975 (#8999)
* 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. |
||
|
|
7e273a0e5c
|
fix(sync): recover tasks when a deleteProject loses an LWW conflict (#8997) (#9007)
* 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. |
||
|
|
29df7e7719
|
fix(sync): preserve local edits during snapshot hydration (#9010)
* 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 |
||
|
|
631e6e9710
|
fix(locale): localize ISO 8601 calendar weekday/month names (#8987) (#9013)
* 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. |
||
|
|
95d3b212bc
|
fix(electron): gate Jira IPC behind a one-shot capability (#9008)
* 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 |
||
|
|
75d3cd3c0a
|
fix(sync): serialize archive read-modify-write vs remote replacement (#8941) (#9006)
* 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. |
||
|
|
8e810edbe7
|
fix(sync): make marked project deletions win LWW conflicts (#9009)
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. |
||
|
|
2864a39c85
|
fix(sync): file-based provider atomicity & conflict UX (#8960) (#9004)
* 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. |
||
|
|
91a1f0eda9
|
fix(tasks): keep REST project moves atomic across replay (#9001)
* 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> |
||
|
|
3ae2360345
|
feat: suppress idle dialog during active work sessions (#8965)
* @ 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> |
||
|
|
b3d8c74257
|
fix(tasks): keep add-task bar visible above iOS keyboard (#8995)
* 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> |
||
|
|
51bf689bd5
|
fix(sync): preserve multi-entity conflict recovery (#8990)
* 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. |
||
|
|
40d9d845a9
|
fix(ui): open time picker for Electron touch input (#8989)
* 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 |
||
|
|
519ea02167
|
fix(android): keep material icons aligned with system font scaling (#8992)
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 |
||
|
|
ce8ed47328
|
fix(locale): localize ISO weekday labels (#8991) | ||
|
|
44df8d1675
|
fix(sync): surface Dropbox OAuth service failures (#8988) | ||
|
|
a7a26588e6
|
fix(sync): keep the conflict summary banner counts live during review (#8946)
* 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> |
||
|
|
5b4200df95
|
fix(sync): enforce conflict-journal retention mid-session, not only at start (#8948)
* 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> |
||
|
|
46e98c67dd
|
fix(sync): harden passkey registration verification (#8985)
* fix(sync): preserve offline ops and harden auth * fix(sync): prevent unverified passkey replacement Keep existing credentials until email ownership is verified and scope failed-delivery cleanup to the exact pending registration. Reject negative operation timestamps while still allowing old offline operations.\n\nRefs #8961 * test(sync): cover registration races in PostgreSQL * ci(sync): run PostgreSQL integration tests * fix(sync): bind passkeys to email verification |
||
|
|
329da9b9f3
|
fix(sync): harden full-state operation recovery (#8973)
* 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. |
||
|
|
24318d11cc
|
fix(sync): reject forged encrypted full-state operations (#8984)
* 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 |
||
|
|
55d3490e19
|
fix(sync): preserve conflict cancellation and harden force overwrite (#8981)
* 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 |
||
|
|
bd67174863
|
fix(sync): make task and time replay deterministic (#8979)
* fix(sync): make task replay values deterministic Capture logical dates and timestamps in task actions and backfill legacy operations. Carry per-day task totals so own-operation replay is idempotent while foreign time remains additive. Fixes #8957 * fix(sync): make time snapshots replay-safe Exclude pending task-time batches from op-log and file-sync snapshots so their later additive operations cannot overlap snapshot state. Preserve concurrent direct credits and normalize legacy replay dates deterministically. Fixes #8957 * fix(sync): align snapshots with queued task time Exclude accumulator and in-flight task-time deltas from operation-log snapshots so later delta operations cannot double-count them. Capture file and direct-upload snapshots at the same operation-log boundary, and flush or clear queued time around destructive state replacement. * fix(tasks): flush queued time at task boundaries Persist queued timer deltas before absolute short-syntax edits, and clear them whenever project, schedule, or repeat cleanup deletes the owning task. This prevents stale batches from recreating or overwriting removed task time. * fix(sync): preserve concurrent task time deltas Treat concurrent task-time batches as commuting updates on both client and server, while retaining causal stale-operation checks. Reject malformed identities, dates, durations, and unsafe timestamps before replay or persistence. * test(sync): cover task time snapshot replay Exercise seeded snapshot and restart invariants plus a real three-client SuperSync convergence path with the initial time inside the snapshot. * fix(sync): select action type for legacy conflicts |
||
|
|
5e754d3552
|
fix(sync): prevent multi-entity conflict corruption (#8980)
* fix(sync): reject disjoint merges for multi-entity ops Scope conflict field extraction and journal titles to the actual entity. Fall back to whole-op LWW whenever either side is multi-entity so sibling updates are never falsely reported as preserved.\n\nFixes #8944. * fix(sync): harden multi-entity conflict resolution |
||
|
|
6ac06df6c2
|
fix(sync): guard full-state apply against late local ops (#8976)
* fix(sync): guard full-state apply against late local ops Recheck pending work inside the upload and operation-log locks before destructive imports. Defer same-tab actions through the cutoff and keep cursors and acknowledgements unchanged when dialog resolution is required. Closes #8310 * test(sync): cover late multi-tab full-state race Use the real Web Lock boundary and shared IndexedDB to prove a sibling-tab operation blocks destructive full-state apply. |
||
|
|
5624f6891d
|
fix(sync): harden op-log replay and recovery (#8978)
* fix(sync): harden operation-log failure recovery Keep compaction, archive replacement, legacy recovery, and reducer replay checkpointing deterministic across crashes and concurrent clients. Refs #8958 * fix(sync): harden remote operation recovery Persist reducer failures across hydration and fail closed for full-state operations. Serialize recovery and archive mutations, and report skipped emergency compaction accurately. * fix(sync): preserve operations across replay failures |
||
|
|
aa75e6f7ee
|
fix(plugins): prevent automation rule data loss (#8972)
Keep initialization read-only, preserve unsupported synced entries across explicit edits, and reject malformed runtime payloads. Stage mutations transactionally so failed persistence cannot leave executable rules that disappear after restart. |
||
|
|
9a38397da5
|
fix(supersync-server): op data-loss on retention/cleanup & auth-endpoint hardening (#8971)
* fix(sync): preserve offline operations and harden auth Accept operations from long-offline clients without rewriting their timestamps, and retain a replayable full-state base during cleanup. Neutralize registration account discovery and suppress repeated login and recovery token emails with atomic claims. Fixes #8961 * fix(sync): harden auth and retention edge cases Make unauthenticated auth responses neutral across delivery and resend failures, isolate WebAuthn ceremonies, and consume login and recovery tokens atomically. Add monitored handling for histories without a replay base and strengthen regression coverage. Refs #8961 * perf(sync): reduce cleanup replay-base queries Reuse the maintained full-state sequence marker during retention cleanup while preserving a query fallback for legacy and stale-marker rows. Clarify no-base warnings and strengthen exact auth-response and recovery transaction tests. Refs #8961 * fix(sync): reject non-integer op timestamps before BigInt persistence A non-integer or non-finite client timestamp passed the schema (timestamp is z.number(), not .int()) and threw at BigInt() during upload, aborting the whole batch as an unstructured 500. Reject it in validateOp as a per-op INVALID_TIMESTAMP instead. Op age stays unbounded so long-offline backlogs are still accepted. Refs #8961 |