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

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

No files matched your search

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