mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-28 18:20:42 +00:00
308 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
57591bd4e6
|
feat(sync): diagnose encrypted operation failures (#9331)
* feat(sync): add encrypted operation diagnostics Capture immutable encrypted SuperSync histories through the read-only download API, then classify per-operation decryption failures offline without exposing plaintext or secrets. Document the recovery-safe workflow and cover pagination, integrity, and CLI safeguards for #9256. * test(sync): reproduce final-page decryption failure Exercise the real browser, encryption, pagination, and SuperSync server path with a valid encrypted full-state page followed by one corrupted operation. Verify the correct key reaches the final page, recovery remains atomic, and retry restarts from sequence zero for #9256. * feat(sync): attribute encrypted download failures Retain server envelopes through decryption so exportable logs can identify the failing operation and sequence without recording user content or credentials. Preserve the existing atomic abort and retry behavior, with real encrypted final-page coverage for #9256. * refactor(sync): drop server-side encrypted-ops diagnostic tool * feat(sync): classify whole failed encrypted batch client-side * fix(sync): address multi-review findings on decrypt diagnosis * test(sync): cover decryptBatchSettled; correct errorName triage docs |
||
|
|
2b2a36ae56
|
fix(sync): reject uploads behind state replacements (#9330)
Persist the latest SYNC_IMPORT/BACKUP_IMPORT boundary and reject incremental uploads whose cursor has not observed it. Reconcile upgrade rows lazily, preserve explicit account-reset recovery, and cover cache, quota, PostgreSQL, and encrypted-client paths. |
||
|
|
76d9836ac3
|
fix(sync): preserve section semantics during conflict replay (#9326)
* fix(sync): preserve section semantics during conflict replay Add real-client regression coverage for SECTION convergence and REPAIR recovery, plus durable WebDAV migration stages, released-client policy, and Capacitor lifecycle I/O.\n\nUse fail-closed SECTION metadata validation and causal replay so concurrent move, removal, and reorder operations cannot collapse into lossy entity snapshots.\n\nRefs #9262 * fix(sync): harden section replay safety Defer semantic replay when retained or pending operations can reorder the transition, while allowing valid null and same-section placements. Verify release provenance and strengthen WebDAV and fresh-client recovery coverage. * fix(sync): make section replay state-based and atomic Project section replacements against a stable reducer snapshot so later accepted actions and deleted anchors are represented. Persist replacements and predecessor rejection in one operation-log transaction. * fix(sync): preserve scoped section replay order Scope SECTION projection to the originating work context and preserve Project/Tag ordering when a removed task is later re-added. Persist dependent placement compensations in current predecessor order and cover the real server rejection path. * fix(sync): preserve legacy section removal ordering Pair projected section removals with an exact work-context state update so v18.4.0-v18.4.3 retain task ordering. * test(sync): require complete legacy compensation state * fix(sync): avoid duplicate rejected-op recovery * test(sync): prove single-cycle rejected-op recovery |
||
|
|
33b91716b1
|
test: close critical E2E and CI coverage gaps (#9274)
* test(worklog): verify archived duration corrections persist * ci(android): run JVM tests on pull requests Run the Play and F-Droid debug JVM suites for Android-affecting changes while keeping a stable required-check status on unrelated PRs.\n\nCloses #8734 * test(supersync): run repair causality integration test Include the PostgreSQL repair-causality regression in the CI integration script's explicit test list.\n\nCloses #8773 * ci(sync): close provider E2E path gaps Select both real-provider suites for every persistent action owner and the client clock, date, and replay guards. Mirror the same coverage on master and release pushes.\n\nRefs #8733\nRefs #9262 * ci(android): run instrumentation tests on emulator Use the AndroidX test runner and execute the Play debug instrumentation suite on an API 35 emulator for Android-affecting pull requests. This gates the on-device CursorWindow data-loss regression.\n\nRefs #8401\nRefs #9262 * ci(shared-schema): run package tests * ci(plugins): discover test suites from manifests Closes #8733 * test(boards): verify board creation persists * test(migration): require legacy error recovery flow * test(navigation): remove redundant route smoke * test(keyboard): verify default shortcut behavior * test(migration): verify error acknowledgement cleanup * test(worklog): verify corrected time CSV export * test(keyboard): verify remapped shortcut persistence * test(recurring): verify occurrence and series removal * test(mobile): add WebKit touch smoke * test(pwa): verify persisted offline reload * test(pwa): verify update activation * test(performance): verify large-list row reuse * test(webdav): cover split migration recovery Exercise a real v2-to-v3 migration through WebDAV when the primary tombstone commits but its response is lost. Verify restart recovery, backup neutralization, pending-data survival, and the split-disabled no-write guard. |
||
|
|
238b91aa59
|
test(e2e): accept beforeunload prompt in shared dialog fallback (#9122)
The USE_REMOTE crash-resume spec reloads Client B mid-sync. While isSyncInProgress is set, startup.service's beforeunload handler calls preventDefault(), so Chrome raises a beforeunload prompt (the preceding button click supplies the user activation it requires). Playwright auto-accepts such prompts only when no 'dialog' listener is registered — but installDevErrorDialogHandler registers one on every page and early- returned on beforeunload, leaving the prompt unanswered. The navigation then never starts and reload() times out (observed on SuperSync shard 6/6; the earlier load→domcontentloaded de-flake could not help because navigation is blocked before any lifecycle event). Accept beforeunload in the shared fallback, restoring Playwright's default; other dialog types keep flowing to spec-specific handlers. Corrects the two crash-resume comments that encoded the wrong theory. |
||
|
|
e5a9ff7866
|
fix(sync): unfreeze the disjoint-field merge to stop data loss (#9095) (#9101)
Drop disableDisjointMerge from the production conflict-resolution entry point. With the merge frozen (#9061), concurrent edits to different fields of one entity resolve by whole-entity LWW: the later side wins a full 'replace' snapshot with a dominating clock and the other side's edit is silently and permanently lost on every client (a rename dies when another device marks the task done). The journal half of the freeze stays. Wire-safe: v18.14.0 predates lwwUpdateMode entirely and applies LWW Update payloads via updateOne, so the merge's 'patch' union-delta merges cleanly on released clients (#8874's back-compat envelope design). Restores the strengthened 3.1 disjoint-merge E2E parked out of #9089 (it fails deterministically while the merge is frozen) and adds a unit regression for the exact rename-vs-done pair; the caller guard spec now pins that the merge stays enabled on the production resolve path. |
||
|
|
b50d5f6d96
|
fix(sync): dedupe surgical-sync retries and keep the import author through pruning (#9089)
* fix(sync): deduplicate surgical sync retries Persist split-file configuration and acknowledge operation IDs already committed remotely after a lost upload response. * fix(sync): preserve import author during clock pruning Keep the active causal full-state author in oversized stored clocks and reuse the stored protected IDs when classifying response-loss retries. * test(sync): harden supersync failure coverage Exercise real upload endpoints and exact operation IDs across response loss, validation failures, schema blockers, full-state boundaries, vector pruning, and concurrent edits. * fix(sync): heal a corrupt primary on duplicate-only uploads The .bak recovery path caches the CORRUPT primary's rev precisely so this cycle's conditional overwrite repairs sync-ops.json. The duplicate-retry short-circuit read that cache and returned before the write, leaving the primary corrupt whenever the recovered buffer already held every pending op. Flag the recovered entry and let those uploads fall through. Also stop synthesising a serverSeq for ops already in the buffer: the field is optional, the upload and download paths number ops differently, and a mixed batch could hand two ops the same value. * perf(sync): look the full-state author up once per upload Batch upload is off by default, so the guarded batch path was not the one serving production: the serial path queries the causal full-state author per op inside a single transaction, and a clock of 21-50 entries passes validation and trips the guard on every one of them. Memoize the author per transaction and resolve it lazily, so only an op whose clock actually overflows pays, and only once. This also retires the batch pre-scan and its loop-carried author, leaving both paths on one mechanism. Report the lookup through ProcessOperationResult so the upload summary stops under-reporting round-trips, and record why reconstructing the stored protected set loosens id-collision detection. * test(sync): wait for the committed title in renameTask renameTask blurred the textarea, slept 300ms and returned without ever checking the rename landed. Blur -> dispatch -> re-render outruns that delay on a loaded machine, so a following sync uploads without the rename op and the caller asserts against a task that was never renamed — which is what supersync 3.1 hits on CI but never locally. Wait for the new title instead, mirroring markTaskDone's done-state wait and the e2e no-waitForTimeout rule. * test(sync): dispatch focus so renameTask actually commits renameTask relied on el.focus() to emit a focus event, but these tests drive two clients as separate pages and only one page can hold focus, so on CI the event often never fires. TaskTitleComponent then keeps _isFocused=false, and resetToLastExternalValueTrigger resets tmpValue to the stored title on the next task-object emission. Blur therefore computes wasChanged=false, task.component skips update(), and the rename is silently dropped without ever becoming an op. That is supersync 3.1: client A's rename lives only in tmpValue, A syncs and uploads nothing, B uploads its done op, A downloads it, the task ref changes and the title reverts to the original — exactly the state the CI artifact captured. A real user always has real focus, so the app itself is unaffected. Dispatch focus explicitly, mirroring the synthetic input/blur already used here. Also correct the previous commit's claim: the toBeVisible wait matches tmpValue, a component-local signal rendered in both template branches, so it never observed the committed title and could not have fixed this. * docs: revert incidental prettier reformat of unrelated docs The master merge ran prettier across files it pulled in, reformatting three documents this branch has no business touching: markdown table cell padding plus *emphasis* -> _emphasis_, with no content change. handover.md documents two unrelated branches entirely. Restores them to master. vector-clocks.md keeps its edits — those are this branch's own and describe the pruning protection. * refactor(sync): drop the full-state author lookup's roundtrip accounting resolveFullStateAuthor memoizes per transaction, so the lookup it counts fires at most once per upload — the plumbing existed to report a number that is always 0 or 1, on a log line already counting dozens. The memo and its own accounting cancelled out. Removing didQuery lets resolveFullStateAuthor return string | undefined and getPruneProtectedIds return string[], instead of both carrying a tuple purely to feed the counter. uploadDbRoundtrips and the batch path's own counter are untouched. * test(sync): assert the committed store title in renameTask The focus dispatch did not fix supersync 3.1 — the shard failed again with an identical snapshot (original title, done, rename gone), so that diagnosis was wrong. Stop guessing at the trigger and make the helper able to observe the thing in question. task-title renders tmpValue, a component-local signal, in BOTH its editing and idle branches. Every DOM assertion here therefore matches as soon as the synthetic input event fires, whether or not an op was ever captured — which is why two rounds of "wait for the title" changed nothing. Read the store instead, via the __e2eTestHelpers.store hook the timeSpent helper already uses. This is a diagnostic as much as a fix: it splits the two remaining explanations. If renameTask now fails, the rename never becomes an op and the bug is in how the test drives the edit. If it passes and 3.1 still fails at the merge assertion, the op is captured and lost during sync — a real defect, and the test is right to fail. * test(sync): move the 3.1 disjoint-merge rewrite out to #9095 3.1 was the last red shard, and it turned out to be right: the store-backed renameTask passes, so the rename IS captured as an op, and the test still fails at the merge assertion — the op is committed and then lost during sync. Filed as #9095. That bug is pre-existing and cannot be reached by anything in this PR: the file-based adapter is not used by SuperSync, and the server-side author memo only engages for clocks over 20 entries where this test carries about three. 3.1 is also the only test here that exercises neither of this PR's fixes — it races a title change against a move-to-done, which is conflict resolution, not retry dedup or clock pruning. So it moves to #9095 rather than holding verified sync fixes red. The rest of the hardening stays: the fault injections whose globs never matched a real endpoint, the schema-mismatch test that asserted nothing, and the compaction suite that called an endpoint which never existed are what actually cover the fixes here. Restoring the old 3.1 puts a misleading test back, so it now carries a comment saying why it proves little and where the real one lives. The strengthened version is kept on test/issue-9095-disjoint-merge-repro. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> |
||
|
|
d1ff7963d0
|
fix(sync): rebase repair op clocks on the durable clock (#8939) (#9080)
The REPAIR paths built their vector clock from the per-tab in-memory clock cache and then REPLACED the durable clock with it. A stale cache (another tab advanced the clock) regressed the durable clock, letting subsequent captures reuse counters already shipped — silently corrupting cross-device dominance comparisons. - createRepairOperation: route through appendMixedSourceBatchSkipDuplicates; the in-transaction rebase makes regression unrepresentable. State cache stores the clock actually written. - replaceRejectedRepair: rebase the replacement clock onto the durable clock inside its transaction (shared rebaseLocalClockOnDurable helper). - Drop client-side clock pruning from repair op building: inert under the rebase, and it dropped client IDs the server still tracks (false CONCURRENT). Server prunes after conflict detection. - Rename appendWithVectorClockUpdate -> appendWithVectorClockOverwrite and document the derivation invariant; the capture path (its only remaining production caller) already satisfies it. |
||
|
|
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 |
||
|
|
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 (
|
||
|
|
f665536ef3
|
test(sync): cover project delete-wins conflicts (#9021) | ||
|
|
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 |
||
|
|
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. |
||
|
|
01d12cacb7 | test(sync): target transient rejection assertion | ||
|
|
5d568dcf8c | test(sync): verify non-task conflict convergence | ||
|
|
19b6fed044 |
test(e2e): cover USE_REMOTE interrupted rebuild crash resume
Reloads client B at the exact atomic-baseline-commit cutoff, then verifies the next sync detects the interrupted rebuild, redoes the raw download, keeps the first attempt's pre-replace backup, finishes on the remote state, and offers an Undo that restores B's original import. |
||
|
|
fc20f81c2c | test(sync): force the upload-piggyback conflict race | ||
|
|
a8f29a3f62 | test(sync): avoid conflict-gate e2e deadlock | ||
|
|
0df3e81155 |
fix(sync): remediate multi-agent review findings
- USE_REMOTE: fix guaranteed self-deadlock — flushPendingWrites() was called while already holding the non-reentrant sp_op_log lock its own Phase 2 re-acquires, so every "Use Server Data" hung 30s and aborted. New OperationWriteFlushService.flushThenRunExclusive owns the bounded flush->lock->recheck barrier; ServerMigrationService reuses it, which also bounds its previously unbounded snapshot-cutoff retry loop. - USE_REMOTE: make the rebuild crash-resumable. A raw_rebuild_incomplete META marker is set atomically with the baseline replacement and cleared after the replay commits; the next sync redoes the raw rebuild instead of the normal download (which excludes own ops server-side and would silently lose the un-replayed suffix). The resume keeps the first attempt's pre-replace backup instead of overwriting the single slot with the partial baseline. - Deferred actions: serialize overlapping drains (two concurrent drains could mint two ops for one user intent), stop the drain at the first transient failure instead of persisting successors out of order (the older edit would win LWW everywhere via clock inversion), abandon permanently invalid actions after one attempt (typed PermanentDeferredWriteError) instead of retrying + snacking on every sync forever, and latch the buffer-size warnings to once per stuck window (previously a blocking dev dialog per action past 100); drop the no-op 5000 "hard cap" tier. - writeOperation: post-append bookkeeping failures no longer propagate into the deferred retry loop, which re-appended the same action under a fresh op id (double-apply of additive payloads). - remote-apply: full-state cleanup retains every full-state op of the current batch, so an archive_pending import can no longer be deleted before its archive retry (startup replay would lose a change already visible at runtime). - Hydration: sanitize a malformed stored schemaVersion per-op instead of failing the whole boot into recovery; strict parsing stays on the receive/upload paths (0 still parses so a below-minimum remote op surfaces as VERSION_UNSUPPORTED, not a generic migration failure). - Server: request fingerprints are computed lazily — only when a dedup entry exists, and otherwise after the rate-limit/pre-quota gates but before uploadOps mutates the parsed ops. Fixes the pre-gate hashing of full payloads (authenticated DoS-cost regression) and repairs three sync-fixes retry tests to model true identical-body retries. - Delete dead code: failed-op-ids util (+spec) and MAX_VERSION_SKIP (the removed forward-compat band); fix the stale clock-merge parity comment. sync-core 210/210, shared-schema 50/50, super-sync-server 825/825, and all touched Angular specs pass. |
||
|
|
d212740254 |
test(e2e): resolve A's post-import conflict as USE_LOCAL in gate test
Client A already synced a populated state, so importing a backup diverges
from the server and A's own sync raises the sync-import conflict gate.
syncAndWait() defaulted to USE_REMOTE, which discarded A's import before it
reached the server, leaving Client B with nothing to conflict against, so
the PHASE 5 conflict dialog never appeared (run 29090279243, SuperSync 1/6).
Pass { useLocal: true } so A force-uploads the import as a new SYNC_IMPORT
the server keeps, letting B's pending simple-counter change trigger the
conflict dialog as intended.
|
||
|
|
f38342eeb9 |
fix(sync): harden replay and conflict recovery
Make remote replay and authoritative state replacement crash-resumable and atomic. Preserve pending changes until piggyback processing commits, and stop cleanly on incompatible operations. Validate sync identities and schemas while strengthening request deduplication and privacy-safe diagnostics. |
||
|
|
6e6ce34c15 |
test(e2e): incoming SYNC_IMPORT must prompt on pending non-task work
Regression test for the widened incoming-full-state conflict gate and the piggyback pre-upload pending-snapshot fix: a pending SIMPLE_COUNTER change (non-TASK/PROJECT/TAG/NOTE) must trigger the conflict dialog instead of being silently discarded by an incoming SYNC_IMPORT from another client. Not yet run (SuperSync docker stack is unavailable in this sandbox); run via npm run e2e:supersync:file or the E2E Tests (Scheduled) workflow. |
||
|
|
b7839e9615 |
fix(e2e): accept overwrite confirmation in provider-switch conflict test
Since #8785, a fresh client with no last-synced baseline gets an OVERWRITE_WARNING_UNKNOWN confirmation after clicking Keep remote. The test never accepted it, so the confirm backdrop blocked the later triggerSync() click and timed out. Accept the confirmation, mirroring the sibling webdav conflict tests. |
||
|
|
10a47888a6
|
feat(sync): offer E2EE before first upload for file-based providers (#8709)
* feat(sync): offer E2EE at setup for file-based providers File-based providers (WebDAV, Nextcloud, Dropbox, OneDrive, local file) support optional client-side encryption but, unlike SuperSync, have no mandatory-encryption upload guard — so their first setup sync would ship plaintext before a user could enable E2EE. Offer to set an encryption password during first-time setup and persist it as part of the SAME config save: the key goes to the provider's privateCfg and isEncryptionEnabled to the global config, atomically with isEnabled. The normal first sync then encrypts from the first op via the standard download-first flow — no separate snapshot-overwrite and no plaintext-upload race. Skipping keeps the existing unencrypted behavior; works offline too. Reuses DialogEnableEncryptionComponent in a new side-effect-free collectPasswordOnly mode (returns the password, writes nothing), with a provider-aware Skip action for the disableClose setup modal. * test(sync): skip file-based setup encryption prompt in webdav e2e helper Fresh file-based setup now opens the optional "Encrypt before first upload?" dialog before the config is persisted, so setupWebdavSync would stall on the disableClose modal. Dismiss it via a new Skip selector so every WebDAV spec proceeds unencrypted, exactly as before; encryption specs still enable it afterwards via enableEncryption(). * test(sync): add e2e for file-based setup-time encryption Adds a @webdav @encryption spec that configures WebDAV with the encryption password set in the setup dialog, then asserts the first upload's remote sync-data.json does NOT contain the task title in plaintext (compression is off by default, so a plaintext upload would) — proving the first sync is encrypted — and that a second client with the same password decrypts and receives the task without overwriting the remote. setupWebdavSync gains an encryptAtSetup option (fills the new dialog instead of skipping it); adds e2e selectors on the setup dialog password/confirm/ submit controls. * ci(e2e): allow targeting the webdav job via workflow_dispatch grep The webdav e2e job hardcoded --grep "@webdav"; add a webdav_grep dispatch input (default @webdav, mirroring the supersync job) so a specific WebDAV test can be run on demand. * test(sync): fix remote sync-file path in setup-encryption e2e Non-production builds nest the file under a /DEV segment (sync-providers.factory.ts). The raw-fetch assertion omitted it and 404'd; the CI trace confirmed the app uploaded to <folder>/DEV/sync-data.json and that the first upload was encrypted. * test(sync): cover joining an unencrypted remote with setup-time encryption Adds the data-safety edge case for file-based setup-time E2EE: a client that sets a password at setup while joining a remote that already holds UNENCRYPTED data. Asserts the join reads and preserves the existing data (does not overwrite the remote with the joining client's empty state) and that a subsequent write upgrades the remote to encrypted (no plaintext titles remain). |
||
|
|
d00e2df354
|
refactor(ui): unify primary/secondary action button treatment across dialogs (#8706)
Standardize dialog actions: primary=mat-flat-button color=primary, secondary/cancel=mat-button, destructive=color=warn. Drop dead Bootstrap 'btn btn-primary' classes and decorative check/close icons. Document the convention in docs/styling-guide.md and update e2e selectors that keyed on the old stroked variant. Closes #8683 |
||
|
|
63253f8e0c
|
fix(sync): never transmit plaintext operations for E2EE-mandatory providers (#8670)
* fix(sync): never upload plaintext ops for E2EE-mandatory providers SuperSync's first-time setup ran an initial sync (dialog-sync-cfg save -> sync(true)) BEFORE the user chose an encryption password, so all local ops (incl. issue-provider credentials) were uploaded to the server in cleartext. Completing setup deleted them; aborting left them stored indefinitely, breaking the E2EE promise (GHSA-9v8x-68pf-p5x7). Add an optional `isEncryptionMandatory` capability to OperationSyncCapable (true for SuperSync) and refuse to upload in the op-log upload path while no usable key is configured. Downloads still run (merge-first) and the encryption- enable flow performs the first, encrypted upload, so the setup flow and prompt are unchanged. File-based providers, where unencrypted sync is a legitimate user choice, leave the flag unset. * fix(sync): fail closed on plaintext snapshot for E2EE-mandatory providers Multi-review hardening for GHSA-9v8x-68pf-p5x7. The op-upload guard closes the reported leak, but SnapshotUploadService.deleteAndReuploadWithNewEncryption could still push a plaintext snapshot for SuperSync on two adjacent paths: the (today UI-unreachable) disable-encryption flow, and a keyless import declaring isEncryptionEnabled:true. Reject an unencrypted snapshot for an encryption- mandatory provider before any destructive deleteAllData, so the "never transmit plaintext" invariant holds regardless of caller. Also lower the mandatory-encryption upload-skip log from warn to normal: it is an expected by-design skip during the pre-encryption setup window and would otherwise fire on every auto-sync cycle. * fix(sync): consolidate op-log into the encryption-enable snapshot Follow-up to the GHSA-9v8x-68pf-p5x7 upload guard. With the guard, first-time SuperSync setup leaves the whole local history unsynced until encryption is enabled; the enable-snapshot then represents that full state, but the ops were re-uploaded incrementally on top of it on the next sync (redundant server op-log bloat, and previously untested at whole-history volume). deleteAndReuploadWithNewEncryption now captures the pending ops the snapshot subsumes (before the destructive delete, under runWithSyncBlocked + a modal dialog so the set is stable) and marks them synced after a successful upload — mirroring planRegularOpsAfterFullStateUpload in the op-log upload path, which this direct snapshot upload bypasses. Also fixes the same latent redundancy in the enable-from-settings-with-pending-ops flow. Adds a multi-client e2e: local task history exists before first-time encrypted setup, then a second client with the same password receives exactly those tasks with no duplicates, conflicts, or errors. * ci(e2e): add optional grep input to manual SuperSync e2e dispatch * fix(sync): capture subsumed ops before state snapshot to prevent mark-synced data loss deleteAndReuploadWithNewEncryption captured getUnsynced() AFTER the full-state snapshot, so an op created in that window was marked synced yet absent from the snapshot — silently lost. Capture the unsynced set first: every marked-synced op is then guaranteed present in the snapshot, and a concurrent op arriving after the capture is left unsynced and re-uploaded next sync (idempotent by op id). * test(sync): mock WebCrypto so mandatory-encryption guard test reaches the guard The 'enabling without a usable key' case passed isEncryptionEnabled: true but did not mock WebCrypto, so the availability check threw WebCryptoNotAvailableError in non-secure CI before the /unencrypted snapshot/ guard under test. |
||
|
|
44030bb06e
|
fix(op-log): backfill SIMPLE_COUNTER fields on LWW recreate (#7330) (#8646)
Issue #7330 ("Data damage detected ... Repair attempted but failed") recurred on SIMPLE_COUNTER for users already on >= v18.6.0, where the original TASK-only fix didn't reach. Root cause is the same partial-LWW-recreate path: a concurrent delete-vs-update across devices resurrects a counter (LWW resolves local-delete + remote-update to 'remote'), and lwwUpdateMetaReducer recreated it from the {id}-only delete payload. Because SIMPLE_COUNTER had no RECREATE_FALLBACK entry, the recreated counter was missing required fields - most often `type`, an enum typia rejects and that dataRepair/autoFixTypiaErrors had no rule for - so post-sync validation dead-ended on the repair dialog every sync. Fix mirrors the TASK fix, two layers kept in lockstep by requiredKeys: - Register SIMPLE_COUNTER in RECREATE_FALLBACK (defaults from EMPTY_SIMPLE_COUNTER, type=ClickCounter) so the meta-reducer recreate path backfills required fields and the bad state is never created. - Add a simpleCounter.<id>.<field>===undefined branch to autoFixTypiaErrors to heal copies already corrupt on disk. Tests: auto-fix + meta-reducer unit specs (incl. a real-typia appDataValidators.simpleCounter proof), a full validate->repair->validate integration repro, and a deterministic SuperSync delete-vs-update e2e asserting no native repair dialog fires. |
||
|
|
d0da0aea93
|
Codex/issue 8081 keep subtask input open (#8423)
* fix(tasks): keep subtask input open after add
* fix(tasks): keep subtask input open after add
* perf(tasks): avoid task row effect reactivity
* fix(tasks): address subtask input review
* feat(tasks): ease in the inline subtask input instead of popping
* fix(tasks): consume inline subtask-input request to prevent stale focus-steal
Address multi-review findings on the inline subtask input:
- The open request was held in a signal that was never cleared, so a task row re-created with the same id (e.g. navigating away from a project and back) re-ran its open effect on init and re-opened the input, stealing focus with no user action. Reset via consume() once the row acts on it.
- Drop the redundant requestId counter (a fresh request value already re-fires the effect); request payload is now just the parentId string.
- Add an aria-label to the input (placeholder is not an accessible name).
- _commit() returns void (its boolean result was unused).
* feat(tasks): animate the inline subtask input out and tighten its top gap
- Use the expandFade animation (enter + leave) so the input collapses out instead of vanishing. Caveat: when the input is the only thing in .sub-tasks (adding the first subtask, then cancelling without creating any), the enclosing @if collapses in the same tick and Angular skips the child leave animation, so it's instant in that one case.
- Reduce the input's top margin from --s-half (4px) to --s-quarter (2px).
* feat(tasks): refocus the task row when the subtask draft is cancelled with Escape
The inline subtask input's 'closed' output now reports why it closed ('escape' vs 'blur'). On Escape (a keyboard cancel) the host task calls focusSelf() so keyboard navigation continues from that row; on blur it doesn't, since focus already moved elsewhere. focusSelf() is a no-op on touch.
Covered by unit tests (reason emitted, host refocus only on escape) and an e2e assertion that the task row is focused after Escape.
* feat(tasks): return focus to the task the subtask draft was opened from
Escape previously refocused the parent row that hosts the input. Capture the originating task (which may be a subtask) when opening the draft — before focus moves into the input and the parent row claims focusedTaskId — and restore that on Escape. Falls back to the host row when no origin was captured; no-op on touch.
Focus-by-id prefers the last #t-<id> instance (the detail side-panel one) to match the existing inline-edit focus convention when a task renders twice.
Covered by unit tests and an e2e proving focus returns to the originating subtask, not its parent.
* test(tasks): update subtask e2e for the inline draft input flow
The add-subtask UX changed from 'press a -> empty subtask title focused for edit' to an inline draft input (type + Enter to commit, stays open for rapid entry). Update every e2e site that added subtasks via the old textarea selector:
- WorkViewPage.addSubTask helper: wait for .e2e-add-subtask-input, fill + Enter, then Escape to close the draft for a clean post-state (fixes simple-subtask, add-to-today, drag-task-into-subtask, finish-day-quick-history, and the sync specs that use the helper).
- add-subtask-with-detail-panel-open.spec.ts: assert the draft input opens focused from panel/main-list focus; rewrite the multi-subtask case to repeated Enter (the new rapid-entry path).
- supersync-archive-subtasks + supersync-lww-conflict: same inline-draft flow (not runnable in-sandbox; fixed by analogy).
Verified locally: detail-panel-open 3/3, simple-subtask, add-to-today 5/5, drag-into-subtask, finish-day-quick-history all green.
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
|
||
|
|
82e7150821 |
test(sync): unblock #8331 e2e by stripping conflict-response piggyback
The #8331 transient-download regression test failed on every scheduled run since it landed (abortedResolutionGets stayed 0). The CI trace shows why: B's pre-upload download is correctly stripped and B's upload IS rejected CONFLICT_CONCURRENT, but the ops-upload conflict comes back as HTTP 200 whose body piggybacks the conflicting remote op in `newOps`. The client LWW-resolves the conflict from that piggyback in-place, so RejectedOpsHandlerService never issues the separate resolution-download GET the #8331 fix guards — the path the test arms and aborts. Fix: the route handler now also forwards B's upload POST to the real server (keeping the rejection server-computed) and strips `newOps`/`hasMorePiggyback` from the response. With no piggybacked ops, the client falls back to the resolution-download GET, which the test aborts — exercising the guarded catch. A new `strippedConflictPiggybacks` guard asserts this fired. This stripped-piggyback condition is real in production when the conflict falls beyond PIGGYBACK_LIMIT (500 ops). No product code changed; behavior under test is unchanged. |
||
|
|
d2367e3bac |
test(sync): deterministically provoke CONFLICT_CONCURRENT in #8331 e2e
The held-POST + re-entrant A-sync ordering was still racy: B's upload was accepted (200) instead of rejected, so the resolution-download catch under test never ran and abortedResolutionGets stayed 0 (runs 27465357634 and 27467427713). Switch to a deterministic hybrid that keeps the real server conflict: - pre-sync A so its concurrent op is unconditionally on the server; - strip ops from B's pre-upload download (return the REAL response with an emptied ops list) so B never merges A's edit and uploads its original concurrent op -> the real server computes a genuine vector-clock CONFLICT_CONCURRENT; - abort every post-upload resolution GET to drive the guarded catch. Pin latestSeq back to the request's sinceSeq on the stripped download: the client persists lastServerSeq even on an empty download, so keeping the advanced seq would make B skip A's edit forever and break convergence. Add a strippedPreUploadDownloads setup guard alongside abortedResolutionGets so a setup that fails to provoke the conflict fails loudly, not vacuously. The countRejectedOps discriminator and end-to-end convergence are unchanged. |
||
|
|
d865d21fa3 |
test(sync): force deterministic CONFLICT_CONCURRENT in #8331 transient-download e2e
The test relied on Client B's upload being rejected CONFLICT_CONCURRENT to exercise the resolution-download catch. But the sync engine downloads before it uploads (sync-wrapper: downloadRemoteOps -> uploadPendingOps), so when A's concurrent edit was already on the server (pre-synced at step 4) B downloaded and merged it, then uploaded a dominating op the server ACCEPTED (200) - the rejection path was never reached and abortedResolutionGets stayed 0. Run 27465357634 failed exactly this way. Force the one ordering that guarantees a rejection: let B's pre-upload download run while the server still has no concurrent op, then land A's edit in the window between B's download and B's upload by syncing A inside B's held upload POST. B never merges, so its original pending edit rides into the resolution path and the server returns a real CONFLICT_CONCURRENT. Keeps the real two-client conflict and the unchanged countRejectedOps/convergence assertions. |
||
|
|
56334897cc
|
fix(sync): persist lastServerSeq after piggybacked ops are applied (#8304) (#8372)
* fix(sync): persist lastServerSeq after piggybacked ops applied #8304 The upload path persisted lastServerSeq covering piggybacked ops before those ops were applied (processRemoteOps runs in the caller, after the upload lock releases). A crash or a cancelled SYNC_IMPORT dialog in that window advanced the seq past unstored ops, so the next download skipped them forever. The upload service now defers the seq persist to the caller once it collects piggybacked ops; the orchestrator persists only after processRemoteOps succeeds, and skips it on the cancel/USE_REMOTE/USE_LOCAL early-returns. The non-piggyback path still persists in-loop (no loss risk). Mirrors the download path's 'persist AFTER ops stored' invariant. Updates 3 upload specs that encoded the buggy persist-before-apply and adds upload-side ordering + cancel-path regression specs. * test(sync): add crash-window regression for deferred lastServerSeq #8304 * test(sync): cross-service integration test for piggyback seq persistence #8304 Wires the real OperationLogUploadService + real OperationLogSyncService over one in-memory provider seq. Verifies the seq is not advanced on cancel/crash and only advanced after processRemoteOps on success. Fails against pre-fix code (all 3). * test(sync): e2e guard for cancel-reconvergence of incoming SYNC_IMPORT Multi-client SuperSync spec covering a scenario no existing test does: an incoming SYNC_IMPORT conflicting with a LOCAL PENDING op -> conflict dialog -> CANCEL -> the next sync RE-OFFERS it -> USE_REMOTE reconverges. Documents that it routes through the (already-correct) download path, so it is a real-server guard for the shared cancel->re-offer->reconverge contract, NOT a #8304 fix regression (that path is race-only and covered by the unit + cross-service integration specs). Requires docker. |
||
|
|
217796b742
|
fix(sync): keep pending ops on transient download failure (#8331) (#8371)
* feat(sync): show actionable error on persistent WebDAV 409 When a WebDAV PUT keeps returning 409 Conflict even after the parent collection is created, the Base URL / Sync Folder Path is misconfigured (the classic Synology / raw-WebDAV setup mistake). Previously the raw "HTTP 409 Conflict" (or a bare "Unknown error") reached the user with no hint at the cause. Throw a dedicated WebDavSyncFolderUnusableSPError with a privacy-safe, actionable message (no path, no response body) at the spot that already detects this condition. Mirrors NetworkUnavailableSPError: a fixed user-facing message matched by instanceof. * ci(release): revive contributors section in release notes * fix(sync): keep pending ops on transient download failure When an upload is rejected CONFLICT_CONCURRENT, the handler downloads to resolve. A transient failure during that download used to call markRejected() on the still-pending local edit — terminal (rejectedAt is never cleared, so the op never re-enters getUnsynced()), so the edit stayed applied locally but silently stopped syncing to other devices. - Drop the markRejected loop in the catch; log + re-throw so the ops stay pending and re-resolve on the next sync. - Roll back the per-entity resolution-attempt increment on a thrown download: a transient failure must not consume the cap (which would otherwise eventually markRejected via the exceeded branch — re-introducing the same data loss). The cap still bounds genuine non-progress loops, whose download succeeds and never reaches this catch. Adds unit regression tests (single + repeated transient throws never reject) and a SuperSync e2e that fails the resolution download past the provider retry budget, asserts the pending edit is not rejected, then converges. Fixes #8331. |
||
|
|
3d2c811e78 | chore(task-repeat): revert RRULE Phase 1 from master (#7948) Develop the full RFC-5545 RRULE epic on the long-running feat/rrule-epic branch and merge once complete and testable in final form, rather than landing 13 phases into master one half-state at a time. Work preserved on feat/rrule-epic. Not yet shipped (post-v18.9.1), so no user impact. | ||
|
|
ccbd66d3d9 |
test(e2e): retry supersync time-estimate dialog until input binds
The fill('10m') could fire its `input` event before the duration input's
value-accessor (an `input` HostListener) was wired, so the typed value
never reached the model and submit() saved an empty time — the panel
stayed "-/-" and toContainText('10m') timed out (scheduled run
27197862391, SuperSync 1/6). Retry the whole open→fill→submit cycle so a
fill that didn't stick is re-applied once the control binds.
|
||
|
|
1718b0a8b8
|
feat(task-repeat): RFC 5545 RRULE recurring schedules — EPIC · Phase 1/13 (Closes #4020) (#7948)
* feat(task-repeat): add cron / natural-language recurring schedules
Add a CRON repeat cycle so a single recurring task can express schedules
the day/week/month/year cycles cannot (e.g. every Saturday, March through
November). The cron expression drives occurrence generation; all other
schedule fields are ignored for CRON cfgs.
- Occurrence engine: getNewestPossibleCronDueDate / getNextCronOccurrence in
cron-occurrence.util.ts (cron-parser), wired into the existing
getNewestPossibleDueDate / getNextRepeatOccurrence dispatchers. Recurrence
stays day-granular: sub-daily crons create at most one task per day.
- English to cron via the crono-eng WASM module (src/assets/crono-eng.wasm,
loaded lazily), with a builtin regex parser as fallback until/if the module
is unavailable. naturalLanguageToCron() also passes raw cron through.
- Cron to english live preview under the input via cronstrue; the field shows
the interpreted cron + humanized reading as the user types and warns when an
expression is sub-daily.
- Invalid / unrecognized input shows an error snack and blocks save.
- Add-task bar: @+<cron-or-phrase> short syntax attaches a CRON repeat cfg.
- tools/build-crono-wasm.js regenerates the WASM asset from the sibling
crono-eng Zig project (committed asset is the source of truth for builds).
* fix(task-repeat): cron edge cases, clearer warnings, and corpus tests
Hardening + UX pass on the cron repeat cycle, driven by verifying the full
crono-eng corpus (415 phrases) end to end.
Fixes
- Silent never-fires: crono-eng can translate phrases (a specific year, "nearest
weekday"/W, "n-to-last day"/L-n, biweekly #-lists) into Quartz forms the
recurrence engine (cron-parser) cannot run. These passed UI validation yet a
task would never be created. naturalLanguageToCron now gates its output on
cron-parser, so such input is rejected at the field instead.
- Off-by-one for midnight crons: cron-parser's next() is exclusive of an
exact-boundary currentDate, so getNextCronOccurrence skipped the first
eligible day for 00:00 schedules (e.g. first occurrence landed a day late).
Seed the search one ms before the lower-bound day so a midnight fire stays
eligible; now matches the non-cron daily convention.
UX
- Live preview shows the interpreted cron + plain-English reading below the
field, and warns when the time of day is ignored (day-granular engine) or the
schedule is sub-daily ("runs at most once per day").
- Friendlier validation/snack message with examples instead of terse jargon.
Tests
- tools/test-crono-wasm.js (npm run test:crono): corpus-driven harness asserting
the committed WASM matches all 415 corpus crons, and that cron-parser +
cronstrue accept them — classifying the 27 known engine-unsupported forms so
it stays a regression guard.
- cron-occurrence.util.spec.ts: occurrence engine across daily/weekly/monthly/
month-range/sub-daily, varied times, start-date and last-creation gating.
- parse-natural-cron.util.spec.ts: raw passthrough, phrase recognition, null
cases, loader resilience.
- task-repeat-cfg-form.cron.spec.ts: field validator + live-preview/time-warning.
* fix(task-repeat): live cron preview + @+ title strip; add E2E
Fixes found by adding end-to-end coverage of the cron feature.
- Live preview did not render: Formly's mat-hint does not reflect a dynamic
expressionProperties.description, so the interpreted-cron/English preview never
updated as the user typed. Move it into the dialog template, driven by a
computed signal off the form's valueChanges (getCronPreview in cron-preview.util),
so it updates live and persists after applying. The time-of-day-ignored and
sub-daily warnings now render as proper translated lines.
- "@+<phrase>" short syntax left the clause in the title: shortSyntax set
taskChanges.title for the cron strip, but the later parseTimeSpentChanges
reassignment wiped it, so a bare "Foo @+every day" submitted the raw title.
Strip into the working title and emit the cleaned title at the end.
- shortSyntax returned cronExpression unconditionally (undefined when absent),
breaking 38 exact-match assertions; only include the key when set.
Tests
- e2e/tests/recurring/cron-repeat.spec.ts: @+ short-syntax attaches a CRON cfg
(title stripped); full dialog flow (phrase → live preview + time warning →
save → CRON cfg persisted).
- short-syntax.spec: @+ extraction/stripping, with and without other syntax.
- getCronPreview unit tests (interpreted cron, English, timed/sub-daily flags).
* test(task-repeat): property/fuzz/metamorphic/edge cron tests; fix first-occurrence
Large second testing pass (≈+58 unit tests, +2 E2E, new property harness),
which surfaced one gap and one upstream quirk.
Fix
- getFirstRepeatOccurrence returned null for CRON cfgs (no case → default), so a
timed CRON task's first instance and the startDate-moved-earlier path fell
back to today instead of the cron's first fire. Add getFirstCronOccurrence
(first fire on/after startDate) and route CRON through it.
Tests
- cron-occurrence.invariants.spec: property/invariant battery over many crons
(no-throw, result strictly-after / on-or-before bounds, determinism,
monotonicity), calendar edges (leap-day, year rollover, DST spring/fall),
pathological "Feb 30" (null, no hang), and getFirstCronOccurrence /
getFirstRepeatOccurrence routing.
- parse-natural-cron.metamorphic.spec: relational tests (case/whitespace/order/
irrelevant-text invariance, idempotence, determinism, raw passthrough).
- short-syntax.spec: more @+ cases (raw cron after @+, bare @ is not cron,
unrecognized phrase ignored, clause stops at the next delimiter).
- tools/test-crono-properties.js (npm run test:crono:props): WASM determinism,
marshalling fuzz (empty/oversized/unicode/boundary), English→cron→English
round-trip, and occurrence simulation across every runnable corpus cron.
- e2e: invalid phrase blocks save + shows inline error (Save disabled);
raw cron expression preview + save. Shared dialog-open helper.
Note: documented a cron-parser DST quirk — prev() skips the spring-forward
midnight, so "newest due" can land a day earlier on that date (day-granular,
once a year; lastTaskCreationDay prevents duplicates).
* test(task-repeat): selector integration, serialization, and WASM buffer-reuse
- selectors.spec: CRON cases for the selectors that drive task creation —
selectAllUnprocessedTaskRepeatCfgs (due today, overdue, already-created,
paused, future start, invalid expr, deleted instance) and
selectTaskRepeatCfgsForExactDay (exact-day match). Proves a CRON cfg is
picked up by addAllDueToday's selector path.
- cron-occurrence.invariants.spec: a CRON cfg survives a JSON round-trip with
identical occurrence behavior (sync / backup integrity).
- test-crono-properties.js: buffer-reuse checks — a short translation after a
long one is uncontaminated, and results stay deterministic across a 600-call
interleaved loop (the WASM module reuses static input/output buffers).
* test(task-repeat): cross-timezone day-resolution harness
The Karma suite only runs in the host timezone (Chrome on Windows ignores the
TZ env var — the reason the pre-existing *.tz.spec fails), so cron day-resolution
was never verified across zones. Add a Node harness (npm run test:crono:tz) that
spawns a child process per timezone — Node honors TZ — and asserts day-class
invariants in each: weekly-Monday resolves to a Monday, monthly day-15 to the
15th, daily to the next calendar day, time-of-day never shifts the day, and a
full-year daily iteration is monotonic with 366 distinct days (no DST skip in
next()). Covers fractional offsets (India +05:30, Chatham +12:45/+13:45) and
Southern-hemisphere DST (Sydney). Mirrors getNextCronOccurrence's core math.
* fix(task-repeat): DST-safe newest-due; verify real engine across timezones
Closes the time-based reliability gaps from the previous testing pass.
Fix
- getNewestPossibleCronDueDate no longer uses cron-parser's prev(), which skips
the spring-forward midnight in DST zones (a daily cron then looked like it
didn't fire that day). It now walks days backward and asks "does the cron fire
during this local day?" via a next()-based probe (next() is monotonic across
DST). Spring-forward and fall-back now resolve the exact day.
Tests
- main.ts exposes the real occurrence utils on __e2eTestHelpers.cron (dev/stage
only, stripped from production).
- e2e/cron-timezone.spec: runs the REAL engine under five forced browser
timezones (Playwright timezoneId — reliable regardless of host OS, unlike the
Karma TZ env). Each asserts the timezone was actually applied (live UTC
offset) AND that day-class results are correct, incl. fractional offsets
(Chatham +12:45) and the spring-forward day.
- test-crono-tz.js: also asserts a daily cron fires on every calendar day of the
year in all 8 zones (regression guard for the prev() skip).
- invariants.spec: spring-forward / fall-back now assert the exact day for both
next() and newest (no longer hedged).
- effects.spec: a CRON cfg's first occurrence is computed through the real
updateTaskAfterMakingItRepeatable$ effect path (live new Date()).
* test(task-repeat): dev jumpDay clock helper + live day-change e2e
Adds __e2eTestHelpers.jumpDay(n) / resetClock() (dev/stage only, stripped from
production) so recurring/day-change behavior can be fast-forwarded from the
DevTools console without touching the OS clock: jumpDay shifts Date.now() by a
cumulative day offset and forces the day-change re-sample so addAllDueToday()
runs. New e2e asserts jumpDay(1) creates the next daily instance — covering the
live day-change -> creation path end to end.
* feat(task-repeat): add "create a task for each missed occurrence" option
By default, opening the app after several scheduled occurrences were
missed creates only a single instance for the most recent occurrence.
When enabled (advanced section), a separate overdue task is created for
every missed occurrence instead, oldest first, capped at the 30 most
recent to avoid flooding the list / emitting a large sync batch.
- new getAllMissedDueDates() util reuses getNextRepeatOccurrence so the
per-cycle and CRON occurrence logic stays single-sourced and DST-safe
- service multi-spawn path is gated to the catch-up flow (target day is
today or earlier) and is mutually exclusive with skipOverdue /
waitForCompletion; the single newest-occurrence path is unchanged
- form checkbox hides when "skip overdue" is set, and vice versa
Part of #4020
* fix(task-repeat): re-anchor lastTaskCreationDay when cron expression changes
cronExpression was missing from SCHEDULE_AFFECTING_FIELDS, so editing a
CRON schedule did not re-run rescheduleTaskOnRepeatCfgUpdate$. A
previously computed (often future) lastTaskCreationDay then stuck around
and silently suppressed every occurrence until real time caught up to it
— e.g. a "Jan-Aug" cron set while the clock was past August anchored to
Jan 1 next year and produced no tasks in between.
Adding 'cronExpression' makes a cron edit re-anchor from the current
date, consistent with repeatCycle / repeatEvery / weekday edits.
Part of #4020
* feat(tasks): surface @+ recurring short syntax in autocomplete and help
The `@+<schedule>` add-task short syntax existed but was undiscoverable.
- add recurring examples to the `@` autocomplete suggestions: typing `@+`
now autocompletes phrases like `@+every monday` or
`@+every saturday from march through november`
- document `@+` in the Settings -> Short Syntax help text
Part of #4020
* fix(tasks): remove @+ recurring autocomplete entries
The `@+` examples added under the `@` mention trigger kept the suggestion
dropdown open while typing `@+<phrase>`, so pressing Enter selected a
suggestion instead of submitting the task — breaking the headline
type-and-go flow. The `@+` syntax stays documented in Settings -> Short
Syntax; only the dropdown entries are removed.
Part of #4020
* refactor(task-repeat): show cron only inside Custom recurring config
Cron appeared twice in the recurring-config dropdown: as a top-level
"Cron / natural language" quick-setting AND as a cycle inside "Custom
recurring config". Drop the duplicate top-level option; cron is now
reached via Custom -> repeatCycle = Cron.
- remove the CRON quick-setting option from the dropdown builder
- keep the repeatCycle select visible when CRON is chosen (only hide
repeatEvery) so the user can switch back
- map existing cron configs (incl. legacy quickSetting 'CRON') to CUSTOM
on load so the dropdown matches the stored cycle
- update the cron E2E to drive the dialog via Custom -> Cron
Part of #4020
* test(task-repeat): make @+ short-syntax e2e date-independent
The test asserted the `@+every monday` task appears in the Today view, but
a weekly schedule's first instance is the next Monday — so on any non-Monday
the task is correctly scheduled for a future day and isn't in Today, making
the test fail depending on the day it runs. Verify the attached CRON config
and the stripped title via the store instead, which is view- and
date-independent.
Part of #4020
* feat(task-repeat): RFC 5545 RRULE recurring schedules [WIP]
Overhaul recurring tasks from the bespoke repeatCycle format to RFC 5545
RRULE as the canonical recurrence representation (legacy fields kept
populated for old-client forward-compat). Adds a structured RRULE builder,
RRULE-backed quick presets, per-instance due-date derivation, multiple end
conditions (COUNT / UNTIL / after-N-completions), an occurrence heatmap with
completion simulation, natural-language @+ short syntax, and a REST API for
recurring tasks. Migration is lazy (on dialog open) so existing configs keep
firing untouched until edited.
WIP: some required features and tests are still outstanding, and it needs
much more real-world (user-based) testing before it is merge-ready.
* feat(task-repeat): per-occurrence overrides, due-date control move, @+ preview, NL fix [WIP]
- RECURRENCE-ID: per-instance overrides (move / re-time / re-title) surfaced to
the engine as RDATE + EXDATE so every projection stays consistent.
- Move the due-date derivation control out of the rrule builder into the dialog
so it applies to all recurring configs (presets + Custom).
- Live @+ recurrence preview (humanized rule + next occurrence date) in the
add-task-bar, so a far-off rule is obvious before submit.
- Fix @+ "every other <weekday> in <month>" mis-parsing to YEARLY;INTERVAL=2
("every 2 years"); weekly-style interval + weekdays now stays WEEKLY.
* refactor(task-repeat): trim PR to engine+builder+migration+preview; fix curator review
Trim the WIP recurring-schedules PR to its reviewable core — RFC 5545 occurrence
engine, structured RRULE builder, legacy<->RRULE migration (both directions),
live text preview, quick-setting presets, and tests — and defer the rest to
follow-up sub-MRs: natural-language @+, due-date derivation, ends-after-N-
completions, missed-occurrence backfill, heatmap preview + simulation, REST
recurring creation, and RECURRENCE-ID per-instance overrides. The full
implementation is preserved on branch feat/recurring-full.
Curator review fixes:
- quickSetting forward-compat: never persist a value outside the released
(master) union. The newer preset literals AND 'RRULE' map to 'CUSTOM' at every
persist boundary (dialog save, add-task-bar, data-repair downgrade); the rich
value drives the dialog UI in-memory only and the builder reconstructs from the
opaque rrule on open. Fixes old/mobile typia sync-validation treating such
tasks as corrupt.
- Malformed rrule now falls back to the legacy schedule fields (guarded by
isRRuleValid) instead of silently stopping the task.
- Reverse converter maps a BYDAY-less FREQ=WEEKLY onto the start weekday, so the
legacy WEEKLY engine still fires on old clients.
- Stop logging the raw rule body (the raw-override field makes it free-text user
input; log history is exportable).
- DRY: share normalizeWeekdays / toNumArray between the two converters.
* refactor(task-repeat): clamp quickSetting at the action creators
The op-log replays the action payload, not reduced state
(operation-capture.service.ts), so a reducer-level clamp would not keep an
out-of-union quickSetting off the wire. Clamp in the addTaskRepeatCfgToTask /
updateTaskRepeatCfg / updateTaskRepeatCfgs creators instead -- the single
boundary every dispatcher passes through, so old/mobile clients always replay a
value their typia union accepts. The newer preset literals and 'RRULE' stay
in-memory in the dialog form only.
Removes the now-redundant per-call-site clamps in the dialog save path and the
add-task-bar; the @+ and REST paths inherit the clamp for free when their phases
return. Adds an action-creator spec covering the clamp at each entry point.
* feat(task-repeat): nth-weekday rows support multiple weekdays per ordinal
Each ordinal row (1st/2nd/3rd/4th/last) now multi-selects weekdays via toggle
buttons, so one line can mean "the 1st Monday and the 1st Tuesday" -> BYDAY=1MO,1TU.
The per-row ordinal dropdown excludes positions already used by other rows (each
ordinal anchors at most one line), and the add button hides once all are used.
Parsing groups weekdays that share an ordinal back into one row. Applies to both
the monthly and yearly nth-weekday modes; updates the helper copy accordingly.
* test(task-repeat): complex RRULE coverage + day-by-day create-loop sim
Adds high-complexity coverage for the occurrence engine and builder:
- engine variants x settings: per-day ordinals, BYSETPOS last-weekday,
BYMONTHDAY=-1, seasonal BYMONTH, BYWEEKNO/BYYEARDAY, leap years, mixed with
COUNT / UNTIL / EXDATE / lastTaskCreationDay
- builder forward + mode-detection + lossless round-trips (incl. multi-weekday
ordinals and sub-daily raw-override)
- cfg->engine routing for complex rules with deletedInstanceDates and the
malformed-rrule legacy fallback
- a "day-march" simulator that drives getNewestPossibleDueDate one day at a time
with lastTaskCreationDay fed back like the service, asserting the created
stream has no dupes/skips, honors EXDATE/COUNT, is idempotent within a day,
and documents Phase-1 app-closed catch-up (newest missed only)
Also trims the deferred @+ short-syntax e2e (returns with its phase) and fixes
the dialog-flow e2e to target the current .rrule-result preview selector.
* feat(task-repeat): custom ordinal input for nth-weekday rows and BYSETPOS
The nth-weekday ordinal dropdown and the weekday-set 'which occurrence'
dropdown each gain a 'custom…' option that reveals a free-form input,
allowing any non-zero ordinal (e.g. -2 = 2nd-to-last, 5FR) and
comma-separated BYSETPOS lists (e.g. 2,-1). Parsed rules with such values
now render structurally instead of falling back to the raw override.
* feat(task-repeat): make weekday-set occurrence (BYSETPOS) a multi-select
Replace the single-value 'which occurrence' dropdown with toggle buttons
matching the weekday toggles, so several occurrences can combine (e.g.
first + last weekday = BYSETPOS=1,-1). 'Every' clears the narrowing; the
custom input stays for values without a toggle.
* fix(task-repeat): address review findings on rrule migration fidelity
- Yearly builder rules seed BYMONTH from the start month: a bare
FREQ=YEARLY;BYMONTHDAY=n expands across every month (RFC 5545) and
fired monthly for fresh yearly configs.
- quickSetting change handler passes the selected start date, so
date-writing presets no longer overwrite a user-picked future anchor
with today.
- Remove the createForEachMissed checkbox + copy: the engine has no
backfill support yet, so the setting silently did nothing (and wrote
an undeclared field into synced state).
- rruleToLegacyTaskRepeatCfg always resets the monthly anchor fields so
stale nth-weekday/last-day anchors can't survive the merge, sets
monthlyLastDay only for a pure BYMONTHDAY=-1 rule, and aligns
startDate to the rule's first occurrence for date-anchored
monthly/yearly rules (legacy clients read day/month from startDate);
save() re-derives the fallback so later startDate edits stay aligned.
- _normalizeMonthlyAnchor keeps monthlyLastDay on RRULE saves — it is
the derived old-client fallback for BYMONTHDAY=-1, not a stale preset
flag.
- Legacy CUSTOM migration preserves clamp semantics: day > 28 anchors
emit BYMONTHDAY=<d>,-1;BYSETPOS=1 (the day, or the last day of
shorter months) instead of a plain BYMONTHDAY that skips such months;
the builder serializer emits BYSETPOS in day-of-month modes so these
rules round-trip structurally.
- Regenerate package-lock.json to drop stale cron-parser/cronstrue
entries left over from the earlier cron approach.
* fix(task-repeat): harden rrule builder + legacy fallback after deep review
Correctness:
- Clear bySetPos (and its custom-input state) on monthly/yearly mode and
frequency switches: a BYSETPOS left over from the weekday-set mode
silently narrowed day-of-month rules (BYMONTHDAY=15;BYSETPOS=2 never
fires) with no UI to see or clear it.
- Move startDate alignment out of rruleToLegacyTaskRepeatCfg into a new
arithmetic getAlignedStartDate, applied once at save and only when the
schedule actually changed: the occurrence-search version corrupted the
clamp idiom (aligned a day-31 rule onto Feb 28/29 so old clients fired
on the wrong day forever), collapsed multi-day BYMONTHDAY lists to
their earliest day, rewrote the visible start-date field live on every
builder interaction, cost ~60ms/keystroke for sparse rules, and put
startDate into the change diff on title-only edits — retriggering the
issue-#7373 reschedule. Weekday-anchored rules are no longer aligned.
- Emit the monthly-anchor resets as null/false instead of undefined (in
the converter AND the presets' MONTHLY_ANCHOR_RESET): JSON.stringify
drops undefined keys from op-log payloads, so the reset never reached
remote clients and stale nth-weekday/last-day anchors survived there.
The anchor model fields now allow null; the nth-weekday engine guard
strips it.
- Enforce the YEARLY BYMONTH guard in the serializer: yearly date mode
without months now emits a plain FREQ=YEARLY (anniversary) instead of
a bare BYMONTHDAY that fires monthly; parsed bare yearly rules fall
back to the raw override, preserving their semantics verbatim.
- Drop the auto-seeded BYMONTH when switching away from YEARLY (it
silently constrained the new rule to one month) and seed from the
CURRENT start date rather than the ngOnInit-captured month.
- Clamp custom nth ordinals per frequency (±5 monthly, ±53 yearly) —
BYDAY=10MO is valid RFC but matches nothing, ever — and reject an
ordinal another row already anchors (duplicate rows collapse on
reload). Re-clamp poses when switching into MONTHLY.
- Drop BYSETPOS=0 on parse (parseString accepts it; re-emitting creates
a rule the occurrence engine silently treats as dead).
- Predefined set-position toggles close the explicitly-opened custom
input (both rendered active with contradictory state).
Cleanup:
- Extract parseIntList (4 cloned int-list sanitizers), pushBySetPos /
pushByDaySet (4 copy-pasted emit blocks), _clampedMonthDayPart
(monthly/yearly clamp duplication); share the monthly/yearly
weekday-set template block via ngTemplateOutlet; parse BYSETPOS via a
computed signal instead of per-CD string parsing.
- Correct the BYMONTHDAY hint: it claimed short months clamp to their
last day, but plain BYMONTHDAY skips them.
* test: make timezone demo specs host-independent
Six .tz.spec demonstration tests branched on the host's CURRENT
getTimezoneOffset() (or a literal 'America/Los_Angeles' name check)
while asserting against January test instants. In DST-observing zones
the summer offset differs from the January one (e.g. New York: EDT 240
vs EST 300), so the wrong branch was taken and the suite failed every
summer on US-east machines, blocking pre-push hooks.
Branch on the offset AT the test instant instead, with the correct
longitude threshold for each instant (07:00 UTC flips the local day at
UTC-7, 06:00 UTC at UTC-6, midnight UTC at UTC±0) — the tests now pass
in any host timezone year-round.
* test: pin browser timezone to Europe/Berlin in karma config
Re-enable the previously commented-out TZ pin so timezone-sensitive
specs run deterministically where Chrome honors the env var
(Linux/macOS, incl. CI). Verified empirically that headless Chrome on
Windows IGNORES TZ and keeps the host zone — the *.tz.spec.ts files
keep their offset-at-test-instant branching as the Windows backstop.
* test: make supersync dump/restore helpers cross-platform
createFullDump/restoreFullDump used POSIX-only shell constructs (output
redirection to /tmp, 'cat | psql', 'rm -f'). On Windows the restore
pipeline failed AFTER the 'DROP SCHEMA public CASCADE' had already
succeeded, leaving the test database without any tables — every
subsequent spec in the run then failed in createTestUser with 'table
public.users does not exist' (5 cascading failures + dozens of
never-ran tests).
Capture pg_dump via execSync stdout + fs.writeFileSync, feed the
restore through stdin (execSync input), use os.tmpdir() and
fs.unlinkSync. Verified: full dump-restore spec plus the three spec
files it previously poisoned all pass on Windows (14/14).
* test(e2e): round-trip coverage for new rrule builder widgets
Covers the four gaps hand-testing would otherwise need to catch, using
the dialog's live result band as the oracle (.rrule-result__expr =
exact assembled rule, .rrule-result__next = engine-computed upcoming
dates — no waiting for real time):
- custom nth ordinal (-2 = 2nd-to-last weekday) emits the right BYDAY
and persists verbatim
- BYSETPOS multi-select (first + last) emits BYSETPOS=1,-1 and persists
- mode switch drops a weekday-set BYSETPOS instead of leaking it into a
day-of-month rule (dead-rule regression)
- switching to YEARLY seeds BYMONTH and the upcoming dates are strictly
one per year
Persistence asserted via the NgRx store: saving a recurring cfg
re-plans the task onto its first occurrence (usually not today), so a
UI reopen from the work view is date-dependent; the parse-to-widget
rendering side is covered by the dialog/builder unit specs.
* fix(add-task-bar): open the repeat dialog for the custom recurring option
The repeat quick-setting menu emits the 'RRULE' value for 'Custom
recurring config', but the add-task-bar still branched on the legacy
'CUSTOM' value — picking the option fell through to the preset path and
silently created a weekly-fallback cfg instead of opening the dialog.
Route both 'RRULE' and 'CUSTOM' through the dialog path, preselect the
RRULE builder in the dialog via a new initialQuickSetting dialog input
(the user explicitly asked for a custom rule), and show the tune icon
for the menu entry again. Covered by a new e2e: menu pick → task
created → builder dialog opens → saved rule persists verbatim.
* fix(task-repeat): address review on rrule alignment + anchor sync safety
- getAlignedStartDate re-anchors onto the rule's actual first occurrence
(occurrence-set-neutral for INTERVAL/COUNT/UNTIL) instead of pure date
math that shifted INTERVAL cadence and skipped valid clamped occurrences
- save() always re-derives the legacy fallback fields against the final
startDate, not only when alignment moved it
- monthly anchors never persist null (released clients' typia schema only
allows absent-or-numeric): undefined resets everywhere, null stripped at
the action-creator boundary, model reverted to master signature
- round-trip guard ignores BYSETPOS=0 so the cleaned rule is emitted
instead of being preserved verbatim via raw override
* docs(wiki): fix MD024 duplicate headings on repeating-tasks page
* fix(task-repeat): harden rrule save guards + restore preset labels on reopen
Correctness (from deep review of the branch diff):
- reject COUNT combined with repeatFromCompletionDate at save: completion
re-anchors startDate/lastTaskCreationDay, restarting the COUNT window, so
the series would never terminate
- reject parseable rules that yield zero occurrences (e.g.
FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30): they persisted as silently dead
recurrences with the legacy fallback bypassed
- map the builder's single-weekday BYSETPOS form (BYDAY=FR;BYSETPOS=-1,
'last Friday') onto the legacy nth-weekday anchor; old clients previously
fell back to startDate's day-of-month — a wrong recurrence
Polish:
- infer the preset back from the stored rrule when quickSetting was
wire-clamped to CUSTOM, so Weekends & co. reopen under their label
instead of the raw builder; new QUICK_SETTING_PRESETS single source
replaces the hand-maintained KNOWN_PRESETS set
- extract shared safeParseRRuleOptions + FREQ_TO_CYCLE (six drifted
hand-rolled parse wrappers); memoize isRRuleValid (per-cfg routing guard
on every overdue scan); share noonUtc/toLocalNoon between engine and
preview; fold toMonthArray onto toNumArray; merge the duplicate
_toPersisted action-creator helpers
- trim the wiki section documenting the create-for-each-missed feature
that was deliberately cut from this PR
* fix(task-repeat): address curator review on anchor validity + fallback phase
- never emit/persist an out-of-union monthlyWeekOfMonth: range-guard the
BYDAY ordinal in rruleToLegacyTaskRepeatCfg (the builder's custom input
allows ±5, the synced model only 1..4|-1), sanitize null AND out-of-range
anchors at the action-creator boundary, and mirror the repair in
data-repair — an out-of-union value trips released clients' typia
validation/repair flow
- reject sub-daily frequencies (raw override FREQ=HOURLY/...) at save: the
day-granular engine would accept but silently collapse them to ~daily,
and they have no legacy repeatCycle for old clients
- log hygiene: the update-all-instances flow now logs changed keys + task
ids instead of raw changes/task objects (title, notes and rrule body are
user content; the log history is exportable); engine warns log the error
name only, since rrule.js messages can embed the free-text rule body
- align single-BYDAY weekly rules onto the engine's first occurrence: the
engine groups weeks by WKST while the legacy fallback counts rolling
7-day blocks from startDate, so biweekly cfgs whose start is off the
pattern day fired on OPPOSITE alternating weeks on old vs new clients
(and WKST shifted the engine's phase, which legacy cannot express);
after re-anchoring the cadence is exactly interval*7 days in both
- diff dialog saves against the PROCESSED cfg: the lazy legacy->rrule
migration (and preset inference) no longer leak into the change set of a
title-only edit — rrule is schedule-affecting, so that leak relocated
today's live instance on unrelated edits; empty change sets skip the
update dispatch entirely instead of creating a no-op sync op
* fix(task-repeat): keep presets rrule-backed + builder mode for completion cfgs
- stop stripping the preset-generated rrule at save: getQuickSettingUpdates
OVERWRITES the rule with the preset's canonical one, so the old 'clear on
switch away from builder' branch broke the every-cfg-carries-its-rrule
contract — and an rrule:undefined clear would not even propagate (dropped
by the op-log JSON wire, leaving remote clients on the old rule); the spec
asserting the stale behavior is inverted
- completion-relative cfgs always open in builder mode: the schedule-type
toggle only exists there, so preset inference (or a stored faithful preset
label) would hide the one control that explains how the cfg fires
- monthly anchor clears: document the wire gap properly instead of flipping
values again — null trips released clients' blocking data-repair confirm
dialog on every sync (typia has no null on these fields), undefined clears
only locally; durable clears require shipping '| null' on both fields in a
release FIRST, then switching the reset value (sequenced migration note in
the model + an op-log JSON round-trip spec pinning current behavior)
- replace 6 hardcoded rrule-builder placeholders with translation keys
- revert the settings help text advertising the deferred @+ short syntax
* test(e2e): drop CUSTOM weekday-checkbox spec superseded by rrule builder
The #8025 e2e (merged in from master) drives the legacy CUSTOM recurring
form's cycle select + weekday checkboxes, which this branch removes — the
RRULE builder replaces that UI and its weekday toggles are covered by
rrule-builder-roundtrip.spec.ts. The #8025 failure mode (a saved weekly
cfg that never fires) is blocked generally at save by the first-occurrence
probe.
* test(e2e): de-flake worklog history day-row wait
The worklog is rebuilt from the archive on the history navigation, so the
day row only appears once that async load + month-expand animation settles.
The bare `waitFor` fell back to the 15s action timeout, which CI load
(prod build, 3 workers) could exceed. Use a web-first `expect().toBeVisible()`
with 30s headroom instead.
* fix(task-repeat-cfg): clear repeat-from-completion when switching to a preset
The "from completion" schedule-type toggle exists only inside the RRULE
builder. Selecting a preset hides it, but the flag stuck on the cfg, so a
preset could keep firing relative to completion with no visible control.
Clear it at the save() edit boundary, but only when actually set, so an
untouched preset save stays an empty-diff no-op (#7373 class) rather than
dispatching a spurious undefined->false op. The ADD path already starts from
a false default, so the dialog is the only path that needs it.
Also reword the monthly-anchor clear comments: the cross-client divergence on
an nth-weekday -> day-of-month switch is an inherent, unfixable limitation for
pre-rrule clients (no in-schema "no anchor" value; a `| null` migration would
help an empty client band), not a deferred TODO. Make the round-trip test pin
this explicitly and start the monthlyLastDay case from `true` so it proves the
`false` clear survives the wire instead of passing vacuously.
|
||
|
|
3ff0eebf92
|
fix(sync): make 'Use Server Data' recoverable + guard the destructive choice (#8107) (#8151)
* fix(sync): back up local state before USE_REMOTE replace #8107 forceDownloadRemoteState cleared all unsynced local ops and replaced NgRx state with the server snapshot with no recovery point, making an unintended 'Use Server Data' conflict choice irreversibly destructive. Capture a pre-wipe snapshot via the existing single-slot IMPORT_BACKUP store (BackupService.captureImportBackup) before clearUnsyncedOps, and abort the replace if the backup fails. Offer a 30s Undo snack afterwards that restores it (BackupService.restoreImportBackup). Covers both the conflict-dialog USE_REMOTE path and the manual force-download. * fix(sync): durable restore entry + one-shot recovery for USE_REMOTE backup #8107 Addresses multi-review findings on the pre-wipe backup: - Add a persistent 'Restore data from before last sync replace' button to the sync settings (all providers, gated on a remaining backup), so recovery survives a missed/replaced Undo snack or a failure that aborts after the wipe — the snack was previously the only reader of the backup. - Make restore one-shot: consume the IMPORT_BACKUP slot after a successful restore, preventing a second restore from toggling back to the replaced state and avoiding a lingering plaintext snapshot. - Undo snack: WARNING type (honest framing + dismiss control) and no auto-dismiss timer instead of SUCCESS/30s. * test(sync): e2e for USE_REMOTE pre-replace backup + undo #8107 WebDAV two-client conflict: Client B's local task is wiped by 'Keep remote' (USE_REMOTE), then the Undo snack restores it. Reproduces the #8107 data loss and verifies recovery. Modeled on the existing webdav-first-sync-conflict USE_REMOTE test. Runnable via npm run e2e:webdav:file (needs the docker WebDAV server; not runnable in the sandbox where published ports are unreachable). * fix(sync): guard USE_REMOTE in conflict dialog; drop durable restore button #8107 Swap the over-built recovery surface for a root-cause fix: - Add a confirmation guard before 'Use Server Data' (USE_REMOTE) discards pending local changes (INCOMING_IMPORT with local ops), mirroring the existing USE_LOCAL guard. The dialog frames the server as 'recommended', so this stops a misclick from silently wiping data the user can't tell is newer than the server's — attacking the cause, not just recovery. - Revert the durable settings restore button + its one-shot clear / hasImportBackup (beyond the minimal fix; only that button used them). Keeps the minimal recovery net: pre-wipe capture + sticky WARNING undo snack (and its passing WebDAV e2e). * fix(sync): explain encryption-blocked restore instead of generic error #8107 Defect 2: server-side Restore-from-History can't replay end-to-end- encrypted ops, so it rejects with a 4xx whose reason mentions encryption (the provider embeds that reason in the thrown error message). The client showed a meaningless 'Failed to restore data' (bbinet's screenshot). Detect the encryption reason in the restore catch and show a dedicated RESTORE_ENCRYPTED message explaining the limitation; falls back to the generic message for any other failure. * fix(sync): guard USE_REMOTE undo against superseded backup slot #8107 The pre-replace backup uses a single IndexedDB slot shared with the backup-import flow. The Undo snack never expires, so an intervening import or a second 'Use Server Data' could overwrite the slot before the user clicks Undo, silently restoring the wrong snapshot. Thread the backup's savedAt token from capture through the snack to restore; restoreImportBackup() now refuses when the stored backup no longer matches the token. Also clear the slot after a successful restore so a full copy of the replaced state stops lingering in IDB (uses the previously-uncalled clearImportBackup). |
||
|
|
7c03c84fad
|
feat(history): merge Quick History and Worklog into a unified History view (#8033)
* feat(history): merge Quick History and Worklog into one view - single /history route; legacy worklog & quick-history redirect to it - one History menu entry; navigate-to-task targets history - show per-day work start-end as its own column in the day table - show enabled habits/simple-counters (icon + tooltip) in the expanded day - extract HistoryTaskRowComponent for archived task rows - remove WorklogComponent and QuickHistoryComponent * fix(history): restore quick history behavior * refactor(history): collapse to a single unified view - remove the Worklog/Quick History toggle; show one full all-time history tree (year -> month -> week -> day) - drop the quick-history mode, significance filter and ?view= param - extract shared HistoryDayMetaComponent; reuse HistoryTaskRowComponent in the Daily Summary "This Week" widget - convert project colors + dateStr auto-expand to signals - update e2e specs to the single-view selectors * refactor(history): remove dead quick-history code and i18n keys After merging Quick History into the unified History view, the quick-history data pipeline had no consumers. Remove it: - _quickHistoryData$, quickHistoryWeeks$, _loadQuickHistoryForWorkContext - map-archive-to-worklog-weeks util (+ tz spec) and WorklogYearsWithWeeks - orphaned F.QUICK_HISTORY, MH.QUICK_HISTORY, MH.WORKLOG translation keys Keep WorklogWeekSimple (base of the still-used WorklogWeek). * docs(history): reflect Quick History merge into unified History The quick-history and worklog routes are now legacy aliases for the single unified History view; drop the stale 'current-year mode/toggle' framing. Concepts-note prose flagged for human review. * feat(history): improve a11y and i18n of the unified history view Accessibility: - week table uses <thead> + <th scope=col>; icon-only work-times header gets a translated aria-label - expandable day row: real <button> disclosure control carrying aria-expanded/aria-controls (keyboard + AT path) instead of a role=button <tr>; row keeps a pointer click - archived-task title is now a <button> (was a non-focusable <span>) i18n: translate previously hardcoded aria-labels (export/restore) and the week-range tooltip; reuse VIEW_TASK_DETAILS/RESTORE_TASK_FROM_ARCHIVE, add EXPORT_DATA + WEEK_RANGE_TOOLTIP keys. Polish (carried in): convert worklogData$ to a toSignal signal (drop AsyncPipe + dead animations), relocate the shared row to features/worklog/worklog-task-row (WorklogTaskRowComponent) to break a worklog<->history cycle, make template-unused services private, tighten projectColor input type. SCSS focus rings use focus-ring tokens. e2e: locate task titles via td.title button after the span->button change. |
||
|
|
dc0378edd6
|
[codex] Fix sync import conflict from startup example tasks (#7976)
* fix(sync): ignore startup example tasks during import
* fix(sync): defer startup example tasks until initial sync completes
Switch ExampleTasksService to afterInitialSyncDoneStrict$ so example tasks
are not created before the first remote import lands. On a fresh synced
client the imported tasks are then already in the store and the length===0
guard skips creation entirely — closing the same conflict for file-based
providers (Dropbox/WebDAV), which the op-log marker gate does not cover.
The isExampleTask marker + gate exclusion stay as a safety net for the
narrow case where example tasks are created on a still-empty server and an
import arrives before they are uploaded.
Also dedupe the two markRejected call sites into _discardExampleTaskOps and
document the local-only read (a remote isExampleTask flag can never bypass
the conflict dialog) and the intentional mixed-case behavior.
* test(sync): cover mixed-pending and empty-discard import gate cases
Add a SyncImportConflictGateService test for the mixed case (real pending
work + startup example tasks): the conflict dialog is still shown, but the
example-task id is reported in discardablePendingOpIds and intentionally
left for the caller to keep on USE_LOCAL — locking the documented invariant.
Also assert markRejected is not called on the config-only silent-accept path,
pinning the empty-array guard in _discardExampleTaskOps.
* test(sync): integration coverage for example-task import gate
Run the real OperationLogStoreService + SyncImportConflictGateService against
IndexedDB (no mocks) to cover the seam the unit specs stub:
- example-task creates persisted with their real multi-entity payload are
recognized as non-meaningful and reported as discardable
- after markRejected they are actually excluded from getUnsynced() (never
uploaded), while a pending config op is preserved
- real user work alongside example tasks still triggers the dialog
- a remote-sourced example-task op is never discardable (gate reads local
pending only)
Verified load-bearing: removing the gate exclusion turns the first case red.
* fix(sync): mark onboarding done when example tasks are skipped
EXAMPLE_TASKS_CREATED was only written after creating example tasks, so a
synced client that skipped creation because real tasks already existed never
set the flag — and could recreate onboarding tasks on a later startup when
the task list happened to be empty.
Set the flag whenever tasks already exist at the initial-sync gate, so
onboarding runs at most once per client.
* docs(sync): note known limits of the example-task import gate
Document two accepted, narrow residuals of syncing onboarding example tasks:
- upload→piggyback path: example ops accepted in the same upload round are
already synced and not in the discard list; state stays correct because the
SYNC_IMPORT filter drops them as CONCURRENT on receivers.
- file-based snapshot path: hasMeaningfulStoreData() counts example tasks (no
in-state marker), so a fresh Dropbox/WebDAV client that made example tasks
while sync was disabled can still see the conflict dialog.
* test(sync): cover fresh-client example-task import gate (e2e)
Add a SuperSync e2e proving a fresh client with first-run example tasks accepts
an incoming SYNC_IMPORT without a conflict dialog, plus a backward-compatible
{ allowExampleTasks } opt-in to createSimulatedClient (the harness otherwise
pre-sets SUP_EXAMPLE_TASKS_CREATED).
Uses waitForInitialSync:false + a race between the conflict dialog and sync
completion so the test actually fails when the dialog appears (pre-fix), and
asserts all four onboarding titles are absent. Guards the op-log isExampleTask
marker/gate path (not the afterInitialSyncDoneStrict$ timing, which a fresh
e2e client cannot exercise since sync is disabled at boot).
* docs(sync): link example-task import-gate limitations to #7985
|
||
|
|
87212472fa |
test(e2e): scope supersync backup-revert DB restore to test user
The backup-revert test restored via a global DROP SCHEMA public CASCADE + full pg_dump on the shared test Postgres. Under Playwright fullyParallel (3 workers/shard) this reverted concurrent @supersync tests' in-flight data mid-run, flaking supersync-snapshot-vector-clock (server latestSeq regressed, e.g. 4 to 1). Replace with a per-user snapshot/restore of operations, user_sync_state and sync_devices via a COPY round-trip in one transaction, preserving exact last_seq so the SYNC_IMPORT_EXISTS path still fires. Other users' rows are never touched. Also correct the misleading #7810 comment in the snapshot test. Verified green: both files pass across --repeat-each=10 with workers=3 (the parallel race reproduced, now clean). Refs #7810 |
||
|
|
827aede24f |
test(e2e): add sync-stall recovery to flaky supersync setup and migration
setupSuperSync now re-triggers sync once if the sync-state icon never appears, recovering first-client setup stalls under parallel CI load. The multi-migration test re-syncs Client B when an op hasn't downloaded yet, since B has auto-sync disabled and DOM polling alone can't recover a not-yet-pulled task. Both paths run only after the original wait times out, so passing runs are unaffected. |
||
|
|
aa1c13e805 |
test(e2e): instrument snapshot-vector-clock flake for diagnosis (#7810)
Capture per-page network traffic (untruncated response bodies) and IDB snapshot state (op-log contents, last_server_seq) around the failing A.syncAndWait at Phase 2. Adds timing marks so the dump segments by phase and dumps on both inner and late failures. Documents that the snapshot fast-forward path is not in play at this assertion despite the test name. |
||
|
|
77f83c5687
|
test(e2e): harden failure signals and provider gates (#7753)
* test: harden e2e failure signals Fail otherwise-passing E2E tests on browser runtime errors, keep Playwright retries disabled, preserve Docker E2E exit codes, and make plugin/WebDAV setup failures hard failures instead of logged or skipped conditions. * test: harden provider e2e runners Make WebDAV and SuperSync runner scripts require provider readiness, preserve cleanup and argument forwarding, and fail manual sync clients on uncaught page errors. * ci: require providers for scheduled e2e Set required-provider flags in scheduled WebDAV and SuperSync jobs, and remove the duplicate provider runner scripts while keeping local npm aliases inline. * test: catch e2e teardown pageerrors and tighten fixture - closeClient now asserts runtime errors AFTER context.close() so pageerrors emitted during teardown (Angular destroy hooks, late RxJS errors) are captured instead of dropped. Matches the pattern in guardContextCloseWithRuntimeErrorCheck. - test.fixture.ts isolatedContext now spreads Playwright's merged contextOptions instead of destructuring 23 fields by hand. Future option additions propagate automatically; the page fixture uses the shared attachPageErrorCollector and only fails on pageerror (not console.error, which is too noisy). Guards against a configured 0 timeout being treated as undefined. - plugin-loading.spec.ts second test now hard-asserts that the plugin menu entry reappears after re-enable, matching the first test instead of silently logging when not visible. * test(sync): stabilize ImmediateUploadService spec Two complementary fixes for flaky failures observed under full-suite random-order runs where the upload pipeline silently never fires: - Pin navigator.onLine = true in beforeEach (restored in afterEach). isOnline() inside _canUpload reads navigator.onLine directly. The keyboard-layout spec replaces the whole navigator and the is-online spec spies on it; if order or restoration ever drifts, every "should fire upload" test fails trivially while the "should NOT" tests pass. - Replace tick(2100) with tick(2000); flush(). The await chain inside withSession() (provider.isReady, withSession entry, uploadPendingOps, optional LWW re-upload) requires more microtask drain than tick's fixed-time window reliably provides under load. flush() drains the pipeline regardless. * test(e2e): guard skipOnboarding init script against data: frames The new page-error collector started failing plugin specs because addInitScript runs in every frame — including the empty data:text/html iframe that plugin-index swaps in on destroy — and localStorage access in a data: URL throws SecurityError. Wrap the four setItem calls in try/catch so the helper noops in storage-less frames. |
||
|
|
508998c6a1
|
Improve on sync (#7736)
* fix(android): restore share title derivation and dedupe shared tasks Commit |
||
|
|
c10c7a79e4 | test(supersync): stabilize migration CI checks | ||
|
|
86497d24de | test(sync): cover incompatible supersync password change | ||
|
|
5a924eeadd | test(sync): scope keep-local validation assertion | ||
|
|
22dbfde12b | test(sync): fix concurrent time tracking assertion | ||
|
|
4b5fc3fb33 | test: stabilize flaky e2e specs | ||
|
|
44e0762fba
|
Feat/issue 7330 bc1a3f (#7522)
* fix(sync): strip same-batch-archived task IDs from TAG/PROJECT LWW payloads Issue #7330. lwwUpdateMetaReducer's orphan filter only sees taskState as it is when each op runs. A TAG LWW Update applied before its sibling archive op in the same bulk batch escapes the filter, leaving TODAY_TAG (or any tag/project) referencing a task the very next op removes — user-visible as "archived tasks reappear in today's view" on hibernate-wake. bulkOperationsMetaReducer already collects archivingOrDeletingEntityIds for the wholesale TASK LWW Update skip; this commit reuses that set to pre-clean taskIds (and PROJECT-only backlogTaskIds) on TAG/PROJECT LWW Update payloads before convertOpToAction. Cross-batch ordering is not addressed — see open issue. * fix(sync): surface post-sync validation failure as ERROR, not IN_SYNC Issue #7330. The reporter saw "State validation failed after sync. Some data may be inconsistent." while sync simultaneously reported status IN_SYNC. validateAfterSync's boolean was discarded above the snackbar layer (#6571 only added the snackbar; nothing flowed up to the status pipeline). Plumb the boolean through: - validateAfterSync: void -> boolean - _validateAndRepairAfterResolution: void -> boolean - autoResolveConflictsLWW + processRemoteOps: add validationFailed - DownloadOutcome.ops_processed + UploadOutcome.completed: add validationFailed - sync-wrapper: if either result.validationFailed, setSyncStatus('ERROR') and return HANDLED_ERROR before the IN_SYNC mark Tests: assert validateAfterSync returns the boolean, assert processRemoteOps surfaces validationFailed, assert sync-wrapper sets ERROR (not IN_SYNC) when either download or upload reports validationFailed. * fix(sync): always set top-level id on LWW Update payloads lwwUpdateMetaReducer drops LWW Updates whose payload lacks a top-level id ("Entity data has no id"). Two creation paths could produce such payloads: - createLWWUpdateOp passed entityState through unchanged, so a malformed selector result silently produced an unusable LWW op. - _convertToLWWUpdatesIfNeeded built mergedEntity by spreading baseEntity and updateChanges; when baseEntity lacked id (corrupt DELETE payload) and updateChanges had id stripped, the merge had no id either. Both sites now force the canonical entityId onto the payload. (#7330) * fix(sync): close 3 secondary gaps in #7330 fixes Multi-agent review surfaced three additional code paths mirroring the same defects we fixed at the primary path: G1: bulkOperationsMetaReducer's pre-scan only saw op.entityIds / op.entityId for archive/delete ops. moveToArchive declares only top-level task IDs, but the reducer cascades to subtasks via [t.id, ...t.subTasks.map(st => st.id)]. deleteTask cascades the same way via taskToDelete.subTaskIds. Same-batch TAG/PROJECT LWW Updates referencing those subtasks slipped through the strip. Add collectCascadedSubTaskIds to harvest subtask IDs from archive/delete payloads. G2: SyncWrapperService's LWW re-upload retry loop captured only reuploadResult.localWinOpsCreated, throwing away validationFailed. A retry-pass piggybacked download with failing post-sync validation still reported IN_SYNC. Track reuploadValidationFailed across retries and OR into the gate before IN_SYNC. G3: OperationLogSyncService's downloadCallback (used by the rejected-op handler) converted the download outcome into DownloadResultForRejection, which has no validationFailed field. A nested download triggering validation failure was lost before uploadPendingOps returned. Route the boolean via the existing piggybackValidationFailed flag in the closure scope. Also fixes one cosmetic logging field (reuploadValidationFailed included alongside download/upload in the ERROR log). * fix(sync): defense-in-depth id derivation in lwwUpdateMetaReducer Multi-agent review surfaced two follow-up improvements for #7330: 1. Consumer-side id fallback: a future LWW Update producer that forgets to backfill payload.id would silently regress the "Entity data has no id" bail. Recover from action.meta.entityId, which convertOpToAction always populates from the canonical op.entityId. Now a single consumer guards the whole class of missing-id producer regressions. 2. Tighten 7 conflict-resolution.service.spec assertions from jasmine.objectContaining({ localWinOpsCreated: N }) back to strict toEqual({...}). The loosening (added when validationFailed was introduced) hid future stray-field regressions; the validation path now returns a deterministic shape per code branch, so we can assert it. * fix(sync): close issues found in follow-up review (#7521) Multi-agent review of the prior #7330 follow-up commits surfaced four real issues. All addressed here: CRITICAL — singleton id pollution at producer + consumer Singletons use entityId='*' as a sentinel. Both the producer-side payload-id backfill (createLWWUpdateOp + _convertToLWWUpdatesIfNeeded) and the consumer-side meta.entityId fallback in lwwUpdateMetaReducer injected `id: '*'` into payloads. For singletons (GLOBAL_CONFIG, app-state, time-tracking) this leaks a synthetic `id: '*'` field into singleton feature state when the reducer spreads entityData. Gate id-injection on entityId \!== '*' at all three sites and move the consumer fallback past the singleton branch. WARNING — null-safety in collectCascadedSubTaskIds `'actionPayload' in payload` check could pass for malformed payload {actionPayload: null}, then crash on the next property access. Tighten to a typeof check. WARNING — retries-exhausted path bypasses validationFailed gate When the LWW re-upload loop exits via MAX retries with pending ops still > 0, sync-wrapper used UNKNOWN_OR_CHANGED without consulting reuploadValidationFailed. Validation failure during a retry pass is now elevated to ERROR — a more honest signal than UNKNOWN_OR_CHANGED, and consistent with the user-visible "State validation failed" snackbar they already saw. SUGGESTION — devError on consumer fallback Producer regressions should scream loudly in dev, not slip through silently. The fallback now emits devError + applies the recovery, so a future LWW producer that forgets payload.id surfaces in dev rather than only-when-the-bail-fires. * fix(sync): close validationFailed gap in USE_REMOTE and DELETE_MULTIPLE cascade Three issues found by multi-agent review of the #7330 sync fixes: 1. forceDownloadRemoteState() discarded the validationFailed result from processRemoteOps. A USE_REMOTE conflict-resolution path that applied corrupt remote state would still mark sync IN_SYNC despite the snackbar. Now returns { validationFailed } and propagates it through _handleSyncImportConflict and DownloadOutcome.no_new_ops to SyncWrapperService, plus the two direct callers (manual force download and first-sync conflict resolution). 2. sync-wrapper retry-exhaustion priority: when LWW retries exhausted AND the initial download/upload had reported validationFailed, the wrapper returned UNKNOWN_OR_CHANGED instead of ERROR. The hoisted downloadValidationFailed/uploadValidationFailed are now checked inside the retry-exhaustion branch alongside reuploadValidationFailed. 3. bulk-hydration pre-scan handled DELETE_MULTIPLE in the loop but the collectCascadedSubTaskIds helper early-returned for that action type. Since deleteTasks payload only carries flat taskIds, subtask cascade must be derived from initial batch state (mirroring what handleDeleteTasks does at apply time). Co-batched TAG/PROJECT LWW Updates referencing those subtasks could otherwise still leak. Adds regression tests for each path. * refactor(sync): centralize LWW id backfill, extract singleton sentinel constant Follow-up simplifications from the multi-agent review of #7330 fixes. - Extract SINGLETON_ENTITY_ID = '*' + isSingletonEntityId() helper in entity-registry.ts. Replaces the bare '*' literal across conflict-resolution.service.ts, lww-update.meta-reducer.ts, validate-operation-payload.ts, operation-log-recovery.service.ts, and operation-log-migration.service.ts. (S2) - Add LWW Update payload.id backfill at the apply boundary in convertOpToAction. Every applied LWW op now has its top-level id set from op.entityId (excluding singletons). The redundant defense-in-depth block in lwwUpdateMetaReducer (which derived id from meta.entityId) is removed — the converter is now the single chokepoint, with producers (createLWWUpdateOp, _convertToLWWUpdatesIfNeeded) keeping their enforcement for an explicit on-disk shape. (S1) - stripBatchArchivedTaskIdsFromLwwPayload: drop non-string entries rather than preserving them, and skip the cleaned[] allocation when no rewrite is needed (two-pass with early exit on first hit). (S5+S7) * refactor(sync): extract bulk-archive util, unify orphan-task filters, type validation propagation Continued review follow-ups for #7330. Behavior unchanged. - Move payload-archaeology helpers (collectCascadedSubTaskIds, stripBatchArchivedTaskIdsFromLwwPayload) out of bulk-hydration.meta-reducer into a co-located bulk-archive-filter.util.ts. The meta-reducer body drops back to its dispatcher role; the helpers gain an independent test surface and clearer naming. (S3) - Add a shared filterTaskIdArraysFromTagOrProjectPayload helper. Both filterOrphanedTaskIdsFromEntityData (lww-update.meta-reducer; predicate: not in live state) and stripBatchArchivedTaskIdsFromLwwPayload (bulk meta-reducer; predicate: in same-batch archive set) now wrap it. The two callers run at different layers because their predicates resolve at different times — the shared helper is the array-walking + payload-cloning + warn-logging core. (S4) - Replace the closure-captured piggybackValidationFailed boolean in uploadPendingOps with typed plumbing: validationFailed flows through DownloadResultForRejection and RejectionHandlingResult. The closure smuggle was fragile; the typed return is grep-able and survives refactors. The wider session-latch refactor proposed in review is deferred — current typed plumbing is well-tested. (S6 minimal) * refactor(sync): replace validationFailed plumbing with session latch + integration test Final review follow-up for #7330. Behavior unchanged; surface area shrinks. The validation-failed signal previously rode through 7 typed boundaries: DownloadOutcome.{ops_processed,no_new_ops}.validationFailed, UploadOutcome.completed.validationFailed, processRemoteOps return, forceDownloadRemoteState return, _handleSyncImportConflict return, DownloadResultForRejection.validationFailed, RejectionHandlingResult .validationFailed, plus a closure-captured piggybackValidationFailed in uploadPendingOps. Threading it correctly required every call site to remember to forward the boolean — a new variant or path that forgot would silently let IN_SYNC ride over corrupt state. - Add SyncSessionValidationService — a no-dep singleton with reset() / setFailed() / hasFailed(). RemoteOpsProcessingService.validateAfterSync and ConflictResolutionService.autoResolveConflictsLWW flip the latch when validation reports corruption. SyncWrapperService resets it at every entry point (sync(), _forceDownload(), resolveSyncConflict USE_REMOTE) and reads it once before deciding IN_SYNC vs ERROR. - Drop validationFailed from DownloadOutcome variants, UploadOutcome, DownloadResultForRejection, RejectionHandlingResult, processRemoteOps return, autoResolveConflictsLWW return, forceDownloadRemoteState return, and _handleSyncImportConflict return. ~120 LOC of plumbing collapsed to single latch reads. - Add a focused integration test (post-sync-validation.integration.spec.ts) that wires real RemoteOpsProcessingService + ConflictResolutionService against a stubbed ValidateStateService. Asserts the latch flips on validation failure and survives discarded-boolean callers — catching plumbing regressions a future code path could otherwise sneak past. Net change vs current branch: ~120 LOC deleted from production sources, ~270 LOC added in new service + tests (latch unit tests + integration). * fix(sync): close 3 latch-bypass gaps from codex review 1. SyncHydrationService.hydrateFromRemoteSync runs validateAndRepair() directly and previously dropped the result on failure. Snapshot hydration (file-based providers, USE_REMOTE force-download) would silently accept corrupt remote data — the wrapper would see a clean latch and report IN_SYNC. Now flips SyncSessionValidationService.setFailed() when isValid is false. 2. WsTriggeredDownloadService called downloadRemoteOps() outside the wrapper session contract, so any validation failure during a realtime apply was either dropped (next sync()'s reset cleared it) or leaked into the next session. The service now resets the latch up-front and reads it after the download, surfacing failures as setSyncStatus('ERROR'). 3. convertOpToAction only backfilled payload.id when missing. A malformed/older remote LWW op with payload.id \!= op.entityId would slip through and update the WRONG entity in lwwUpdateMetaReducer (which trusts entityData.id). Now forces id from op.entityId for non-singleton LWW ops, making "entityId is canonical" a hard invariant at the apply boundary. Adds regression tests for each: integration test for the snapshot path, two new tests on ws-triggered-download (latch flip → ERROR; latch reset between sessions), and a converter test for the entityId-mismatch case. * test(sync): unstick three failing supersync e2e tests Three independent flakes surfaced in the same run; root-caused from playwright traces: - daily-summary: time-estimate row icon was renamed timer→hourglass_empty on master ( |