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:
@@ -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);
|
||||
|
||||
@@ -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) ?? {};
|
||||
|
||||
Reference in New Issue
Block a user