mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
1 parent
15bece9849
commit
824dcbb01a
9 files changed
+358
-49
No files matched your search
@@ -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
@@ -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
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user