Sync contacts by what changed, and hold fewer calendar windows

A pushed contact change, and every edit or import made here, reloaded the
whole address book. The store now keeps the state its cards were read at
and asks ContactCard/changes what changed since, fetching only those cards,
split to maxObjectsInGet. A server that cannot say falls back to the full
load.

The calendar held every week or month the reader had visited, queried each
of them again on any event change, and walked them all on every render. It
now holds the four most recently shown; a change reloads those in place,
without emptying the view first, and a window dropped is loaded again when
it is next shown. Shared calendars' events are fetched from every account
at once, and instancesIn builds the added-shares set once.

The mock keeps a ContactCard change log, answers ContactCard/changes, and
announces a ContactCard/set, as Stalwart does.
This commit is contained in:
2026-09-16 10:41:21 -07:00
parent 6090442058
commit 360420402d
5 changed files with 295 additions and 22 deletions
+12
View File
@@ -18,6 +18,18 @@ export function recordEmailChange(change: { created?: string[]; updated?: string
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
}
/** The same for contact cards, so `ContactCard/changes` can answer too. */
export const cardChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
/** Changes at or below this state have been dropped from the log, so a client that far behind cannot be answered. */
export const cardLog = { floor: 0 };
export function recordCardChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
cardChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
if (cardChanges.length > 200) {
const dropped = cardChanges.splice(0, cardChanges.length - 200);
cardLog.floor = dropped[dropped.length - 1]!.state;
}
}
export function broadcast(types: string[]) {
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
for (const c of sseClients) c.write(payload);
+24 -2
View File
@@ -1,5 +1,5 @@
import { checkOtp } from "./auth.js";
import { emailChanges, recordEmailChange, broadcast } from "./events.js";
import { cardChanges, cardLog, emailChanges, recordCardChange, recordEmailChange, broadcast } from "./events.js";
import { randomUUID } from "node:crypto";
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
@@ -428,7 +428,29 @@ export const handlers: Record<string, Handler> = {
// An empty `properties` list returns `id` alone, which `pick` already does.
// 0.16.22 made Stalwart agree; through 0.16.21 it returned every property.
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
"ContactCard/set": genericSet(cards, "cc"),
/*
* Recorded and announced like Email/set, so the client's incremental sync
* (`ContactCard/changes`, then fetching what it names) runs here too. A
* state older than the log's window cannot be answered, as on a real server.
*/
"ContactCard/set": (a) => {
const r = genericSet(cards, "cc")(a);
nextState();
recordCardChange({
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
updated: Object.keys((r.updated ?? {}) as Obj),
destroyed: (r.destroyed as string[] | undefined) ?? [],
});
broadcast(["ContactCard"]);
return r;
},
"ContactCard/changes": (a) => {
const since = Number(a.sinceState ?? 0);
if (since < cardLog.floor) throw new MethodError("cannotCalculateChanges", "That state is too old to answer from.");
const relevant = cardChanges.filter((c) => c.state > since);
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
return { accountId: a.accountId ?? ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
},
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"FileNode/query": (a) => {
const f = (a.filter as Obj) ?? {};