mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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>
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
/**
|
|
* v1.7.0 — opaque multi-domain keyset cursor codec for `/api/sync/changes`.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
import { encodeCursor, decodeCursor, type SyncCursor } from "../cursor";
|
|
|
|
describe("sync cursor codec", () => {
|
|
it("round-trips a per-domain keyset map through encode/decode", () => {
|
|
const cursor: SyncCursor = {
|
|
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" },
|
|
};
|
|
const token = encodeCursor(cursor);
|
|
expect(typeof token).toBe("string");
|
|
expect(decodeCursor(token)).toEqual(cursor);
|
|
});
|
|
|
|
it("round-trips a partial map (only some domains advanced)", () => {
|
|
const cursor: SyncCursor = {
|
|
measurements: { updatedAtMs: 1_717_000_000_000, id: "clx0meas" },
|
|
};
|
|
expect(decodeCursor(encodeCursor(cursor))).toEqual(cursor);
|
|
});
|
|
|
|
it("round-trips an empty map (every domain a fresh scan)", () => {
|
|
expect(decodeCursor(encodeCursor({}))).toEqual({});
|
|
});
|
|
|
|
it("produces a base64url token with no JSON-visible structure", () => {
|
|
const token = encodeCursor({
|
|
measurements: { updatedAtMs: 1, id: "z" },
|
|
});
|
|
// Opaque to the client — must not contain `{` / `:` / `updatedAt`.
|
|
expect(token).not.toMatch(/[{}:"]/);
|
|
expect(token).not.toContain("updatedAt");
|
|
});
|
|
|
|
it("returns null for a garbage / unparseable token", () => {
|
|
expect(decodeCursor("not-a-cursor!!!")).toBeNull();
|
|
expect(decodeCursor("")).toBeNull();
|
|
// valid base64url but not the expected JSON shape
|
|
expect(decodeCursor(Buffer.from("[]").toString("base64url"))).toBeNull();
|
|
// unsupported version (v3 is not yet a thing) → clean re-init.
|
|
expect(
|
|
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(
|
|
decodeCursor(Buffer.from('{"v":2}').toString("base64url")),
|
|
).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")),
|
|
).toEqual({});
|
|
});
|
|
|
|
it("round-trips the v1.15.0 cycle domains", () => {
|
|
const cursor: SyncCursor = {
|
|
cycleDays: { updatedAtMs: 1_717_001_000_000, id: "clx0cday" },
|
|
cycles: { updatedAtMs: 1_717_001_500_000, id: "clx0cyc" },
|
|
};
|
|
expect(decodeCursor(encodeCursor(cursor))).toEqual(cursor);
|
|
});
|
|
|
|
it("drops a domain whose watermark is malformed but keeps valid ones", () => {
|
|
// measurements has a string `u` (invalid); mood is well-formed.
|
|
const token = Buffer.from(
|
|
JSON.stringify({
|
|
v: 2,
|
|
d: {
|
|
measurements: { u: "x", i: "z" },
|
|
mood: { u: 5, i: "clx0mood" },
|
|
},
|
|
}),
|
|
).toString("base64url");
|
|
expect(decodeCursor(token)).toEqual({
|
|
mood: { updatedAtMs: 5, id: "clx0mood" },
|
|
});
|
|
});
|
|
});
|