Calendar: never mutate an occurrence by an id we are holding

Verified against the live 0.16.20 instance, which found two things the
mock had guessed wrong about.

A synthetic id encodes a position in the expanded series, and writing a
`recurrenceOverrides` entry renumbers it. A five-week series came back as
`e i m q u` over 03-01..03-29; after one override was written to 03-08
the same five ids addressed 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing
was rejected. A stale id is not invalid, it is wrong - a confident answer
about the wrong day - so a delete meant for one occurrence removes
another.

`recurrenceId` is the stable name for a slot in a series, because it is
the date. `updateEvent` and `destroyEvent` now look the current id up by
it immediately before acting, and refuse outright when the date has left
the series rather than falling back to the id in hand.

The mock had this exactly backwards: it kept ids stable on purpose, which
agreed with the belief that is wrong. It now renumbers too - a different
permutation to Stalwart's, with the property that matters - and a test
holds an id across a write and watches it change meaning.

Second finding: the inherited properties are dropped *after* the server
has decided to write an override, so a patch made only of them still
writes one, carrying the server-filled start and duration and nothing
else. `{"privacy":"private"}` on one occurrence answered "updated", left
privacy untouched, and left that date with no title at all. Sending
nothing when narrowing empties a patch was written as a principle - a
request whose response could only be a meaningless "updated" is worse
than no request - and it turns out to prevent real data loss.

