Merge branch 'release/v1.45.0' into fix/v145-sheet-motion

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 20:51:07 +02:00
commit 32b71a7df9
111 files changed
+9035 -3718

No files matched your search

@@ -16,7 +16,11 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -176,7 +180,7 @@ describe("the cookie-only helpers cannot see the acting account", () => {
const COOKIE_ONLY = ["requireAdmin", "requireCookieAuth", "requireFreshMfa"];
it.each(COOKIE_ONLY)("%s resolves the actor and nothing else", (name) => {
const body = functionBody(read("lib/api-handler.ts"), name);
const body = functionBody(readSource("src/lib/api-handler.ts", name), name);
// Non-zero proof, twice over: the slice found a function, and the slice is
// the function it claims to be. A renamed helper must fail here rather
@@ -190,7 +194,7 @@ describe("the cookie-only helpers cannot see the acting account", () => {
});
it("substitutes a data-scope user in exactly two functions", () => {
const src = read("lib/api-handler.ts");
const src = readSource("src/lib/api-handler.ts", "requireRecordAuth");
const record = functionBody(src, "requireRecordAuth");
expect(record).toContain("readActingCarrier");
@@ -291,7 +295,7 @@ describe("ending a grant and clearing the switch are one act", () => {
it("runs the transition and the cleanup in one transaction", () => {
const body = privateFunctionBody(
read("lib/sharing/grants.ts"),
readSource("src/lib/sharing/grants.ts", "endAndClear"),
"endAndClear",
);
// Non-zero proof: a renamed helper fails here rather than asserting
@@ -308,7 +312,7 @@ describe("ending a grant and clearing the switch are one act", () => {
// claim's own transaction (it takes `tx`), so the guard here is that the
// cleanup is part of the same function as the ending.
const body = functionBody(
read("lib/sharing/grants.ts"),
readSource("src/lib/sharing/grants.ts", "settleHandoverGuardianGrant"),
"settleHandoverGuardianGrant",
);
expect(body.length).toBeGreaterThan(0);
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 124,
"total": 146508,
"count": 123,
"total": 145551,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
@@ -10,7 +10,7 @@
"app/api/measurements/route.ts": 938,
"app/api/medications/[id]/route.ts": 1281,
"app/api/medications/intake/bulk/route.ts": 1044,
"app/api/workouts/batch/route.ts": 971,
"app/api/workouts/batch/route.ts": 958,
"app/medications/page-client.tsx": 839,
"app/page-client.tsx": 2324,
"components/admin/backups-section.tsx": 1462,
@@ -44,7 +44,7 @@
"lib/ai/coach/coach-prose-grounding.ts": 1079,
"lib/ai/coach/persistence.ts": 1219,
"lib/ai/coach/results/metric-table-tool.ts": 1102,
"lib/ai/coach/snapshot.ts": 1362,
"lib/ai/coach/snapshot.ts": 1347,
"lib/ai/coach/stream-events.ts": 817,
"lib/ai/coach/tools/executor.ts": 1522,
"lib/ai/coach/turn/model.ts": 820,
@@ -65,7 +65,7 @@
"lib/daily/today-overview.ts": 838,
"lib/dashboard-layout.ts": 971,
"lib/dashboard/snapshot.ts": 1550,
"lib/day/records.ts": 1175,
"lib/day/records.ts": 1174,
"lib/doctor-report/collect.ts": 803,
"lib/export/backup-plan.ts": 853,
"lib/export/coach-backup.ts": 1070,
@@ -90,9 +90,8 @@
"lib/insights/mood-aggregates.ts": 1055,
"lib/insights/mood-status.ts": 814,
"lib/insights/status-cache.ts": 812,
"lib/insights/strain-score.ts": 832,
"lib/integrations/status.ts": 1445,
"lib/jobs/apple-health-import-worker.ts": 863,
"lib/jobs/apple-health-import-worker.ts": 801,
"lib/jobs/backup-restore.ts": 888,
"lib/jobs/coach-nudge.ts": 1089,
"lib/jobs/insight-pregenerate.ts": 1266,
@@ -115,7 +114,7 @@
"lib/medications/scheduling/slot-upsert.ts": 926,
"lib/notifications/senders/apns.ts": 1100,
"lib/oura/client.ts": 1360,
"lib/oura/sync.ts": 917,
"lib/oura/sync.ts": 902,
"lib/polar/client.ts": 1010,
"lib/rollups/measurement-rollups.ts": 1496,
"lib/search/search-records.ts": 996,
@@ -124,7 +123,7 @@
"lib/telegram-webhook-handlers.ts": 1341,
"lib/validations/inbound-documents.ts": 1130,
"lib/whoop/client.ts": 917,
"lib/withings/sync-sleep.ts": 903,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 978,
"proxy.ts": 870
}
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 411,
"total": 148298,
"total": 148137,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -64,8 +64,8 @@
"app/api/sync/changes/route.ts :: apiHandler(arg)": 427,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 252,
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
"app/api/workouts/[id]/route.ts :: apiHandler(arg)": 313,
"app/api/workouts/batch/route.ts :: postBatch": 722,
"app/api/workouts/[id]/route.ts :: apiHandler(arg)": 297,
"app/api/workouts/batch/route.ts :: postBatch": 709,
"app/auth/login/page.tsx :: LoginPage": 576,
"app/auth/register/page.tsx :: RegisterPage": 285,
"app/coach/conversations/page.tsx :: CoachConversationsBody": 249,
@@ -287,7 +287,7 @@
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218,
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 898,
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 882,
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
"lib/ai/coach/turn/model.ts :: runTurnModel": 560,
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
@@ -305,7 +305,7 @@
"lib/api-handler.ts :: apiHandler > wrapped": 201,
"lib/auth/profile-update.ts :: applyProfileUpdate": 353,
"lib/cache/server-cache.ts :: createServerCaches": 230,
"lib/cycle/backup.ts :: restoreCycleData": 243,
"lib/cycle/backup.ts :: restoreCycleData": 224,
"lib/cycle/day-log-write.ts :: writeDayLog": 311,
"lib/daily/digest.ts :: buildDailyDigest": 227,
"lib/daily/load-digest.ts :: loadDailyDigest": 452,
@@ -350,7 +350,7 @@
"lib/insights/weight-status.ts :: prepareWeightStatusForUser": 598,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227,
"lib/integrations/status.ts :: recordSyncFailure": 292,
"lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 357,
"lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300,
"lib/jobs/coach-nudge.ts :: findTriggerForUser": 279,
"lib/jobs/coach-nudge.ts :: runCoachNudgeTick": 292,
"lib/jobs/coach-reminder-sweep.ts :: runCoachReminderSweep": 222,
@@ -409,9 +409,9 @@
"lib/targets/vitals-builder.ts :: buildVitalTargets": 324,
"lib/telegram-webhook-handlers.ts :: handleCallback": 599,
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 337,
"lib/withings/sync-activity.ts :: syncUserActivity": 391,
"lib/withings/sync-ecg.ts :: syncUserEcg": 213,
"lib/withings/sync-sleep.ts :: syncUserSleep": 429,
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
"lib/withings/sync.ts :: syncUserMeasurements": 373,
"proxy.ts :: proxy": 499
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 524,
"total": 5496,
"total": 5495,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -253,7 +253,7 @@
"app/api/withings/credentials/__tests__/route.test.ts": 4,
"app/api/withings/webhook/__tests__/route.test.ts": 26,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 8,
"app/api/workouts/__tests__/batch-create.test.ts": 14,
"app/api/workouts/__tests__/batch-create.test.ts": 13,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 20,
"app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4,
"app/c/[token]/__tests__/report-download.test.ts": 11,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2954,
"total": 10508,
"total": 10504,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1706,7 +1706,7 @@
"lib/ai/coach/snapshot-cache.ts": 2,
"lib/ai/coach/snapshot-prelude.ts": 17,
"lib/ai/coach/snapshot-series.ts": 5,
"lib/ai/coach/snapshot.ts": 42,
"lib/ai/coach/snapshot.ts": 41,
"lib/ai/coach/source-measurement-types.ts": 1,
"lib/ai/coach/suggest-action.ts": 1,
"lib/ai/coach/suggest-gate.ts": 1,
@@ -2341,7 +2341,7 @@
"lib/jobs/__tests__/whoop-resource-sync.test.ts": 1,
"lib/jobs/__tests__/withings-queues.test.ts": 2,
"lib/jobs/__tests__/workout-insight-queue.test.ts": 1,
"lib/jobs/apple-health-import-worker.ts": 5,
"lib/jobs/apple-health-import-worker.ts": 4,
"lib/jobs/coach-message-cleanup.ts": 1,
"lib/jobs/coach-nudge-ai.ts": 3,
"lib/jobs/coach-nudge.ts": 17,
@@ -2948,9 +2948,9 @@
"lib/withings/client.ts": 5,
"lib/withings/oauth-state.ts": 1,
"lib/withings/response-classifier.ts": 1,
"lib/withings/sync-activity.ts": 8,
"lib/withings/sync-activity.ts": 7,
"lib/withings/sync-ecg.ts": 4,
"lib/withings/sync-sleep.ts": 10,
"lib/withings/sync-sleep.ts": 9,
"lib/withings/sync.ts": 9,
"lib/withings/webhook-handler.ts": 3,
"lib/workouts/list-read.ts": 1,
@@ -15,7 +15,11 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -276,7 +280,10 @@ describe("T1 — the Bearer resolution set is frozen", () => {
});
it("the resolver exposes exactly one authorisation arm", () => {
const src = read("lib/auth/bearer.ts");
const src = readSource(
"src/lib/auth/bearer.ts",
'permissions.includes("*")',
);
// One wildcard escape hatch, guarding one deny block. If a second
// `permissions.includes("*")` short-circuit appears, the arm has forked.
const wildcardChecks = src.match(/permissions\.includes\("\*"\)/g) ?? [];
@@ -375,7 +382,10 @@ describe("T3 — the mint sites are frozen", () => {
// does not contradict this: what was retired is a mint at THIS path that
// named no scope and answered for the whole API. A child path minting one
// named scope, listed in `MINT_SITES` above, is the shape that replaced it.
const tokensRoute = read("app/api/tokens/route.ts");
const tokensRoute = readSource(
"src/app/api/tokens/route.ts",
"export const GET",
);
expect(tokensRoute).not.toMatch(/export const POST/);
expect(tokensRoute).toMatch(/export const GET/);
});
@@ -442,12 +452,18 @@ describe("T4b — `documents:write` opens exactly two doors", () => {
]);
// Within the upload file, once: the GET list beside the POST must not
// name it.
const upload = read("app/api/documents/inbound/route.ts");
const upload = readSource(
"src/app/api/documents/inbound/route.ts",
"DOCUMENTS_WRITE_SCOPE",
);
expect(
requireAuthScopes(upload).filter((x) => x === "DOCUMENTS_WRITE_SCOPE"),
).toHaveLength(1);
// And the lookup is a read of one key and nothing else: a single GET.
const lookup = read("app/api/documents/inbound/source/route.ts");
const lookup = readSource(
"src/app/api/documents/inbound/source/route.ts",
"export const GET",
);
expect(
[...lookup.matchAll(/export const (GET|POST|PUT|PATCH|DELETE)\b/g)].map(
(m) => m[1],
@@ -469,7 +485,10 @@ describe("T4c — `workouts:write` opens exactly one door", () => {
);
});
expect(declaring).toEqual(["app/api/workouts/batch/route.ts"]);
const batch = read("app/api/workouts/batch/route.ts");
const batch = readSource(
"src/app/api/workouts/batch/route.ts",
"WORKOUTS_WRITE_SCOPE",
);
expect(
requireAuthScopes(batch).filter((x) => x === "WORKOUTS_WRITE_SCOPE"),
).toHaveLength(1);
@@ -524,7 +543,7 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
// it and every other leg in this file stays green while a single-purpose
// token gains delegated write — so the refusal is asserted where it lives,
// not only through the behaviour that depends on it.
const src = read("lib/api-handler.ts");
const src = readSource("src/lib/api-handler.ts", "narrow_scope_selector");
expect(src).toMatch(/narrow_scope_selector/);
expect(src).toMatch(/isScopedCredential\(auth\)/);
});
@@ -21,6 +21,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
readSource,
readSourceText,
stripComments,
walkSourceFiles,
@@ -59,8 +60,14 @@ describe("one Pearson correlation", () => {
});
it("both homes still hold their function", () => {
const engine = readSourceText(join(SRC, "lib/insights/correlations.ts"));
const wrapper = readSourceText(join(SRC, "lib/analytics/correlations.ts"));
const engine = readSource(
"src/lib/insights/correlations.ts",
"export function pearson",
);
const wrapper = readSource(
"src/lib/analytics/correlations.ts",
"export function significantPearsonCorrelation",
);
expect(engine).toMatch(/export function pearson\s*\(/);
expect(wrapper).toMatch(
/export function significantPearsonCorrelation\s*\(/,
@@ -24,7 +24,7 @@ import {
mayBeCycleContext,
parse,
} from "./helpers/cycle-verdict-derivation";
import { readSourceText } from "./helpers/source-files";
import { readSource, readSourceText } from "./helpers/source-files";
const ROOT = process.cwd();
@@ -110,19 +110,21 @@ describe("the surfaces that show the verdict actually read it", () => {
// The BBT chart used to walk the calendar itself to find the cycle start,
// which was a second copy of the same walk. It now takes the resolved
// value as a prop, and the cycle page feeds it from the verdict.
const chart = readSourceText(
join(ROOT, "src/components/cycle/bbt-chart.tsx"),
const chart = readSource(
"src/components/cycle/bbt-chart.tsx",
"cycleStartDate",
);
expect(chart).toContain("cycleStartDate");
const paths = accessPaths(parseFile("src/components/cycle/cycle-view.tsx"));
const view = "src/components/cycle/cycle-view.tsx";
const paths = accessPaths(parse(view, readSource(view, "verdict")));
expect([...paths]).toContain("verdict.cycleStartDate");
});
it("the calendar response type declares the verdict as always present", () => {
// An optional verdict is a plausible default that lets a client fall back
// to deriving its own.
const types = readSourceText(join(ROOT, "src/components/cycle/types.ts"));
const types = readSource("src/components/cycle/types.ts", "CycleVerdict");
expect(types).toContain("verdict: CycleVerdict;");
expect(types).not.toContain("verdict?:");
});
@@ -24,7 +24,11 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const VALIDATOR_MODULE = "@/lib/validations/external-id";
@@ -280,9 +284,9 @@ describe("identities derived outside a Zod schema stay inside the floor", () =>
const b = body("lib/import/csv-measurements.ts");
expect(b).toContain("isStableExternalId");
expect(b).toContain('skip("unstable_external_id")');
expect(read("lib/import/csv-measurements.ts")).toContain(
'| "unstable_external_id"',
);
expect(
readSource("src/lib/import/csv-measurements.ts", "unstable_external_id"),
).toContain('| "unstable_external_id"');
});
it("the MCP write path checks the key BEFORE it is hashed into an identity", () => {
@@ -0,0 +1,39 @@
/**
* The nightly property runs multiply each property's own case count instead
* of replacing it. A nightly count of 100 for every property ran the backup
* round trip, which states twelve cases for its cost, eight times over
* against a timeout sized for twelve.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
async function loadWith(factor: string | undefined) {
vi.resetModules();
if (factor === undefined) vi.stubEnv("FC_RUNS_FACTOR", "");
else vi.stubEnv("FC_RUNS_FACTOR", factor);
return import("../../tests/property/fast-check");
}
afterEach(() => {
vi.unstubAllEnvs();
});
describe("FC_RUNS_FACTOR", () => {
it("keeps each property's own count on a pull request", async () => {
const { propertyParameters, propertyTimeout } = await loadWith(undefined);
expect(propertyParameters(12).numRuns).toBe(12);
expect(propertyParameters().numRuns).toBe(100);
expect(propertyTimeout(60_000)).toBe(60_000);
});
it("multiplies each property's count and the timeout it was given", async () => {
const { propertyParameters, propertyTimeout } = await loadWith("4");
expect(propertyParameters(12).numRuns).toBe(48);
expect(propertyParameters(300).numRuns).toBe(1200);
expect(propertyTimeout(60_000)).toBe(240_000);
});
it("ignores a value that is not a positive number", async () => {
const { propertyParameters } = await loadWith("-3");
expect(propertyParameters(12).numRuns).toBe(12);
});
});
@@ -10,7 +10,11 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -65,7 +69,12 @@ describe("one least-squares line", () => {
// The implementation itself matches, so the sweep read real files.
expect(
HAND_ROLLED_SLOPE.test(
stripComments(readSourceText(join(SRC, "lib/stats/linear-fit.ts"))),
stripComments(
readSource(
"src/lib/stats/linear-fit.ts",
"export function linearFit",
),
),
),
).toBe(true);
});
@@ -22,7 +22,11 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const MEMORY = "lib/ai/coach/memory";
@@ -59,18 +63,21 @@ function checksBeforeRead(body: string, check: RegExp): boolean {
describe("memory block egress guard", () => {
const block = functionBody(
read(`${MEMORY}/context-block.ts`),
readSource(`src/${MEMORY}/context-block.ts`, "buildMemoryContextBlock"),
"buildMemoryContextBlock",
);
const lines = functionBody(
read(`${MEMORY}/plan-progress.ts`),
readSource(`src/${MEMORY}/plan-progress.ts`, "buildPlanProgressLines"),
"buildPlanProgressLines",
);
it("the memory block checks egress before it reads anything", () => {
expect(block.length).toBeGreaterThan(200);
expect(checksBeforeRead(block, /egressAllowed\(/)).toBe(true);
const helper = read(`${MEMORY}/context-block.ts`);
const helper = readSource(
`src/${MEMORY}/context-block.ts`,
"aiEgressRefusal",
);
expect(helper).toMatch(/aiEgressRefusal\(\s*"coach"/);
expect(helper).toMatch(/aiCapabilityForRecord\(args\.userId, "coach"\)/);
// Broken on purpose: the check moved below the reads.
@@ -55,18 +55,12 @@ const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record<string, string> = {
moodEntryId:
"re-bound to the restored parent row's id; the context rides inside its entry",
userId: "user-scoped backup; owner is re-derived from the restoring account",
createdAt:
"the sub-row's own bookkeeping stamp; the entry carries the moment the person logged the day, and the context has nothing separate to say about when it was typed",
updatedAt:
"the sub-row's own bookkeeping stamp, moved by every write; a restore re-stamps it and nothing reads the old value",
};
const EXCLUDED_TAG_LINK_COLUMNS: Record<string, string> = {
moodEntryId: "re-bound to the restored parent row's id, never carried",
moodTagId:
"the link travels by the tag's key; a source-instance id need not exist in the target catalogue",
createdAt:
"the link's own row stamp; the entry carries the moment the person logged it, and a key-only BINARY tag has nowhere to put a second one",
};
function modelBlock(schema: string, model: string): string {
@@ -16,7 +16,11 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -181,7 +185,10 @@ describe("the recent-proof gate", () => {
});
it("the gate reads the session's own stamps, and only a second factor on an enrolled account", () => {
const src = read("lib/api-handler.ts");
const src = readSource(
"src/lib/api-handler.ts",
"export async function assertRecentCookieProof(",
);
const start = src.indexOf("export async function assertRecentCookieProof(");
expect(start).toBeGreaterThan(-1);
const body = src.slice(start, src.indexOf("\nexport ", start + 1));
@@ -41,7 +41,11 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -80,7 +84,10 @@ function functionBody(src: string, name: string): string {
return "";
}
const API_HANDLER = read("lib/api-handler.ts");
const API_HANDLER = readSource("src/lib/api-handler.ts", [
"requireRecordAuth",
"requireGuardianAuth",
]);
/** The two helpers that resolve a record and therefore must consult the fence. */
const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const;
@@ -180,8 +187,11 @@ describe("the fence runs in exactly two auth helpers", () => {
// client's context is unknown; the switch endpoint is the way out of a
// context that cannot be proved. Fencing either would make an unprovable
// context unrecoverable.
const me = read("app/api/auth/me/route.ts");
const sw = read("app/api/account/switch/route.ts");
const me = readSource("src/app/api/auth/me/route.ts", "requireActorAuth");
const sw = readSource(
"src/app/api/account/switch/route.ts",
"requireActorAuth",
);
expect(me).toContain("requireActorAuth");
expect(sw).toContain("requireActorAuth");
expect(me).not.toContain("assertRecordSessionFence");
@@ -228,7 +238,10 @@ describe("the epoch has one writer and a closed reader set", () => {
});
it("is consulted by the idempotency wrapper, above the cache", () => {
const src = read("lib/idempotency.ts");
const src = readSource(
"src/lib/idempotency.ts",
"refuseStaleRecordSessionClaim(claim)",
);
const fence = src.indexOf("refuseStaleRecordSessionClaim(claim)");
const cache = src.indexOf("findCached(ctx)");
const claimRow = src.indexOf("claimKey(ctx)");
@@ -20,6 +20,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
readSource,
readSourceText,
stripComments,
walkSourceFiles,
@@ -87,10 +88,17 @@ describe("one standard deviation", () => {
});
it("the series API and the AI and MCP payload report the same spread", () => {
expect(read("app/api/measurements/series/route.ts")).toMatch(
/standardDeviation\(\s*values,\s*READING_SPREAD\s*\)/,
expect(
stripComments(
readSource(
"src/app/api/measurements/series/route.ts",
"READING_SPREAD",
),
),
).toMatch(/standardDeviation\(\s*values,\s*READING_SPREAD\s*\)/);
const features = stripComments(
readSource("src/lib/insights/features.ts", "READING_SPREAD"),
);
const features = read("lib/insights/features.ts");
expect(
features.match(/roundedSpread\(\s*vals,\s*READING_SPREAD\s*\)/g),
).toHaveLength(2);
+2 -2
View File
@@ -16,7 +16,7 @@
import { readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import { readSourceText } from "./helpers/source-files";
import { readSource, readSourceText } from "./helpers/source-files";
const ROOT = join(__dirname, "..", "..");
const SRC = join(ROOT, "src");
@@ -85,7 +85,7 @@ function bareReads(): string[] {
describe("raw SQL clock reads are anchored to UTC", () => {
it("finds the anchored form across the swept files", () => {
// The matcher must see something, or a broken walk would pass.
const passkey = readSourceText(join(SRC, "lib/auth/passkey.ts"));
const passkey = readSource("src/lib/auth/passkey.ts", ANCHORED);
expect(passkey.match(ANCHORED)?.length).toBeGreaterThanOrEqual(3);
expect(SQL_CLOCK.test("AND expires_at > NOW()")).toBe(true);
expect(SQL_CLOCK.test("updated_at = now()")).toBe(true);
+21 -5
View File
@@ -25,7 +25,11 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -256,7 +260,10 @@ describe("S6 — the erasure routes accept a fresh-factor elevation", () => {
// A password-proved elevation must not erase an account, and the gate
// must not grow a second, inline factor check beside the elevation: one
// mint surface, one redemption path.
const src = read("lib/api-handler.ts");
const src = readSource(
"src/lib/api-handler.ts",
"export async function requireFreshMfaOrElevationIfEnrolled(",
);
const start = src.indexOf(
"export async function requireFreshMfaOrElevationIfEnrolled(",
);
@@ -307,7 +314,10 @@ describe("S2 — mint and redeem happen in exactly one place each", () => {
describe("S3 — requireAdmin stays cookie-only", () => {
it("resolves through getSession and nothing else", () => {
const src = read("lib/api-handler.ts");
const src = readSource("src/lib/api-handler.ts", [
"export async function requireAdmin(",
"export async function requireCookieAuth(",
]);
const body = src.slice(
src.indexOf("export async function requireAdmin("),
src.indexOf("export async function requireCookieAuth("),
@@ -335,7 +345,10 @@ describe("S4 — the mint surface refuses a cookie", () => {
});
it("requireBearerAuth never consults the session cookie", () => {
const src = read("lib/api-handler.ts");
const src = readSource("src/lib/api-handler.ts", [
"export async function requireBearerAuth(",
"export const STEP_UP_ELEVATION_HEADER",
]);
const body = src.slice(
src.indexOf("export async function requireBearerAuth("),
src.indexOf("export const STEP_UP_ELEVATION_HEADER"),
@@ -372,7 +385,10 @@ describe("S5 — a password proof cannot satisfy a fresh-factor gate", () => {
});
it("the claim re-checks the factor rather than trusting the validation", () => {
const src = read("lib/auth/step-up.ts");
const src = readSource(
"src/lib/auth/step-up.ts",
"export async function claimStepUpElevation(",
);
const claim = src.slice(
src.indexOf("export async function claimStepUpElevation("),
);
+58 -2
View File
@@ -19,6 +19,9 @@
* assertion then passes for the wrong reason. New ones go through
* `readSource(path, anchors)` or `readSourceFolder(dir, { floor })` in
* `helpers/source-files.ts`, which this count does not include.
* `readSourceText` is the unanchored reader the sweeps use; called with a
* fixed source path, directly or through a local wrapper that is, it is
* the same read without the anchors, and it counts.
*
* A new offender fails; one that disappears (or loses casts) fails until
* the lower number is written back with
@@ -29,7 +32,10 @@
* the Prisma client entry itself, or a spy on every delegate, is not a
* pattern in use and is not matched. The source-read check is a text
* heuristic: a file that calls `readFileSync`/`readFile` and names a source
* path (a literal ending in `.ts`, `.tsx`, `.js` or `.mjs`).
* path (a literal ending in `.ts`, `.tsx`, `.js` or `.mjs`), or that calls
* `readSourceText` with such a literal, or calls a local one-parameter wrapper
* of `readSourceText` with one. A wrapper fed from a list of paths is not
* matched.
*/
import { describe, expect, it } from "vitest";
import ts from "typescript";
@@ -45,6 +51,26 @@ const DB_MOCK =
const HANDLER_STUB = /vi\.(?:do)?[mM]ock\(\s*["'`]@\/lib\/api-handler["'`]/;
const READS_FILE = /\b(?:readFileSync|readFile)\s*\(/;
const SOURCE_PATH = /["'`][^"'`\n]*\.(?:tsx?|mjs|js)["'`]/;
/** `readSourceText(...)` whose argument names a source path literal. */
const READ_SOURCE_TEXT_FIXED = new RegExp(
String.raw`\breadSourceText\(\s*(?:join\([^)]*?)?` + SOURCE_PATH.source,
);
/** A local wrapper whose one parameter becomes the `readSourceText` path. */
const READ_SOURCE_TEXT_WRAPPER =
/(?:function\s+(\w+)|(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?)\s*\(\s*(\w+)\s*(?::\s*string)?[^)]*\)[^{=]*(?:=>|\{)[\s\S]{0,160}?readSourceText\(\s*(?:join\([^)]*?\b\3\b|\3\b)/g;
/** Whether a test reads a fixed source path through `readSourceText`. */
function readsSourceTextByPath(text: string): boolean {
if (!text.includes("readSourceText")) return false;
if (READ_SOURCE_TEXT_FIXED.test(text)) return true;
for (const m of text.matchAll(READ_SOURCE_TEXT_WRAPPER)) {
const name = m[1] ?? m[2];
if (new RegExp(`\\b${name}\\(\\s*` + SOURCE_PATH.source).test(text)) {
return true;
}
}
return false;
}
function asNeverCount(rel: string, text: string): number {
if (!text.includes("as never")) return 0;
@@ -68,7 +94,9 @@ function classifyTest(rel: string, text: string) {
dbMock: routeTest && DB_MOCK.test(text),
handlerStub: routeTest && HANDLER_STUB.test(text),
asNever: asNeverCount(rel, text),
sourceRead: READS_FILE.test(text) && SOURCE_PATH.test(text),
sourceRead:
(READS_FILE.test(text) && SOURCE_PATH.test(text)) ||
readsSourceTextByPath(text),
};
}
@@ -143,6 +171,8 @@ describe("test realism ratchet", () => {
});
const READ_CALL = ["read", "File", "Sync"].join("");
// Split for the same reason: this file must not match its own pattern.
const READ_TEXT = ["read", "Source", "Text"].join("");
describe("the classifier", () => {
it("sees the shapes it counts", () => {
@@ -182,6 +212,32 @@ describe("the classifier", () => {
});
});
it("counts a fixed path read through the unanchored reader", () => {
const direct = `const src = ${READ_TEXT}(join(SRC, "lib/x.ts"));`;
const wrapped = [
`function read(rel: string): string {`,
` return ${READ_TEXT}(join(SRC, rel));`,
`}`,
`const src = read("lib/api-handler.ts");`,
].join("\n");
expect(classifyTest("__tests__/a.test.ts", direct).sourceRead).toBe(true);
expect(classifyTest("__tests__/a.test.ts", wrapped).sourceRead).toBe(true);
});
it("leaves an anchored read and a sweep through the reader alone", () => {
const anchored = 'const src = readSource("src/lib/x.ts", "anchor");';
const sweep = [
`function read(rel: string): string {`,
` return ${READ_TEXT}(join(SRC, rel));`,
`}`,
`for (const rel of walkSourceFiles(SRC, { floor: 10 })) read(rel);`,
].join("\n");
expect(classifyTest("__tests__/a.test.ts", anchored).sourceRead).toBe(
false,
);
expect(classifyTest("__tests__/a.test.ts", sweep).sourceRead).toBe(false);
});
it("counts a relative database mock and only under app/api", () => {
expect(
classifyTest(
@@ -0,0 +1,63 @@
/**
* `scripts/verify-message-catalogs.mjs`, which the image build runs in the
* builder and again in the runner stage, passes the shipped catalogs and fails
* on a missing or corrupt one. A check that cannot fail would let an image
* without its catalogs through, so both directions are pinned.
*/
import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const SCRIPT = join(process.cwd(), "scripts", "verify-message-catalogs.mjs");
const CONFIG = join(process.cwd(), "src", "lib", "i18n", "config.ts");
function run(args: string[]) {
return spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" });
}
let scratch: string | null = null;
afterEach(() => {
if (scratch) rmSync(scratch, { recursive: true, force: true });
scratch = null;
});
describe("verify-message-catalogs", () => {
it("passes the catalogs in the tree and writes the locale list", () => {
scratch = mkdtempSync(join(tmpdir(), "hl-catalog-check-"));
const list = join(scratch, "locales");
const res = run([
"--dir",
join(process.cwd(), "messages"),
"--config",
CONFIG,
"--write",
list,
]);
expect(res.status).toBe(0);
expect(res.stdout).toMatch(/message catalogs ok: .*\ben\b/);
// The runner-stage form, against the list the builder wrote.
expect(
run(["--dir", join(process.cwd(), "messages"), "--locales", list]).status,
).toBe(0);
});
it("fails on a missing catalog and on one that does not parse", () => {
scratch = mkdtempSync(join(tmpdir(), "hl-catalog-check-"));
const dir = join(scratch, "messages");
mkdirSync(dir);
writeFileSync(join(dir, "en.json"), "{}");
writeFileSync(join(dir, "de.json"), '{"common": ');
const list = join(scratch, "locales");
writeFileSync(list, "en\nde\nfr\n");
const res = run(["--dir", dir, "--locales", list]);
expect(res.status).toBe(1);
expect(res.stderr).toMatch(/de\.json: does not parse/);
expect(res.stderr).toMatch(/fr\.json: missing/);
expect(res.stderr).not.toMatch(/en\.json/);
});
});
@@ -0,0 +1,96 @@
/**
* A workflow step that pipes a command into `tee` runs with pipefail.
*
* GitHub runs a `run:` script with `bash -e {0}` unless the step names a
* shell, and without pipefail a pipeline's exit status is its last command's.
* `tee` always succeeds, so `node scripts/mutation-floors.mjs | tee -a
* "$GITHUB_STEP_SUMMARY"` passed whatever the floors said. A step that names
* `shell: bash` runs as `bash --noprofile --norc -eo pipefail {0}`; this guard
* asks for that and for `set -o pipefail` in the script as well, so the
* intent is visible where the pipe is.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import YAML from "yaml";
const WORKFLOWS = join(process.cwd(), ".github", "workflows");
interface Step {
name?: string;
run?: string;
shell?: string;
}
/** Whether a script pipes something into `tee` on a line that is not a comment. */
function pipesIntoTee(script: string): boolean {
return script
.split("\n")
.some((line) => !/^\s*#/.test(line) && /[^|]\|\s*tee\b/.test(line));
}
/** The steps of a workflow that pipe into `tee` without pipefail. */
function offenders(file: string, text: string): string[] {
const doc = YAML.parse(text) as {
defaults?: { run?: { shell?: string } };
jobs?: Record<
string,
{ defaults?: { run?: { shell?: string } }; steps?: Step[] }
>;
};
const out: string[] = [];
for (const [jobName, job] of Object.entries(doc.jobs ?? {})) {
(job.steps ?? []).forEach((step, index) => {
if (!step.run || !pipesIntoTee(step.run)) return;
const shell =
step.shell ?? job.defaults?.run?.shell ?? doc.defaults?.run?.shell;
const ok = shell === "bash" && /\bset -o pipefail\b/.test(step.run);
if (!ok) out.push(`${file} ${jobName} #${index} ${step.name ?? ""}`);
});
}
return out;
}
function workflowFiles(): string[] {
return readdirSync(WORKFLOWS).filter((f) => /\.ya?ml$/.test(f));
}
describe("workflow steps that pipe into tee", () => {
it("reads the workflows it claims to check", () => {
expect(workflowFiles().length).toBeGreaterThan(10);
const teeSteps = workflowFiles().filter((f) =>
pipesIntoTee(readFileSync(join(WORKFLOWS, f), "utf8")),
);
expect(teeSteps.length).toBeGreaterThan(0);
});
it("run with pipefail", () => {
const found = workflowFiles().flatMap((f) =>
offenders(f, readFileSync(join(WORKFLOWS, f), "utf8")),
);
expect(found).toEqual([]);
});
it("catches the shape it guards against", () => {
const bad = [
"jobs:",
" check:",
" steps:",
" - name: floors",
' run: node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY"',
].join("\n");
expect(offenders("bad.yml", bad)).toEqual(["bad.yml check #0 floors"]);
const good = [
"jobs:",
" check:",
" steps:",
" - name: floors",
" shell: bash",
" run: |",
" set -o pipefail",
' node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY"',
].join("\n");
expect(offenders("good.yml", good)).toEqual([]);
});
});
@@ -31,11 +31,21 @@ const SRC = join(process.cwd(), "src");
const PICKER = "lib/measurements/pick-canonical-workout-rows.ts";
/**
* Every production call site of the picker, frozen.
* The only module that hands rows to the picker: the windowed reader, which
* picks over rows it read with the duplicate-window margin. Every surface goes
* through it, and its output types make a read without the margin a compile
* error. The strain engine's per-cluster impulse sum lives there too and is
* handed the rows of the engine's own windowed read.
*/
const PICKER_CALLERS = ["lib/workouts/canonical-read.ts"];
/**
* Every production read that decides which workout is canonical, frozen.
*
* Adding a surface that collapses workouts is a deliberate act: it must fetch
* the identity fields or it silently disagrees with every other surface. Adding
* the file here is the acknowledgement.
* Adding a surface that collapses workouts is a deliberate act: it must read
* through `readWorkoutsAroundRange` and fetch the identity fields, or it
* silently disagrees with every other surface. Adding the file here is the
* acknowledgement. The value is the file that owns the read's `select`.
*/
const IDENTITY_OWNER: Record<string, string> = {
"app/api/workouts/[id]/route.ts": "app/api/workouts/[id]/route.ts",
@@ -49,15 +59,15 @@ const IDENTITY_OWNER: Record<string, string> = {
// The activity calendar counts workouts per day; a session two devices
// recorded must count once there, as it does in the list.
"lib/activity/calendar-read.ts": "lib/activity/calendar-read.ts",
// The only indirection in the set. The Coach block is handed rows it does not
// read itself; the snapshot builder fetches them in parallel with every other
// block's data. Recording the owner here rather than assuming the call site
// owns its own select is the whole point of this map.
"lib/ai/coach/snapshot-blocks/workouts-block.ts": "lib/ai/coach/snapshot.ts",
// The write path: the batch route runs the same picker over the rows it is
// about to insert, so a duplicate inside one batch is dropped by the rule
// the list later applies.
"app/api/workouts/batch/route.ts": "app/api/workouts/batch/route.ts",
// The Coach block is handed rows it does not read itself; the snapshot
// builder fetches them through this read in parallel with every other
// block's data.
"lib/ai/coach/snapshot-blocks/workouts-read.ts":
"lib/ai/coach/snapshot-blocks/workouts-read.ts",
// The write path: the batch route clusters the rows it is about to insert
// together with the stored rows around them, so a duplicate is dropped by
// the rule the list later applies.
"lib/workouts/batch-dedup.ts": "lib/workouts/batch-dedup.ts",
// The day-total training impulse, the briefing's workout tally and the day
// view's workout section: each counted a session synced by two services
// twice while the list showed it once.
@@ -72,10 +82,16 @@ const IDENTITY_OWNER: Record<string, string> = {
/**
* How a call site proves its rows carry the duration. A read selects the
* column; the batch route maps it off the row it computed for the insert.
* column; the batch route also maps it off the row it computed for the insert.
*/
const IDENTITY_PROOF: Record<string, string> = {
"app/api/workouts/batch/route.ts": "durationSec: p.row.durationSec",
const IDENTITY_PROOF: Record<string, string[]> = {
// These two select `WORKOUT_PICK_SELECT`, whose duration is checked below.
"lib/day/records.ts": ["select: WORKOUT_PICK_SELECT"],
"lib/insights/correlation-channel-series.ts": ["select: WORKOUT_PICK_SELECT"],
"lib/workouts/batch-dedup.ts": [
"durationSec: true",
"durationSec: p.row.durationSec",
],
};
const EXPECTED_CALL_SITES = Object.keys(IDENTITY_OWNER).sort();
@@ -93,17 +109,13 @@ function read(rel: string): string {
}
/**
* Lines that actually invoke the picker. Comment lines are excluded: a
* docblock that names the picker is documentation rather than a call.
* Lines that actually invoke `call`. Comment lines are excluded: a docblock
* that names the function is documentation rather than a call.
*/
function invokesPicker(source: string): boolean {
function invokes(source: string, call: string): boolean {
return source
.split("\n")
.some(
(line) =>
line.includes("pickCanonicalWorkoutRows(") &&
!/^\s*(\*|\/\/|\/\*)/.test(line),
);
.some((line) => line.includes(call) && !/^\s*(\*|\/\/|\/\*)/.test(line));
}
describe("the picker's call-site set is frozen", () => {
@@ -114,13 +126,29 @@ describe("the picker's call-site set is frozen", () => {
expect(sourceFiles().length).toBeGreaterThan(1500);
});
it("has exactly the call sites this file acknowledges", () => {
it("hands rows to the picker only from the windowed reader", () => {
const found = sourceFiles()
.filter((rel) => rel !== PICKER)
.filter((rel) => invokesPicker(read(rel)));
.filter((rel) => invokes(read(rel), "pickCanonicalWorkoutRows("));
expect(found).toEqual(PICKER_CALLERS);
});
it("has exactly the windowed reads this file acknowledges", () => {
const found = sourceFiles()
.filter((rel) => rel !== "lib/workouts/canonical-read.ts")
.filter((rel) => invokes(read(rel), "readWorkoutsAroundRange("));
expect(found).toEqual(EXPECTED_CALL_SITES);
});
it("scores strain only on the sessions of the windowed range", () => {
// The strain engine sums impulses over the rows of its windowed read; it
// must keep the sessions its range owns, not every cluster the margin read.
expect(read("lib/insights/strain-score.ts")).toContain(
"(at) => startsInRange(workouts.range, at)",
);
});
});
describe("every call site fetches the session identity", () => {
@@ -130,9 +158,20 @@ describe("every call site fetches the session identity", () => {
// rows came from. Coarse in the safe direction — it can only fail to catch
// a second unrelated select in the same file, never pass a caller that
// fetches no duration at all.
expect(read(IDENTITY_OWNER[rel])).toContain(
IDENTITY_PROOF[rel] ?? "durationSec: true",
for (const proof of IDENTITY_PROOF[rel] ?? ["durationSec: true"]) {
expect(read(IDENTITY_OWNER[rel])).toContain(proof);
}
});
});
describe("the shared pick select carries the identity", () => {
it("selects durationSec", () => {
const reader = read("lib/workouts/canonical-read.ts");
const select = reader.slice(
reader.indexOf("export const WORKOUT_PICK_SELECT"),
reader.indexOf("} as const;"),
);
expect(select).toContain("durationSec: true");
});
});
+104 -81
View File
@@ -6,9 +6,9 @@
* canonical envelope per workout. The endpoint:
*
* - Returns the canonical row when the requested id wins its
* `(startedAt slot, sportType)` cluster against the user's
* source-priority ladder. The picker reuses
* `pickCanonicalWorkoutRows()` from v1.4.30 so the dedup contract
* duplicate cluster against the user's source-priority ladder. The
* cluster is resolved through the same windowed read the list uses
* (`src/lib/workouts/canonical-read.ts`), so the dedup contract
* stays in sync with the list endpoint.
* - When the requested id is a non-canonical twin (e.g. the user
* opened a deep-link to a Withings row but the cluster's winner is
@@ -40,7 +40,14 @@ import {
lockPersonalRecordsForUser,
workoutPrSlotsForSport,
} from "@/lib/personal-records/pr-detection-worker";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalClusterOf,
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
type CanonicalReadRow,
type WorkoutBoundedRead,
type WorkoutStartBounds,
} from "@/lib/workouts/canonical-read";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
@@ -60,6 +67,38 @@ import { userDayKey } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
/** How many earlier rows one step of the previous-session search reads. */
const PREVIOUS_SESSION_BATCH = 20;
/** Steps before the search gives up; each reaches past the last one. */
const PREVIOUS_SESSION_MAX_STEPS = 5;
/**
* The newest canonical workout of the sport that starts before `before` (the
* first start of the current session's cluster). A batch of earlier rows names
* the span to resolve; the span is read through the windowed reader so its
* twins are resolved the way the list resolves them. A span whose rows are all
* hidden by a twin further back moves the search back one batch.
*/
async function previousCanonicalWorkoutId<T extends CanonicalReadRow>(
read: WorkoutBoundedRead<T>,
latestStartsBefore: (to: Date, take: number) => Promise<Date[]>,
before: Date,
priorityJson: unknown,
): Promise<string | null> {
let to = before;
for (let step = 0; step < PREVIOUS_SESSION_MAX_STEPS; step += 1) {
const earlier = await latestStartsBefore(to, PREVIOUS_SESSION_BATCH);
if (earlier.length === 0) return null;
const from = earlier[earlier.length - 1]!;
const windowed = await readWorkoutsAroundRange({ from, to: before }, read);
const canonical = canonicalWorkoutsInRange(windowed, priorityJson);
const newest = canonical[canonical.length - 1];
if (newest) return newest.id;
to = from;
}
return null;
}
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -113,38 +152,38 @@ export const GET = apiHandler(
return apiError("Workout not found", 404);
}
// Resolve the canonical winner for the row's cluster. The picker
// reads every workout in the same 5-minute slot and sport-type
// bucket; in practice that's ≤ 4 rows (Apple + Withings + the two
// legacy sources). The query is bounded so the round-trip is cheap.
const slotMs = 5 * 60 * 1000;
const slotStart = new Date(
Math.floor(row.startedAt.getTime() / slotMs) * slotMs,
// Resolve the canonical winner for the row's cluster. The read goes
// through the same windowed reader as the list, so the cluster drawn here
// is the one the list draws: a twin that straddles a clock slot boundary,
// or one whose cluster begins with an earlier workout, resolves to the row
// the list shows.
const readSameSport = (bounds: WorkoutStartBounds) =>
prisma.workout.findMany({
where: {
userId: user.id,
sportType: row.sportType,
startedAt: bounds,
},
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
// Part of the canonical session identity consulted by the picker —
// without it a HealthKit re-send of this very workout stays in the
// cluster and the deep link can resolve to the duplicate instead of
// the row the list shows.
durationSec: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
},
});
const around = await readWorkoutsAroundRange(
{ from: row.startedAt, to: row.startedAt, inclusiveEnd: true },
readSameSport,
);
const slotEnd = new Date(slotStart.getTime() + slotMs);
const clusterRows = await prisma.workout.findMany({
where: {
userId: user.id,
sportType: row.sportType,
startedAt: { gte: slotStart, lt: slotEnd },
},
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
// Part of the canonical session identity consulted by
// `pickCanonicalWorkoutRows` below — without it a HealthKit re-send of
// this very workout stays in the cluster and the deep link can resolve
// to the duplicate instead of the row the list shows.
durationSec: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
},
});
const userRow = await prisma.user.findUnique({
where: { id: user.id },
@@ -158,17 +197,15 @@ export const GET = apiHandler(
const timezone = await resolveUserTimezone(user.id);
const dayKey = userDayKey(row.startedAt, timezone);
const canonicalCluster = pickCanonicalWorkoutRows(
clusterRows,
const cluster = canonicalClusterOf(
around,
row.id,
userRow?.sourcePriorityJson ?? null,
);
// The canonical pick may carry multiple rows (same winning source);
// pick the closest in time to the requested row so deep-link
// redirects land on the most similar twin.
const canonical =
canonicalCluster.find((c) => c.id === row.id) ??
canonicalCluster[0] ??
null;
// The cluster's canonical rows, the requested row first when it is one of
// them and otherwise the nearest in time, so a deep link to a hidden twin
// lands on the most similar row.
const canonical = cluster?.canonical[0] ?? null;
const effectiveAvgHeartRate = canonical?.avgHeartRate ?? row.avgHeartRate;
const effectiveMaxHeartRate = canonical?.maxHeartRate ?? row.maxHeartRate;
@@ -233,43 +270,29 @@ export const GET = apiHandler(
// The session before this one in the same sport. "How does this compare
// to last time?" is the most likely next question after reading a
// workout, and today it costs a trip back to the list plus a scan.
// Scoped to rows that start before this cluster's slot, so a twin of THIS
// session can never be offered as the previous one, and run through the
// same canonical picker so the link lands on the row the list shows.
const previousRows = await prisma.workout.findMany({
where: {
userId: user.id,
sportType: row.sportType,
startedAt: { lt: slotStart },
},
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
// Enough to survive a run of same-session twins and re-sends without
// reading the whole history; the picker collapses them to a handful.
take: 20,
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
durationSec: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
},
});
// The picker takes rows in ascending order and does not promise to hand
// them back sorted, so pick the newest survivor explicitly.
const previousWorkoutId =
pickCanonicalWorkoutRows(
[...previousRows].reverse(),
userRow?.sourcePriorityJson ?? null,
).reduce<{ id: string; startedAt: Date } | null>(
(newest, candidate) =>
newest === null || candidate.startedAt > newest.startedAt
? candidate
: newest,
null,
)?.id ?? null;
// Scoped to rows that start before this session's cluster, so a twin of
// THIS session can never be offered as the previous one, and resolved
// through the same windowed read so the link lands on the row the list
// shows.
const clusterStart = cluster?.members[0]?.startedAt ?? row.startedAt;
const previousWorkoutId = await previousCanonicalWorkoutId(
readSameSport,
async (to, take) =>
(
await prisma.workout.findMany({
where: {
userId: user.id,
sportType: row.sportType,
startedAt: { lt: to },
},
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
take,
select: { startedAt: true },
})
).map((earlier) => earlier.startedAt),
clusterStart,
userRow?.sourcePriorityJson ?? null,
);
// A route whose track cannot be read (a sealed value that no longer
// opens, or an old row stored as a JSON null) is reported as no route at
@@ -128,6 +128,19 @@ function mockInsertReturning(
}) => (args.where === undefined && args.select?.id ? rows : [])) as never);
}
/**
* Rows the pre-flight `(source, externalId)` probe finds. The probe is the
* read with an `OR` of keys; the windowed read of the stored workouts around
* the batch (a `startedAt` range) finds nothing here.
*/
function mockExistingByKey(
rows: Array<{ source: string; externalId: string }>,
) {
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
where?: { OR?: unknown };
}) => (args.where?.OR ? rows : [])) as never);
}
function validWorkout(
externalId: string,
overrides: Record<string, unknown> = {},
@@ -345,9 +358,9 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
});
it("flags entries already in the DB as duplicate without re-inserting", async () => {
vi.mocked(prisma.workout.findMany).mockResolvedValue([
mockExistingByKey([
{ source: "APPLE_HEALTH", externalId: "uuid-existing" },
] as never);
]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
@@ -825,9 +838,7 @@ describe("POST /api/workouts/batch — PR detection enqueue (v1.4.25 W16c)", ()
// stays 0 but duplicateCount becomes 1. The hook fires anyway —
// the detector is cheap and a duplicate batch can still reveal a
// PR if the row was written by another path between dispatches.
vi.mocked(prisma.workout.findMany).mockResolvedValue([
{ source: "APPLE_HEALTH", externalId: "uuid-dup" } as never,
]);
mockExistingByKey([{ source: "APPLE_HEALTH", externalId: "uuid-dup" }]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
const res = await POST(
makeRequest({ workouts: [validWorkout("uuid-dup")] }),
@@ -358,9 +358,11 @@ describe("GET /api/workouts — canonical dedup", () => {
where: {
userId: "user-1",
sportType: "running",
// The range plus one duplicate window on either side, so a twin
// just outside the filter still decides which row is canonical.
startedAt: {
gte: new Date("2026-01-01T00:00:00.000Z"),
lte: new Date("2026-01-02T00:00:00.000Z"),
gte: new Date("2025-12-31T23:55:00.000Z"),
lt: new Date("2026-01-02T00:05:00.000Z"),
},
},
}),
+10 -23
View File
@@ -92,11 +92,11 @@ import {
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { batchWorkoutIndicesToKeep } from "@/lib/workouts/batch-dedup";
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
import { Prisma, type MeasurementSource } from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
// v1.4.25 — push-suppression threshold for workout PRs. A batch
// larger than this fires the detection job with `silent: true` so a
@@ -508,29 +508,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
}
// Write-time cross-source dedup through the one workout picker the reads
// use, with the same source ladder: one logical workout from two sources
// in one batch (or one HealthKit session re-sent under a new id) lands
// once. Rows the picker hides surface to the client as `duplicate`, so
// the sync cursor advances past them like the externalId case below.
const canonicalPrepared = pickCanonicalWorkoutRows(
prepared.map((p) => ({
index: p.index,
userId: p.row.userId,
sportType: p.row.sportType,
startedAt: p.row.startedAt as Date,
durationSec: p.row.durationSec,
// Zod's `measurementSourceEnum.optional().default("MANUAL")`
// guarantees `source` is set post-parse, but Prisma's
// `WorkoutCreateManyInput` types it optional.
source: (p.row.source ?? "MANUAL") as MeasurementSource,
avgHeartRate: p.row.avgHeartRate ?? null,
maxHeartRate: p.row.maxHeartRate ?? null,
metadata: p.row.metadata ?? null,
})),
// Write-time cross-source dedup through the one workout clustering the
// reads use (see `batchWorkoutIndicesToKeep`): one logical workout from two
// sources in one batch, or one HealthKit session re-sent under a new id,
// lands once. Rows hidden here surface to the client as `duplicate`, so the
// sync cursor advances past them like the externalId case below.
const survivingIndices = await batchWorkoutIndicesToKeep(
user.id,
prepared,
userPriority?.sourcePriorityJson ?? null,
);
const survivingIndices = new Set(canonicalPrepared.map((r) => r.index));
const droppedByWriteDedup: number[] = [];
for (const p of prepared) {
if (!survivingIndices.has(p.index)) {
+8
View File
@@ -13,6 +13,14 @@ export async function register() {
],
);
startEventLoopLagMonitor();
// Every locale falls back to the English message catalog, so a process
// without a readable `messages/en.json` cannot serve a page or a job.
// Refuse to start with the file named, rather than fail every request.
const { assertDefaultCatalogReadable } =
await import("@/lib/i18n/server-catalogs");
assertDefaultCatalogReadable();
beginBootStep("readiness_summary");
// First-boot readiness summary: a human-readable block on the core secrets
+24 -16
View File
@@ -15,7 +15,10 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
} from "@/lib/workouts/canonical-read";
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
@@ -118,26 +121,31 @@ export async function readActivityDays(args: {
),
),
includeWorkouts
? prisma.workout.findMany({
where: { userId, startedAt: { gte: from, lt: to } },
select: {
startedAt: true,
durationSec: true,
sportType: true,
source: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
},
orderBy: { startedAt: "asc" },
})
: Promise.resolve([]),
? readWorkoutsAroundRange({ from, to }, (bounds) =>
prisma.workout.findMany({
where: { userId, startedAt: bounds },
select: {
id: true,
startedAt: true,
durationSec: true,
sportType: true,
source: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
},
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
}),
)
: Promise.resolve(null),
]);
const loadIndex = loads.findIndex((m) => m.size > 0);
const load = loadIndex >= 0 ? loads[loadIndex] : null;
const workouts = pickCanonicalWorkoutRows(workoutRows, priorityJson);
const workouts = workoutRows
? canonicalWorkoutsInRange(workoutRows, priorityJson)
: [];
const workoutsByDay = new Map<
string,
{ minutes: number; count: number; bySport: Record<string, number> }
@@ -372,9 +372,12 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
// collapse, so a dual-device user's Coach prompt double-counted the
// session and inflated `perSport`/`totalInWindow` vs. the app. Pin the fix.
it("collapses a same-session cross-source workout pair to one, matching the UI", async () => {
const sharedStart = new Date("2026-06-03T06:30:00Z");
// Inside the 30-day window: the block keeps only the sessions that
// start in it.
const sharedStart = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
const workouts = [
{
id: "w-apple",
sportType: "RUNNING",
startedAt: sharedStart,
durationSec: 1800,
@@ -385,6 +388,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
source: "APPLE_HEALTH",
},
{
id: "w-withings",
sportType: "RUNNING",
// 90 s apart — same 5-minute bucket the picker uses.
startedAt: new Date(sharedStart.getTime() + 90_000),
+22 -1
View File
@@ -51,6 +51,7 @@
* is always owner-scoped and safe to read), so the block is `null` only when the
* account has no recent readings.
*/
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import {
UNBOUNDED_REACH,
reachFloor,
@@ -240,11 +241,26 @@ export async function buildLabsSnapshotBlock(
// Newest reading first: the first row seen per biomarker is its MOST RECENT
// value, every later row of it feeds its history. Field-by-field `where` —
// never a spread.
//
// With an analyte the match runs in SQL, before the row cap: matched in
// memory after it, the newest rows of every other marker used up the cap,
// and the asked-for marker lost its older readings (or vanished) without a
// word. A row's resolved name is its biomarker's when it is linked, its own
// otherwise; the in-memory check below stays the final word.
const rows = await prisma.labResult.findMany({
where: {
userId,
deletedAt: null,
takenAt: floor ? { gte: floor, lte: now } : { lte: now },
OR: wanted
? [
{
biomarkerId: null,
analyte: { contains: wanted, mode: "insensitive" },
},
{ biomarker: { name: { contains: wanted, mode: "insensitive" } } },
]
: undefined,
},
orderBy: { takenAt: "desc" },
take: MAX_ROWS,
@@ -297,7 +313,12 @@ export async function buildLabsSnapshotBlock(
const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`;
const existing = entries.get(key);
if (existing) {
if (wanted && row.value !== null && resolved.unit === existing.unit) {
// One unit written two ways ("mg/dl", "mg/dL") is one series.
if (
wanted &&
row.value !== null &&
sameLabUnit(resolved.unit, existing.unit)
) {
existing.earlier.push({
value: row.value,
takenAt: row.takenAt,
@@ -12,18 +12,23 @@ import type { UnitPreference } from "@/lib/measurements/display-transform";
function build(unitPreference: UnitPreference) {
const snapshot: Record<string, unknown> = {};
buildWorkoutsBlock({
workoutRows: [
{
sportType: "running",
startedAt: new Date("2026-09-20T07:00:00Z"),
durationSec: 1800,
totalEnergyKcal: 300,
totalDistanceM: 5000,
avgHeartRate: 150,
maxHeartRate: 170,
source: "APPLE_HEALTH",
},
],
workoutRows: {
range: { from: null, to: null },
__windowed: true,
rows: [
{
id: "w1",
sportType: "running",
startedAt: new Date("2026-09-20T07:00:00Z"),
durationSec: 1800,
totalEnergyKcal: 300,
totalDistanceM: 5000,
avgHeartRate: 150,
maxHeartRate: 170,
source: "APPLE_HEALTH",
},
],
},
sourcePriorityJson: null,
userTz: "UTC",
snapshot,
@@ -25,7 +25,10 @@ import {
getQuantityTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalWorkoutsInRange,
type WindowedWorkouts,
} from "@/lib/workouts/canonical-read";
import type { MeasurementSource } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
import { tzDayKey, tzWeekday } from "../snapshot-series";
@@ -43,7 +46,8 @@ import type {
const WORKOUT_RECENT_CAP = 15;
interface WorkoutsBlockContext {
workoutRows: Array<{
workoutRows: WindowedWorkouts<{
id: string;
sportType: string;
startedAt: Date;
durationSec: number;
@@ -85,7 +89,7 @@ export function buildWorkoutsBlock(ctx: Readonly<WorkoutsBlockContext>): void {
// `startedAt` ASC (its documented contract) — the builder wants
// most-recent-first for the capped `recent` list, so re-establish
// DESC order after the pick, same as `GET /api/workouts` does.
const workoutRows = pickCanonicalWorkoutRows(
const workoutRows = canonicalWorkoutsInRange(
rawWorkoutRows,
sourcePriorityJson ?? null,
).sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
@@ -0,0 +1,30 @@
/**
* The workout rows the Coach's workouts block narrates, read through the
* windowed reader so a session two sources recorded collapses to the row the
* workout list shows, also at the edge of the Coach's window.
*/
import { prisma } from "@/lib/db";
import { readWorkoutsAroundRange } from "@/lib/workouts/canonical-read";
export function readCoachWorkoutRows(userId: string, since: Date) {
return readWorkoutsAroundRange({ from: since, to: null }, (bounds) =>
prisma.workout.findMany({
where: { userId, startedAt: bounds },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
select: {
id: true,
sportType: true,
startedAt: true,
durationSec: true,
totalEnergyKcal: true,
totalDistanceM: true,
avgHeartRate: true,
maxHeartRate: true,
// Needed by the canonical pick in the block builder to collapse a
// dual-source session (e.g. Apple Watch + WHOOP recording the same
// run) to one, matching `GET /api/workouts`.
source: true,
},
}),
);
}
+2 -17
View File
@@ -79,6 +79,7 @@ import {
buildCoarseTimelineTail,
type CoarseTimelineTail,
} from "./snapshot-series";
import { readCoachWorkoutRows } from "./snapshot-blocks/workouts-read";
import { buildWorkoutsBlock } from "./snapshot-blocks/workouts-block";
import {
buildGlucoseBlock,
@@ -567,23 +568,7 @@ async function buildCoachSnapshotImpl(
? readSleepStageRows(userId, sleepRhythmCutoff)
: null;
const workoutRowsPromise = sources.has("workouts")
? prisma.workout.findMany({
where: { userId, startedAt: { gte: additiveCutoff("workouts") } },
orderBy: { startedAt: "desc" },
select: {
sportType: true,
startedAt: true,
durationSec: true,
totalEnergyKcal: true,
totalDistanceM: true,
avgHeartRate: true,
maxHeartRate: true,
// v1.30.4 — needed by `pickCanonicalWorkoutRows` in the block
// builder to collapse a dual-source session (e.g. Apple Watch +
// WHOOP recording the same run) to one, matching `GET /api/workouts`.
source: true,
},
})
? readCoachWorkoutRows(userId, additiveCutoff("workouts"))
: null;
// v1.17.0 — the glucose CLINICAL panel is a fixed 30-day clinical artifact,
// identical to the one the insights panel + doctor report render. It must NOT
+17
View File
@@ -21,3 +21,20 @@ export function closedBlockUntouched(key: string, touchedFrom: Date): boolean {
const match = CLOSED_BLOCK_RE.exec(key);
return match !== null && Number(match[1]) <= touchedFrom.getTime();
}
/**
* The earliest `measuredAt` among rows a write touched: the `touchedFrom` a
* writer that knows every row it wrote hands to `invalidateUserMeasurements`.
* `undefined` for no rows, which makes the invalidation drop closed blocks too.
*/
export function earliestMeasuredAt(
rows: Iterable<{ measuredAt: Date }>,
): Date | undefined {
let earliest: Date | undefined;
for (const row of rows) {
if (earliest === undefined || row.measuredAt < earliest) {
earliest = row.measuredAt;
}
}
return earliest;
}
+15 -2
View File
@@ -92,8 +92,16 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
updatedAt,
// One symptom the person put a number on, one they did not.
symptomLinks: [
{ severity: 4, symptom: { key: "cramps" } },
{ severity: null, symptom: { key: "fatigue" } },
{
severity: 4,
symptom: { key: "cramps" },
createdAt: new Date("2026-04-10T07:00:00.000Z"),
},
{
severity: null,
symptom: { key: "fatigue" },
createdAt: new Date("2026-04-10T07:00:00.000Z"),
},
],
},
]),
@@ -171,6 +179,11 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
// Only the rated link is listed. An unrated one is left out rather than
// written as a zero, so the file says "never rated" and not "rated none".
symptomSeverities: [{ key: "cramps", severity: 4 }],
// When each link was written, every link, disaster recovery only.
symptomLinkCreatedAt: [
{ key: "cramps", createdAt: "2026-04-10T07:00:00.000Z" },
{ key: "fatigue", createdAt: "2026-04-10T07:00:00.000Z" },
],
});
});
});
+3
View File
@@ -122,6 +122,9 @@ export const cycleDayLogSchema = z
updatedAt: isoDateTime.optional(),
symptomKeys: z.array(z.string()).default([]),
symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]),
symptomLinkCreatedAt: z
.array(z.object({ key: z.string().min(1), createdAt: isoDateTime }))
.optional(),
})
.passthrough();
/** Cycle-tracking preferences (one row per user). */
+59 -20
View File
@@ -154,6 +154,12 @@ export interface CycleBackupSection {
* never rated it — which is not the same as rating it zero.
*/
symptomSeverities: Array<{ key: string; severity: number }>;
/**
* When each symptom link was written, by key. Disaster-recovery files
* only, and sparse like `symptomSeverities`; a file without it restores
* the links stamped with the restore.
*/
symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>;
}>;
/**
* The account's OWN symptom definitions.
@@ -329,6 +335,14 @@ export async function buildCycleBackupSection(
symptomSeverities: d.symptomLinks
.filter((l): l is typeof l & { severity: number } => l.severity != null)
.map((l) => ({ key: l.symptom.key, severity: l.severity })),
...(disasterRecovery
? {
symptomLinkCreatedAt: d.symptomLinks.map((l) => ({
key: l.symptom.key,
createdAt: l.createdAt.toISOString(),
})),
}
: {}),
})),
customSymptoms: customSymptoms.map((sym) => ({
...(disasterRecovery ? { id: sym.id } : {}),
@@ -445,6 +459,50 @@ function enumOrNull<T extends string>(
: null;
}
/**
* The symptom links of one restored day log.
*
* Severities: the day's rated intensities, keyed by symptom. A file written
* before severities were carried has no list at all and every link comes back
* unrated, which is what it was. An entry naming a symptom the day does not
* have is simply never read. Each link's own write time comes back when the
* file carries it (disaster recovery), and is the restore's otherwise.
*
* Every unresolvable key was counted into `skips` by the caller, so dropping
* it here is a reported drop rather than a silent one. The rest of the day is
* unaffected by a symptom chip that will not resolve.
*/
function dayLogSymptomLinks(
d: {
symptomKeys?: string[];
symptomSeverities?: Array<{ key: string; severity?: number | null }>;
symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>;
},
symptomIdByKey: ReadonlyMap<string, string>,
) {
const severityByKey = new Map<string, number>();
for (const s of d.symptomSeverities ?? []) {
const severity = severityOrNull(s.severity);
if (severity !== null) severityByKey.set(s.key, severity);
}
const linkCreatedAt = new Map(
(d.symptomLinkCreatedAt ?? []).map((l) => [l.key, l.createdAt]),
);
return (d.symptomKeys ?? []).flatMap((k) => {
const id = symptomIdByKey.get(k);
const createdAt = linkCreatedAt.get(k);
return id
? [
{
symptomId: id,
severity: severityByKey.get(k) ?? null,
...(createdAt ? { createdAt: new Date(createdAt) } : {}),
},
]
: [];
});
}
/**
* Restore the cycle tables for `ownerId` from a parsed backup payload,
* inside an existing transaction. Delete-then-recreate, matching the
@@ -617,26 +675,7 @@ export async function restoreCycleData(
};
for (const d of payload.cycleDayLogs) {
// The day's rated intensities, keyed by symptom. A file written before
// severities were carried has no list at all and every link comes back
// unrated, which is what it was. An entry naming a symptom the day does not
// have is simply never read.
const severityByKey = new Map<string, number>();
for (const s of d.symptomSeverities ?? []) {
const severity = severityOrNull(s.severity);
if (severity !== null) severityByKey.set(s.key, severity);
}
// Every unresolvable key was counted into `skips` above, so dropping it
// here is a reported drop rather than a silent one. The rest of the day —
// flow, temperature, the encrypted note, every observation the person
// actually wrote — is unaffected by a symptom chip that will not resolve.
const symptomLinks = (d.symptomKeys ?? []).flatMap((k) => {
const id = symptomIdByKey.get(k);
return id
? [{ symptomId: id, severity: severityByKey.get(k) ?? null }]
: [];
});
const symptomLinks = dayLogSymptomLinks(d, symptomIdByKey);
const cycleId =
d.cycleId !== undefined
? d.cycleId === null
+17 -18
View File
@@ -42,7 +42,11 @@ import { effectiveMoodTz } from "@/lib/mood/date-key";
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { visitPath } from "@/lib/encounters/visit-links";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
WORKOUT_PICK_SELECT,
} from "@/lib/workouts/canonical-read";
/** Everything a section reader needs about the day. */
export interface DayFrame {
@@ -905,29 +909,24 @@ async function assessmentsPart(frame: DayFrame): Promise<SectionPart> {
/* ─── workouts, cycle ─────────────────────────────────────────────────────── */
async function workoutsPart(frame: DayFrame): Promise<SectionPart> {
const [stored, account] = await Promise.all([
prisma.workout.findMany({
where: {
userId: frame.userId,
startedAt: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: {
id: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
},
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
}),
const [account, stored] = await Promise.all([
prisma.user.findUnique({
where: { id: frame.userId },
select: { sourcePriorityJson: true },
}),
// The windowed reader orders the rows itself.
readWorkoutsAroundRange(
{ from: frame.dayStart, to: frame.dayEnd },
(bounds) =>
prisma.workout.findMany({
where: { userId: frame.userId, startedAt: bounds },
select: WORKOUT_PICK_SELECT,
}),
),
]);
// One session synced by two services is one workout on the day, as in the
// workout list it links to.
const rows = pickCanonicalWorkoutRows(
// workout list it links to, a twin across midnight included.
const rows = canonicalWorkoutsInRange(
stored,
account?.sourcePriorityJson ?? null,
);
+23 -15
View File
@@ -11,7 +11,10 @@
* session two sources reported counts once.
*/
import { prisma } from "@/lib/db";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
} from "@/lib/workouts/canonical-read";
import type { DoctorReportWorkouts } from "@/lib/doctor-report-types";
/**
@@ -29,20 +32,25 @@ export async function loadReportWorkouts(params: {
sourcePriorityJson: unknown;
}): Promise<DoctorReportWorkouts | null> {
const { userId, start, end, days, sourcePriorityJson } = params;
const rows = await prisma.workout.findMany({
where: { userId, startedAt: { gte: start, lte: end } },
orderBy: { startedAt: "asc" },
select: {
sportType: true,
startedAt: true,
durationSec: true,
source: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
},
});
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
const rows = await readWorkoutsAroundRange(
{ from: start, to: end, inclusiveEnd: true },
(bounds) =>
prisma.workout.findMany({
where: { userId, startedAt: bounds },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
select: {
id: true,
sportType: true,
startedAt: true,
durationSec: true,
source: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
},
}),
);
const canonical = canonicalWorkoutsInRange(rows, sourcePriorityJson);
return summariseReportWorkouts(canonical, days);
}
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { Buffer } from "node:buffer";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { parseBackupPayload } from "@/lib/validations/backup";
process.env.ENCRYPTION_KEY =
@@ -201,6 +201,7 @@ function makePrisma() {
enteredUnit: "IU",
source: "MANUAL",
createdAt: new Date("2026-07-01T08:00:00.000Z"),
updatedAt: new Date("2026-07-01T08:05:00.000Z"),
},
],
// v1.40 (#1024) — one ended course with a note, one running.
@@ -211,6 +212,7 @@ function makePrisma() {
endsOn: new Date("2026-03-07T00:00:00.000Z"),
noteEncrypted: encryptToBytes("the March flu"),
createdAt: new Date("2026-03-01T08:00:00.000Z"),
updatedAt: new Date("2026-03-01T08:05:00.000Z"),
},
{
id: "course-june",
@@ -218,6 +220,7 @@ function makePrisma() {
endsOn: null,
noteEncrypted: null,
createdAt: new Date("2026-06-10T08:00:00.000Z"),
updatedAt: new Date("2026-06-10T08:05:00.000Z"),
},
],
// A two-step ramp, one row carrying an encrypted note and one
@@ -374,10 +377,12 @@ function makePrisma() {
{
rating: 4,
moodTag: { key: "sleep_quality", kind: "RATED" },
createdAt: new Date("2026-07-19T20:00:00.000Z"),
},
{
rating: null,
moodTag: { key: "headache", kind: "BINARY" },
createdAt: new Date("2026-07-19T20:00:01.000Z"),
},
],
},
@@ -393,6 +398,7 @@ function makePrisma() {
source: "MANUAL",
externalSourceVersion: null,
createdAt: new Date("2026-07-19T20:15:00.000Z"),
updatedAt: new Date("2026-07-19T20:16:00.000Z"),
},
]),
},
@@ -785,32 +791,47 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
* decision.
*/
describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => {
it.each(["portable-export", "disaster-recovery"] as const)(
"carries every course with its note readable in a %s file",
async (purpose) => {
installSectionMocks();
const { payload } = await buildFullBackupPayload(
makePrisma() as never,
"user-1",
{ purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") },
);
const parsed = parseBackupPayload(payload);
expect(parsed.medications[0].courses).toEqual([
{
startsOn: "2026-03-01",
endsOn: "2026-03-07",
note: "the March flu",
createdAt: "2026-03-01T08:00:00.000Z",
},
{
startsOn: "2026-06-10",
endsOn: null,
note: null,
createdAt: "2026-06-10T08:00:00.000Z",
},
]);
},
);
async function courses(purpose: "portable-export" | "disaster-recovery") {
installSectionMocks();
const { payload } = await buildFullBackupPayload(
makePrisma() as never,
"user-1",
{ purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") },
);
return parseBackupPayload(payload).medications[0].courses;
}
it("carries every course with its note readable in a portable file", async () => {
expect(await courses("portable-export")).toEqual([
{
startsOn: "2026-03-01",
endsOn: "2026-03-07",
note: "the March flu",
createdAt: "2026-03-01T08:00:00.000Z",
},
{
startsOn: "2026-06-10",
endsOn: null,
note: null,
createdAt: "2026-06-10T08:00:00.000Z",
},
]);
});
it("carries every course with its note as ciphertext in a disaster-recovery file", async () => {
const [march, june] = (await courses("disaster-recovery"))!;
expect(march).toMatchObject({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
note: null,
createdAt: "2026-03-01T08:00:00.000Z",
updatedAt: "2026-03-01T08:05:00.000Z",
});
expect(decryptFromBytes(Buffer.from(march!.noteEncrypted!, "base64"))).toBe(
"the March flu",
);
expect(june).toMatchObject({ note: null, noteEncrypted: null });
});
});
describe("buildFullBackupPayload — supplement composition (v1.43)", () => {
@@ -836,6 +857,9 @@ describe("buildFullBackupPayload — supplement composition (v1.43)", () => {
enteredUnit: "IU",
source: "MANUAL",
createdAt: "2026-07-01T08:00:00.000Z",
...(purpose === "disaster-recovery"
? { updatedAt: "2026-07-01T08:05:00.000Z" }
: {}),
},
]);
},
@@ -112,7 +112,13 @@ function buildMockPrisma() {
feverC: null,
noteEncrypted: encryptToBytes("Bad day."),
updatedAt: new Date("2026-04-10T00:00:00.000Z"),
symptomLinks: [{ severity: 3, symptom: { key: "headache" } }],
symptomLinks: [
{
severity: 3,
symptom: { key: "headache" },
createdAt: new Date("2026-04-10T07:00:00.000Z"),
},
],
},
],
},
@@ -298,7 +304,13 @@ describe("buildRecordsBackupSection", () => {
createdAt,
updatedAt,
deletedAt,
symptomLinks: [{ severity: 3, symptom: { key: "headache" } }],
symptomLinks: [
{
severity: 3,
symptom: { key: "headache" },
createdAt: new Date("2026-04-10T07:00:00.000Z"),
},
],
},
],
},
@@ -384,7 +396,13 @@ describe("buildRecordsBackupSection", () => {
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
symptoms: [{ key: "headache", severity: 3 }],
symptoms: [
{
key: "headache",
severity: 3,
createdAt: "2026-04-10T07:00:00.000Z",
},
],
},
],
});
@@ -139,7 +139,12 @@ function sourceClient() {
deletedAt: null,
createdAt: new Date("2026-07-19T00:00:00.000Z"),
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
symptomLinks: [{ symptom: { key: "custom:jaw_tension" } }],
symptomLinks: [
{
symptom: { key: "custom:jaw_tension" },
createdAt: new Date("2026-07-19T08:00:00.000Z"),
},
],
},
]),
},
@@ -8,7 +8,7 @@
*/
import { z } from "zod/v4";
import { FamilyRelationship } from "@/generated/prisma/enums";
import { isoDateTime } from "@/lib/export/backup-schema-kit";
import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit";
export const familyHistoryBackupSchema = z
.object({
@@ -17,7 +17,9 @@ export const familyHistoryBackupSchema = z
condition: z.string().min(1),
ageAtOnset: z.number().int().nullable().optional(),
note: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
+52 -6
View File
@@ -1,26 +1,61 @@
/**
* The family history, with both backup ends in one file.
*/
import { Buffer } from "node:buffer";
import type { PrismaClient } from "@/generated/prisma/client";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
toFamilyHistoryEntryDTO,
type FamilyHistoryEntryDTO,
} from "@/lib/records/dto";
import type { RestoreContext } from "@/lib/export/restore-context";
import {
decodeEncryptedBytes,
type RestoreContext,
} from "@/lib/export/restore-context";
// ── Export ───────────────────────────────────────────────────────────────────
/** The family-history slice of the records section. */
/**
* A family-history entry as the disaster-recovery file carries it: the note
* as the ciphertext at rest, and the deletion. The portable file carries the
* readable DTO of the live entries only.
*/
export type CanonicalFamilyHistoryEntry = FamilyHistoryEntryDTO & {
notesEncrypted?: string | null;
deletedAt?: string | null;
};
/**
* The family-history slice of the records section. A disaster-recovery file
* carries deleted entries too, as every other record with a tombstone does:
* a restore from it has to give back the account as it was, deletions
* included, and the file used to drop them.
*/
export async function buildFamilyHistoryBackupSlice(
prisma: Pick<PrismaClient, "familyHistoryEntry">,
userId: string,
): Promise<{ familyHistory: FamilyHistoryEntryDTO[] }> {
disasterRecovery: boolean,
): Promise<{ familyHistory: CanonicalFamilyHistoryEntry[] }> {
const familyRows = await prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
});
const familyHistory = familyRows.map(toFamilyHistoryEntryDTO);
const familyHistory: CanonicalFamilyHistoryEntry[] = disasterRecovery
? familyRows.map((row) => ({
id: row.id,
relationship: row.relationship,
condition: row.condition,
ageAtOnset: row.ageAtOnset,
note: null,
notesEncrypted: row.notesEncrypted
? Buffer.from(row.notesEncrypted).toString("base64")
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
}))
: familyRows.map(toFamilyHistoryEntryDTO);
return { familyHistory };
}
@@ -44,8 +79,19 @@ export async function restoreFamilyHistory(ctx: RestoreContext): Promise<void> {
relationship: familyEntry.relationship as never,
condition: familyEntry.condition,
ageAtOnset: familyEntry.ageAtOnset ?? null,
// The ciphertext verbatim when the file carries it, the readable note
// sealed here otherwise.
notesEncrypted:
familyEntry.note == null ? null : encryptToBytes(familyEntry.note),
familyEntry.notesEncrypted !== undefined
? familyEntry.notesEncrypted === null
? null
: decodeEncryptedBytes(familyEntry.notesEncrypted)
: familyEntry.note == null
? null
: encryptToBytes(familyEntry.note),
deletedAt: familyEntry.deletedAt
? new Date(familyEntry.deletedAt)
: null,
...(familyEntry.createdAt
? { createdAt: new Date(familyEntry.createdAt) }
: {}),
+3
View File
@@ -18,6 +18,9 @@ export const illnessSymptomBackupSchema = z
.object({
key: z.string().min(1),
severity: z.number().int().nullable().optional(),
// When the link was written. Disaster-recovery files only; without it the
// link is stamped with the restore, as before.
createdAt: isoDateTime.optional(),
})
.passthrough();
export const illnessDayLogBackupSchema = z
+18 -2
View File
@@ -94,6 +94,7 @@ export async function buildIllnessBackupSlice(
symptoms: dayLog.symptomLinks.map((link) => ({
key: link.symptom.key,
severity: link.severity,
createdAt: link.createdAt.toISOString(),
})),
})),
}))
@@ -168,7 +169,14 @@ async function restoreEpisodes(ctx: RestoreContext): Promise<void> {
if (episode.parentConditionId) {
await tx.illnessEpisode.update({
where: { id: episode.id, userId: ownerId },
data: { parentConditionId: episode.parentConditionId },
// The pointer is a second write to the row; the row's own last write
// stays the one the file carries.
data: {
parentConditionId: episode.parentConditionId,
...(episode.updatedAt
? { updatedAt: new Date(episode.updatedAt) }
: {}),
},
});
}
}
@@ -212,7 +220,15 @@ async function restoreDayLogs(ctx: RestoreContext): Promise<void> {
const symptomLinks = dayLog.symptoms.flatMap((symptom) => {
const symptomId = symptomByKey.get(symptom.key);
return symptomId
? [{ symptomId, severity: symptom.severity ?? null }]
? [
{
symptomId,
severity: symptom.severity ?? null,
...(symptom.createdAt
? { createdAt: new Date(symptom.createdAt) }
: {}),
},
]
: [];
});
await tx.illnessDayLog.create({
+3
View File
@@ -81,6 +81,9 @@ export const biomarkerBackupSchema = z
// The catalogue analyte the marker resolves to.
analyteKey: z.string().nullable().optional(),
context: z.string().nullable().optional(),
// The context's ciphertext, disaster-recovery files only. A file without
// it carries the readable context, which the restore seals.
contextEncrypted: base64BytesSchema.nullable().optional(),
// The other spellings the marker answers to. Absent on
// files written before them.
aliases: z
+28 -8
View File
@@ -103,6 +103,11 @@ export interface BiomarkerBackupEntry {
panel: string | null;
hidden: boolean;
context: string | null;
/**
* The context's ciphertext as base64, disaster-recovery files only, where
* `context` is null. A file without it carries the readable context.
*/
contextEncrypted?: string | null;
/** The catalogue key the marker's name and unit resolve to. */
analyteKey?: string | null;
/**
@@ -259,11 +264,22 @@ export async function buildLabsBackupSlice(
upperBound: b.upperBound,
panel: b.panel,
hidden: b.hidden,
context: readForExport(
b.contextEncrypted,
"biomarker context",
decryptContextFromBytes,
),
// Ciphertext verbatim in a disaster-recovery file, like every other
// sealed column; the readable context in a portable one.
...(disasterRecovery
? {
context: null,
contextEncrypted: b.contextEncrypted
? Buffer.from(b.contextEncrypted).toString("base64")
: null,
}
: {
context: readForExport(
b.contextEncrypted,
"biomarker context",
decryptContextFromBytes,
),
}),
analyteKey: b.analyteKey,
aliases: aliasesByMarker.get(b.id) ?? [],
}));
@@ -347,9 +363,13 @@ export async function restoreLabMarkers(ctx: RestoreContext): Promise<void> {
panel: biomarker.panel ?? null,
hidden: biomarker.hidden ?? false,
contextEncrypted:
biomarker.context == null
? null
: encryptContextToBytes(biomarker.context),
biomarker.contextEncrypted !== undefined
? biomarker.contextEncrypted === null
? null
: decodeEncryptedBytes(biomarker.contextEncrypted)
: biomarker.context == null
? null
: encryptContextToBytes(biomarker.context),
analyteKey: biomarker.analyteKey ?? null,
...(biomarker.createdAt
? { createdAt: new Date(biomarker.createdAt) }
+21 -2
View File
@@ -109,6 +109,9 @@ export const medicationIngredientSchema = z
enteredUnit: z.string().max(8).nullable().optional(),
source: z.enum(["MANUAL", "LABEL_AI"]).default("MANUAL"),
createdAt: isoDateTime.optional(),
// The row's last write. Disaster-recovery files only; a file without it
// restores the row stamped with the restore, as before.
updatedAt: isoDateTime.optional(),
})
.passthrough();
/**
@@ -121,7 +124,11 @@ export const medicationCourseSchema = z
startsOn: z.iso.date(),
endsOn: z.iso.date().nullable(),
note: z.string().max(280).nullable().optional(),
// The note's ciphertext, disaster-recovery files only. A file without it
// (any written before) carries the readable note, which the restore seals.
noteEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
/**
@@ -248,12 +255,21 @@ export const medicationEfficacyTargetSchema = z
export const customMedicationCategorySchema = z
.object({
key: z.string().regex(/^custom:[0-9a-f-]{36}$/),
label: z.string().trim().min(1).max(40),
// The readable label: a portable file, and every disaster-recovery file
// written before the label travelled as ciphertext.
label: z.string().trim().min(1).max(40).nullable().optional(),
// The label's ciphertext, as a disaster-recovery file carries it now.
labelEncrypted: base64BytesSchema.optional(),
sortOrder: z.number().int().min(0).default(0),
isActive: z.boolean().default(true),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
.passthrough()
.refine((c) => c.labelEncrypted !== undefined || (c.label ?? "") !== "", {
message: "a custom category needs its label or its ciphertext",
path: ["label"],
});
export const medicationSchema = z
.object({
id: z.string().min(1).optional(),
@@ -293,6 +309,9 @@ export const medicationSchema = z
// whole file. Since v1.40 it may be `custom:<uuid>`, the key of an entry
// in `customMedicationCategories`.
category: z.string().max(64).optional(),
// When the category was last set. Disaster-recovery files only; without
// it the restored assignment is stamped with the restore, as before.
categoryUpdatedAt: isoDateTime.nullable().optional(),
deliveryForm: z.enum(MedicationDeliveryForm).optional(),
trackInjectionSites: z.boolean().optional(),
allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(),
+28 -6
View File
@@ -54,6 +54,9 @@ const MEDICATION_BACKUP_INCLUDE = {
{ id: "asc" },
] as const,
},
// When the category was last set. Read for the disaster-recovery file,
// which gives the assignment back with its own timestamp.
categoryAssignment: { select: { updatedAt: true } },
// What the drug was supposed to move. The biomarker is read by NAME
// rather than by id, exactly as the lab results beside it are: a
// portable restore mints a fresh biomarker id, and `(userId, name)` is
@@ -101,6 +104,8 @@ function serialiseMedicationRow(
externalSource: m.externalSource,
externalId: m.externalId,
updatedAt: m.updatedAt.toISOString(),
categoryUpdatedAt:
m.categoryAssignment?.updatedAt.toISOString() ?? null,
}
: // No id: a portable restore mints a fresh one. The relation links
// still name this drug, so the file says which row they mean.
@@ -206,7 +211,9 @@ function serialiseMedicationHistory(
// counting toward the day's micronutrients. Canonical amount plus the
// amount as entered, so the form shows what was typed.
ingredients: m.ingredients.map((i) => ({
...(disasterRecovery ? { id: i.id } : {}),
...(disasterRecovery
? { id: i.id, updatedAt: i.updatedAt.toISOString() }
: {}),
nutrient: i.nutrient,
amountPerUnit: i.amountPerUnit,
unit: i.unit,
@@ -215,10 +222,21 @@ function serialiseMedicationHistory(
source: i.source,
createdAt: i.createdAt.toISOString(),
})),
// The note follows the contract of every other sealed column: the
// disaster-recovery file carries the ciphertext verbatim (and the row's
// own last write), a portable file the decrypted text.
courses: m.courses.map((c) => ({
startsOn: dayKeyOfDate(c.startsOn),
endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null,
...(disasterRecovery
? {
note: null,
noteEncrypted: c.noteEncrypted
? Buffer.from(c.noteEncrypted).toString("base64")
: null,
updatedAt: c.updatedAt.toISOString(),
}
: { note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null }),
createdAt: c.createdAt.toISOString(),
})),
// Titration: when the dose moved, to what, and why. The drug comes
@@ -473,11 +491,15 @@ export async function buildMedicationsBackupPart(
prisma,
);
// The account's own categories, restored before the medications that name
// them by key. Labels travel decrypted so a file restores on an instance
// with a different key; every one is carried, hidden ones included,
// because a medication can still be filed under it.
// them by key. Every one is carried, hidden ones included, because a
// medication can still be filed under it. A portable file carries the
// label decrypted, so it restores on an instance with another key; a
// disaster-recovery file carries the ciphertext verbatim, like every other
// sealed column, and the row's own last write.
const customMedicationCategories =
await readMedicationCategoryLabelsForBackup(userId, prisma);
await readMedicationCategoryLabelsForBackup(userId, prisma, {
disasterRecovery,
});
// Where each archived era sits in its OWN drug's ordered list. Built once
// here rather than per row, and scoped per medication because the
// supersede pointer never crosses a drug — every route that writes it
+41 -2
View File
@@ -77,10 +77,18 @@ async function restoreCustomCategories(
data: {
userId: ownerId,
key,
labelEncrypted: encryptCategoryLabel(c.label),
// The ciphertext verbatim when the file carries it (a
// disaster-recovery file), the readable label sealed here otherwise
// (a portable file, or a recovery file written before the label
// travelled sealed).
labelEncrypted:
c.labelEncrypted !== undefined
? decodeEncryptedBytes(c.labelEncrypted)
: encryptCategoryLabel(c.label ?? ""),
sortOrder: c.sortOrder,
isActive: c.isActive,
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}),
},
});
}
@@ -254,6 +262,7 @@ function medicationChildrenData(m: BackupMedication, ownerId: string) {
enteredUnit: i.enteredUnit ?? null,
source: i.source,
...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}),
...(i.updatedAt ? { updatedAt: new Date(i.updatedAt) } : {}),
})),
},
// `resumedAt` passes through as null when the era is open. It is not
@@ -341,14 +350,20 @@ async function restoreCourses(
startsOn: Date;
endsOn: Date | null;
note: string | null;
noteEncrypted?: string | null;
createdAt?: Date;
updatedAt?: Date;
}> =
m.courses !== undefined
? m.courses.map((c) => ({
startsOn: dateOfDayKey(c.startsOn),
endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null,
note: c.note ?? null,
...(c.noteEncrypted !== undefined
? { noteEncrypted: c.noteEncrypted }
: {}),
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}),
}))
: (() => {
const derived = courseFromWindow(
@@ -365,8 +380,18 @@ async function restoreCourses(
userId: ownerId,
startsOn: c.startsOn,
endsOn: c.endsOn,
noteEncrypted: c.note ? encryptToBytes(c.note) : null,
// Ciphertext verbatim from a disaster-recovery file; the readable note
// of a portable or older file sealed here.
noteEncrypted:
c.noteEncrypted !== undefined
? c.noteEncrypted === null
? null
: decodeEncryptedBytes(c.noteEncrypted)
: c.note
? encryptToBytes(c.note)
: null,
...(c.createdAt ? { createdAt: c.createdAt } : {}),
...(c.updatedAt ? { updatedAt: c.updatedAt } : {}),
})),
});
const window = projectCourseWindow(courseRows);
@@ -589,6 +614,12 @@ export async function restoreMedications(ctx: RestoreContext): Promise<void> {
categoryKeyRemap.get(m.category) ?? m.category,
tx,
);
if (m.categoryUpdatedAt) {
await tx.medicationCategoryAssignment.update({
where: { medicationId: created.id },
data: { updatedAt: new Date(m.categoryUpdatedAt) },
});
}
}
await restoreCourses(tx, ownerId, m, created, restoreTz);
await restoreScheduleRevisions(
@@ -598,6 +629,14 @@ export async function restoreMedications(ctx: RestoreContext): Promise<void> {
medicationIndex,
unresolvedRevisionLinks,
);
// The writes above (the course window) moved the row's `updatedAt` to
// now; a file that carries the row's own last write puts it back.
if (m.updatedAt) {
await tx.medication.update({
where: { id: created.id },
data: { updatedAt: new Date(m.updatedAt) },
});
}
for (const target of m.efficacyTargets) {
links.pendingEfficacyTargets.push({ medicationId: created.id, target });
}
+9
View File
@@ -81,6 +81,12 @@ export const moodEntrySchema = z
// RATED-only meaning, so every file written before this one parses
// unchanged and restores identically: absent reads as none.
structuredTags: z.array(z.string().min(1)).default([]),
// When each link (rated or not) was written, by tag key. Disaster-recovery
// files only; a file without it restores the links stamped with the
// restore, as before.
linkCreatedAt: z
.array(z.object({ key: z.string().min(1), createdAt: isoDateTime }))
.optional(),
// The day context, when the entry had one. Optional and nullable at the
// top: a file written before this existed carries no `context` key at all
// and must parse and restore exactly as it did. Every field inside is
@@ -116,6 +122,9 @@ export const moodEntrySchema = z
// plain text, a disaster-recovery file the ciphertext.
note: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
// The context row's own stamps, disaster-recovery files only.
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough()
.nullable()
+12 -3
View File
@@ -60,6 +60,7 @@ const MOOD_ENTRY_BACKUP_SELECT = {
*/
const MOOD_ENTRY_TAG_LINK_SELECT = {
rating: true,
createdAt: true,
moodTag: { select: { key: true, kind: true } },
} as const satisfies Prisma.MoodEntryTagLinkSelect;
@@ -69,9 +70,8 @@ const MOOD_ENTRY_TAG_LINK_SELECT = {
* `moodEntryId` and `userId` are absent for the reasons the exclusion list in
* `mood-backup-completeness.test.ts` states: the context rides inside its
* entry and is re-bound to whatever id that entry got, and the owner is
* re-derived from the restoring account. The two row stamps are absent for the
* third reason there — they describe when the sub-row was written, not when
* the day happened, and the entry already carries the moment that matters.
* re-derived from the restoring account. The two row stamps travel in the
* disaster-recovery file only, which gives the account back as it was.
*
* Named rather than inlined so the guard can compare it field-by-field against
* the model, exactly as it does for the two selects above. This is the whole
@@ -97,6 +97,8 @@ const MOOD_CONTEXT_BACKUP_SELECT = {
eventValence: true,
eventAt: true,
notesEncrypted: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.MoodContextSelect;
type BackupMoodContext = Prisma.MoodContextGetPayload<{
@@ -149,6 +151,8 @@ function serialiseMoodContext(
notesEncrypted: moodContext.notesEncrypted
? Buffer.from(moodContext.notesEncrypted).toString("base64")
: null,
createdAt: moodContext.createdAt.toISOString(),
updatedAt: moodContext.updatedAt.toISOString(),
}
: { note: readNote(moodContext.notesEncrypted, null) }),
workStatus: moodContext.workStatus,
@@ -220,6 +224,11 @@ function serialiseMoodEntry(
syncVersion: moodEntry.syncVersion,
createdAt: moodEntry.createdAt.toISOString(),
updatedAt: moodEntry.updatedAt.toISOString(),
// When each link was written, by tag key, for both link arms.
linkCreatedAt: moodEntry.tagLinks.map((tagLink) => ({
key: tagLink.moodTag.key,
createdAt: tagLink.createdAt.toISOString(),
})),
}
: { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }),
id: moodEntry.id,
+13 -1
View File
@@ -256,6 +256,9 @@ async function restoreMoodContext(
context.notesEncrypted == null
? encryptNote(context.note ?? null)
: decodeEncryptedBytes(context.notesEncrypted),
// The row's own stamps when the file carries them (disaster recovery).
...(context.createdAt ? { createdAt: new Date(context.createdAt) } : {}),
...(context.updatedAt ? { updatedAt: new Date(context.updatedAt) } : {}),
},
});
}
@@ -320,7 +323,16 @@ async function restoreMoodEntry(
? [{ moodEntryId: restored.id, moodTagId, rating: null }]
: [];
});
const links = [...factorLinks, ...tagLinks];
// Each link's own write time, when the file carries it.
const linkCreatedAt = new Map(
(entry.linkCreatedAt ?? []).map((link) => [link.key, link.createdAt]),
);
const keyOfTag = new Map<string, string>();
for (const [key, id] of [...factorByKey, ...tagByKey]) keyOfTag.set(id, key);
const links = [...factorLinks, ...tagLinks].map((link) => {
const createdAt = linkCreatedAt.get(keyOfTag.get(link.moodTagId) ?? "");
return createdAt ? { ...link, createdAt: new Date(createdAt) } : link;
});
if (links.length > 0) {
await tx.moodEntryTagLink.createMany({ data: links });
}
@@ -29,5 +29,8 @@ export const nutrientDaySchema = z
// Absent from files written before v1.39.7.
externalSourceVersion: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
// Disaster-recovery files only; without it the day is stamped with the
// restore, as before.
updatedAt: isoDateTime.optional(),
})
.passthrough();
+7
View File
@@ -33,6 +33,7 @@ export async function buildNutrientsBackupPart(
source: true,
externalSourceVersion: true,
createdAt: true,
updatedAt: true,
},
orderBy: [{ day: "desc" }, { nutrient: "asc" }],
});
@@ -46,6 +47,11 @@ export async function buildNutrientsBackupPart(
source: n.source,
externalSourceVersion: n.externalSourceVersion,
createdAt: n.createdAt.toISOString(),
// The day's last write, for the disaster-recovery file, which gives
// the account back as it was.
...(ctx.disasterRecovery
? { updatedAt: n.updatedAt.toISOString() }
: {}),
})),
},
counts: { nutrientDays: nutrientDays.length },
@@ -79,6 +85,7 @@ export async function restoreNutrientDays(ctx: RestoreContext): Promise<void> {
source: n.source,
externalSourceVersion: n.externalSourceVersion ?? null,
...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}),
...(n.updatedAt ? { updatedAt: new Date(n.updatedAt) } : {}),
})),
});
}
+1 -1
View File
@@ -122,7 +122,7 @@ export async function buildRecordsBackupSection(
buildLabsBackupSlice(prisma, userId, disasterRecovery),
buildIllnessBackupSlice(prisma, userId, disasterRecovery),
buildAllergiesBackupSlice(prisma, userId, disasterRecovery),
buildFamilyHistoryBackupSlice(prisma, userId),
buildFamilyHistoryBackupSlice(prisma, userId, disasterRecovery),
buildWorkoutsBackupSlice(prisma, userId, disasterRecovery),
buildDocumentsBackupSlice(
prisma,
@@ -0,0 +1,91 @@
/**
* A message catalog that cannot be read costs its own locale, not every
* request.
*
* The catalogs are read from `messages/` at run time. A missing or corrupt
* file used to throw from `serverCatalog`, and because the matchers iterate
* every locale (`localisedValues`), one bad non-English file made every
* request that touched them a 500. Now that locale is served the English
* catalog and the failure is logged once; the English catalog itself, which
* everything falls back to, stops the process at boot with the file named.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/logging/signal", () => ({ emitSignal: vi.fn() }));
import { emitSignal } from "@/lib/logging/signal";
import { localisedValues } from "../shared-resolve";
import {
assertDefaultCatalogReadable,
serverCatalog,
} from "../server-catalogs";
const CACHE = Symbol.for("healthlog.i18n.serverCatalogs");
const EN = { common: { save: "Save" } };
function clearCatalogCache() {
(globalThis as { [CACHE]?: Map<string, unknown> })[CACHE]?.clear();
}
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "hl-catalogs-"));
mkdirSync(join(root, "messages"));
writeFileSync(join(root, "messages", "en.json"), JSON.stringify(EN));
writeFileSync(join(root, "messages", "de.json"), '{"common": {"save": ');
vi.spyOn(process, "cwd").mockReturnValue(root);
vi.mocked(emitSignal).mockClear();
clearCatalogCache();
});
afterEach(() => {
vi.restoreAllMocks();
clearCatalogCache();
rmSync(root, { recursive: true, force: true });
});
describe("a catalog that cannot be read", () => {
it("serves a corrupt locale the English catalog and logs it once", () => {
expect(serverCatalog("de")).toEqual(EN);
expect(serverCatalog("de")).toEqual(EN);
expect(emitSignal).toHaveBeenCalledTimes(1);
expect(emitSignal).toHaveBeenCalledWith(
expect.objectContaining({
action: "i18n.catalog.fallback",
level: "warn",
meta: { locale: "de", reason: "corrupt", fallback: "en" },
}),
);
});
it("serves a missing locale the English catalog", () => {
expect(serverCatalog("fr")).toEqual(EN);
expect(emitSignal).toHaveBeenCalledWith(
expect.objectContaining({
meta: { locale: "fr", reason: "missing", fallback: "en" },
}),
);
});
it("keeps a matcher that reads every locale answering", () => {
expect(localisedValues("common.save")).toEqual(["Save"]);
});
it("refuses to boot without the English catalog, naming the file", () => {
rmSync(join(root, "messages", "en.json"));
expect(() => assertDefaultCatalogReadable()).toThrow(
/en message catalog at .*messages[/\\]en\.json is missing/,
);
});
it("refuses to boot on an English catalog that does not parse", () => {
writeFileSync(join(root, "messages", "en.json"), "[1, 2");
expect(() => assertDefaultCatalogReadable()).toThrow(/is corrupt/);
});
});
+94 -7
View File
@@ -18,12 +18,23 @@
* milliseconds.
*
* The directory is `messages/` under the working directory: the checkout in
* development and tests, `/app` in the image, which copies it there.
* development and tests, `/app` in the image, which copies it there (and the
* image build checks every locale's file is there and parses).
*
* A catalog that cannot be read is not every request's problem. A missing or
* corrupt non-English catalog is logged once per process and that locale is
* served the English one; before, the read threw, and since the matchers
* iterate every locale, one bad file turned every request into a 500. The
* English catalog is the fallback itself, so a process without it cannot serve
* at all: the boot check (`assertDefaultCatalogReadable`) refuses to start one
* with a message that names the file.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { locales, type Locale } from "./config";
import { emitSignal } from "@/lib/logging/signal";
import { defaultLocale, locales, type Locale } from "./config";
export type ServerCatalog = Record<string, unknown>;
@@ -51,11 +62,87 @@ export function serverCatalog(locale: Locale): ServerCatalog {
if (!(locales as readonly string[]).includes(locale)) {
throw new Error(`unknown locale: ${String(locale)}`);
}
const parsed = JSON.parse(
readFileSync(join(catalogDirectory(), `${locale}.json`), "utf8"),
) as ServerCatalog;
loaded.set(locale, parsed);
return parsed;
const read = readCatalog(locale);
if (read.ok) {
loaded.set(locale, read.catalog);
return read.catalog;
}
if (locale === defaultLocale) {
throw new Error(defaultCatalogMessage(read));
}
// Logged once: the fallback is cached under the locale, so the next read
// of it is a cache hit.
emitSignal({
action: "i18n.catalog.fallback",
level: "warn",
meta: { locale, reason: read.reason, fallback: defaultLocale },
error: read.error,
});
const fallback = serverCatalog(defaultLocale);
loaded.set(locale, fallback);
return fallback;
}
type CatalogRead =
| { ok: true; catalog: ServerCatalog }
| {
ok: false;
path: string;
reason: "missing" | "unreadable" | "corrupt";
error: unknown;
};
function readCatalog(locale: Locale): CatalogRead {
const path = join(catalogDirectory(), `${locale}.json`);
let text: string;
try {
text = readFileSync(path, "utf8");
} catch (error) {
const missing = (error as NodeJS.ErrnoException)?.code === "ENOENT";
return {
ok: false,
path,
reason: missing ? "missing" : "unreadable",
error,
};
}
try {
const parsed: unknown = JSON.parse(text);
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
return {
ok: false,
path,
reason: "corrupt",
error: new Error("the catalog is not a JSON object"),
};
}
return { ok: true, catalog: parsed as ServerCatalog };
} catch (error) {
return { ok: false, path, reason: "corrupt", error };
}
}
function defaultCatalogMessage(read: Extract<CatalogRead, { ok: false }>) {
const detail =
read.error instanceof Error ? read.error.message : String(read.error);
return (
`The ${defaultLocale} message catalog at ${read.path} is ${read.reason} ` +
`(${detail}). Every other locale falls back to it, so the server cannot ` +
`serve without it; the image must ship messages/*.json next to server.js.`
);
}
/**
* Boot check: read the English catalog now and throw a message naming the
* file when it is missing or does not parse, so a broken image stops at boot
* instead of failing every request.
*/
export function assertDefaultCatalogReadable(): void {
serverCatalog(defaultLocale);
}
/** Locales whose catalog this process has read so far (for tests and logs). */
@@ -9,6 +9,7 @@
* - the per-day idempotency key + insufficient-data gate.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { canonicalWorkoutTrimps } from "@/lib/workouts/canonical-read";
/**
* The post-mutation tail reads the real `@/lib/db` client, not the fake this
@@ -32,7 +33,6 @@ import {
percentile,
resolvePersonalReference,
persistStrainScore,
canonicalWorkoutTrimps,
canonicalDayActiveEnergy,
STRAIN_ACTIVE_ENERGY_REFERENCE,
STRAIN_SCORE_EXTERNAL_ID_PREFIX,
+15 -15
View File
@@ -76,7 +76,11 @@ import {
userDayKey,
} from "@/lib/tz/format";
import { MIN_PAIRED_N } from "@/lib/insights/correlations";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
WORKOUT_PICK_SELECT,
} from "@/lib/workouts/canonical-read";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type {
@@ -1256,19 +1260,7 @@ export async function fetchTrainingSeries(
tz: string,
since: Date,
): Promise<NamedSeries[]> {
const [workouts, account, trimp] = await Promise.all([
prisma.workout.findMany({
where: { userId, startedAt: { gte: since } },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
take: WORKOUT_READ_CAP,
select: {
id: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
},
}),
const [account, trimp, workouts] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
@@ -1280,8 +1272,16 @@ export async function fetchTrainingSeries(
take: 1000,
select: { day: true, dayTrimp: true },
}),
readWorkoutsAroundRange({ from: since, to: null }, (bounds) =>
prisma.workout.findMany({
where: { userId, startedAt: bounds },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
take: WORKOUT_READ_CAP,
select: WORKOUT_PICK_SELECT,
}),
),
]);
const sessions = pickCanonicalWorkoutRows(
const sessions = canonicalWorkoutsInRange(
workouts,
account?.sourcePriorityJson ?? null,
);
+21 -15
View File
@@ -28,7 +28,10 @@ import {
readRollupBuckets,
ROLLUP_FOLD_WINDOW_MS,
} from "@/lib/rollups/measurement-rollups";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
} from "@/lib/workouts/canonical-read";
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
import type {
AggregatedFeatures,
@@ -536,29 +539,32 @@ export async function readWorkoutsBlock(
now: number,
): Promise<AggregatedFeatures["workouts"] | undefined> {
const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY);
const [stored, account] = await Promise.all([
prisma.workout.findMany({
where: { userId, startedAt: { gte: since } },
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
take: 2000,
select: {
source: true,
sportType: true,
startedAt: true,
durationSec: true,
totalDistanceM: true,
},
}),
const [account, stored] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
}),
readWorkoutsAroundRange({ from: since, to: null }, (bounds) =>
prisma.workout.findMany({
where: { userId, startedAt: bounds },
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
take: 2000,
select: {
id: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
totalDistanceM: true,
},
}),
),
]);
// One session synced by two services (or re-sent by HealthKit) is one
// workout: the same collapse the workout list applies, so the count and the
// minutes here match what the person sees there. The picker returns its rows
// oldest first; the block reads the newest at index 0.
const rows = pickCanonicalWorkoutRows(
const rows = canonicalWorkoutsInRange(
stored,
account?.sourcePriorityJson ?? null,
).reverse();
+27 -97
View File
@@ -73,11 +73,11 @@ import {
} from "@/lib/insights/score-row";
import { shiftDateKey } from "@/lib/tz/format";
import {
pickCanonicalWorkoutRows,
workoutDuplicateClusterKeys,
workoutSourceLadder,
type WorkoutPickerRow,
} from "@/lib/measurements/pick-canonical-workout-rows";
canonicalWorkoutTrimps,
readWorkoutsAroundRange,
rowsInRange,
startsInRange,
} from "@/lib/workouts/canonical-read";
import {
pickCanonicalSourceRows,
type SourcePickerRow,
@@ -432,24 +432,28 @@ export async function computeStrainScore(
// The day's workouts + their HR series, with the session identity the
// cross-source picker needs: one run recorded by the watch and synced again
// through a second service is one training impulse, not two.
const [workouts, account] = await Promise.all([
prisma.workout.findMany({
where: { userId, startedAt: { gte: dayStart, lt: dayEnd } },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
select: {
id: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
samples: { select: { samples: true } },
},
take: 100,
}),
const [account, workouts] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
}),
// Read through the windowed reader: a session whose twin lies across
// midnight is counted once, on the day the list shows it.
readWorkoutsAroundRange({ from: dayStart, to: dayEnd }, (bounds) =>
prisma.workout.findMany({
where: { userId, startedAt: bounds },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
select: {
id: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
samples: { select: { samples: true } },
},
take: 100,
}),
),
]);
// The watch and the phone relay (or a ring) both write the day's active
@@ -465,7 +469,7 @@ export async function computeStrainScore(
let dayTrimp = 0;
let workoutsWithSeries = 0;
if (canTrimp) {
const trimpOf = (w: (typeof workouts)[number]): number => {
const trimpOf = (w: (typeof workouts.rows)[number]): number => {
const raw = w.samples?.samples;
if (!Array.isArray(raw)) return 0;
return banisterTrimp(
@@ -476,9 +480,10 @@ export async function computeStrainScore(
);
};
const sessions = canonicalWorkoutTrimps(
workouts,
workouts.rows,
account?.sourcePriorityJson ?? null,
trimpOf,
(at) => startsInRange(workouts.range, at),
);
for (const trimp of sessions) {
dayTrimp += trimp;
@@ -535,7 +540,7 @@ export async function computeStrainScore(
return {
score: null,
reason:
workouts.length > 0 && !canTrimp
rowsInRange(workouts).length > 0 && !canTrimp
? "insufficient_profile"
: "insufficient_inputs",
dayTrimp: 0,
@@ -572,81 +577,6 @@ export function canonicalDayActiveEnergy<
return best;
}
/**
* One training impulse per workout SESSION, not per stored row.
*
* The same session can be stored more than once: the watch writes it to Apple
* Health and a second service (Strava, WHOOP, a ring) syncs it again, or
* HealthKit re-sends it under a new identifier. The workout list collapses
* those through `pickCanonicalWorkoutRows`; summing every row here counted the
* session twice in the day's TRIMP while the list showed it once.
*
* The picker chooses the row by source order, which says nothing about heart
* rate: the winning row can be one without a series while its dropped twin
* carries one. So the impulse of a slot is the sum over its picked rows, and
* only when that is zero the largest impulse among the slot's dropped rows.
*
* The picker keeps every row of a slot none of whose sources is on the
* ladder, so the picked rows of one slot can come from several sources. Only
* one source's rows count: the best-ranked on the ladder, a source off the
* ladder ranking below every listed one, and among sources off the ladder
* the one with the largest impulse. Rows of that one source still add up
* (two distinct sessions of the same sport in one duplicate cluster).
* Returns the positive impulses, one per session. Pure; exported for tests.
*/
export function canonicalWorkoutTrimps<
T extends WorkoutPickerRow & { id: string },
>(
rows: readonly T[],
priorityJson: unknown,
trimpOf: (row: T) => number,
): number[] {
// By id: the picker may hand back a copy of a row (it fills a missing heart
// rate from a WHOOP twin).
const picked = new Set(
pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id),
);
const ladder = workoutSourceLadder(priorityJson);
const rankOf = (source: string) => {
const index = ladder.indexOf(source as (typeof ladder)[number]);
return index === -1 ? ladder.length : index;
};
const slots = new Map<
string,
{ picked: Map<string, number>; dropped: number }
>();
const keys = workoutDuplicateClusterKeys(rows);
for (const [index, row] of rows.entries()) {
const slot = slots.get(keys[index]!) ?? { picked: new Map(), dropped: 0 };
const trimp = trimpOf(row);
if (trimp > 0) {
if (picked.has(row.id)) {
slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + trimp);
} else {
slot.dropped = Math.max(slot.dropped, trimp);
}
}
slots.set(keys[index]!, slot);
}
const out: number[] = [];
for (const slot of slots.values()) {
let best: { rank: number; trimp: number } | null = null;
for (const [source, trimp] of slot.picked) {
const rank = rankOf(source);
if (
best === null ||
rank < best.rank ||
(rank === best.rank && trimp > best.trimp)
) {
best = { rank, trimp };
}
}
const trimp = best !== null ? best.trimp : slot.dropped;
if (trimp > 0) out.push(trimp);
}
return out;
}
/**
* Build the Strain profile (age + sex) and resting HR from the user row +
* the latest resting-HR reading. `prisma` is the (worker) client.
@@ -50,8 +50,23 @@ vi.mock("@/lib/insights/status-provider", () => ({
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: vi.fn(() => new Uint8Array([1, 2, 3])),
}));
vi.mock("@/lib/measurements/pick-canonical-workout-rows", () => ({
pickCanonicalWorkoutRows: vi.fn((rows) => rows),
vi.mock("@/lib/workouts/canonical-read", () => ({
readWorkoutsAroundRange: vi.fn(
async (
range: { from: Date | null; to: Date | null },
read: (bounds: { gte?: Date; lt?: Date }) => Promise<unknown[]>,
) => ({
rows: await read({
gte: range.from ?? undefined,
lt: range.to ?? undefined,
}),
range,
__windowed: true,
}),
),
canonicalWorkoutsInRange: vi.fn((windowed: { rows: unknown[] }) => [
...windowed.rows,
]),
}));
import {
@@ -636,8 +651,8 @@ describe("Activity Insight — locale", () => {
*/
describe("Activity Insight — own-history comparison", () => {
it("routes the history rows through the canonical picker", async () => {
const { pickCanonicalWorkoutRows } =
await import("@/lib/measurements/pick-canonical-workout-rows");
const { canonicalWorkoutsInRange } =
await import("@/lib/workouts/canonical-read");
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
locale: "en",
@@ -650,20 +665,27 @@ describe("Activity Insight — own-history comparison", () => {
await runWorkoutInsightGenerate({ userId: USER, workoutId: WORKOUT }, NOW);
expect(pickCanonicalWorkoutRows).toHaveBeenCalledTimes(1);
expect(canonicalWorkoutsInRange).toHaveBeenCalledTimes(1);
// The user's own source ladder decides the winner, not a default.
expect(vi.mocked(pickCanonicalWorkoutRows).mock.calls[0][1]).toEqual({
expect(vi.mocked(canonicalWorkoutsInRange).mock.calls[0][1]).toEqual({
ladder: ["APPLE_HEALTH"],
});
});
it("excludes the workout being described from its own baseline", async () => {
it("excludes the workout being described, twins included, from its own baseline", async () => {
const { canonicalWorkoutsInRange } =
await import("@/lib/workouts/canonical-read");
await runWorkoutInsightGenerate({ userId: USER, workoutId: WORKOUT }, NOW);
const where = vi.mocked(prisma.workout.findMany).mock.calls[0][0]
?.where as { userId: string; id: { not: string } };
?.where as { userId: string; id?: unknown };
expect(where.userId).toBe(USER);
expect(where.id).toEqual({ not: WORKOUT });
// The row stays in the read so its whole duplicate cluster can be left
// out after the pick; dropping it in SQL left a twin behind.
expect(where.id).toBeUndefined();
expect(vi.mocked(canonicalWorkoutsInRange).mock.calls[0][2]).toEqual({
excludeClusterOf: WORKOUT,
});
});
});
+9 -71
View File
@@ -26,7 +26,7 @@
*/
import { unlinkSync } from "node:fs";
import { getHeapStatistics } from "node:v8";
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
import type { Job } from "pg-boss";
@@ -40,18 +40,13 @@ import {
type WorkoutRouteReference,
} from "@/lib/apple-health/route-import";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { afterBulkImport } from "@/lib/jobs/import-tail";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import {
streamParseExportXml,
type ImportJobProgress,
type ImportJobResult,
} from "@/lib/measurements/import-apple-health-export";
import {
recomputeUserRollups,
ROLLUP_FOLD_WINDOW_MS,
} from "@/lib/rollups/measurement-rollups";
import { withBackgroundEvent } from "@/lib/logging/background";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
@@ -589,70 +584,13 @@ export async function handleAppleHealthImport(
},
});
// v1.5.0 — fold the persistent rollup table for the imported
// user. The per-row write hooks are intentionally skipped on the
// streaming-ingest path (a 100k-row import would otherwise pay
// 100k DAY-recompute round-trips); we run the rollup once at
// the end, scoped to the user's full measurement span, so
// post-import reads of the analytics + comprehensive surfaces
// hit the warm rollup table on first paint.
//
// The miss rides out as a fact rather than as a failed job: the
// import is committed and its mirror row already reads `done`, so
// failing here would report a completed import as failed and
// contradict the row the status poll surfaces to the operator.
let rollupFailed = false;
try {
// Folded over what this import wrote, not over the account's whole
// history, and never further back than the fold window every other
// path keeps to: buckets older than `ROLLUP_FOLD_WINDOW_MS` are by
// design never written, so reads there fall back to live SQL. The old
// span ran from the account's first measurement, whatever the import
// carried, and folded any history older than the window as well.
const span = result.measuredSpan;
if (span) {
const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS);
const spanFrom = new Date(span.from);
const from = spanFrom > windowStart ? spanFrom : windowStart;
// `to` is exclusive in the aggregator, so one millisecond past the
// newest row.
const to = new Date(new Date(span.to).getTime() + 1);
if (to > from) {
await recomputeUserRollups(userId, { from, to });
}
}
} catch (rollupErr) {
// Rollup failure is non-fatal — the next read falls through to
// live aggregation. Log but don't poison the import.
rollupFailed = true;
console.warn(
`[apple-health-import] Rollup recompute failed for user ${userId}`,
rollupErr,
);
}
// The imported types' cached status assessments describe data that just
// changed; re-warm them, the second leg every other measurement write
// runs (`afterMeasurementMutation`). The rollups above are refolded over
// the import's span rather than per day, so the leg is called directly.
const importedTypes = Object.entries(result.perType)
.filter(([, stat]) => stat.inserted + stat.updated > 0)
.map(([type]) => type as MeasurementType);
if (importedTypes.length > 0) {
// Registered so the test suite can await the detached handle.
trackBackgroundTask(
invalidateStatusInsightsForTypes(userId, importedTypes, {
// An export file the person chose to import, narrated like any
// other import rather than held to the sync gap.
source: "manual",
}).catch((err) => {
console.warn(
`[apple-health-import] status-insight invalidate failed for user ${userId}`,
err,
);
}),
);
}
// Rollups, measurement caches and status assessments over what the
// import wrote (`afterBulkImport`).
const { rollupFailed } = await afterBulkImport(
userId,
result,
"apple-health-import",
);
// Best-effort cleanup. A failed unlink is not fatal — `/tmp` is
// periodically swept on the host.
+8 -44
View File
@@ -19,7 +19,6 @@ import type { Job } from "pg-boss";
import { caughtAs, logCaught } from "@/lib/logging/signal";
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
import { extractHealthConnectDb } from "@/lib/import/unzip-export-xml";
import {
@@ -28,15 +27,10 @@ import {
} from "@/lib/import/health-connect/import";
import { HealthConnectImportError } from "@/lib/import/health-connect/reader";
import { connectedDirectIntegrations } from "@/lib/import/health-connect/skip-packages";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { afterBulkImport } from "@/lib/jobs/import-tail";
import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import {
recomputeUserRollups,
ROLLUP_FOLD_WINDOW_MS,
} from "@/lib/rollups/measurement-rollups";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const HEALTH_CONNECT_IMPORT_QUEUE = "health-connect-import";
@@ -203,43 +197,13 @@ export async function runHealthConnectImport(
},
});
// The per-row tails are skipped on a bulk import; refold the rollups over
// what this import wrote, inside the window every other path keeps to,
// and re-warm the cached assessments of the types it touched.
let rollupFailed = false;
const span = result.measuredSpan;
if (span) {
try {
const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS);
const spanFrom = new Date(span.from);
const from = spanFrom > windowStart ? spanFrom : windowStart;
const to = new Date(new Date(span.to).getTime() + 1);
if (to > from) await recomputeUserRollups(userId, { from, to });
} catch (err) {
rollupFailed = true;
console.warn(
`[health-connect-import] Rollup recompute failed for user ${userId}`,
err,
);
}
}
const touched = Object.entries(result.perType)
.filter(([, stat]) => stat.inserted + stat.updated > 0)
.map(([type]) => type as MeasurementType);
if (touched.length > 0) {
trackBackgroundTask(
invalidateStatusInsightsForTypes(userId, touched, {
// An export file the person chose to import, like the Apple Health
// import: narrated like a hand entry, not held to the sync gap.
source: "manual",
}).catch((err) => {
console.warn(
`[health-connect-import] status-insight invalidate failed for user ${userId}`,
err,
);
}),
);
}
// Rollups, measurement caches and status assessments over what the
// import wrote, as the Apple Health import does (`afterBulkImport`).
const { rollupFailed } = await afterBulkImport(
userId,
result,
"health-connect-import",
);
annotate({
action: { name: "import.health-connect.done" },
+82
View File
@@ -0,0 +1,82 @@
/**
* The tail of a bulk export import (Apple Health, Health Connect), run once
* the import is committed. The per-row write hooks are skipped while the
* export streams in (a 100k-row import would otherwise pay 100k DAY
* recomputes), so the three legs every other measurement write runs through
* `afterMeasurementMutation` run here once, over what the import wrote:
*
* 1. the rollup table, refolded over the span of the rows written and never
* further back than the fold window every other path keeps to (buckets
* older than `ROLLUP_FOLD_WINDOW_MS` are by design never written, so
* reads there fall back to live SQL);
* 2. the measurement read caches, marked stale back to the oldest sample
* written. An export carries years of history, and a closed block that a
* later sync leaves in place would otherwise keep serving the pre-import
* all-time values. An import that wrote no measurement (only workouts or
* ECGs) drops them all;
* 3. the cached status assessments of the types written, narrated like a
* hand entry: an export is a file the person chose to import.
*
* A rollup miss rides out as a fact rather than a failed job: the import is
* committed and its row already reads `done`, so failing here would report a
* completed import as failed.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import {
recomputeUserRollups,
ROLLUP_FOLD_WINDOW_MS,
} from "@/lib/rollups/measurement-rollups";
export async function afterBulkImport(
userId: string,
result: {
measuredSpan: { from: string; to: string } | null;
perType: Record<string, { inserted: number; updated: number }>;
},
label: string,
): Promise<{ rollupFailed: boolean }> {
const span = result.measuredSpan;
let rollupFailed = false;
if (span) {
try {
const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS);
const spanFrom = new Date(span.from);
const from = spanFrom > windowStart ? spanFrom : windowStart;
// `to` is exclusive in the aggregator: one millisecond past the newest.
const to = new Date(new Date(span.to).getTime() + 1);
if (to > from) await recomputeUserRollups(userId, { from, to });
} catch (err) {
rollupFailed = true;
console.warn(
`[${label}] Rollup recompute failed for user ${userId}`,
err,
);
}
}
invalidateUserMeasurements(
userId,
span ? { touchedFrom: new Date(span.from) } : undefined,
);
const types = Object.entries(result.perType)
.filter(([, stat]) => stat.inserted + stat.updated > 0)
.map(([type]) => type as MeasurementType);
if (types.length > 0) {
// Registered so the test suite can await the detached handle.
trackBackgroundTask(
invalidateStatusInsightsForTypes(userId, types, {
source: "manual",
}).catch((err) => {
console.warn(
`[${label}] status-insight invalidate failed for user ${userId}`,
err,
);
}),
);
}
return { rollupFailed };
}
+32 -29
View File
@@ -44,7 +44,10 @@ import {
WORKOUT_INSIGHT_PROMPT_VERSION,
} from "@/lib/ai/prompts/workout-insight";
import { prisma } from "@/lib/db";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
} from "@/lib/workouts/canonical-read";
import type { Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { finalizeStatusSummary } from "@/lib/insights/status-shared";
@@ -357,37 +360,37 @@ export async function runWorkoutInsightGenerate(
whoopZoneDurations: parseWhoopZoneDurations(row.metadata),
});
// The current workout is excluded in SQL before canonicalization. The
// canonical picker then collapses cross-source twins before any median or
// prompt is built.
const historyRows = await prisma.workout.findMany({
where: {
userId,
sportType: row.sportType,
id: { not: row.id },
startedAt: {
gte: new Date(
row.startedAt.getTime() - OWN_HISTORY_LOOKBACK_DAYS * MS_PER_DAY,
),
lt: row.startedAt,
},
// The canonical sessions of the sport before this one, without this
// session's duplicate cluster: a twin a second source recorded a minute
// earlier is not the previous session.
const historyRows = await readWorkoutsAroundRange(
{
from: new Date(
row.startedAt.getTime() - OWN_HISTORY_LOOKBACK_DAYS * MS_PER_DAY,
),
to: row.startedAt,
},
orderBy: { startedAt: "desc" },
take: OWN_HISTORY_MAX_ROWS,
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
durationSec: true,
avgHeartRate: true,
totalDistanceM: true,
totalEnergyKcal: true,
},
});
const history = pickCanonicalWorkoutRows(
(bounds) =>
prisma.workout.findMany({
where: { userId, sportType: row.sportType, startedAt: bounds },
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
take: OWN_HISTORY_MAX_ROWS,
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
durationSec: true,
avgHeartRate: true,
totalDistanceM: true,
totalEnergyKcal: true,
},
}),
);
const history = canonicalWorkoutsInRange(
historyRows,
profile?.sourcePriorityJson ?? null,
{ excludeClusterOf: row.id },
);
const evidence = buildWorkoutInsightEvidence({
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { canonicalWorkoutTrimps } from "@/lib/insights/strain-score";
import { canonicalWorkoutTrimps } from "@/lib/workouts/canonical-read";
import {
pickCanonicalWorkoutRows,
@@ -133,8 +133,20 @@ function deduplicateWinningRows<T extends WorkoutPickerRow>(rows: T[]): T[] {
* within the window straddle a slot boundary, the clusters are exactly the
* old slots: every pair inside one slot is less than five minutes apart, and
* a cluster cannot reach past its slot without such a pair.
*
* Because a cluster is anchored on its first start, which cluster a workout
* lands in depends on the workouts before it. A reader that decides which row
* is canonical therefore reads through `readWorkoutsAroundRange` in
* `src/lib/workouts/canonical-read.ts`, which widens the read until the
* clusters of the requested range no longer depend on where the read began.
*/
const DUPLICATE_WINDOW_MS = 5 * 60 * 1000;
export const WORKOUT_DUPLICATE_WINDOW_MS = 5 * 60 * 1000;
const DUPLICATE_WINDOW_MS = WORKOUT_DUPLICATE_WINDOW_MS;
/** Rows of different groups never share a duplicate cluster. */
export function workoutClusterGroup(row: WorkoutPickerRow): string {
return `${row.userId ?? ""}:${row.sportType}`;
}
/**
* The duplicate cluster of every row, aligned with `rows`. Rows of different
@@ -151,7 +163,7 @@ export function workoutDuplicateClusterKeys(
): string[] {
const groups = new Map<string, number[]>();
rows.forEach((row, index) => {
const group = `${row.userId ?? ""}:${row.sportType}`;
const group = workoutClusterGroup(row);
const list = groups.get(group);
if (list) list.push(index);
else groups.set(group, [index]);
+15 -2
View File
@@ -1,3 +1,4 @@
import { Buffer } from "node:buffer";
import { randomUUID } from "node:crypto";
import type { Prisma } from "@/generated/prisma/client";
@@ -254,13 +255,16 @@ export async function listMedicationCategoryLabels(
export async function readMedicationCategoryLabelsForBackup(
userId: string,
client: CategoryClient = prisma,
options: { disasterRecovery?: boolean } = {},
): Promise<
Array<{
key: string;
label: string;
label: string | null;
labelEncrypted?: string;
sortOrder: number;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}>
> {
const rows = await client.medicationCategoryLabel.findMany({
@@ -272,14 +276,23 @@ export async function readMedicationCategoryLabelsForBackup(
sortOrder: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
return rows.map((r) => ({
key: r.key,
label: decryptCategoryLabel(r.labelEncrypted),
...(options.disasterRecovery
? {
label: null,
labelEncrypted: Buffer.from(r.labelEncrypted).toString("base64"),
}
: { label: decryptCategoryLabel(r.labelEncrypted) }),
sortOrder: r.sortOrder,
isActive: r.isActive,
createdAt: r.createdAt.toISOString(),
...(options.disasterRecovery
? { updatedAt: r.updatedAt.toISOString() }
: {}),
}));
}
+1 -1
View File
@@ -336,7 +336,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "List workouts (v1.4.32)",
description:
"Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.",
"Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and clusters workouts of one sport whose starts lie less than five minutes after the cluster's first start — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. A twin just outside `since` / `until` still decides which row inside the range is canonical, so the list agrees with `canonicalId` on the detail. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.",
requestParams: {
query: z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
+9 -23
View File
@@ -19,12 +19,8 @@ import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import {
collapseToTypeDayKeys,
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { getEvent } from "@/lib/logging/context";
import { collapseToTypeDayKeys } from "@/lib/rollups/measurement-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import {
forEachHeartRatePage,
mapHeartRateSample,
@@ -120,23 +116,13 @@ export async function refoldOuraTouchedDays(
touched: TouchedDays,
): Promise<void> {
if (touched.size === 0) return;
const keys = touched.list();
try {
await recomputeBucketsForKeys(userId, keys);
invalidateStatusInsightsForTypes(
userId,
[...new Set(keys.map((k) => k.type))],
{ source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`oura: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`oura: heart-rate rollup recompute failed for ${userId}: ${err}`,
);
}
// The shared tail: the rollups, the measurement read caches back to the
// first touched day (a backfill window reaches years back), and the cached
// assessments of the types.
await afterMeasurementMutation(userId, touched.list(), {
source: "sync",
label: "oura heart rate",
});
}
/**
+7 -22
View File
@@ -45,17 +45,12 @@ import {
toFailureKind,
type FailureKind,
} from "@/lib/integrations/status";
import {
collapseToTypeDayKeys,
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import {
emitInsertedMeasurementArrivals,
type InsertedMeasurementArrivalRow,
} from "@/lib/arrivals/measurement-emit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import {
@@ -895,23 +890,13 @@ export async function upsertOuraMeasurements(
() => {},
);
}
try {
const keys = collapseToTypeDayKeys(touched);
await recomputeBucketsForKeys(userId, keys);
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
{ source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`oura: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`oura: rollup recompute failed for ${userId}: ${err}`,
);
}
// The shared tail: the rollups, the measurement read caches back to the
// oldest reading written (a history backfill writes through here and has
// no other invalidation), and the cached assessments of the types.
await afterMeasurementMutation(userId, touched, {
source: "sync",
label: "oura",
});
return imported;
}
@@ -12,6 +12,16 @@
* inherently: a day left empty drops its rollup row.
* 2. the cached per-metric status assessments — re-warmed via
* `invalidateStatusInsightsForTypes` (fire-and-forget).
* 3. the measurement read caches (analytics, targets, derived, workouts),
* marked stale back to the earliest touched reading once the rollups
* are refolded. Without it a writer that relied on the tail alone (the
* CSV and JSON imports, the score engines, the WHOOP webhook, the Withings
* activity, sleep and ECG legs, the Oura history backfill) left
* every cached read, the closed history blocks above all, on the
* pre-write values until their TTL. A caller that invalidated already
* (an interactive write evicts) loses nothing: marking stale after an
* eviction only catches an entry rebuilt from the old rollups between
* the two.
*
* Both legs are best-effort: a populator hiccup never fails the user's
* mutation. Callers collect the affected `(type, measuredAt)` pairs
@@ -27,6 +37,8 @@ import {
type StatusIngestSource,
} from "@/lib/insights/status-invalidation";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { earliestMeasuredAt } from "@/lib/cache/closed-block";
import type { MeasurementType } from "@/generated/prisma/client";
export async function afterMeasurementMutation(
@@ -54,6 +66,7 @@ export async function afterMeasurementMutation(
} catch (err) {
console.warn(`[${label}] rollup recompute failed`, err);
}
invalidateUserMeasurements(userId, { touchedFrom: earliestMeasuredAt(rows) });
// Status-insight re-warm is fire-and-forget — it must run even when
// the rollup leg above failed, so the two legs never gate each other.
+5
View File
@@ -30,6 +30,7 @@
import type { MeasurementSource } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { getEvent } from "@/lib/logging/context";
/** One just-fetched sleep session: its externalId prefix + the fresh ids. */
@@ -79,5 +80,9 @@ export async function sweepStaleSleepSegments(
);
}
}
// A tombstoned segment changes the nights the cached reads hold. The sweep
// does not know how far back the swept rows reach, so every cached block
// goes; it removes rows only when a session was re-scored, which is rare.
if (removed > 0) invalidateUserMeasurements(userId);
return removed;
}
+7 -25
View File
@@ -44,11 +44,7 @@ import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { getUnitForType } from "@/lib/validations/measurement";
import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import {
collapseToTypeDayKeys,
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import {
hasActivityScope,
@@ -578,26 +574,12 @@ export async function syncUserActivity(
// v1.4.39.1 — refresh the persistent rollup table for every distinct
// (type, day) the sync touched. Collapsed so a multi-day Withings
// catch-up costs at most ~N (type, day) recomputes.
try {
const keys = collapseToTypeDayKeys(touched);
await recomputeBucketsForKeys(userId, keys);
// v1.8.0 — drop the per-metric assessment caches the synced types
// dirty (steps feed the general overview). Fire-and-forget.
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
{ source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`withings activity: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings activity: rollup recompute failed for ${userId}: ${err}`,
);
}
// The shared tail: the rollups, the measurement read caches back to the
// oldest touched reading, and the cached assessments of the types.
await afterMeasurementMutation(userId, touched, {
source: "sync",
label: "withings activity",
});
// Only a clean write stamps success. A swallowed batch-write left the
// ledger honest via the transient failure recorded above.
+7 -22
View File
@@ -54,11 +54,7 @@ import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { persistEcgRecording } from "@/lib/ecg/persist-recording";
import {
collapseToTypeDayKeys,
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import {
WithingsApiError,
@@ -517,23 +513,12 @@ export async function syncUserEcg(
// Refresh the persistent rollup tier for every distinct (type, day) touched,
// then drop the per-metric assessment caches the new events dirty.
try {
const keys = collapseToTypeDayKeys(touched);
await recomputeBucketsForKeys(userId, keys);
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
{ source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`withings ecg: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings ecg: rollup recompute failed for ${userId}: ${err}`,
);
}
// The shared tail: the rollups, the measurement read caches back to the
// oldest touched reading, and the cached assessments of the types.
await afterMeasurementMutation(userId, touched, {
source: "sync",
label: "withings ecg",
});
if (sourceWriteError) {
await recordWithingsSyncFailure(
+7 -26
View File
@@ -43,11 +43,7 @@ import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { getUnitForType } from "@/lib/validations/measurement";
import {
collapseToTypeDayKeys,
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import {
@@ -693,27 +689,12 @@ export async function syncUserSleep(
// v1.4.39.1 — refresh the persistent rollup table for every distinct
// (type, day) the sync touched. Sleep segments collapse heavily —
// ~10 stage rows per night land in one DAY recompute.
try {
const keys = collapseToTypeDayKeys(touched);
await recomputeBucketsForKeys(userId, keys);
// v1.8.0 — drop the per-metric assessment caches the synced types
// dirty (sleep + resting-HR feed the pulse / general cards).
// Fire-and-forget.
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
{ source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`withings sleep: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings sleep: rollup recompute failed for ${userId}: ${err}`,
);
}
// The shared tail: the rollups, the measurement read caches back to the
// oldest touched reading, and the cached assessments of the types.
await afterMeasurementMutation(userId, touched, {
source: "sync",
label: "withings sleep",
});
// If a stage segment for last night just landed, kick the debounced
// morning refresh so the digest/score finalise with the current sleep. The
@@ -0,0 +1,240 @@
/**
* The windowed read gives every surface the canonical workouts a read of the
* whole history would give, whatever range it asked for.
*
* The store is an in-memory array behind the same bounded-read callback the
* Prisma readers pass, so the property covers the reader's reach-back loop and
* the range filter, not a copy of them.
*/
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import { assertAsyncProperty } from "../../../../tests/property/fast-check";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalClusterOf,
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
startsInRange,
type WorkoutStartBounds,
type WorkoutStartRange,
} from "../canonical-read";
type Source = "APPLE_HEALTH" | "WITHINGS" | "WHOOP" | "MANUAL";
interface Row {
id: string;
startedAt: Date;
durationSec: number;
sportType: string;
source: Source;
avgHeartRate: number | null;
maxHeartRate: number | null;
metadata: null;
}
const MIN = 60_000;
const BASE = Date.UTC(2026, 9, 1, 9, 0, 0);
function memoryStore(rows: readonly Row[]) {
let reads = 0;
const read = async (bounds: WorkoutStartBounds) => {
reads += 1;
return rows
.filter(
(row) =>
(!bounds.gte || row.startedAt >= bounds.gte) &&
(!bounds.lt || row.startedAt < bounds.lt),
)
.sort(
(a, b) =>
a.startedAt.getTime() - b.startedAt.getTime() ||
a.id.localeCompare(b.id),
);
};
return { read, reads: () => reads };
}
function sortedAll(rows: readonly Row[]): Row[] {
return [...rows].sort(
(a, b) =>
a.startedAt.getTime() - b.startedAt.getTime() || a.id.localeCompare(b.id),
);
}
/** What a read of the whole history says is canonical inside the range. */
function oracle(rows: readonly Row[], range: WorkoutStartRange): string[] {
return pickCanonicalWorkoutRows(sortedAll(rows), null)
.filter((row) => startsInRange(range, row.startedAt))
.map((row) => row.id)
.sort();
}
/**
* Workouts packed tightly enough that chains of starts under five minutes
* apart are common: the case where the cluster of a row depends on rows far
* before it.
*/
const rowsArb = fc
.array(
fc.record({
gapMin: fc.integer({ min: 0, max: 9 }),
gapSec: fc.integer({ min: 0, max: 59 }),
sportType: fc.constantFrom("running", "running", "cycling"),
source: fc.constantFrom<Source>(
"APPLE_HEALTH",
"WITHINGS",
"WHOOP",
"MANUAL",
),
durationMin: fc.integer({ min: 10, max: 60 }),
hr: fc.option(fc.integer({ min: 90, max: 170 }), { nil: null }),
}),
{ minLength: 1, maxLength: 40 },
)
.map((specs) => {
let at = BASE;
return specs.map((spec, index): Row => {
at += spec.gapMin * MIN + spec.gapSec * 1000;
return {
id: `w${String(index).padStart(2, "0")}`,
startedAt: new Date(at),
durationSec: spec.durationMin * 60,
sportType: spec.sportType,
source: spec.source,
avgHeartRate: spec.hr,
maxHeartRate: null,
metadata: null,
};
});
});
const rangeArb = fc.record({
fromOffsetMin: fc.option(fc.integer({ min: -10, max: 400 }), { nil: null }),
lengthMin: fc.option(fc.integer({ min: 0, max: 200 }), { nil: null }),
inclusiveEnd: fc.boolean(),
});
function toRange(spec: {
fromOffsetMin: number | null;
lengthMin: number | null;
inclusiveEnd: boolean;
}): WorkoutStartRange {
const from =
spec.fromOffsetMin === null
? null
: new Date(BASE + spec.fromOffsetMin * MIN);
const to =
spec.lengthMin === null
? null
: new Date((from?.getTime() ?? BASE) + spec.lengthMin * MIN);
return { from, to, inclusiveEnd: spec.inclusiveEnd };
}
describe("readWorkoutsAroundRange", () => {
it("resolves the range's canonical workouts as a whole-history read does", async () => {
await assertAsyncProperty(
fc.asyncProperty(rowsArb, rangeArb, async (rows, rangeSpec) => {
const range = toRange(rangeSpec);
const store = memoryStore(rows);
const windowed = await readWorkoutsAroundRange(range, store.read);
const got = canonicalWorkoutsInRange(windowed, null)
.map((row) => row.id)
.sort();
expect(got).toEqual(oracle(rows, range));
}),
300,
);
});
it("gives one workout the same cluster whichever range found it", async () => {
await assertAsyncProperty(
fc.asyncProperty(rowsArb, fc.nat(), async (rows, pick) => {
const target = rows[pick % rows.length]!;
const store = memoryStore(rows);
const point = await readWorkoutsAroundRange(
{ from: target.startedAt, to: target.startedAt, inclusiveEnd: true },
store.read,
);
const whole = await readWorkoutsAroundRange(
{ from: null, to: null },
store.read,
);
const fromPoint = canonicalClusterOf(point, target.id, null);
const fromWhole = canonicalClusterOf(whole, target.id, null);
expect(fromPoint?.members.map((row) => row.id)).toEqual(
fromWhole?.members.map((row) => row.id),
);
expect(fromPoint?.canonical[0]?.id).toBe(fromWhole?.canonical[0]?.id);
}),
300,
);
});
it("depends on the history before the range, which the range alone does not show", async () => {
// Three runs four minutes apart. Read from 09:55 the 09:58 run anchors a
// cluster with 10:02, and 10:06 stands alone; read from 10:00 the 10:02
// run anchors one with 10:06 and hides it behind the Apple row. The
// windowed read reaches back to 09:58 and draws the clusters a
// whole-history read draws.
const at = (h: number, m: number) => new Date(Date.UTC(2026, 9, 1, h, m));
const rows: Row[] = [
["a", at(9, 58), "WITHINGS"],
["b", at(10, 2), "APPLE_HEALTH"],
["c", at(10, 6), "WITHINGS"],
].map(([id, startedAt, source]) => ({
id: id as string,
startedAt: startedAt as Date,
durationSec: 1800,
sportType: "running",
source: source as Source,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
}));
const range = { from: at(10, 0), to: at(11, 0) };
// The range read alone drops the 10:06 run as a twin of 10:02.
const naive = pickCanonicalWorkoutRows(
rows.filter((row) => startsInRange(range, row.startedAt)),
null,
).map((row) => row.id);
expect(naive).toEqual(["b"]);
const windowed = await readWorkoutsAroundRange(
range,
memoryStore(rows).read,
);
expect(
canonicalWorkoutsInRange(windowed, null).map((row) => row.id),
).toEqual(["b", "c"]);
expect(oracle(rows, range)).toEqual(["b", "c"]);
});
it("leaves a workout's twins out of a baseline along with the workout", async () => {
const at = (m: number) => new Date(BASE + m * MIN);
const rows: Row[] = [
{ id: "old", startedAt: at(-600) },
{ id: "apple", startedAt: at(0) },
{ id: "withings", startedAt: at(1) },
].map((row, index) => ({
...row,
durationSec: 1800,
sportType: "running",
source: index === 2 ? "WITHINGS" : "APPLE_HEALTH",
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
}));
const windowed = await readWorkoutsAroundRange(
{ from: at(-1000), to: null },
memoryStore(rows).read,
);
expect(
canonicalWorkoutsInRange(windowed, null, {
excludeClusterOf: "withings",
}).map((row) => row.id),
).toEqual(["old"]);
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* The write-time duplicate pick of `POST /api/workouts/batch`.
*
* The rows a batch is about to insert are clustered together with the stored
* workouts around them, read through the same windowed reader the list uses,
* so a cluster boundary falls where every read will later draw it. Clustering
* the batch alone could merge a row into a cluster the list then splits (a
* stored 09:58 run anchors a cluster with an incoming 10:02, which leaves an
* incoming 10:06 a session of its own) and drop a workout the list would have
* shown.
*/
import type { MeasurementSource, Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
incomingWorkoutsToKeep,
readWorkoutsAroundRange,
} from "./canonical-read";
/** The indexes of the batch rows to write; the rest are duplicates. */
export async function batchWorkoutIndicesToKeep(
userId: string,
prepared: ReadonlyArray<{
index: number;
row: Prisma.WorkoutCreateManyInput;
}>,
priorityJson: unknown,
): Promise<Set<number>> {
if (prepared.length === 0) return new Set();
const incoming = prepared.map((p) => ({
index: p.index,
userId: p.row.userId,
sportType: p.row.sportType,
startedAt: p.row.startedAt as Date,
durationSec: p.row.durationSec,
// Zod's `measurementSourceEnum.optional().default("MANUAL")` guarantees
// `source` is set post-parse, but Prisma's `WorkoutCreateManyInput` types
// it optional.
source: (p.row.source ?? "MANUAL") as MeasurementSource,
avgHeartRate: p.row.avgHeartRate ?? null,
maxHeartRate: p.row.maxHeartRate ?? null,
metadata: p.row.metadata ?? null,
}));
const starts = incoming.map((row) => row.startedAt.getTime());
const stored = await readWorkoutsAroundRange(
{
from: new Date(Math.min(...starts)),
to: new Date(Math.max(...starts)),
inclusiveEnd: true,
},
(bounds) =>
prisma.workout.findMany({
where: { userId, startedAt: bounds },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
select: {
id: true,
userId: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
},
}),
);
return new Set(
incomingWorkoutsToKeep(stored, incoming, priorityJson).map(
(row) => row.index,
),
);
}
+400
View File
@@ -0,0 +1,400 @@
/**
* The read every surface that decides which workout is canonical goes through.
*
* `pickCanonicalWorkoutRows` clusters workouts of one sport by start time,
* anchored on each cluster's first start (see `workoutDuplicateClusterKeys`).
* That makes the cluster of a workout depend on the workouts around it: on
* the twin a few minutes after it, which may lie past the end of the range a
* surface asked for, and on the run of workouts before it, which decides where
* the anchors fall. A surface that handed the picker only the rows inside its
* own range got an answer that depended on the range: the list and the detail
* of one workout disagreed about which row was canonical, and 09:58, 10:02 and
* 10:06 were grouped one way by a read starting at 09:55 and another way by a
* read starting at 10:00.
*
* `readWorkoutsAroundRange` reads the range plus one duplicate window after its
* end, and before its start as far as it takes to reach, for every sport with a
* row in the range, a workout that starts a full window after the one before
* it. Such a workout is an anchor whatever comes before it, so from there on
* the clusters are the ones a read of the whole history would produce. The
* surfaces then pick over everything read and keep the rows that start inside
* their range: a workout is canonical, or not, the same way on every surface.
*
* The reader is async and calls the callback before it first awaits, so in a
* `Promise.all` it goes after the plain Prisma calls: an element before it
* that threw while the array was being built would leave its rejection
* unhandled.
*
* A read callback may still carry a row cap as a runaway guard. A capped read
* that actually hits its cap has lost rows at one end and is outside this
* guarantee; the caps sit far above any real account's volume.
*/
import {
pickCanonicalWorkoutRows,
WORKOUT_DUPLICATE_WINDOW_MS,
workoutClusterGroup,
workoutDuplicateClusterKeys,
workoutSourceLadder,
type WorkoutPickerRow,
} from "@/lib/measurements/pick-canonical-workout-rows";
/**
* The columns a read needs for the canonical pick and nothing more: the
* session identity (`sportType`, `startedAt`, `durationSec`), the source the
* ladder ranks, and the id the windowed read orders by.
*/
export const WORKOUT_PICK_SELECT = {
id: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
} as const;
/** A row the windowed read can order deterministically. */
export type CanonicalReadRow = WorkoutPickerRow & { id: string };
/**
* The range of starts a surface asks about. `null` leaves that side open.
* `inclusiveEnd` makes `to` part of the range (`lte`), as the list's `until`
* and the doctor report's end are.
*/
export interface WorkoutStartRange {
from: Date | null;
to: Date | null;
inclusiveEnd?: boolean;
}
/** The `startedAt` bounds one read callback must apply. */
export interface WorkoutStartBounds {
gte?: Date;
lt?: Date;
}
/**
* Reads the caller's workouts whose start lies inside `bounds`, with every
* other filter (user, sport) the caller applies. Called more than once when
* the read has to reach further back.
*/
export type WorkoutBoundedRead<T extends CanonicalReadRow> = (
bounds: WorkoutStartBounds,
) => Promise<T[]>;
/**
* Rows read for a range, in `startedAt ASC, id ASC` order, together with the
* range they were read for. Only `readWorkoutsAroundRange` builds one, so a
* picker call below cannot be handed rows that were read without the margin.
*/
export interface WindowedWorkouts<T extends CanonicalReadRow> {
readonly rows: readonly T[];
readonly range: WorkoutStartRange;
readonly __windowed: true;
}
/**
* How many times the read reaches further back before it stops. Each step
* doubles the reach, so the last one covers more than a week of workouts
* every one of which started less than five minutes after the previous one.
*/
const MAX_BACKWARD_STEPS = 12;
function compareRows(a: CanonicalReadRow, b: CanonicalReadRow): number {
const delta = a.startedAt.getTime() - b.startedAt.getTime();
if (delta !== 0) return delta;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
/**
* The earliest start that is not yet known to be an anchor, among the sports
* that have a row at or after `from`. A sport's earliest row read is an anchor
* once the read reaches a full window before it (nothing of that sport starts
* inside that window, or the read would hold it). `null` when every sport is
* settled.
*/
function earliestUnsettledStart(
rows: readonly CanonicalReadRow[],
from: Date,
lowerMs: number,
): number | null {
const earliest = new Map<string, number>();
const relevant = new Set<string>();
for (const row of rows) {
const group = workoutClusterGroup(row);
const start = row.startedAt.getTime();
const known = earliest.get(group);
if (known === undefined || start < known) earliest.set(group, start);
if (start >= from.getTime()) relevant.add(group);
}
let unsettled: number | null = null;
for (const group of relevant) {
const start = earliest.get(group)!;
if (start - WORKOUT_DUPLICATE_WINDOW_MS < lowerMs) {
unsettled = unsettled === null ? start : Math.min(unsettled, start);
}
}
return unsettled;
}
export async function readWorkoutsAroundRange<T extends CanonicalReadRow>(
range: WorkoutStartRange,
read: WorkoutBoundedRead<T>,
): Promise<WindowedWorkouts<T>> {
const window = WORKOUT_DUPLICATE_WINDOW_MS;
// A cluster is never wider than one window from its anchor, and an anchor
// of the range starts no later than the range's end.
const upper = range.to ? new Date(range.to.getTime() + window) : undefined;
const from = range.from;
let lowerMs = from ? from.getTime() - window : null;
const rows: T[] = [
...(await read({
gte: lowerMs === null ? undefined : new Date(lowerMs),
lt: upper,
})),
];
if (from && lowerMs !== null) {
for (let step = 0; step < MAX_BACKWARD_STEPS; step += 1) {
const unsettled = earliestUnsettledStart(rows, from, lowerMs);
if (unsettled === null) break;
const nextMs: number = Math.min(
unsettled - window,
lowerMs - window * 2 ** step,
);
const earlier = await read({
gte: new Date(nextMs),
lt: new Date(lowerMs),
});
lowerMs = nextMs;
// Nothing between the new and the old bound: every unsettled sport now
// has a full window of nothing before its earliest row.
if (earlier.length === 0) break;
rows.push(...earlier);
}
}
rows.sort(compareRows);
return { rows, range, __windowed: true };
}
/** Whether a start lies inside the range a surface asked about. */
export function startsInRange(range: WorkoutStartRange, at: Date): boolean {
const t = at.getTime();
if (range.from && t < range.from.getTime()) return false;
if (range.to) {
const end = range.to.getTime();
if (range.inclusiveEnd ? t > end : t >= end) return false;
}
return true;
}
/** The rows read that start inside the requested range. */
export function rowsInRange<T extends CanonicalReadRow>(
windowed: WindowedWorkouts<T>,
): T[] {
return windowed.rows.filter((row) =>
startsInRange(windowed.range, row.startedAt),
);
}
function clusterKeyById<T extends CanonicalReadRow>(
windowed: WindowedWorkouts<T>,
): Map<string, string> {
const keys = workoutDuplicateClusterKeys(windowed.rows);
return new Map(windowed.rows.map((row, index) => [row.id, keys[index]!]));
}
/**
* The canonical workouts that start inside the range, oldest first.
*
* `excludeClusterOf` leaves out the cluster of one workout: a baseline that
* compares a session against the others of its sport must drop the session's
* twins along with the session itself. Removing the row from the read instead
* left its twin behind as a second session, and moved the cluster anchors of
* whatever followed.
*/
export function canonicalWorkoutsInRange<T extends CanonicalReadRow>(
windowed: WindowedWorkouts<T>,
priorityJson: unknown,
options: { excludeClusterOf?: string } = {},
): T[] {
const picked = pickCanonicalWorkoutRows(windowed.rows, priorityJson).filter(
(row) => startsInRange(windowed.range, row.startedAt),
);
if (!options.excludeClusterOf) return picked;
const keyById = clusterKeyById(windowed);
const excluded = keyById.get(options.excludeClusterOf);
if (excluded === undefined) return picked;
return picked.filter((row) => keyById.get(row.id) !== excluded);
}
/**
* The duplicate cluster of one workout: its rows in start order, and the
* canonical ones among them, nearest to the workout's own start first. `null`
* when the workout is not among the rows read.
*/
export function canonicalClusterOf<T extends CanonicalReadRow>(
windowed: WindowedWorkouts<T>,
workoutId: string,
priorityJson: unknown,
): { members: T[]; canonical: T[] } | null {
const self = windowed.rows.find((row) => row.id === workoutId);
if (!self) return null;
const keyById = clusterKeyById(windowed);
const key = keyById.get(workoutId);
const members = windowed.rows.filter((row) => keyById.get(row.id) === key);
const at = self.startedAt.getTime();
const canonical = pickCanonicalWorkoutRows(members, priorityJson).sort(
(a, b) =>
Number(b.id === workoutId) - Number(a.id === workoutId) ||
Math.abs(a.startedAt.getTime() - at) -
Math.abs(b.startedAt.getTime() - at) ||
compareRows(a, b),
);
return { members, canonical };
}
/**
* The write-time pick of the batch route. The incoming rows are clustered
* together with the stored workouts around them, so a cluster boundary falls
* where every read will later draw it. An incoming row is dropped only when
* another incoming row of its cluster is the one kept: a row that loses to a
* stored workout is still written (the reads hide it), as before.
*
* Returns the incoming rows to keep, in their input order.
*/
export function incomingWorkoutsToKeep<
S extends CanonicalReadRow,
I extends WorkoutPickerRow,
>(
stored: WindowedWorkouts<S>,
incoming: readonly I[],
priorityJson: unknown,
): I[] {
type Tagged = WorkoutPickerRow & { tag: number };
// Stored rows first on an equal start, which is where a read that orders by
// start finds them relative to rows written after them.
const union: Tagged[] = [
...stored.rows.map((row) => ({ ...row, tag: -1 })),
...incoming.map((row, index) => ({ ...row, tag: index })),
].sort(
(a, b) =>
a.startedAt.getTime() - b.startedAt.getTime() ||
Number(a.tag >= 0) - Number(b.tag >= 0) ||
a.tag - b.tag,
);
const keys = workoutDuplicateClusterKeys(union);
const keyByTag = new Map<number, string>();
union.forEach((row, index) => {
if (row.tag >= 0) keyByTag.set(row.tag, keys[index]!);
});
const keptTags = new Set(
pickCanonicalWorkoutRows(union, priorityJson)
.map((row) => row.tag)
.filter((tag) => tag >= 0),
);
const clustersWithIncomingWinner = new Set(
[...keptTags].map((tag) => keyByTag.get(tag)!),
);
return incoming.filter(
(_, index) =>
keptTags.has(index) ||
!clustersWithIncomingWinner.has(keyByTag.get(index)!),
);
}
/**
* One training impulse per workout SESSION, not per stored row.
*
* The same session can be stored more than once: the watch writes it to Apple
* Health and a second service (Strava, WHOOP, a ring) syncs it again, or
* HealthKit re-sends it under a new identifier. The workout list collapses
* those through `pickCanonicalWorkoutRows`; summing every row here counted the
* session twice in the day's TRIMP while the list showed it once.
*
* The picker chooses the row by source order, which says nothing about heart
* rate: the winning row can be one without a series while its dropped twin
* carries one. So the impulse of a slot is the sum over its picked rows, and
* only when that is zero the largest impulse among the slot's dropped rows.
*
* The picker keeps every row of a slot none of whose sources is on the
* ladder, so the picked rows of one slot can come from several sources. Only
* one source's rows count: the best-ranked on the ladder, a source off the
* ladder ranking below every listed one, and among sources off the ladder
* the one with the largest impulse. Rows of that one source still add up
* (two distinct sessions of the same sport in one duplicate cluster).
*
* `belongs` keeps the sessions of the scored range: the rows come from a
* windowed read that reaches past the day on both sides, and a session counts
* on the day its earliest picked row starts, the day the list shows it on.
* Returns the positive impulses, one per session. Pure; the strain
* engine's day TRIMP sums it, and the tests call it directly.
*/
export function canonicalWorkoutTrimps<
T extends WorkoutPickerRow & { id: string },
>(
rows: readonly T[],
priorityJson: unknown,
trimpOf: (row: T) => number,
belongs: (startedAt: Date) => boolean = () => true,
): number[] {
// By id: the picker may hand back a copy of a row (it fills a missing heart
// rate from a WHOOP twin).
const picked = new Set(
pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id),
);
const ladder = workoutSourceLadder(priorityJson);
const rankOf = (source: string) => {
const index = ladder.indexOf(source as (typeof ladder)[number]);
return index === -1 ? ladder.length : index;
};
const slots = new Map<
string,
{
picked: Map<string, number>;
dropped: number;
firstPickedAt: Date | null;
}
>();
const keys = workoutDuplicateClusterKeys(rows);
for (const [index, row] of rows.entries()) {
const slot = slots.get(keys[index]!) ?? {
picked: new Map(),
dropped: 0,
firstPickedAt: null,
};
if (
picked.has(row.id) &&
(slot.firstPickedAt === null || row.startedAt < slot.firstPickedAt)
) {
slot.firstPickedAt = row.startedAt;
}
const trimp = trimpOf(row);
if (trimp > 0) {
if (picked.has(row.id)) {
slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + trimp);
} else {
slot.dropped = Math.max(slot.dropped, trimp);
}
}
slots.set(keys[index]!, slot);
}
const out: number[] = [];
for (const slot of slots.values()) {
if (slot.firstPickedAt === null || !belongs(slot.firstPickedAt)) continue;
let best: { rank: number; trimp: number } | null = null;
for (const [source, trimp] of slot.picked) {
const rank = rankOf(source);
if (
best === null ||
rank < best.rank ||
(rank === best.rank && trimp > best.trimp)
) {
best = { rank, trimp };
}
}
const trimp = best !== null ? best.trimp : slot.dropped;
if (trimp > 0) out.push(trimp);
}
return out;
}
+51 -46
View File
@@ -18,7 +18,11 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
rowsInRange,
} from "@/lib/workouts/canonical-read";
import { cached, caches } from "@/lib/cache/server-cache";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -131,62 +135,63 @@ export async function buildWorkoutsProjection(
): Promise<WorkoutsProjection> {
const { since, until, sportType } = params;
const where: Record<string, unknown> = { userId };
if (since || until) {
const range: Record<string, Date> = {};
if (since) {
const d = new Date(since);
if (!Number.isNaN(d.getTime())) range.gte = d;
}
if (until) {
const d = new Date(until);
if (!Number.isNaN(d.getTime())) range.lte = d;
}
if (Object.keys(range).length > 0) {
where.startedAt = range;
}
}
if (sportType) {
where.sportType = sportType;
}
const parse = (value: string | null): Date | null => {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
};
const range = {
from: parse(since),
to: parse(until),
inclusiveEnd: true,
};
// Independent reads — the sourcePriorityJson lookup doesn't depend on the
// row read, so run them concurrently rather than paying two sequential
// round trips.
const [rows, userRow] = await Promise.all([
prisma.workout.findMany({
where,
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
createdAt: true,
route: { select: { id: true } },
samples: { select: { sampleCount: true } },
},
}),
// round trips. The rows come through the windowed reader: a twin just past
// `until`, or a cluster that began just before `since`, decides which row
// of the range is canonical exactly as it does for the detail route.
const [userRow, around] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
}),
readWorkoutsAroundRange(range, (bounds) =>
prisma.workout.findMany({
where: {
userId,
startedAt: bounds,
...(sportType ? { sportType } : {}),
},
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
createdAt: true,
route: { select: { id: true } },
samples: { select: { sampleCount: true } },
},
}),
),
]);
const canonical = pickCanonicalWorkoutRows(
rows,
const canonical: CanonicalWorkoutRow[] = canonicalWorkoutsInRange(
around,
userRow?.sourcePriorityJson ?? null,
);
const rawCount = rowsInRange(around).length;
// `pickCanonicalWorkoutRows` re-sorts its output to `startedAt ASC` (its
// The canonical pick comes back in `startedAt ASC` order (its
// documented contract for the rollup callers). This surface is a
// most-recent-first list, so the DB `orderBy startedAt desc` must be
// re-established AFTER the pick — otherwise `offset`/`limit` slice the
@@ -197,7 +202,7 @@ export async function buildWorkoutsProjection(
return a.id.localeCompare(b.id);
});
return { canonical, rawCount: rows.length };
return { canonical, rawCount };
}
export function sliceWorkoutsProjection(
+29 -22
View File
@@ -18,7 +18,10 @@ import {
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import { prisma } from "@/lib/db";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
} from "@/lib/workouts/canonical-read";
/** Own-history lookback for the sport-average comparison line. */
const SPORT_CONTEXT_LOOKBACK_DAYS = 180;
@@ -47,28 +50,32 @@ export async function buildSportContext(
const since = new Date(
Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
);
const rows = await prisma.workout.findMany({
// `userId` stays in the where clause — the universal tenancy narrow. The
// selected workout is not part of the baseline it is compared against.
where: {
userId,
sportType,
startedAt: { gte: since },
...(excludeWorkoutId ? { id: { not: excludeWorkoutId } } : {}),
},
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
take: SPORT_CONTEXT_MAX_ROWS,
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
durationSec: true,
totalDistanceM: true,
avgHeartRate: true,
},
// `userId` stays in the where clause — the universal tenancy narrow. The
// selected workout is not part of the baseline it is compared against, and
// neither are its twins: the selected row stays in the read so its whole
// duplicate cluster can be left out after the pick. Dropping only the row in
// SQL left a twin from a second source behind as a session of its own.
const rows = await readWorkoutsAroundRange(
{ from: since, to: null },
(bounds) =>
prisma.workout.findMany({
where: { userId, sportType, startedAt: bounds },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
take: SPORT_CONTEXT_MAX_ROWS,
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
durationSec: true,
totalDistanceM: true,
avgHeartRate: true,
},
}),
);
const canonical = canonicalWorkoutsInRange(rows, sourcePriorityJson, {
excludeClusterOf: excludeWorkoutId,
});
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
if (canonical.length === 0) return null;
let durationSum = 0;