mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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>
124 lines
4.7 KiB
TypeScript
124 lines
4.7 KiB
TypeScript
/**
|
|
* Opaque multi-domain keyset cursor for the `/api/sync/changes` delta
|
|
* feed (v1.7.0).
|
|
*
|
|
* The cursor carries one independent `(updatedAt, id)` high-water mark
|
|
* per sync domain (measurements / mood / intakes). A per-domain keyset
|
|
* (not a single shared watermark) is mandatory: each domain walks its
|
|
* own table at its own `updatedAt` rate, and a backfill writes hundreds
|
|
* of rows in the same millisecond, so a bare-timestamp watermark would
|
|
* skip or double-count rows that share a tick. The `id` tie-breaker
|
|
* makes each domain's ordering total.
|
|
*
|
|
* The encoding is deliberately opaque to the client — iOS echoes the
|
|
* 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.
|
|
*
|
|
* 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. */
|
|
export type SyncDomain =
|
|
| "measurements"
|
|
| "mood"
|
|
| "intakes"
|
|
| "cycleDays"
|
|
| "cycles";
|
|
|
|
export const SYNC_DOMAINS: readonly SyncDomain[] = [
|
|
"measurements",
|
|
"mood",
|
|
"intakes",
|
|
"cycleDays",
|
|
"cycles",
|
|
];
|
|
|
|
/** A single domain's keyset position. */
|
|
export interface DomainWatermark {
|
|
/** `updatedAt` of the last drained row, as epoch milliseconds. */
|
|
updatedAtMs: number;
|
|
/** `id` of the last drained row (cuid) — the keyset tie-breaker. */
|
|
id: string;
|
|
}
|
|
|
|
/**
|
|
* The decoded cursor: a per-domain watermark map. Domains the client has
|
|
* not yet advanced are simply absent (treated as a fresh scan).
|
|
*/
|
|
export type SyncCursor = Partial<Record<SyncDomain, DomainWatermark>>;
|
|
|
|
// v1.15.0 — bumped to 2 when the `cycleDays` + `cycles` domains were
|
|
// 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 }> = {};
|
|
for (const domain of SYNC_DOMAINS) {
|
|
const wm = cursor[domain];
|
|
if (wm) d[domain] = { u: wm.updatedAtMs, i: wm.id };
|
|
}
|
|
const json = JSON.stringify({ v: CURSOR_VERSION, d });
|
|
return Buffer.from(json, "utf-8").toString("base64url");
|
|
}
|
|
|
|
function parseWatermark(raw: unknown): DomainWatermark | null {
|
|
if (typeof raw !== "object" || raw === null) return null;
|
|
const u = (raw as { u?: unknown }).u;
|
|
const i = (raw as { i?: unknown }).i;
|
|
if (typeof u !== "number" || !Number.isFinite(u)) return null;
|
|
if (typeof i !== "string" || i.length === 0) return null;
|
|
return { updatedAtMs: u, id: i };
|
|
}
|
|
|
|
/**
|
|
* Decode an opaque token back into a per-domain keyset map. Returns null
|
|
* for a malformed / unparseable / wrong-version token so the caller can
|
|
* treat a garbage cursor as a clean initial sync rather than throwing a
|
|
* 500. A token whose envelope parses but carries zero valid domain
|
|
* watermarks decodes to an empty map (a fresh scan of every domain),
|
|
* which is also a clean init.
|
|
*/
|
|
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 ||
|
|
typeof version !== "number" ||
|
|
!SUPPORTED_CURSOR_VERSIONS.includes(version) ||
|
|
typeof (parsed as { d?: unknown }).d !== "object" ||
|
|
(parsed as { d: unknown }).d === null
|
|
) {
|
|
return null;
|
|
}
|
|
const d = (parsed as { d: Record<string, unknown> }).d;
|
|
const cursor: SyncCursor = {};
|
|
for (const domain of SYNC_DOMAINS) {
|
|
const wm = parseWatermark(d[domain]);
|
|
if (wm) cursor[domain] = wm;
|
|
}
|
|
return cursor;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|