Commit graph

63 commits

Author SHA1 Message Date
Mohamed Abdeltawab
32aec63023
Cleanup/remove dead super sync surface (#8526)
* cleanup: remove unused latestSnapshotSeq from HTTP response and client types

latestSnapshotSeq was computed on every download but never read by any
client code. sibling snapshotVectorClock IS consumed — only this field
is dead. The server still computes it internally for the snapshot
optimization logic, but it no longer serializes it in the response.

Removed from:
- DownloadOpsResponse type and response object (sync.routes.ts)
- Zod schema (supersync-http-contract.ts) + associated test
- OpDownloadResponseBase interface (sync-providers)
- Client validator test + stale comment references
- Server integration test assertions

* cleanup: remove deprecated CONFLICT_STALE error code

Server never emits CONFLICT_STALE (fully migrated to
CONFLICT_SUPERSEDED).

* cleanup: remove unused SyncConfig fields (maxOpsPerUpload, downloadLimit, downloadRateLimit)

These three config fields were declared and defaulted but never read by
any code. Routes hardcode their own limits:

- maxOpsPerUpload: gate is MAX_OPS_PER_BATCH constant
(sync.routes.payload.ts)
- downloadLimit: hardcoded Math.min(limit, 1000) in sync.routes.ts
- downloadRateLimit: hardcoded max:200 in route rateLimit config

* cleanup: remove dead/drifted duplicate types from sync.types.ts

Removed 6 types with zero references repo-wide:

- SnapshotResponse: orphaned after previous PR removed its importers
- UploadSnapshotRequest: zero uses, missing 7 fields vs actual Zod
schema
- RestorePointType: zero uses, included dead 'DAILY_BOUNDARY' value
- RestorePoint: zero uses (live version in snapshot.service.ts)
- RestorePointsResponse: zero uses
- RestoreSnapshotResponse: zero uses
2026-06-22 14:34:18 +02:00
Mohamed Abdeltawab
1423409a13
refactor: remove unused isCleanSlate from POST /ops contract and handler (#8491)
isCleanSlate was accepted and forwarded by the /ops endpoint, but the
production client never sends it — clean slate flows exclusively via
POST /snapshot. Removing it from the schema, the handler, and the
UploadOpsRequest interface for defense-in-depth. Zod's default strip
mode silently ignores it if an old/rogue client sends the field.

The snapshot schema (SuperSyncUploadSnapshotRequestSchema) and the
internal uploadOps(..., isCleanSlate?) parameter used by the snapshot
handler are unchanged.
2026-06-19 14:23:37 +02:00
Mohamed Abdeltawab
c2bf7b26a0
refactor(sync-core): drop shared-schema vector-clock compat re-export anda app enum (#8441)
* feat(sync-core): drop shared-schema vector-clock compat re-export and app enum

- Remove the shared-schema → sync-core compatibility re-export of
vector-clock types/functions; retarget app files
(vector-clock.ts,operation-log.const.ts) and the server (sync.types.ts)
to import from @sp/sync-core directly
- Add @sp/sync-core to super-sync-server/package.json deps (it was
load-bearing through the re-export)
- Convert VectorClockComparison from a bare type to an as const object +
derived type in sync-core,drop the app-side enum copy and the as cast
- Update spec imports and pa ckage-boundaries.md

* docs: remove stale shared-schema vector-clock references

- Update comments in vector-clocks.md, client vector-clock.ts, and
  server sync.types.ts to reference @sp/sync-core directly
- Remove @sp/sync-core dependency from shared-schema/package.json
- Regenerate package-lock.json to reflect the removed edge

* docs(sync): fix orphaned VectorClockComparison comment

The comment block describing VectorClockComparison was left dangling
above no declaration after the app-side enum was removed, and still
claimed 'Uses enum for client-side ergonomics'. Move it above the
re-export it documents and correct the wording.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-06-17 16:21:26 +02:00
dependabot[bot]
f8317929dd
chore(deps): bump vitest from 3.2.4 to 4.1.6 (#7687)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.4 to 4.1.6.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 12:50:58 +02:00
Johannes Millan
70dd6f9eca build: ignore files 2026-05-13 19:59:02 +02:00
Johannes Millan
4f15df8f40 feat(supersync): persist full-state vector clock and add snapshot retry idempotency
Two related improvements to snapshot/full-state handling:

- Persist the incoming op's vector clock as `user_sync_state.latest_full_state_vector_clock`
  so downloads can skip the expensive aggregate scan when the snapshot clock is
  still valid; falls back to the legacy aggregate when missing or invalid.
- Treat retried snapshot uploads (same opId) as idempotent successes instead of
  409/DUPLICATE_OPERATION so a dropped response doesn't push clients into the
  download-and-merge path. Per-namespace request dedup keeps ops and snapshot
  caches isolated.
2026-05-13 17:01:39 +02:00
Johannes Millan
47ce4b304c perf(super-sync): optimize status and conflict checks 2026-05-13 11:40:43 +02:00
Johannes Millan
9fd9d386a8 refactor(sync): move vector clocks to sync-core 2026-05-11 15:21:08 +02:00
dependabot[bot]
6112619bf2
chore(deps): bump zod from 4.3.6 to 4.4.3 (#7431)
Bumps [zod](https://github.com/colinhacks/zod) from 4.3.6 to 4.4.3.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.3)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 18:47:34 +02:00
Johannes Millan
60c0ba4e42 refactor(sync): share SuperSync HTTP contract 2026-05-09 18:11:41 +02:00
Iván Velázquez
f8f405c623
feat: add sections in projects (#6066)
* feat(sections): Introduce core section state, model, and persistence

* feat(sections): Implement SectionService and link sectionId to Task model

* feat(sections): Add 'Add Section' functionality to project context menu

* feat(sections): Implement cascading task deletion for sections

* feat(sections): Enable drag & drop for tasks into sections

* feat(sections): Display, manage, and reorder sections in Work View

* refactor(tasks): Add guard for invalid subtask moves in reducer

* feat(markdown): Add interfaces for markdown sections

* feat(markdown): Implement markdown section parsing utility

* feat(section): Add helper methods to SectionService

* feat(markdown): Add i18n for markdown section paste confirmation

* refactor(markdown): Prepare MarkdownPasteService for section support

* feat(markdown): Implement markdown section paste handling in MarkdownPasteService

* feat(markdown): Update paste detection to include markdown sections

* test(markdown): Add simple test for markdown section parsing utility

* fix(sections): post-merge type errors and stray file cleanup

- model-config.ts: drop unsupported `validate` field on section ModelCfg;
  validators are looked up via validateAppDataProperty(key, data) instead.
- app-data-mock.ts: add `section: createEmptyEntity()` so AppDataComplete is satisfied.
- Remove test-sections.js (development artifact from the original PR).

* refactor(sections): atomize section deletion via meta-reducer

Replace the dispatch-chained section.effects.ts with a section-shared
meta-reducer that removes the section entity and cascade-deletes its
tasks (and subtasks) plus their references in projects and tags in a
single reducer pass.

Why:
- The previous effect used inject(Actions), so it would re-fire during
  remote sync replay and double-delete tasks.
- It dispatched a separate deleteTasks action, producing two operations
  in the sync log; a partial sync between them could leave a tree of
  orphaned tasks pointing at a vanished section.

Changes:
- Add src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.ts
- Register sectionSharedMetaReducer in Phase 5 of meta-reducer-registry.ts
- Remove src/app/features/section/store/section.effects.ts and its
  EffectsModule.forFeature registration in feature-stores.module.ts

* refactor(sections): visual layer model + tag/today support

Make sections a pure visual grouping (analogous to boards) rather than a
property of tasks. This eliminates the cross-entity coupling that drove
most of the original PR's sync-correctness issues.

Section model:
- Drop Task.sectionId entirely. Sections own their membership via
  Section.taskIds: string[].
- Generalize Section.projectId → contextId + contextType ('PROJECT' | 'TAG').
  This unblocks sections in tag and TODAY views.
- New addTaskToSection action atomically removes the task from any other
  section and inserts it into the target at the requested position via
  adapter.updateMany — one reducer pass, one sync operation. Replaces the
  old project.effects moveProjectTaskToSection effect, which dispatched
  two persistent actions and was non-atomic.

Section deletion:
- Pure entity removal; no task cascade. Tasks in the deleted section
  simply become "no section" tasks (DELETE_SECTION confirmation reflects
  this; the now-obsolete DELETE_SECTION_CASCADE string is removed).

Section-shared meta-reducer (Phase 5):
- Repurposed from the previous deleteSection cascade handler. Now listens
  for TaskSharedActions.deleteTask/deleteTasks and prunes deleted task IDs
  from any section.taskIds, keeping section state consistent without
  requiring an effect.

Selector / perf:
- selectSectionsByContextId is backed by a single memoized
  selectSectionsByContextIdMap. Replaces the per-call factory pattern that
  broke memoization across consumers.
- WorkViewComponent.undoneTasksBySection now indexes section→task in
  O(n + m) using a Map keyed by taskId.

WorkContextMenu:
- Drop the @if (isForProject) gate on Add Section. The menu now offers it
  for tags (incl. TODAY) and propagates the appropriate contextType.

Cleanup:
- Add takeUntilDestroyed to all dialog .subscribe() chains in
  work-view.component.ts and work-context-menu.component.ts.
- Drop unused MatMenu/MatMenuTrigger/MatMenuItem imports.
- Yield the event loop every 10 sections during markdown imports.
- Revert the es.json edits added by the original PR (en-only policy).

* test(sections): cover reducer + meta-reducer behavior

- sectionReducer: addSection (default empty taskIds), deleteSection
  (entity-only, no cascade), updateSection partial changes,
  updateSectionOrder scoped to one context, addTaskToSection (anchor
  positioning, uniqueness across sections, intra-section moves, ungrouping
  on null sectionId, no-op when target missing).
- sectionSharedMetaReducer: deleteTask removes the id from any section,
  cascades through subtasks, deleteTasks bulk variant, passes through
  unrelated actions unchanged.

* fix(sections): wire new entity through suite-wide invariants

Fixes 11 unit-test failures introduced by adding the section model:

- ActionType enum gains SECTION_ADD_TASK (the new atomic move action)
  and a corresponding ACTION_TYPE_TO_CODE entry; spec member-count
  expectation bumps from 136 → 141 to match.
- extractEntityKeysFromState now emits SECTION:<id> keys so
  OperationLogCompactionService recognizes section as a valid model.
- task-list enterPredicate distinguishes section drop-lists from
  subtask drop-lists via drop.data.listId. Parent tasks may drop
  into PARENT_ALLOWED_LISTS or any parent-level (section) drop-list,
  but never into a subtask drop-list (listId === 'SUB').
- Add section: { ids: [], entities: {} } to the empty-state helper in
  validate-state.service.spec; without it Typia validation fails
  because section is now a required slice of AppDataComplete.
- Add activeWorkContextId$: of(null) to the WorkContextService mock
  in work-view.component.spec; the section signal subscribes to that
  observable in the constructor.
- Add 'section' → 'SECTION' to the modelToEntityType map in
  operation-log-compaction.service.spec.

Five immediate-upload.service.spec.ts failures remain; they reproduce
on master with identical files, so they're unrelated to this branch.

* fix(sections): apply multi-review C1-C4 + harden taskIds reads

C1: Move sectionSharedMetaReducer before taskSharedCrudMetaReducer.
At Phase 5 it ran AFTER the task entity was already removed, so
state.task.entities[id]?.subTaskIds was always undefined and section
references to deleted subtasks leaked. The section spec passed only
because its mock reducer didn't apply the cascade.

C2: Tighten task-list enterPredicate. Subtasks may now drop only into
another subtask drop-list (listId === 'SUB') or appear as top-level
in DONE/UNDONE. Section drop-lists (listId === 'PARENT' with a section
id) reject subtask drags so section.taskIds stays parent-only.

C3: Add cdkDropListEnterPredicate to the sections-wrapper. Without it,
a task drag could land on the section-reorder list and dropSection
would treat a Task as a Section, corrupting ordering. The new
acceptSectionDragOnly predicate brand-checks the drag's data.

C4 + cleanup: drop the unused Section.isCollapsed field; remove the
redundant `// 1.` `// 2.` comments in section.reducer; tighten the
`(state as any).section` cast (AppStateSnapshot already declares the
field); fix the misleading meta-reducer-registry comment.

Plus a reported runtime crash: cleanupSectionTaskIds threw on
undefined `s.taskIds` for sections persisted before this branch added
the field. Defensive `?? []` everywhere that reads `taskIds`, and a
normalizeLoadedSections pass on `loadAllData` so old persisted state
is brought up to the current shape.

* fix(sections): Add Section button — drop takeUntilDestroyed on dialog sub

WorkContextMenuComponent lives inside a <mat-menu>. The menu (and the
component) is destroyed the moment the dialog opens, so
takeUntilDestroyed(this._destroyRef) tore down the afterClosed()
subscription before it could ever emit the typed title. Result: the
dialog appeared, the user typed, clicked Save — but no section was
ever dispatched.

MatDialog cleans up its own subscription once the dialog closes, so
the explicit teardown is unnecessary here. WorkViewComponent's similar
dialog subscriptions are unaffected (that component is not inside a
mat-menu).

* style(sections): move Add Section above Settings in context menu

* fix(sections): clean up orphan sections on project/tag deletion

Closes the cleanup gap surfaced by user audit: until now, deleting a
project or tag left behind its sections — they kept their stale
contextId and contextType but no longer matched any context, piling up
in state and the sync log. Section-level cleanup was only wired for
task deletion.

sectionSharedMetaReducer now also handles:
- TaskSharedActions.deleteProject → remove sections where
  contextType='PROJECT' and contextId === projectId.
- deleteTag (single) → remove sections where contextType='TAG' and
  contextId === id.
- deleteTags (bulk) → remove sections where contextType='TAG' and
  contextId is in ids.

contextType is matched explicitly so a project and a tag that happen
to share the same id (theoretical, but possible) don't cross-pollute.
Added 3 specs covering each path, including the same-id collision case.

* fix(sections): clean section.taskIds on task move + tag removal

Two more cleanup paths surfaced by the user audit:

1. Task moves to another project (TaskSharedActions.moveToOtherProject):
   the moved task and its subtasks were left lingering in any section
   owned by the previous project. Now the meta-reducer reads the task's
   old projectId from state (action runs before taskSharedCrud) and
   strips the task ids from project-scoped sections in that project.
   Tag-scoped sections are intentionally untouched because tag
   membership doesn't change on a project move.

2. Task tagIds change (TaskSharedActions.updateTask): when a tag is
   removed from a task, any section owned by that tag still kept the
   task id. Now we diff oldTagIds vs new tagIds, and for each removed
   tag, strip the task id from sections matching contextType='TAG' and
   contextId === removedTagId. Tag additions are not auto-joined to a
   section — that stays a user action.

The new cleanupTaskIdsInContexts helper does both jobs (restricting
edits to a specific contextType + a set of contextIds), keeping the
logic narrow.

Specs cover: project move strips only old-project sections (and
subtasks), tag removal strips only the dropped tags' sections, and
updateTask without a tagIds change is a no-op.

* refactor(sections): replace SectionContextType with WorkContextType

Drop the parallel `SectionContextType = 'PROJECT' | 'TAG'` union in
favour of the existing `WorkContextType` enum, which has identical
string values. Same on-disk shape (the Typia validator's literal-string
serialization is unchanged), but the model now reuses the canonical
work-context taxonomy and consumers can pass `activeWorkContextType`
straight through without translation ternaries.

Touches: section.model + section.service signature, the section-shared
meta-reducer's helpers and handlers (`WorkContextType.PROJECT/TAG`
instead of literals), the work-context-menu / work-view / markdown-paste
call sites (drop the `=== WorkContextType.PROJECT ? 'PROJECT' : 'TAG'`
ternary), and the spec fixtures.

* refactor(sections): split addTaskToSection — drop the 'NONE' sentinel

The 'NONE' string sentinel for ungrouped op-log entries had two
problems: (1) two clients ungrouping different tasks concurrently
both wrote ops keyed on entityId='NONE', so LWW conflict resolution
collapsed them and one ungroup was discarded; (2) magic string with
no constant or test pinning it.

Split into two persistent actions:
- addTaskToSection({ sectionId, taskId, afterTaskId? }) — sectionId
  is now non-nullable; entityId tracks the destination section. The
  reducer still atomically strips the task from any other section
  (uniqueness invariant), so a single op covers a full move.
- removeTaskFromSection({ sectionId, taskId }) — entityId is the
  source section. Concurrent ungroups from different sources are now
  independent ops with distinct entityIds; LWW conflict resolution
  treats them correctly.

The drag-drop call site already knows both source and target list ids
(srcIsSection / targetIsSection), so the SectionService just exposes
two narrow methods: `addTaskToSection(target, ...)` and
`removeTaskFromSection(source, ...)`. The work-view caller uses
addTaskToSection only (markdown paste never ungroups).

Two new specs cover removeTaskFromSection: strips only the named
section, no-op when the task isn't there, no-op when the section is
missing.

* fix(sections): apply round-2 review batch

Addresses ten findings from the second multi-review pass.

Security
- Replace plain-object grouping caches with Map<string, …> so a
  malicious sync peer can't poison Object.prototype via crafted
  contextIds like '__proto__'. Affects selectSectionsByContextIdMap;
  the per-call factory selector is removed (see Performance below).

Correctness
- normalizeLoadedSections defends against state.ids === undefined
  (very old persisted shapes wrote `section: {}`).
- Section meta-reducer now also handles TaskSharedActions.updateTasks
  (bulk). Previously only updateTask fired the tag-removal cleanup;
  a future bulk dispatcher mutating tagIds would silently leak orphan
  task ids in tag-context sections. Spec covers it.

Architecture
- Phase 3.5 promoted to a named phase in the registry doc block.
- validateMetaReducerOrdering pins sectionSharedMetaReducer to run
  before taskSharedCrudMetaReducer; any future reorder fails
  dev-mode validation. The handlers' pre-CRUD state read is now
  documented at the top of section-shared.reducer.ts.

Alternatives + Performance
- Drop the per-call selectSectionsByContextId factory. The service
  selects selectSectionsByContextIdMap and pipes map(...) — no fresh
  MemoizedSelector per call, no leak.
- addTaskToSection reducer breaks out of the uniqueness scan after
  the first removal (a task is in at most one section at a time).
- Pre-filter sectionSharedMetaReducer on a static
  HANDLED_ACTION_TYPES Set so the 99% of dispatches that don't match
  short-circuit before allocating the handler dispatch table.

Simplicity
- Collapse SectionService.{addSection, addSectionWithId,
  generateSectionId} into a single addSection that returns the new
  id synchronously. Markdown-paste consumes it directly.
- Remove collectTaskAndSubtaskIds (single-task path is just
  collectAffectedTaskIds with a one-element array).
- dropSection drops the object spread + extra map; reorder ids in
  place.
- Tighten ExtendedState — SECTION_FEATURE_NAME is always registered,
  so the optional-typing dance and the four `if (\!sectionState)`
  early-returns inside helpers are removed.

* fix(sections): apply round-3 review batch

Fixes from a multi-reviewer pass (codex + claude + code-reviewer
sub-agent) on the sections feature.

Critical
- task-list: subtask-to-subtask drag was being misidentified as a
  section move. _move() now takes srcListId/targetListId and only
  treats a non-reserved listModelId as a section when listId is
  PARENT. Subtask drop-lists ('SUB') fall through to moveSubTask.
- parse-markdown-tasks: tasks before the first markdown header were
  silently dropped by parseMarkdownWithSections. They now flush into
  a top-of-list "No Section" entry. Also broaden the header regex
  from /^#\s+/ to /^#{1,6}\s+/ so H2-H6 are recognized.
- section.actions/reducer/service: addTaskToSection now carries an
  explicit sourceSectionId. The reducer strips from that source
  rather than searching state, making replay deterministic. Action
  meta sets entityIds: [src, dest] when src is provided so vector-
  clock conflict detection covers both. Callers in task-list and
  markdown-paste pass the actual source (or null for new tasks).

Warnings / cleanups
- section-shared meta-reducer: handle removeTasksFromTodayTag and
  localRemoveOverdueFromToday — TODAY is virtual so the existing
  tagIds path doesn't catch them. Doc enumerates the residual gap
  (scheduling/planner/short-syntax/crud/lww) and proposes a future
  Phase 6.5 diff-based meta-reducer for full coverage.
- section-shared reducer: typed as MetaReducer<RootState> /
  ActionReducer<RootState, Action> instead of any.
- markdown-paste: yield every 30 dispatches (was: every 10 sections)
  per CLAUDE.md rule #11. Type the reduce accumulator as number.
- task-list: extract RESERVED_LIST_IDS constant; comment why it
  diverges from PARENT_ALLOWED_LISTS on LATER_TODAY.
- en.json/es.json: restore trailing newlines (es.json now identical
  to master).

Tests
- parse-markdown-tasks.spec: 6 cases for parseMarkdownWithSections
  (H1, H2/H3, pre-header tasks, empty sections, null input).
- section.reducer.spec: 4 cases for sourceSectionId behavior
  (explicit source, null, omitted, intra-section reorder).
- section-shared.reducer.spec: 3 cases for TODAY removal cleanup.
- task-list.component.spec: 5 cases for _move() routing
  (subtask vs section disambiguation, source propagation).

* fix(sections): apply round-3 review nits

- task-list: type RESERVED_LIST_IDS values via `satisfies
  DropListModelSource[]` so adding a new variant to the union
  surfaces a typo here without forcing casts at every `.has()`.
- section.actions: tighten addTaskToSection doc — the `entityIds:
  [src, dest]` claim only holds when src is a non-null string
  different from dest; intra-section / null fall back to
  single-entity meta.
- task-list spec: add two anchor cases for `_move` so
  `getAnchorFromDragDrop` is actually exercised on section drops
  (previous tests passed `[taskId]` only, which short-circuited to
  null).

* fix(sections): apply round-4 review batch

High-severity sync/UX fixes:
- deleteProject now also strips cascaded task ids from tag-context
  sections (one extra cleanupSectionTaskIds pass in the meta-reducer);
  + unit test
- Edit Section dialog prefill: val → txtValue
- Markdown paste: drop yieldIfNeeded mid-loop (widened the sync
  interleave window); residual atomic-bulk-action gap documented
- Add Section menu item guarded against TODAY_TAG
- Paste hook switched from id-prefix scan to data-task-id attribute
- New section referential validators + auto-repair (data-repair)
- Section view bypasses customizer when filter/sort active
- Parser hardening: input cap, CRLF/BOM normalization, reduce-based
  min-indent (avoids RangeError on huge spreads), JSDoc fix
- moveSubTask guard now also rejects self-moves; console.warn →
  TaskLog.warn
- Work-view styling: use --s/--s3 tokens, narrow \!important rules
- Drop dead loadSections action + SectionService.sections$
- Trailing-space prettier nits in sync.model.ts + feature-stores.module

* fix(sections): apply multi-review batch — moveToArchive + cleanup

- moveToArchive: add handler in section-shared meta-reducer so archived
  tasks (and their subtasks) no longer leak as stale ids in
  section.taskIds until dataRepair clears them.
- Collapse the dual HANDLED_ACTION_TYPES + handlers map into a single
  ACTION_HANDLERS record; the prior split is what allowed moveToArchive
  to drift out of sync.
- Make addTaskToSection's sourceSectionId required (string | null) and
  delete the legacy defensive sweep branch in the reducer.
- updateSectionOrder now keeps other-context sections in their slot in
  state.ids instead of pushing them to the front.
- Sanitize section titles (trim + 200-char cap) on add/update.
- Remove dead addSection() in work-view.component.ts (template only
  wires editSection/deleteSection; addSection lives on work-context-menu).
- Remove dead translation keys WW.ADD_SECTION and WW.ADD_SOME_TASKS.
- Drop hasHeaders field from MarkdownWithSections (null return already
  encodes header-less input).
- Drop normalizeLoadedSections + ?? [] guards (Section is brand-new, no
  legacy persisted shape exists) and unused entity adapter exports.

* perf(sections): apply perf review batch — drop-list coalesce + meta-reducer hot paths

- DropListService: coalesce burst register/unregister calls via a
  microtask flush. Mounting 50 sections previously emitted 50 times
  through the BehaviorSubject; cdkDropListConnectedTo rebuilds its
  sibling graph per emission, so first paint cost was ~O(L²). Now
  one downstream emission per CD pass.
- section-shared meta-reducer: replace Object.values(entities) sweeps
  with `for-of state.ids` (no intermediate array alloc on every matched
  task action) and collapse `.some + .filter` double-walk into a single
  pass that returns null when nothing changed. Halves walk count on
  affected sections under op-log replay.
- updateTasks handler: aggregate (taskId → removedTagIds) across the
  batch into a `tagId → tasksToRemove` map, then sweep candidate
  sections once with a single adapter.updateMany. Drops a 50-task
  batch from O(N·S) to O(N + S).
- section.service: stable EMPTY_SECTIONS constant for contexts with no
  sections, so OnPush downstream isn't broken by a fresh `[]` per
  emission.
- work-view template: drop `|| []` on dict[section.id] — the dict is
  pre-populated per section, so the fallback was allocating a fresh
  array reference per CD pass for every empty section.
- acceptSectionDragOnly: bind [cdkDragData]="section" and reduce the
  predicate to one property read; was running shape-validation on
  every dragOver tick.
- markdown paste: cheap regex pre-screen in isMarkdownTaskList so
  plain-text Ctrl+V pastes bail before invoking the parser, and
  memoise the sectioned-parse result by string reference so the
  immediately-following handleMarkdownPaste doesn't re-parse.

* feat(sections): project-only Add Section + empty main-list hint

- work-context-menu: tighten Add Section gating from
  `isForProject || contextId \!== TODAY_TAG_ID` to `isForProject`. The
  menu item is now hidden for the today list and for tag contexts in
  general; sections in tag contexts can still arrive via markdown paste.
- work-view: when the sectioned view's no-section bucket is empty
  (every task lives in a section), render a small italic hint
  ("All tasks are organized into sections") instead of leaving the
  area silently empty.

* fix(sections): keep no-section drop target alive when empty

Previously the no-section area swapped task-list for a <p> hint when
empty, removing the cdkDropList. That blocked drops from sections back
into the main list. Restore the always-rendered task-list and pass the
hint via its built-in [noTasksMsg]:
- task-list keeps its drop target so cross-section moves work.
- The hint is muted via task-list's existing .no-tasks styling
  (var(--text-color-muted)).
- The hint is hidden when undoneTasks() is empty so it doesn't
  duplicate the horizon "no tasks planned" empty state.

* feat(sections): show Add Section in every work-context menu

Drop the gate around the Add Section menu item so it shows for
projects, regular tags, and the today list. Sections in TODAY are
stored as TAG-context sections under the TODAY tag id, matching the
existing tag-section flow.

* fix(sections): apply round-6 multi-review batch

Critical:
- enterPredicate rejects backlog → section drops; previous path left
  the task in both backlog and section.taskIds.
- editSection / addSection trim whitespace before the truthy check
  (sanitizeSectionTitle would otherwise produce empty titles).
- TODAY_TAG cleanup: post-reducer diff in section-shared meta-reducer
  catches every flow that removes ids from TODAY (scheduling, planner,
  short-syntax, undo, lww), closing the documented residual gap. Bulk
  action handlers retained — handler + diff are idempotent.

Sync-replay defense:
- updateSectionOrder accepts partial / out-of-date payloads via dedup +
  append-missing instead of a fragile cursor walk; new tests cover
  shorter and stale payloads.
- sanitizeSectionTitle moved to section.model and applied inside the
  reducer for addSection / updateSection so remote ops can't bypass
  the 200-char cap.

Cleanup:
- markdown-paste memo moved from module scope to instance state, with
  explicit clear after consumption (clipboard content no longer
  retained for the rest of the session).
- undoneTasksBySection bound once via @let in the work-view template.
- Factory selector selectSectionsForContext(contextId) replaces the
  whole-map mapping in SectionService.
- Drop log-only validateSections (data-repair already cleans).

Follow-ups documented inline (not in this PR):
- task.sectionId membership model (W4)
- removeTaskFromSection → addTaskToSection consolidation (W3)
- marked-based parseMarkdownWithSections (S1)

* fix(sections): guard TODAY-tag diff against undefined state slices

`diffRemovedTodayTaskIds`, `applyTodayTagSectionCleanup`, and
`collectAffectedTaskIds` all assumed their slices existed. During
early boot / undo / hydration paths the tag, task, or section slice
can be undefined, causing the meta-reducer to crash on
`prevTagState.entities[...]`. The crash cascaded into selector
errors (idleTime, activeType, isShowAddTaskBar) because subsequent
reducer passes never ran.

Bail out early when slices are absent — there's nothing to clean up
yet.

* test(sections): add e2e coverage for basic section flows

Covers the core user-visible section behavior:
- create section via project header context menu
- reject whitespace-only titles (verifies the C2 trim fix)
- edit section title via the per-section menu
- delete section after dialog confirmation
- drag a task into a section (Angular CDK drag-drop helper, since
  Playwright's `dragTo` uses HTML5 drag events that CDK ignores)
- sections persist across a page reload (IndexedDB hydration)

The reverse drag (section → no-section bucket) is fixme'd: forward
drag passes, but the reverse target's bounding box collapses to a
hint message when empty and the CDK drop won't register reliably
in headless. Reducer-level coverage in section.reducer.spec.ts
(`removeTaskFromSection`) substitutes for now.

* fix(sections): apply round-7 multi-review batch

Hardening:
- Reducer-side `sanitizeSectionTitle` now coerces non-string input
  (null / undefined) to "" instead of throwing. A malformed remote op
  (`addSection({ section: { title: undefined } })`) was the threat
  model the cap was added for; previously it crashed the reducer.
- `updateSection` now sanitizes on key-presence (`'title' in changes`)
  rather than `typeof === 'string'` so a peer shipping `title: null`
  cannot bypass the cap and corrupt the entity's typed contract.
- Single dispatcher-level boot guard in `sectionSharedMetaReducer`
  catches every action handler that touches task / tag / section
  slices, replacing the asymmetric per-function guards from round 6.

Cleanup:
- Revert the round-6 `selectSectionsForContext` factory selector. It
  allocated a fresh `MemoizedSelector` per `getSectionsByContextId$`
  call, undermining the memoization the simplification claimed. The
  service's previous `.pipe(map(m => m.get(id) ?? EMPTY))` shape is
  one fewer layer with identical behavior — single consumer, no win.
- Move `sanitizeSectionTitle` + `MAX_SECTION_TITLE_LENGTH` to
  `section.utils.ts` (model files in this repo are pure type files).
- Inline `_clearSectionsCache()` (single caller).
- Drop the redundant 5-step settle-move at the end of the e2e
  `cdkDragTo` helper.

Tests:
- Pin the actual ordering in the `updateSectionOrder` partial-payload
  test (was only checking dedupe count, missed the explicit shape).
- New: empty-string title passes through (legitimate clear).
- New: null/undefined title is coerced, not stored as null.
- New: malformed remote op with undefined title doesn't crash addSection.
- Replace `waitForTimeout(500)` in the e2e whitespace-rejection test
  with a polling `toHaveCount(0)` assertion (project rule violation).
- Strengthen the `updateSection` cap test with leading-whitespace input.

Deferred (tracked as follow-ups):
- W2: explicit `handleRemoveFromTodayTag` redundant with the diff —
  keep both for now; soak the diff before unifying.

* test(sections): drop unused eslint-disable directives

Use `as unknown as string` casts instead of `as any` + eslint-disable
in the malformed-input reducer tests. Same intent (force the type
assertion to model what a malicious peer's payload looks like at
runtime), no lint suppression needed.

* style(sections): drop redundant message from Add Section dialog

The Add Section dialog passed both `placeholder: T.G.TITLE` and
`message: T.CONFIRM.ADD_SECTION` to DialogPromptComponent. The
message was redundant — its text ("Add Section") duplicated the
dialog's contextual purpose, and showing it forced the
mat-dialog-content out of `.isNoMsg` mode, adding outer padding
that the Add Tag dialog doesn't have.

Drop the message so the Add Section dialog matches the Add Tag
visual pattern (no outer padding, just the input field). Also
remove the now-unused `T.CONFIRM.ADD_SECTION` translation key.

* fix(sections): apply round-8 multi-review batch

Hardening:
- `sanitizeSectionTitle` swaps `String(title ?? '')` for a
  `typeof === 'string'` fast-path. Closes two real defense gaps:
  - `String(Symbol())` would throw and crash the reducer.
  - `String({toString: () => 'x'.repeat(2**27)})` would materialize
    a ~256 MB string before the slice. JSON op-log payloads can
    smuggle 2 MB+ literal strings that survive the wire.
  Same behavior for the documented `null` / `undefined` cases.
- Reducer + service now share a `hasTitleChange()` helper using
  `Object.hasOwn` instead of `'title' in changes`. Strictly better
  semantics — `'in'` walks the prototype chain, so a peer payload
  with `Object.create({title: 'x'})` would have triggered
  sanitization on a key the entity doesn't carry.
- Service-side `updateSection` now uses the same key-presence check
  as the reducer (was `typeof === 'string'`); removes a future
  reader's "why two checks?" smell.

UX / a11y:
- Add Section dialog now passes `placeholder: T.WW.ADD_SECTION_TITLE`
  ("Add Section") instead of generic `T.G.TITLE` ("Title"). The
  dialog has no title element and no message text, so the
  placeholder is the only context users (especially screen-reader
  users) get for what they're naming. Mirrors the Add Tag pattern
  ("Add new Tag" placeholder).

Convention:
- Rename `section.utils.ts` → `section.util.ts`. Repo uses singular
  `*.util.ts` (60+ files) vs plural `*.utils.ts` (was 2 outliers).

* feat(sections): Add Section in work-view background context menu

Right-click on the empty area of the work-view (project, tag, or
Today view) now opens a small context menu with one item: "Add
Section". This is a discoverability complement to the side-nav
overflow button — the work-view is where the user is already
focused when they decide they need a section, so the action should
be reachable without navigating back to the side-nav.

Implementation:
- `(contextmenu)` listener on `.task-list-wrapper` calls
  `onBgContextMenu`, which skips when the click target is inside
  an interactive element (task, button, input, drag handle, …) so
  per-element context menus and native form behavior aren't shadowed.
- A hidden `[matMenuTriggerFor]` div is positioned at the cursor
  via signals (`bgContextMenuX`, `bgContextMenuY`).
- `addSection()` reuses the same DialogPromptComponent shape as
  the side-nav addSection (no `message`, descriptive placeholder),
  and reads `activeWorkContextId` / `activeWorkContextType` from
  `WorkContextService` so it works across project / tag / Today.

E2E test: right-click → menu item → submit dialog → assert the
section is rendered.

* fix(sections): initialize section slice in dataRepair for legacy migrations

Legacy 'pf' databases predate the sections feature and don't carry
a section field. After Typia validation rejects the missing field,
`dataRepair` was supposed to fix it — but `_repairSections` early-
returned on missing state instead of initializing it, so re-validation
failed and `OperationLogMigrationService._performMigration` aborted
with "Migration failed."

Match the existing init pattern (archiveYoung, archiveOld, reminders):
populate `dataOut.section = { ids: [], entities: {} }` if absent
before per-slice repair runs.

Verified by running e2e/tests/migration/legacy-data-migration.spec.ts.

* fix(sections): apply round-9 thorough review batch

Critical:
- Reject section → BACKLOG drag in `enterPredicate`. The handler
  was removing the task from `section.taskIds` but never dispatching
  `moveProjectTaskToBacklogList`, so the task vanished from the
  section without appearing in the backlog. Mirrors the existing
  BACKLOG → section guard.

Warnings:
- `section.reducer.ts: loadAllData` now falls back to
  `initialSectionState` when the payload omits `section`. Previous
  behavior preserved stale local state, violating SYNC_IMPORT /
  BACKUP_IMPORT semantics ("complete fresh start").
- `markdown-paste.service.ts` checks `sectionTitle?.trim()` before
  calling `addSection`, so headers like `## ​` (zero-width space)
  fall through to the noSection bucket instead of creating a
  titleless section.
- `moveToArchive` section cleanup unions payload subtasks with the
  state-derived expansion. Robust under both threat models: replay
  where the parent entity is gone from state (payload carries the
  tree) AND callers passing `subTasks: []` (state lookup is the
  only signal).

Suggestions:
- Section overflow button gets an `aria-label="Section options"`
  (new `T.WW.SECTION_OPTIONS` translation key).
- `_repairSections` now does `Array.isArray(taskIds)` instead of
  `?? []` — defends against malformed remote payloads where
  `taskIds` is a truthy non-array value.
- `acceptSectionDragOnly` discriminates on `'contextType' in data`
  rather than `Array.isArray(data.taskIds)`. The latter would
  silently accept Task drags if Task ever gained a `taskIds` field.

Documentation:
- Added LWW conflict-resolution gap to the section-shared
  meta-reducer's KNOWN FOLLOW-UPs block. Project- and non-TODAY-tag-
  context section.taskIds can leak phantom references after LWW
  resolves a tagIds / projectId update; the diff catches TODAY but
  not other contexts. Visible impact bounded by render-time
  intersection in `undoneTasksBySection`. Cleaned by next
  `dataRepair` pass.

False positive: `extract-entity-keys.ts` already guards via
`entityState?.ids` optional chain — no change needed.

Verified: 24 reducer tests, 17 meta-reducer tests, 51 data-repair
tests, 35 task-list tests, 7 work-view tests, 7 active section
e2e tests + migration e2e all pass.

* refactor(sections): simplification pass — drop micro-helpers + comment cleanup

A focused simplification review of the branch found small wins
that the correctness-focused rounds had accreted around.

Removed:
- `hasTitleChange()` helper. Single-line `Object.hasOwn(c, 'title')`
  is clearer at the two call sites than a 3-line wrapper.
- `_parseSectionsCached` instance memo + private fields in
  `MarkdownPasteService`. The `MARKDOWN_TASK_OR_HEADER_RE` pre-screen
  already gates plain-text pastes; for genuine sectioned input the
  parser walks once cheaply and the dialog wait dwarfs parse time.
  Removes mutable instance state and the `MarkdownWithSections` type
  import that only existed for the cache.

Comment trimming (74 LOC removed across 5 sites, no behavior change):
- `addTaskToSection`: 22 → 8 lines (the architectural follow-up was
  duplicated elsewhere; kept the essential meta-shape note).
- `Phase 3.5 placement` block: 11 → 3 lines (runtime
  `validateMetaReducerOrdering()` already enforces it).
- `moveToArchive` handler: tightened the union-rationale.
- `acceptSectionDragOnly`: 8 → 2 lines.
- `updateSection` reducer: 6 → 2 lines.
- `updateSection` service: dropped redundant explainer.

Things considered and intentionally kept (defended):
- `section.util.ts` — file is clean (one constant + one normalizer);
  matches the codebase's per-feature util pattern.
- `section.service.ts` — codebase has a service per feature.
- `EMPTY_SECTIONS` frozen const — a fresh `[]` per emission would
  cause downstream computed signals to recompute even for empty
  contexts.
- Reducer-side `sanitizeSectionTitle(unknown)` — defends the real
  schema-evolution case where a peer ships malformed payloads.
- Boot-guard 3-slice check — needed because the diff path
  dereferences tag state.

Verified: 24 reducer tests, 17 meta-reducer tests pass.

* refactor(sections): restrict scope to projects + TODAY tag

Custom-tag sections added per-tag cleanup machinery (deleteTag /
deleteTags handlers, handleTaskTagsChange, handleBulkTaskTagsChange,
removeTasksFromTodayTag/localRemoveOverdueFromToday explicit handlers)
and broadened the validation surface for limited end-user value.

- Add isValidSectionContext helper enforcing PROJECT or TODAY-only.
- Guard at all entry points: SectionService.addSection (returns null),
  section.reducer (rejects invalid contextId/Type), data-repair drops
  invalid sections on import.
- Hide "Add Section" in custom-tag context menu and suppress the
  work-view bg right-click menu there.
- Drop tag-scope handlers from section-shared meta-reducer; rely on
  the existing diff-based TODAY_TAG.taskIds path for TODAY cleanup.
- Update unit tests to match.

Net: -243 LOC.

* refactor(sections): drop markdown section paste

The H1-grouped markdown paste path created sections + tasks atomically
from a hand-rolled parser, but the feature is niche, the parser is
duplicated logic next to the existing flat-list and structured paste
paths, and the documented atomicity caveat (partial state on
concurrent sync) was never resolved.

Falls back to the existing flat-list / sub-task paste paths, which
cover the dominant use case.

- Remove parseMarkdownWithSections + MarkdownWithSections /
  SectionWithTasks types.
- Drop the section-paste branch and SectionService /
  WorkContextService deps from MarkdownPasteService.
- Drop CONFIRM_SECTIONS i18n key.
- Drop spec coverage for the removed parser.

Net: -251 LOC.

* refactor(sections): post-review hardening + cleanups

Apply five findings from the latest cross-reviewer pass.

- loadAllData filters imported sections through isValidSectionContext.
  Typia validates structure but not invariants, so a backup from an
  older client could otherwise smuggle custom-tag sections back in.
- updateSection rejects payloads that touch contextId/contextType.
  No legitimate flow rewrites a section's context — a malformed peer
  would otherwise morph a project section into a custom-tag one.
- Add 'section' to ENTITY_STATE_KEYS so _resetEntityIdsFromObjects
  reconciles ids ↔ entities like every other entity slice.
- Drop redundant service-side sanitize in updateSection — the reducer
  is authoritative.
- Refresh stale addSection JSDoc (markdown-paste reference removed
  with the section paste path in 16a0011d5f).

* refactor(sections): drop YAGNI defenses against non-existent older clients

The Sections feature is brand new on this branch — no released version
ever produced custom-tag sections, so loadAllData filtering and an
updateSection contextId/contextType reject defend against a threat that
cannot exist. Trust internal code per project guidelines.

Reverts two of the five defenses added in d4c9680837. The other three
(ENTITY_STATE_KEYS section parity, redundant service-side sanitize,
stale JSDoc) stay — they're not threat-defensive.

* refactor(sections): drop redundant addSection reducer context guard

The service-level guard at section.service.ts:44 is the only dispatch
path; layering a hot-path reducer guard on top defends against a
"developer dispatches the action directly past the service" scenario
that doesn't exist. Trust internal code per project guidelines.

Data-repair keeps its custom-tag check — that module's design contract
is defensive cleanup of impossible state, a different category.

* refactor(sections): merge bg right-click menu with Settings menu

Two background context menus existed: app-level "Change Settings" and
work-view-level "Add Section". Combine them into the single existing
app-level menu, gating "Add Section" by PROJECT or TODAY context.

- Move addSection() to AppComponent (mirrors openSettings).
- Add the gated "Add Section" item to the backgroundContextMenu
  template.
- Remove the work-view bg trigger, signals, viewChild, handler, and
  matching template block.
- Update the right-click section e2e to dispatchEvent on
  .task-list-wrapper (the merged menu uses target.matches, not
  target.closest, so the previous position-based click was fragile
  against the empty-state placeholder).

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-04-29 15:02:33 +02:00
Johannes Millan
d32f7037a3 fix(sync): preserve own vector clock counter across full-state op resets
When a full-state op (SYNC_IMPORT/BACKUP_IMPORT) arrived from another
client, mergeRemoteOpClocks reset the local clock to a "minimal" form
but only preserved the current client's counter from the incoming op's
clock. If the current client had issued ops (e.g. GLOBAL_CONFIG) not
reflected in the incoming full-state op's clock, its counter was dropped,
causing subsequent ops to reuse the same counter value. Downstream clients
then saw these ops as EQUAL (duplicate) and skipped them silently.

Fix: take max(mergedClock[clientId], currentClock[clientId]) when
rebuilding the clock after a full-state op reset.

Also add __SP_E2E_BLOCK_WS_DOWNLOAD flag to WsTriggeredDownloadService
to allow E2E tests to block automatic WS-triggered downloads during
concurrent conflict scenarios.

Fix archive conflict test by blocking WS downloads on Client A during
the concurrent edit phase so it doesn't auto-receive B's rename via
WebSocket before archiving (restoring the intended conflict scenario).

Fix LWW singleton test to assert convergence rather than specific winner.
Fix renameTask helper to avoid Playwright/Angular re-render races.
Fix shepherd.js import paths that broke the Angular dev server build.
2026-04-01 15:41:13 +02:00
dependabot[bot]
f9ba198302
chore(deps): bump vitest from 3.2.4 to 4.1.2 (#7036)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.4 to 4.1.2.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 13:39:16 +02:00
Johannes Millan
861425fd28 refactor(sync): improve vector clock implementation quality
- Replace O(N) Prisma findMany with PostgreSQL SQL aggregate
  (jsonb_each_text + GROUP BY + MAX) for snapshot vector clock
  computation, avoiding loading all ops' clocks into Node.js memory
- Add defensive regex filter (^[0-9]+$) for non-numeric JSONB values
- Fix || to ?? for nullish coalescing in vector clock value lookups
- Remove unreachable CONCURRENT check in compareVectorClocks
- Remove unused VectorClockMetrics interface and measureVectorClock
- Add 7 new unit tests for SQL aggregate path verification
- Add 10 integration tests running actual SQL against PostgreSQL
2026-03-17 13:59:40 +01:00
Johannes Millan
454fefcb04
test(sync): add vector clock pruning edge case tests (#6506)
Add tests for previously untested vector clock pruning scenarios:

- Multi-preserve-ID pruning (two low-counter IDs both preserved)
- Overlapping/duplicate preserve IDs (Set deduplication)
- Preserve IDs missing from clock (silent skip, no crash)
- Snapshot vector clock aggregation + pruning integration
- Post-snapshot comparison correctness after pruning
- Server sanitizeVectorClock counter cap at 100M
- DoS cap at 2.5x MAX (reject, not prune)
- Invalid inputs (null, arrays, empty keys, long keys, negative/float values)

https://claude.ai/code/session_01GdsbKoo8eax394j2UyWUu1

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-13 12:42:36 +01:00
Johannes Millan
43b5808a3f fix(sync): harden vector clock sanitization and improve E2E test robustness
- Cap sanitizeVectorClock values at 100M instead of MAX_SAFE_INTEGER (prevents adversarial clock inflation)
- Add early-exit optimization in compareVectorClocks for CONCURRENT detection
- Add test.setTimeout(180000) to token-expiry and lastseq-preservation E2E tests
- Remove dead code (unreachable guard) in compaction E2E test
- Extract shared config helpers (navigateToMiscSettings, toggleSetting, etc.) into e2e/utils/config-helpers.ts
- Fix Client B cleanup in provider-switch test (move to finally block)
- Add clarifying comments for raw SQL schema coupling, CORS header, and mergeRemoteOpClocks caller contract
- Extract enforceStorageQuota helper in sync routes to reduce duplication
- Add conflictType to conflict detection for structured error handling
- Add MAX_CLEANUP_ITERATIONS guard to freeStorageForUpload
2026-02-12 16:27:56 +01:00
Johannes Millan
5b7c9bdfb7 test(sync): add comprehensive SuperSync test coverage
Add 7 E2E tests (lastSeq preservation, token expiry recovery, cascade
delete, compaction resilience, global config edge cases, provider switch,
multi-tab), 2 server unit tests (replaceToken expiry regression,
middleware auth), 2 client integration tests (IndexedDB error recovery,
schema version sync), and locale-independent sort edge cases for vector
clocks.
2026-02-12 16:27:56 +01:00
Johannes Millan
c70ced204e fix(sync): fix stale comments, doc refs, and preserve lastSeq on clean-slate
Remove references to deleted docs/ai/ files, update stale comments about
protectedClientIds and pruning-aware comparison to reflect current REPLACE
semantics, fix MAX=30→20 heading, client_0..29→19 comment, use ?? over ||,
preserve lastSeq in server clean-slate path to prevent sequence reuse, and
add clarifying comments for mock limitations and validation guards.
2026-02-12 16:27:56 +01:00
Johannes Millan
ea91e02b1a test(sync): add regression tests and update stale doc reference
- Add deterministic tie-breaking test for vector clock pruning with
  equal counters (shared-schema)
- Remove last isLikelyPruningArtifact reference from entity versioning doc
2026-02-12 16:27:55 +01:00
Johannes Millan
3f99e3773c fix(sync): fix replaceToken expiry, locale-independent sort, and stale comments
- Use JWT_EXPIRY_PASSKEY (7d) for replaceToken instead of 365d magic link
  expiry — token replacement is a security action, shorter lifetime is safer
- Replace localeCompare with locale-independent comparator in vector clock
  tie-breaking to ensure deterministic behavior across environments
- Fix 5 additional stale MAX=30 references in docs and tests (now 20)
- Update authentication.md to reflect dual JWT expiry tiers
- Clean up isLikelyPruningArtifact references in docs and LEGACY_MAX in tests
2026-02-12 16:27:55 +01:00
Johannes Millan
0b73461690 fix(sync): fix vector clock comparison parity and validation consistency
- Fix client/server comparison divergence: remove isVectorClockEmpty
  short-circuit that caused {} vs {a:0} to return LESS_THAN on client
  but EQUAL on shared impl. Now delegates all comparisons to shared.
- Align server value cap with client: raise from 10M to MAX_SAFE_INTEGER
  to prevent silent clock entry stripping on long-term usage.
- Make client validation require integers (Number.isInteger) matching
  server-side validation.
- Add deterministic tie-breaking (localeCompare) to pruning sort for
  equal counter values.
- Fix stale comments: MAX references and CLAUDE.md DoS cap (5x→2.5x).
2026-02-12 16:27:55 +01:00
Johannes Millan
f37280e082 refactor(sync): reduce MAX_VECTOR_CLOCK_SIZE from 30 to 20
Lower the cap to leave headroom for future increases and surface
size-related edge cases earlier. All pruning logic is MAX-agnostic
so this is a safe constant change with documentation updates.
2026-02-12 16:27:55 +01:00
Johannes Millan
29541951a3 refactor(sync): increase MAX_VECTOR_CLOCK_SIZE from 10 to 30 and remove defense layers
At MAX=10, pruning triggered frequently enough (11+ unique client IDs from
reinstalls/new browsers) to require 4 defense layers compensating for
information loss: pruning-aware comparison, protected client IDs with
migration, isLikelyPruningArtifact heuristic, and same-client check.

At MAX=30, pruning almost never triggers (needs 31+ unique client IDs).
A 30-entry clock is ~500 bytes — negligible bandwidth. This allows removing
most defense layers while keeping two cheap backward-compat checks for old
10-entry pruned data still on servers.

Removed:
- Pruning-aware mode in compareVectorClocks (standard comparison now)
- Protected client IDs mechanism (storage, migration, preservation)
- selectProtectedClientIds function
- Clock normalization in SyncImportFilterService

Kept temporarily (backward compat with old 10-entry data):
- isLikelyPruningArtifact with LEGACY_MAX=10
- Same-client check (always mathematically correct)
2026-02-12 16:27:55 +01:00
Johannes Millan
9e11b326bd test(sync): add integration tests for vector clock pruning pipeline
Cover the full round-trip of limitVectorClockSize → compareVectorClocks →
isLikelyPruningArtifact with realistic MAX-entry clocks to guard against
regressions in the layered pruning heuristics.
2026-02-10 20:01:26 +01:00
Johannes Millan
8937163513 fix(sync): remove client-side vector clock pruning and fix pruning-aware comparison
Client-side pruning in OperationLogEffects dropped client IDs from
vector clocks (12→10 entries). Combined with the >= threshold in
compareVectorClocks' bothPossiblyPruned check, this caused false
CONCURRENT results and an infinite sync rejection loop.

- Remove limitVectorClockSize call from OperationLogEffects (server
  already prunes AFTER comparison but BEFORE storage per arch doc #13)
- Change bothPossiblyPruned from >= to === MAX_VECTOR_CLOCK_SIZE
  (a clock exceeding MAX was never pruned by limitVectorClockSize)
- Update test expectations and comments in both shared-schema and
  client-side specs to reflect the corrected behavior
2026-02-09 17:55:12 +01:00
Johannes Millan
2a6de0e41f fix(sync): remove dead code, add defensive checks, and clean up vector clock logic
- Remove redundant subset condition in compareVectorClocks
- Add clarifying comment for conservative return in hasVectorClockChanges
- Batch per-key verbose logs into single summary log
- Add defensive warning for negative startingSeq in file-based sync
- Extract magic timeout constant in E2E test
- Update stale doc date
2026-02-02 17:15:13 +01:00
Johannes Millan
192ec62d4a fix(sync): harden vector clock comparison and fix docs
- Replace fragile VectorClockComparison[result] enum lookup with safe cast
- Return CONCURRENT instead of EQUAL when only one side has non-shared
  keys in pruning-aware mode (safe direction: triggers LWW instead of
  silent skip)
- Fix docs claiming clocks "reset" at MAX_SAFE_INTEGER (they throw)
2026-01-30 20:05:48 +01:00
Johannes Millan
c5409bbd25 fix(sync): add server pruning log, harden test coverage for vector clocks
- Add Logger.warn() in ValidationService when oversized vector clocks
  are pruned server-side, improving observability for buggy clients
- Add tests for hasVectorClockChanges (previously zero coverage)
- Add symmetric LESS_THAN test for pruning-aware comparison mode
- Export MAX_LWW_REUPLOAD_RETRIES constant to eliminate magic number
  coupling between sync-wrapper service and its test
- Add test verifying uploading client ID is preserved during server-side
  clock pruning
- Add test for limitVectorClockSize when preserveClientIds exceeds MAX
2026-01-30 19:50:30 +01:00
Johannes Millan
495f0fe0d3 fix(sync): improve vector clock pruning awareness and harden tests
Make hasVectorClockChanges pruning-aware by checking clock size before
logging missing keys — downgrade to verbose when pruning is likely,
warn when corruption is likely. Add return value assertion to LWW retry
exhaustion test, asymmetric pruning test cases, server-side pruning
tradeoff documentation, and fix stale "max 50" in docs.
2026-01-30 19:35:51 +01:00
Johannes Millan
b113a2d7dd fix(sync): return CONCURRENT instead of EQUAL when pruned clocks have non-shared keys
When both vector clocks are at MAX size (pruning-aware mode) and shared
keys compare as equal, non-shared keys on both sides indicate genuinely
different causal histories. Returning EQUAL caused silent data loss by
skipping operations as duplicates. Returning CONCURRENT safely triggers
LWW conflict resolution instead.

Also fixes server snapshot pruning to preserve the requesting client's
ID and the snapshot author's client ID when limiting vector clock size.
2026-01-30 19:21:45 +01:00
Johannes Millan
002c37e054 fix(sync): guard empty intersection in vector clock comparison and fix LWW return value
Return CONCURRENT instead of EQUAL when two max-size clocks share no
keys (independent client populations). Fix misleading SyncStatus.InSync
return when LWW retry exhaustion leaves pending ops. Document known
limitation of size-based pruning heuristic. Add missing edge case tests.
2026-01-30 19:17:02 +01:00
Johannes Millan
fd3d06c5ff test(sync): add tests for vector clock pruning and LWW retry loop fix
Cover all 4 changes from cb36c09538: shared-schema vector clock tests
(22 Vitest), sync wrapper LWW retry loop limit tests (4 Jasmine),
client vector clock pruning-aware comparison tests (3 Jasmine), and
E2E tests for heavy LWW conflict, TAG:TODAY convergence, and
multi-client vector clock consistency (3 Playwright).
2026-01-30 19:04:44 +01:00
Johannes Millan
c268b1a9a9 fix(sync): harden vector clock pruning and post-review sync fixes
Address issues found by code review agents after the LWW loop fix
(cb36c09538):

- Enforce MAX_VECTOR_CLOCK_SIZE on server upload to prevent oversized
  clocks from buggy/adversarial clients
- Limit aggregated snapshotVectorClock in download service to prevent
  unbounded growth with many clients
- Set UNKNOWN_OR_CHANGED status when LWW retry cap is exhausted instead
  of misleadingly reporting IN_SYNC
- Wrap startPostSyncCooldown() in try-catch so endApplyingRemoteOps()
  always runs even on failure
- Cap limitVectorClockSize output at MAX even when preserved IDs exceed
  the limit
- Update stale doc comments referencing MAX_VECTOR_CLOCK_SIZE=8 to 10
2026-01-30 18:40:54 +01:00
Johannes Millan
cb36c09538 fix(sync): prevent infinite LWW auto-resolve loop for TAG:TODAY
Three root causes addressed:

1. Pruning asymmetry: Different clients preserve their own clientId
   during vector clock pruning, producing different clock shapes.
   When compared, pruned-away keys default to 0, causing false
   CONCURRENT verdicts and infinite rejection loops. Fix: when both
   clocks are at MAX_VECTOR_CLOCK_SIZE, compare only shared keys
   (intersection) instead of the union.

2. Timing gap: Between endApplyingRemoteOps() and
   startPostSyncCooldown(), isInSyncWindow() returns false, allowing
   selector-based effects to fire and create TAG:TODAY operations.
   Fix: start cooldown BEFORE ending remote ops flag.

3. No retry limit: LWW re-upload had no cap, enabling infinite loops.
   Fix: cap at 3 retries, defer remaining to next sync cycle.

Also moves MAX_VECTOR_CLOCK_SIZE and limitVectorClockSize into
@sp/shared-schema so client and server share the same pruning
constant and algorithm.
2026-01-30 18:24:29 +01:00
Johannes Millan
be4b8ba241 fix(migrations): ensure unique IDs and prevent data loss in split operations
When splitting one operation into misc and tasks operations:
- Both operations now get unique ID suffixes (_misc and _tasks)
- Skip logic verifies both conditions before skipping:
  1. tasks config has migrated field (isConfirmBeforeDelete)
  2. misc config has NO migrated fields remaining

This prevents:
- Potential operation ID conflicts in the sync log
- Data loss in partial migration scenarios

Adds test cases for partial migration and correct skip scenarios.
2026-01-20 17:07:23 +01:00
Ivan Kalashnikov
1987292433 refactor: streamline migration logic and enhance field mapping for settings 2026-01-19 14:38:42 +07:00
Ivan Kalashnikov
800ba30f4f refactor: rename misc to tasks settings migration file and object. 2026-01-19 14:30:42 +07:00
Ivan Kalashnikov
356278fc87 feat: enhance migration tests for settings and operations handling 2026-01-19 14:21:09 +07:00
Ivan Kalashnikov
218e74f882 feat: implement migration of settings from misc to tasks with operation handling 2026-01-19 14:19:31 +07:00
Ivan Kalashnikov
263495b8cd feat: update migration functions to support splitting operations into multiple results 2026-01-19 13:58:27 +07:00
Ivan Kalashnikov
b3da4e4850 fix: update CURRENT_SCHEMA_VERSION to 2 for upcoming migration 2026-01-19 13:16:48 +07:00
Ivan Kalashnikov
5002bae1c0 fix: add todo comment to bump CURRENT_SCHEMA_VERSION for upcoming migration 2026-01-19 01:09:00 +07:00
Ivan Kalashnikov
325e24f461 fix: revert CURRENT_SCHEMA_VERSION to 1 2026-01-19 00:03:33 +07:00
Ivan Kalashnikov
6705033d15 feat: implement migration to move settings from MiscConfig to TasksConfig 2026-01-18 23:31:06 +07:00
Ivan Kalashnikov
2473b9698d fix: update migration test to correctly structure migrated state with globalConfig 2026-01-18 23:30:57 +07:00
Ivan Kalashnikov
d6506e95d1 refactor: rename test spec migration in format 'v1 to v2' 2026-01-18 23:18:42 +07:00
Ivan Kalashnikov
651d5dc183 feat: add migration to move settings from MiscConfig to TasksConfig as separate file 2026-01-18 23:08:30 +07:00
Ivan Kalashnikov
f2940fd7ae fix: remove outdated todo comment regarding schema version synchronization 2026-01-18 23:07:12 +07:00
Ivan Kalashnikov
0d6d17c103 fix: correct task confirmation field name in migration test 2026-01-18 23:06:37 +07:00
Ivan Kalashnikov
5f4e1cf24e fix: correct task migration field names and add markdown formatting flag 2026-01-18 21:54:24 +07:00