diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 3c48620..48017e7 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -6,7 +6,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomUUID } from "node:crypto"; import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js"; -import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js"; +import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js"; import { parseOtpauthUrl, verifyTotp } from "../totp.js"; import { holdUntilOf, undoStatusOf } from "./futurerelease.js"; @@ -358,6 +358,24 @@ function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number { /** A server that does not implement sorting on keywords, so the fallback can be developed against. */ const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1"; +/** The floor Stalwart puts under a requested EventSource ping interval. */ +const PING_FLOOR_SECONDS = 30; + +/* + * An account that may not send calendar invitations. + * + * 0.16.21 rejects a `CalendarEvent/set` that asks for scheduling messages when + * the account lacks the `calendarSchedulingSend` permission, rather than + * accepting the write and quietly sending nothing. **Confirmed live on 0.16.21 + * (2026-09-06)** against an account holding a role with that permission + * disabled: `sendSchedulingMessages: true` came back `notCreated` with + * `forbidden` and the text below, while the identical request with the flag + * false was created normally. Set MOCK_NO_SCHEDULING_SEND=1 to develop against + * that account. + */ +const NO_SCHEDULING_SEND = process.env.MOCK_NO_SCHEDULING_SEND === "1"; +const SCHEDULING_FORBIDDEN = "This account is not allowed to send calendar scheduling messages."; + const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks); /** One per contact, by index; a gap means that card has no birthday. */ const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [ @@ -542,12 +560,19 @@ function enforceLimits(name: string, args: Obj): void { const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra }); /* - * Stalwart does not return `shareWith` unless a client asks for it by name: a - * `/get` with no `properties` comes back without the field at all. Confirmed on - * 0.16.19 (2026-08-27) against a calendar and an address book that really were - * shared. The mock handing it over unasked meant a client that never asked - * still saw every share, and the one place that did not -- the real server -- - * showed nothing shared at all. + * `Mailbox/get` does not return `shareWith` unless a client asks for it by + * name: a `/get` with no `properties` comes back without the field at all. + * Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared. + * The mock handing it over unasked meant a client that never asked still saw + * every share, and the one place that did not -- the real server -- showed + * nothing shared at all. + * + * Calendars and address books used to behave the same way and no longer do. + * 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property + * when `properties` is omitted or null, `shareWith` included. **Confirmed live + * on 0.16.21 (2026-09-06):** both come back with the full set, while + * `Mailbox/get` on the same server still omits it — so this stays, and it + * stays applied to mailboxes alone. */ function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } { if (a.properties) return res; @@ -564,9 +589,9 @@ function genericGet(list: Obj[]) { /** * An id, as either a stored event or one occurrence of one. * - * A synthetic id whose base is gone, or whose index falls outside the series - * (deleted, or past a `count`), resolves to nothing — `notFound`, the way the - * server answers for an occurrence that is not there any more. + * A synthetic id whose base is gone, or whose date the rule no longer + * generates (excluded, or past a `count`), resolves to nothing — `notFound`, + * the way the server answers for an occurrence that is not there any more. */ function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null { const direct = list.find((x) => x.id === id); @@ -575,7 +600,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.slot); + const occ = occurrenceAt(base, parsed.recurrenceId); return occ ? { base, occ } : null; } @@ -697,6 +722,27 @@ function calendarEventSet(a: Obj) { const notUpdated: Obj = {}; const notDestroyed: Obj = {}; + /* + * An account that may not send invitations refuses the whole request the + * moment it asks for them, and refuses it per object rather than as a method + * error. Confirmed live on 0.16.21 for all three of create, update and + * destroy; the same requests with the flag absent or false went through. + * The flag alone decides it — the server does not first check whether the + * event has anyone to notify. + */ + if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) { + const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON(); + for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied(); + for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied(); + for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied(); + return setResp({ + created, updated, destroyed, + ...(Object.keys(notCreated).length ? { notCreated } : {}), + ...(Object.keys(notUpdated).length ? { notUpdated } : {}), + ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}), + }); + } + for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` }; // Stalwart 0.16 rejects the RFC 8984 array outright and silently discards @@ -1154,7 +1200,7 @@ const handlers: Record = { "SieveScript/get": genericGet(sieveScripts), "SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; }, "SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }), - "Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never, + "Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a), "Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a), /* * With `expandRecurrences` every id that comes back is synthetic — a one-off @@ -1173,7 +1219,7 @@ const handlers: Record = { 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, slotOfOccurrence(e, occ))); + for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId)); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length }; }, "CalendarEvent/get": (a) => { @@ -1211,7 +1257,7 @@ const handlers: Record = { } return { accountId: ACCOUNT, list }; }, - "AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never, + "AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a), "AddressBook/set": (a) => { /* Stalwart refuses any update to a book shared read-only, `isSubscribed` included -- "You are not allowed to modify this address book", confirmed @@ -1393,13 +1439,37 @@ export const server = createServer(async (req, res) => { res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length }); return res.end(b.data); } + /* + * The `ping` query parameter, and what comes back for it. + * + * **Confirmed live on 0.16.21 (2026-09-06):** the interval is in **seconds** + * — `data: {"interval": 30}` — where up to 0.16.20 the same field carried + * milliseconds. The server floors it at 30 s (asking for 1, 2 or 5 all + * answered 30 and pinged every 30 s) and honours anything above (45 pinged + * at 45 s and said 45, 60 at 60 and said 60). `ping=0` disables pings + * altogether; a value that is not a number at all — `abc`, or empty — is a + * 400 before the stream opens. + * + * The first ping arrives one whole interval in, not on connect, so nothing + * is written here: `flushHeaders` opens the stream on its own. A mock that + * pinged immediately would let a client treat the first ping as an + * connection-established signal and hang forever against the real thing. + */ if (url.pathname.startsWith("/jmap/eventsource")) { + const raw = url.searchParams.get("ping"); + const asked = Number(raw); + if (raw === null || raw === "" || !Number.isInteger(asked) || asked < 0) { + res.writeHead(400, { "content-type": "application/json" }); + return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:notRequest", status: 400 })); + } res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); - res.write(`event: ping\ndata: {}\n\n`); + res.flushHeaders(); sseClients.add(res); - const t = setInterval(() => res.write(`event: ping\ndata: {}\n\n`), 25000); - req.on("close", () => { clearInterval(t); sseClients.delete(res); }); - // Simulate a new message every 90s + const interval = asked === 0 ? 0 : Math.max(asked, PING_FLOOR_SECONDS); + const t = interval + ? setInterval(() => res.write(`event: ping\ndata: {"interval": ${interval}}\n\n`), interval * 1000) + : null; + req.on("close", () => { if (t) clearInterval(t); sseClients.delete(res); }); return; } res.writeHead(404, { "content-type": "application/json" }); diff --git a/server/src/mock/recurrence.test.ts b/server/src/mock/recurrence.test.ts index 70f4198..472a944 100644 --- a/server/src/mock/recurrence.test.ts +++ b/server/src/mock/recurrence.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js"; +import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js"; /** * The mock expands recurrences so that per-occurrence editing can be developed @@ -68,9 +68,9 @@ describe("expandOccurrences", () => { describe("occurrenceView", () => { it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => { const base = series(); - const occ = occurrenceAt(base, 1)!; + const occ = occurrenceAt(base, "2026-09-08T09:00:00")!; const view = occurrenceView(base, occ); - assert.equal(view.id, syntheticId("ev1", 1)); + assert.equal(view.id, syntheticId("ev1", "2026-09-08T09:00:00")); assert.equal(view.baseEventId, "ev1"); assert.equal(view.recurrenceId, "2026-09-08T09:00:00"); assert.equal(view.recurrenceRule, undefined); @@ -81,8 +81,8 @@ describe("occurrenceView", () => { // Both halves matter. The id is why `baseEventId` proves nothing about a // series; the absent `recurrenceId` is why a one-off does not read as one. const base = oneOff(); - const view = occurrenceView(base, occurrenceAt(base, 0)!); - assert.equal(view.id, "ev2-o0"); + const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!); + assert.equal(view.id, "ev2-r20260908T120000"); assert.equal(view.baseEventId, "ev2"); assert.notEqual(view.id, view.baseEventId); assert.equal(view.recurrenceId, undefined); @@ -90,9 +90,9 @@ describe("occurrenceView", () => { it("lets an override win over the series", () => { const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } }; - // 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)!); + // The same recurrence id as before the override was written, because that + // is now the whole point: the write does not move any other occurrence. + const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!); assert.equal(view.start, "2026-09-08T09:00:00"); assert.equal(view.title, "Moved"); }); @@ -100,11 +100,15 @@ describe("occurrenceView", () => { describe("parseSyntheticId", () => { it("round-trips", () => { - assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 }); + assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")), + { baseId: "ev1", recurrenceId: "2026-09-08T09:00:00" }); }); it("does not claim a stored id", () => { assert.equal(parseSyntheticId("ev1"), null); }); + it("does not claim an id that merely ends in digits", () => { + assert.equal(parseSyntheticId("ev1-r2026"), null); + }); }); describe("splitOccurrencePatch", () => { @@ -135,35 +139,47 @@ describe("splitOccurrencePatch", () => { }); -describe("synthetic ids are only true until the next write", () => { +describe("synthetic ids survive a 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. + * This used to assert the opposite, and the reversal is the point. + * + * Up to 0.16.20 a synthetic id encoded a position, so writing one override + * renumbered the series and a held id silently began naming a different + * date — confirmed live on 2026-08-31, and reproduced here on purpose so a + * client could not be written against a comfort the server did not offer. + * + * 0.16.21 identifies an occurrence by its recurrence id instead. + * **Confirmed live on 0.16.21 (2026-09-06):** a five-week series was + * expanded, its third occurrence retitled through the synthetic id, and all + * five original ids re-read. Every one resolved, and every one still named + * its own date. So the hazard is gone, and the mock stops teaching it. */ - it("makes a cached id address a different date after an override is written", () => { + it("keeps a cached id on the same 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 held = syntheticId("ev1", occurrenceAt(before, "2026-09-10T09:00:00")!.recurrenceId); + const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.recurrenceId)!.start; const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } }; - const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start; + const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.recurrenceId)!.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); + assert.equal(dateAfter, dateBefore); }); - it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => { + it("resolves every id of a series after one of them is overridden", () => { 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); + const held = expandOccurrences(before, new Date("2026-09-07T00:00:00"), new Date("2026-09-12T00:00:00")) + .map((o) => syntheticId("ev1", o.recurrenceId)); + const after = { ...before, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "changed" } } }; + for (const id of held) { + const occ = occurrenceAt(after, parseSyntheticId(id)!.recurrenceId); + assert.ok(occ, `${id} should still resolve`); + assert.equal(syntheticId("ev1", occ.recurrenceId), id); + } + }); + + it("still refuses an id whose date the rule no longer generates", () => { + const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } }; + assert.equal(occurrenceAt(base, "2026-09-09T09:00:00"), null); }); }); diff --git a/server/src/mock/recurrence.ts b/server/src/mock/recurrence.ts index 160b1df..c66d6e8 100644 --- a/server/src/mock/recurrence.ts +++ b/server/src/mock/recurrence.ts @@ -1,5 +1,5 @@ /** - * Enough recurrence expansion for the mock to behave like Stalwart 0.16.20. + * Enough recurrence expansion for the mock to behave like Stalwart 0.16.21. * * The mock used to hand a recurring event back once, as its stored self. Three * things that only a live server showed were therefore impossible to develop @@ -8,8 +8,8 @@ * - an expanded query gives *everything* a synthetic id over a `baseEventId`, * a one-off included, so `baseEventId` is no evidence of a series; * - an occurrence carries a `recurrenceId` and no rule of its own; - * - 0.16.20 takes a write aimed at a synthetic id and turns it into a - * `recurrenceOverrides` entry rather than touching the series. + * - a write aimed at a synthetic id becomes a `recurrenceOverrides` entry + * rather than touching the series. * * A mock that agrees with the client rather than with the server is how #26 and * #30 reached a live instance, so the refusals matter as much as the successes: @@ -25,41 +25,41 @@ const MAX_ITERATIONS = 750; const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"]; /** - * The id an occurrence is addressed by, which is only true until the next write. + * The id an occurrence is addressed by: its `recurrenceId`, not its position. * * Stalwart's are opaque; the mock's are parseable because it has to resolve * 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. + * **They are stable, and that is a change.** Up to 0.16.20 a synthetic id + * encoded a *position* in the expanded series, so writing one override + * renumbered the rest and a held id silently began addressing a different + * date — a hazard this file used to reproduce on purpose. 0.16.21 fixed it: + * an occurrence is now identified by its recurrence id. * - * 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. + * **Confirmed live on 0.16.21 (2026-09-06):** a five-week weekly series was + * expanded, the third occurrence retitled through its synthetic id, and all + * five original ids re-read afterwards. Every one still resolved, and every + * one still named its own date; nothing was renumbered and nothing was + * `notFound`. Only the *order* of the ids from an expanded query changed — + * the overridden occurrence moved to the end of the list — which is why a + * client sorts by `start` rather than trusting query order. + * + * The real ids look nothing like these (`h1fo9uaaaaab` for the first of that + * series); what has to match is that holding one across a write stays correct. */ -export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`; +const compact = (recurrenceId: string): string => recurrenceId.replace(/[-:]/g, ""); -export function parseSyntheticId(id: string): { baseId: string; slot: number } | null { - const m = /^(.+)-o(\d+)$/.exec(id); - return m ? { baseId: m[1]!, slot: Number(m[2]) } : null; -} +export const syntheticId = (baseId: string, recurrenceId: string): string => + `${baseId}-r${compact(recurrenceId)}`; -/** 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 | undefined) ?? {}).length; -} - -/** The id slot this occurrence currently answers to. */ -export function slotOfOccurrence(base: Obj, occ: Occurrence): number { - return occ.index + rotation(base); +export function parseSyntheticId(id: string): { baseId: string; recurrenceId: string } | null { + const m = /^(.+)-r(\d{8}T\d{6})$/.exec(id); + if (!m) return null; + const c = m[2]!; + const recurrenceId = + `${c.slice(0, 4)}-${c.slice(4, 6)}-${c.slice(6, 8)}` + + `T${c.slice(9, 11)}:${c.slice(11, 13)}:${c.slice(13, 15)}`; + return { baseId: m[1]!, recurrenceId }; } /** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */ @@ -104,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 is simply gone from the expansion. Its slot is not - // reserved -- see `syntheticId` for why nothing here pretends otherwise. + // An excluded date is simply gone from the expansion. Nothing is + // reserved in its place, and no other occurrence's id moves because of it. if (override?.excluded === true) return true; /* * An override may move the occurrence, and then `start` and `recurrenceId` @@ -115,9 +115,9 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[] * came back `start: 2027-06-14T14:00:00` with `recurrenceId` still * `2027-06-14T09:00:00`. * - * Which is exactly why `recurrenceId` is what a client holds on to. It is - * the one name for this instance that neither a renumbering nor a move - * changes. + * Which is exactly why `recurrenceId` is what a client holds on to, and + * since 0.16.21 what the id is built from: the one name for this instance + * that a move does not change. */ const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId; const shown = parseLocal(start); @@ -178,7 +178,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, slotOfOccurrence(base, occ)); + view.id = syntheticId(base.id as string, occ.recurrenceId); view.baseEventId = base.id; view.start = occ.start; // Only a genuine instance of a series carries one. A one-off expanded into @@ -229,10 +229,13 @@ export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied: return { applied }; } -/** 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; +/** + * The occurrence a recurrence id addresses, which no later write moves. + * + * An id whose date the rule no longer generates — excluded, or past a `count` + * — resolves to nothing, and the caller turns that into `notFound`. + */ +export function occurrenceAt(base: Obj, recurrenceId: string): Occurrence | null { const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000)); - return all.find((o) => o.index === index) ?? null; + return all.find((o) => o.recurrenceId === recurrenceId) ?? null; }