Merge pull request #293 from Coffey-Labs/mock-0-16-21
Follow Stalwart 0.16.21 in the mock
This commit is contained in:
+88
-18
@@ -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<string, Handler> = {
|
||||
"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<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, 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<string, Handler> = {
|
||||
}
|
||||
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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<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);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user