Sync contacts by what changed, and hold fewer calendar windows #384

Closed
opened 2026-09-16 17:41:39 +00:00 by jcoffey-dev · 0 comments
Owner

Summary

Contacts: fetch what changed, not everything.

  • Before, every ContactCard push, and every edit, import or book deletion made in the app, ran loadAll(): up to 50 pages of 500 cards with all their properties.
  • loadAll() now records the ContactCard/get state from its first page, as cardState.
  • The new syncCards() works in three steps:
    1. It pages ContactCard/changes from that state. An id updated on one page and destroyed on a later page ends up destroyed, not fetched.
    2. It fetches only the created and updated cards, split to maxObjectsInGet.
    3. It removes destroyed cards and any notFound ids, and advances cardState.
  • Fallback: if there's no state yet, or ContactCard/changes fails for any reason, including cannotCalculateChanges, it does the full loadAll() as before.
  • Callers: applyChanges and the four write paths call syncCards().

Calendar: hold fewer windows.

  • Before, ranges grew with every week or month visited. Any CalendarEvent change emptied ranges and re-queried every one of them, and instancesIn walked all of them on every render.
  • Four windows at most. keepRecent() keeps the four most recently shown (RANGES_KEPT), and a revisit moves a held window to the back of the queue. sharedRanges is trimmed to match. A dropped window is simply loaded again when CalendarView next asks for it.
  • Refresh in place. invalidate() reloads the held windows without clearing them first, so the calendar no longer goes blank while the answers are coming back.
  • Shared accounts in parallel. loadSharedRange() queries every shared account at once instead of one after another, and doesn't store its result for a window that was dropped in the meantime.
  • Smaller fix. instancesIn built new Set(settings().addedShares) once per shared event; it now builds it once per call.

Mock server. Its ContactCard/set now records a change log, bumps the state and pushes ContactCard, as Stalwart does. ContactCard/changes answers from that log, and returns cannotCalculateChanges for a state older than what the log still holds.

Not verified against a real server: that Stalwart implements ContactCard/changes. If it doesn't, each sync falls back to the full load it replaces, at the cost of one failed call.

Related issues

None.

Translations

Adds none.

Testing

  • New contacts-calendar-sync.test.ts, 7 tests: all fail against main. They cover:
    • only changed cards fetched, in batches of the server's limit;
    • an update followed by a destroy across pages;
    • the full-load fallback on cannotCalculateChanges;
    • loadAll recording the state;
    • keepRecent keeping the most recent windows;
    • a change re-querying only the held windows after 10 were visited;
    • invalidate not emptying the view.
  • npm test -w web (1,331 tests), npm test -w server (242 tests) and npm run typecheck are clean.
  • Checked in Chrome against npm run dev:mock:
    • Contacts: with the Contacts view loaded, renaming a card from a second session made the tab send exactly ContactCard/changes and ContactCard/get[cc0]. The new name appeared on the page.
    • Calendar: after browsing 8 weeks ahead and back to today, 4 windows were held, including the current week. A CalendarEvent change sent 8 queries: the 4 held windows and the shared calendar's copy of each. The events stayed on screen throughout.

Merged 2026-09-16 as coffey-labs/ihasmail@da87925b9c

Rebuilt from: git history, session transcript.

## Summary **Contacts: fetch what changed, not everything.** - Before, every `ContactCard` push, and every edit, import or book deletion made in the app, ran `loadAll()`: up to 50 pages of 500 cards with all their properties. - `loadAll()` now records the `ContactCard/get` state from its first page, as `cardState`. - The new `syncCards()` works in three steps: 1. It pages `ContactCard/changes` from that state. An id updated on one page and destroyed on a later page ends up destroyed, not fetched. 2. It fetches only the created and updated cards, split to `maxObjectsInGet`. 3. It removes destroyed cards and any `notFound` ids, and advances `cardState`. - **Fallback:** if there's no state yet, or `ContactCard/changes` fails for any reason, including `cannotCalculateChanges`, it does the full `loadAll()` as before. - **Callers:** `applyChanges` and the four write paths call `syncCards()`. **Calendar: hold fewer windows.** - Before, `ranges` grew with every week or month visited. Any `CalendarEvent` change emptied `ranges` and re-queried every one of them, and `instancesIn` walked all of them on every render. - **Four windows at most.** `keepRecent()` keeps the four most recently shown (`RANGES_KEPT`), and a revisit moves a held window to the back of the queue. `sharedRanges` is trimmed to match. A dropped window is simply loaded again when `CalendarView` next asks for it. - **Refresh in place.** `invalidate()` reloads the held windows without clearing them first, so the calendar no longer goes blank while the answers are coming back. - **Shared accounts in parallel.** `loadSharedRange()` queries every shared account at once instead of one after another, and doesn't store its result for a window that was dropped in the meantime. - **Smaller fix.** `instancesIn` built `new Set(settings().addedShares)` once per shared event; it now builds it once per call. **Mock server.** Its `ContactCard/set` now records a change log, bumps the state and pushes `ContactCard`, as Stalwart does. `ContactCard/changes` answers from that log, and returns `cannotCalculateChanges` for a state older than what the log still holds. **Not verified against a real server:** that Stalwart implements `ContactCard/changes`. If it doesn't, each sync falls back to the full load it replaces, at the cost of one failed call. ## Related issues None. ## Translations Adds none. ## Testing - New `contacts-calendar-sync.test.ts`, 7 tests: **all fail against `main`**. They cover: - only changed cards fetched, in batches of the server's limit; - an update followed by a destroy across pages; - the full-load fallback on `cannotCalculateChanges`; - `loadAll` recording the state; - `keepRecent` keeping the most recent windows; - a change re-querying only the held windows after 10 were visited; - `invalidate` not emptying the view. - `npm test -w web` (1,331 tests), `npm test -w server` (242 tests) and `npm run typecheck` are clean. - Checked in Chrome against `npm run dev:mock`: - **Contacts:** with the Contacts view loaded, renaming a card from a second session made the tab send exactly `ContactCard/changes` and `ContactCard/get[cc0]`. The new name appeared on the page. - **Calendar:** after browsing 8 weeks ahead and back to today, 4 windows were held, including the current week. A `CalendarEvent` change sent 8 queries: the 4 held windows and the shared calendar's copy of each. The events stayed on screen throughout. **Merged** 2026-09-16 as coffey-labs/ihasmail@da87925b9c8d <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.