Both recorded in KNOWN-ISSUES with the dates they were confirmed on.
This commit is contained in:
2026-08-30 21:39:30 -07:00
parent 5ced44ec13
commit 91481965bc
6 changed files with 166 additions and 33 deletions
+3 -3
View File
@@ -5,7 +5,7 @@
*/
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
@@ -414,7 +414,7 @@ function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence }
if (!parsed) return null;
const base = list.find((x) => x.id === parsed.baseId);
if (!base) return null;
const occ = occurrenceAt(base, parsed.index);
const occ = occurrenceAt(base, parsed.slot);
return occ ? { base, occ } : null;
}
@@ -929,7 +929,7 @@ const handlers: Record<string, Handler> = {
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
const ids: string[] = [];
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.index));
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, slotOfOccurrence(e, occ)));
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
},
"CalendarEvent/get": (a) => {
+43 -8
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
/**
* The mock expands recurrences so that per-occurrence editing can be developed
@@ -44,18 +44,17 @@ describe("expandOccurrences", () => {
assert.equal(expandOccurrences(ev, a, b).length, 3);
});
it("skips an excluded date but does not renumber the ones after it", () => {
// The whole reason an index rather than a position is the id: deleting
// Tuesday must not turn Wednesday's id into Tuesday's.
it("drops an excluded date from the expansion, keeping the series positions", () => {
const ev = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { excluded: true } } };
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
const out = expandOccurrences(ev, a, b);
assert.deepEqual(out.map((o) => o.start), [
"2026-09-07T09:00:00", "2026-09-09T09:00:00", "2026-09-10T09:00:00", "2026-09-11T09:00:00",
]);
// Wednesday is still index 2, as it was before Tuesday went.
// The position within the series is unchanged — Wednesday is still the
// third date the rule produces, whatever happened to Tuesday. It is the
// *id* built on top of that which moves, and only after a write.
assert.equal(out[1]!.index, 2);
assert.equal(occurrenceAt(ev, 2)!.start, "2026-09-09T09:00:00");
});
it("carries an override onto the occurrence it keys", () => {
@@ -91,14 +90,17 @@ describe("occurrenceView", () => {
it("lets an override win over the series", () => {
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
const view = occurrenceView(base, occurrenceAt(base, 1)!);
// Slot 2, not 1: one override has already shifted the numbering. Reaching
// for the id this occurrence had *before* the write is the bug below.
const view = occurrenceView(base, occurrenceAt(base, 2)!);
assert.equal(view.start, "2026-09-08T09:00:00");
assert.equal(view.title, "Moved");
});
});
describe("parseSyntheticId", () => {
it("round-trips", () => {
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", index: 12 });
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
});
it("does not claim a stored id", () => {
assert.equal(parseSyntheticId("ev1"), null);
@@ -131,3 +133,36 @@ describe("splitOccurrencePatch", () => {
assert.deepEqual(splitOccurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).applied, {});
});
});
describe("synthetic ids are only true until the next write", () => {
/*
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
* entry renumbered a five-week series so that the *same* ids addressed
* different dates. Nothing was rejected. The mock reproduces the shape of
* that rather than the exact permutation, because the property that bites is
* not which date an id moves to but that it moves at all, silently.
*/
it("makes a cached id address a different date after an override is written", () => {
const before = series();
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
assert.notEqual(dateAfter, dateBefore);
// And crucially it still resolves — a stale id is wrong, not invalid, so a
// client that trusts it gets a confident answer about the wrong day.
assert.ok(dateAfter);
});
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
const before = series();
const occ = occurrenceAt(before, 3)!;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
.find((o) => o.recurrenceId === occ.recurrenceId);
assert.equal(same!.start, occ.start);
});
});
+37 -12
View File
@@ -25,18 +25,41 @@ const MAX_ITERATIONS = 750;
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
/**
* The id an occurrence is addressed by.
* The id an occurrence is addressed by, which is only true until the next write.
*
* Stalwart's are opaque; the mock's are parseable because it has to resolve
* them, and nothing in ihasmail may read either. The index counts from the
* start of the series and survives an excluded date, so an id keeps meaning the
* same occurrence after one of its neighbours is deleted.
* them, and nothing in ihasmail may read either.
*
* They are also deliberately **unstable**, because the real ones are.
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
* component that renumbers it. A five-week series held `e i m q u` over
* 03-01…03-29; after one override was written to 03-08 the same ids addressed
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
* different dates.
*
* That is the hazard worth reproducing, and note which way round it goes: a
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
* would hand back a loud `notFound` and let a client that caches ids look
* careful. So the numbering is shifted by the number of overrides — an
* arbitrary stand-in for Stalwart's renumbering, with the one property that
* matters: hold an id across a write and it silently addresses another date.
*/
export const syntheticId = (baseId: string, index: number): string => `${baseId}-o${index}`;
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
export function parseSyntheticId(id: string): { baseId: string; index: number } | null {
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
const m = /^(.+)-o(\d+)$/.exec(id);
return m ? { baseId: m[1]!, index: Number(m[2]) } : null;
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
}
/** How far the id numbering has been rotated away from the series order. */
function rotation(base: Obj): number {
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
}
/** The id slot this occurrence currently answers to. */
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
return occ.index + rotation(base);
}
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
@@ -81,8 +104,8 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
const emit = (index: number, at: Date): boolean => {
const recurrenceId = localDateTime(at);
const override = overrides[recurrenceId];
// An excluded date still consumes its index: ids have to stay stable when a
// neighbour is deleted, or every occurrence after it silently renumbers.
// An excluded date is simply gone from the expansion. Its slot is not
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
if (override?.excluded === true) return true;
if (at >= from && at < to) {
out.push({ index, recurrenceId, start: recurrenceId, ...(override ? { override } : {}) });
@@ -141,7 +164,7 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj {
const view: Obj = { ...base };
for (const k of SERIES_ONLY) delete view[k];
Object.assign(view, occ.override ?? {});
view.id = syntheticId(base.id as string, occ.index);
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
view.baseEventId = base.id;
view.start = occ.start;
// Only a genuine instance of a series carries one. A one-off expanded into
@@ -192,8 +215,10 @@ export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied:
return { applied };
}
/** One occurrence by its index, wherever in the series it falls. */
export function occurrenceAt(base: Obj, index: number): Occurrence | null {
/** The occurrence a slot currently addresses — which is not a fixed thing. */
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
const index = slot - rotation(base);
if (index < 0) return null;
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
return all.find((o) => o.index === index) ?? null;
}