mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Vendored
+17
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
@@ -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) }
|
||||
: {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
@@ -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) } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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() }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user