perf(cycle): scope the reminder cron to the in-window cohort and harden the period write

Push the cycle-reminder cron's gating into the query instead of loading
every CyclePrediction row each tick: filter on the user's
cycleTrackingEnabled / predictionEnabled relation and restrict to rows
whose nextPeriodStart falls inside a tz-generous [today-grace, today+lead]
string window. The per-tick cost now scales with the in-window cohort, not
the whole table; the exact per-user-timezone window still runs in JS.

Make the period-boundary shortcut transactional and idempotent: wrap the
close-prior + open-new (or stamp-current) cycle mutations in a single
$transaction so two concurrent taps cannot double-close a prior cycle or
compute a length against a stale read, and add withIdempotency so an exact
key replay is a no-op. The boundary day-log now ranks the incoming flow
against any existing same-day entry and never downgrades a richer flow.

Decode sync cursors additively: a live beta v1 token keeps its
measurement / mood / intake watermarks and simply fresh-scans the two
domains added in v2 (cycleDays, cycles) rather than forcing a full
re-init. Correct the stale "no live consumer" comment.

Thread the user's timezone through the Coach cycle snapshot so "today",
day-of-cycle and days-until-period derive from the user's zone — matching
the calendar and insights routes — instead of wall-clock Berlin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 12:17:07 +02:00
1 parent 15bece9849
commit 824dcbb01a
9 files changed
+358 -49

No files matched your search

+32 -3
View File
@@ -42,10 +42,13 @@ describe("sync cursor codec", () => {
expect(decodeCursor("")).toBeNull();
// valid base64url but not the expected JSON shape
expect(decodeCursor(Buffer.from("[]").toString("base64url"))).toBeNull();
// wrong version — v1.15.0 bumped CURSOR_VERSION to 2, so a v1 token
// (the pre-cycle format) now decodes to null → clean re-init.
// unsupported version (v3 is not yet a thing) → clean re-init.
expect(
decodeCursor(Buffer.from('{"v":1,"d":{}}').toString("base64url")),
decodeCursor(Buffer.from('{"v":3,"d":{}}').toString("base64url")),
).toBeNull();
// a non-numeric version is garbage too.
expect(
decodeCursor(Buffer.from('{"v":"1","d":{}}').toString("base64url")),
).toBeNull();
// missing the `d` envelope
expect(
@@ -53,6 +56,32 @@ describe("sync cursor codec", () => {
).toBeNull();
});
it("decodes a live v1 beta token additively — keeps known domains, fresh-scans the two new ones", () => {
// A pre-cycle v1 cursor only ever carried measurements/mood/intakes.
// It must NOT force a full re-init now that v2 added cycleDays/cycles;
// the known watermarks survive and the new domains decode as absent
// (a fresh scan of just those two).
const v1Token = Buffer.from(
JSON.stringify({
v: 1,
d: {
measurements: { u: 1_717_000_000_000, i: "clx0meas" },
mood: { u: 1_717_000_500_000, i: "clx0mood" },
intakes: { u: 1_717_000_900_000, i: "clx0intk" },
},
}),
).toString("base64url");
const decoded = decodeCursor(v1Token);
expect(decoded).toEqual({
measurements: { updatedAtMs: 1_717_000_000_000, id: "clx0meas" },
mood: { updatedAtMs: 1_717_000_500_000, id: "clx0mood" },
intakes: { updatedAtMs: 1_717_000_900_000, id: "clx0intk" },
});
// cycleDays + cycles absent → caller fresh-scans only those domains.
expect(decoded?.cycleDays).toBeUndefined();
expect(decoded?.cycles).toBeUndefined();
});
it("decodes a current-version empty envelope to a fresh-scan map", () => {
expect(
decodeCursor(Buffer.from('{"v":2,"d":{}}').toString("base64url")),
+21 -7
View File
@@ -14,9 +14,16 @@
* token back verbatim and never parses it (iOS-coord §7.6), so the
* server is free to change this format without a client release. The
* current encoding is base64url-of-JSON; nothing depends on that choice.
* A device on the old single-domain token simply decodes to null (the
* shape no longer matches) and re-inits cleanly — acceptable because no
* live consumer cursors exist yet.
*
* Version compatibility is additive, not breaking. The iOS client has
* been in public beta since v1.5 and the delta feed shipped in v1.7.0, so
* live v1 cursors DO exist in the field. A v1 token (which only carried
* the measurements / mood / intakes domains) decodes normally: its known
* domain watermarks are preserved and the two domains added in v2
* (`cycleDays`, `cycles`) are simply absent → a fresh scan of just those
* two domains on the next sync, with no re-download of the measurement /
* mood / intake history. Only a genuinely malformed / unparseable token
* re-inits from scratch.
*/
/** The sync domains the feed serves. */
@@ -50,11 +57,16 @@ export interface DomainWatermark {
export type SyncCursor = Partial<Record<SyncDomain, DomainWatermark>>;
// v1.15.0 — bumped to 2 when the `cycleDays` + `cycles` domains were
// added. An in-flight v1 token decodes to null (wrong version) → clean
// re-init, which is acceptable because the cursor is opaque and the
// client re-syncs from scratch.
// added. New tokens are stamped v2; decoding accepts both v1 and v2
// envelopes (see `decodeCursor`) so live beta v1 cursors keep their
// measurement / mood / intake watermarks and only fresh-scan the two new
// domains rather than re-initialising the whole feed.
const CURSOR_VERSION = 2;
/** Versions whose envelope shape `decodeCursor` accepts. v1 lacked the
* `cycleDays`/`cycles` domains; they decode as absent (fresh scan). */
const SUPPORTED_CURSOR_VERSIONS: readonly number[] = [1, 2];
/** Encode a per-domain keyset map into an opaque token. */
export function encodeCursor(cursor: SyncCursor): string {
const d: Record<string, { u: number; i: string }> = {};
@@ -87,10 +99,12 @@ export function decodeCursor(token: string): SyncCursor | null {
try {
const json = Buffer.from(token, "base64url").toString("utf-8");
const parsed = JSON.parse(json) as unknown;
const version = (parsed as { v?: unknown }).v;
if (
typeof parsed !== "object" ||
parsed === null ||
(parsed as { v?: unknown }).v !== CURSOR_VERSION ||
typeof version !== "number" ||
!SUPPORTED_CURSOR_VERSIONS.includes(version) ||
typeof (parsed as { d?: unknown }).d !== "object" ||
(parsed as { d: unknown }).d === null
) {