From 466dfb3785b996b65d8b2427b2e4e21c8c5bf88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 18:21:02 +0200 Subject: [PATCH 01/13] fix(workouts): resolve duplicate clusters the same way on every surface The cross-source picker anchors each duplicate cluster on its first start, so which cluster a workout lands in depends on the workouts around it. The detail route still read the old fixed five-minute clock slot, and every other reader handed the picker only the rows of its own range. The detail of a twin that straddled a slot boundary declared itself canonical while the list hid it, and runs at 09:58, 10:02 and 10:06 were grouped one way by a read starting at 09:55 and another by a read starting at 10:00. Every reader that decides which workout is canonical (list, detail and its previous session, activity calendar, day view, doctor report, Coach, briefing and correlation blocks, strain, per-sport context, the activity insight's history, the batch write) now reads through one windowed reader. It reads a duplicate window past the end of the range and reaches back until every sport in the range starts at a workout that is an anchor whatever came before it, then picks over everything read and keeps the rows that start inside the range. The per-sport baseline and the insight history leave out the whole cluster of the current session instead of the row alone, which left its twin behind as a session of its own. The batch write clusters its rows together with the stored workouts around them and drops only a row that loses to another row of the same batch. A property test checks the windowed read against a whole-history read for random dense histories and ranges; route tests pin list, detail, deep link, previous session and the batch write against the database. --- docs/api/openapi.yaml | 8 +- .../baselines/source-file-lines.json | 11 +- .../baselines/source-function-lines.json | 8 +- src/__tests__/baselines/test-as-never.json | 4 +- .../baselines/version-stamp-comments.json | 4 +- .../workout-canonical-identity-guard.test.ts | 95 +++-- src/app/api/workouts/[id]/route.ts | 185 ++++---- .../workouts/__tests__/batch-create.test.ts | 21 +- .../__tests__/canonical-dedup.test.ts | 6 +- src/app/api/workouts/batch/route.ts | 33 +- src/lib/activity/calendar-read.ts | 40 +- .../__tests__/snapshot-new-metrics.test.ts | 6 +- .../__tests__/workouts-block-units.test.ts | 29 +- .../coach/snapshot-blocks/workouts-block.ts | 10 +- .../ai/coach/snapshot-blocks/workouts-read.ts | 30 ++ src/lib/ai/coach/snapshot.ts | 19 +- src/lib/day/records.ts | 33 +- src/lib/doctor-report/workouts.ts | 38 +- .../insights/__tests__/strain-score.test.ts | 2 +- .../insights/correlation-channel-series.ts | 28 +- src/lib/insights/feature-blocks.ts | 34 +- src/lib/insights/strain-score.ts | 122 ++---- .../workout-insight-generate.test.ts | 40 +- src/lib/jobs/workout-insight-generate.ts | 61 +-- ...ck-canonical-workout-rows.property.test.ts | 2 +- .../pick-canonical-workout-rows.ts | 16 +- src/lib/openapi/routes/workouts.ts | 2 +- .../__tests__/canonical-read.property.test.ts | 240 +++++++++++ src/lib/workouts/batch-dedup.ts | 73 ++++ src/lib/workouts/canonical-read.ts | 395 ++++++++++++++++++ src/lib/workouts/list-read.ts | 97 +++-- src/lib/workouts/sport-context.ts | 51 ++- .../routes/workouts-canonical-window.test.ts | 215 ++++++++++ 33 files changed, 1486 insertions(+), 472 deletions(-) create mode 100644 src/lib/ai/coach/snapshot-blocks/workouts-read.ts create mode 100644 src/lib/workouts/__tests__/canonical-read.property.test.ts create mode 100644 src/lib/workouts/batch-dedup.ts create mode 100644 src/lib/workouts/canonical-read.ts create mode 100644 tests/integration/routes/workouts-canonical-window.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b19c7de6c..b2d97d4bd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 1dc83d219..901c7d2aa 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -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": 146512, + "count": 123, + "total": 145651, "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,7 +90,6 @@ "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/backup-restore.ts": 888, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 86967afbb..c20379ec3 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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": 148369, + "total": 148324, "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, diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index ede0dd3fb..e852565a5 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 37e3bc44e..aa461064a 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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": 2959, - "total": 10521, + "total": 10520, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1710,7 +1710,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, diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 810a59174..88d05da85 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -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 = { "app/api/workouts/[id]/route.ts": "app/api/workouts/[id]/route.ts", @@ -49,15 +59,15 @@ const IDENTITY_OWNER: Record = { // 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 = { /** * 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 = { - "app/api/workouts/batch/route.ts": "durationSec: p.row.durationSec", +const IDENTITY_PROOF: Record = { + // 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"); }); }); diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 428c03c59..274abaefd 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -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( + read: WorkoutBoundedRead, + latestStartsBefore: (to: Date, take: number) => Promise, + before: Date, + priorityJson: unknown, +): Promise { + 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 diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index ecdc3b436..877dee0cc 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -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 = {}, @@ -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")] }), diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 1386b5d61..1dff5e968 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -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"), }, }, }), diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 90c88cb54..cc07172c3 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -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 { }); } - // 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)) { diff --git a/src/lib/activity/calendar-read.ts b/src/lib/activity/calendar-read.ts index 470fc0aa9..8fc919d0d 100644 --- a/src/lib/activity/calendar-read.ts +++ b/src/lib/activity/calendar-read.ts @@ -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 } diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 536b31ec6..97f0e68be 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -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), diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts index 9ed3415a4..29c495b94 100644 --- a/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts @@ -12,18 +12,23 @@ import type { UnitPreference } from "@/lib/measurements/display-transform"; function build(unitPreference: UnitPreference) { const snapshot: Record = {}; 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, diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts index e3d3fd976..634a26833 100644 --- a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts @@ -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): 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()); diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-read.ts b/src/lib/ai/coach/snapshot-blocks/workouts-read.ts new file mode 100644 index 000000000..5d32f56a9 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/workouts-read.ts @@ -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, + }, + }), + ); +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index c9140715b..57ca5fd1a 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -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 diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 4d1cd2bf4..f490f35b1 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -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 { @@ -906,28 +910,23 @@ async function assessmentsPart(frame: DayFrame): Promise { async function workoutsPart(frame: DayFrame): Promise { 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" }], - }), + // 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, + }), + ), prisma.user.findUnique({ where: { id: frame.userId }, select: { sourcePriorityJson: true }, }), ]); // 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, ); diff --git a/src/lib/doctor-report/workouts.ts b/src/lib/doctor-report/workouts.ts index dc20b1817..740807c02 100644 --- a/src/lib/doctor-report/workouts.ts +++ b/src/lib/doctor-report/workouts.ts @@ -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 { 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); } diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 03ea6f010..84c061b7a 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -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, diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index cce0f970f..ed386cc69 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -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 { @@ -1257,18 +1261,14 @@ export async function fetchTrainingSeries( since: Date, ): Promise { 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, - }, - }), + 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, + }), + ), prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, @@ -1281,7 +1281,7 @@ export async function fetchTrainingSeries( select: { day: true, dayTrimp: true }, }), ]); - const sessions = pickCanonicalWorkoutRows( + const sessions = canonicalWorkoutsInRange( workouts, account?.sourcePriorityJson ?? null, ); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 5e3916870..f501f1825 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -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, @@ -537,18 +540,21 @@ export async function readWorkoutsBlock( ): Promise { 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, - }, - }), + 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, + }, + }), + ), prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, @@ -558,7 +564,7 @@ export async function readWorkoutsBlock( // 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(); diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 863776d17..b5a5240b2 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -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, @@ -433,19 +433,23 @@ export async function computeStrainScore( // 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, - }), + // 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, + }), + ), prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, @@ -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; 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. diff --git a/src/lib/jobs/__tests__/workout-insight-generate.test.ts b/src/lib/jobs/__tests__/workout-insight-generate.test.ts index f1443b204..95d54083f 100644 --- a/src/lib/jobs/__tests__/workout-insight-generate.test.ts +++ b/src/lib/jobs/__tests__/workout-insight-generate.test.ts @@ -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, + ) => ({ + 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, + }); }); }); diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index 699a68aa7..2f17773bb 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -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({ diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts index 009643764..6379ca114 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts @@ -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, diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index de26a85ef..cd46fbccb 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -133,8 +133,20 @@ function deduplicateWinningRows(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(); 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]); diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 818f6080c..1fec9ab70 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -336,7 +336,7 @@ export const workoutPaths: NonNullable = { 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(), diff --git a/src/lib/workouts/__tests__/canonical-read.property.test.ts b/src/lib/workouts/__tests__/canonical-read.property.test.ts new file mode 100644 index 000000000..09f38c438 --- /dev/null +++ b/src/lib/workouts/__tests__/canonical-read.property.test.ts @@ -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( + "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"]); + }); +}); diff --git a/src/lib/workouts/batch-dedup.ts b/src/lib/workouts/batch-dedup.ts new file mode 100644 index 000000000..a7705123c --- /dev/null +++ b/src/lib/workouts/batch-dedup.ts @@ -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> { + 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, + ), + ); +} diff --git a/src/lib/workouts/canonical-read.ts b/src/lib/workouts/canonical-read.ts new file mode 100644 index 000000000..22777a630 --- /dev/null +++ b/src/lib/workouts/canonical-read.ts @@ -0,0 +1,395 @@ +/** + * 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. + * + * 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 = ( + bounds: WorkoutStartBounds, +) => Promise; + +/** + * 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 { + 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(); + const relevant = new Set(); + 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( + range: WorkoutStartRange, + read: WorkoutBoundedRead, +): Promise> { + 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( + windowed: WindowedWorkouts, +): T[] { + return windowed.rows.filter((row) => + startsInRange(windowed.range, row.startedAt), + ); +} + +function clusterKeyById( + windowed: WindowedWorkouts, +): Map { + 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( + windowed: WindowedWorkouts, + 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( + windowed: WindowedWorkouts, + 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, + 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(); + 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; + 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; +} diff --git a/src/lib/workouts/list-read.ts b/src/lib/workouts/list-read.ts index c61e11d9c..ff4f896d0 100644 --- a/src/lib/workouts/list-read.ts +++ b/src/lib/workouts/list-read.ts @@ -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 { const { since, until, sportType } = params; - const where: Record = { userId }; - if (since || until) { - const range: Record = {}; - 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 [around, userRow] = await Promise.all([ + 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 } }, + }, + }), + ), prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: 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( diff --git a/src/lib/workouts/sport-context.ts b/src/lib/workouts/sport-context.ts index 9e16828d0..f97f69e4d 100644 --- a/src/lib/workouts/sport-context.ts +++ b/src/lib/workouts/sport-context.ts @@ -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; diff --git a/tests/integration/routes/workouts-canonical-window.test.ts b/tests/integration/routes/workouts-canonical-window.test.ts new file mode 100644 index 000000000..7dfdbd0cf --- /dev/null +++ b/tests/integration/routes/workouts-canonical-window.test.ts @@ -0,0 +1,215 @@ +/** + * The workout list and the workout detail resolve a duplicate cluster the same + * way, whatever range either of them read. + * + * - Two copies of one run thirty seconds apart on either side of a + * five-minute clock boundary: the list shows the Apple Health row, and the + * detail of the Withings copy names that row as `canonicalId`. The detail + * used to read only the clock slot of the requested row, found no twin, + * and declared the hidden copy canonical. + * - Runs at 09:58 (Withings), 10:02 (Apple Health) and 10:06 (Withings): a + * whole-history read pairs 09:58 with 10:02 and leaves 10:06 alone. A list + * filtered from 10:00 shows 10:02 and 10:06, the detail of every row agrees + * with that, and the session before 10:06 is 10:02. + * - The batch write clusters what it is about to insert together with the + * stored workouts around it: with 09:58 stored, a batch of 10:02 (Apple + * Health) and 10:06 (entered by hand) keeps both, where clustering the + * batch alone dropped 10:06 as a twin of 10:02 although every read shows + * it as a session of its own. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { GET as getDetail } from "@/app/api/workouts/[id]/route"; +import { GET as getList } from "@/app/api/workouts/route"; +import { POST as postBatch } from "@/app/api/workouts/batch/route"; + +import { callRoute, type RouteAuth } from "../../kit/call-route"; +import { makeSession, makeUser, makeWorkout } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); + +beforeEach(() => truncateAllTables(prisma)); + +const DAY_MS = 24 * 60 * 60 * 1000; +/** A day ten days back, so the list's default window always holds it. */ +const DAY = new Date(Date.now() - 10 * DAY_MS).toISOString().slice(0, 10); +/** An instant on that day, `HH:MM:SS` in UTC. */ +const at = (time: string) => `${DAY}T${time}.000Z`; + +interface ListBody { + workouts: Array<{ id: string }>; +} +interface DetailBody { + id: string; + canonicalId: string; + previousWorkoutId: string | null; +} + +async function run( + user: Pick, + startedAt: string, + source: "APPLE_HEALTH" | "WITHINGS", +) { + return makeWorkout(user, { + startedAt: new Date(startedAt), + endedAt: new Date(new Date(startedAt).getTime() + 30 * 60 * 1000), + durationSec: 30 * 60, + source, + externalId: `${source}-${startedAt}`, + }); +} + +async function list(auth: RouteAuth, query: Record = {}) { + const res = await callRoute(getList, { + method: "GET", + route: "/api/workouts", + query, + auth, + }); + expect(res.status).toBe(200); + return res.data!.workouts.map((w) => w.id); +} + +async function detail(auth: RouteAuth, id: string) { + const res = await callRoute(getDetail, { + method: "GET", + route: "/api/workouts/{id}", + params: { id }, + query: { compact: "1" }, + auth, + }); + expect(res.status).toBe(200); + return res.data!; +} + +describe("list and detail agree on the canonical workout", () => { + it("resolves a twin across a clock-slot boundary to the row the list shows", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + const apple = await run(user, at("08:04:50"), "APPLE_HEALTH"); + const withings = await run(user, at("08:05:20"), "WITHINGS"); + + expect(await list(auth)).toEqual([apple.id]); + expect((await detail(auth, apple.id)).canonicalId).toBe(apple.id); + // The deep link to the hidden copy lands on the row the list shows. + expect((await detail(auth, withings.id)).canonicalId).toBe(apple.id); + }); + + it("draws the clusters of a filtered range as a read of the whole history does", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + const early = await run(user, at("09:58:00"), "WITHINGS"); + const middle = await run(user, at("10:02:00"), "APPLE_HEALTH"); + const late = await run(user, at("10:06:00"), "WITHINGS"); + + expect(await list(auth)).toEqual([late.id, middle.id]); + // Filtered from 10:00 the list no longer holds the 09:58 run, and still + // draws the clusters the whole history draws: 10:06 is a session of its + // own, not a twin of 10:02. + expect(await list(auth, { since: at("10:00:00") })).toEqual([ + late.id, + middle.id, + ]); + // Up to 10:04 the list must still hide 10:02's twin from 09:58 and show + // 10:02, though the read stops before 10:06. + expect( + await list(auth, { + since: at("09:59:00"), + until: at("10:04:00"), + }), + ).toEqual([middle.id]); + + expect((await detail(auth, early.id)).canonicalId).toBe(middle.id); + expect((await detail(auth, middle.id)).canonicalId).toBe(middle.id); + const lateDetail = await detail(auth, late.id); + expect(lateDetail.canonicalId).toBe(late.id); + expect(lateDetail.previousWorkoutId).toBe(middle.id); + // The session before the 10:02 run is none: 09:58 is its own twin. + expect((await detail(auth, middle.id)).previousWorkoutId).toBeNull(); + }); +}); + +describe("the batch write draws clusters where the reads draw them", () => { + it("keeps a workout the reads show as a session of its own", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + await run(user, at("09:58:00"), "WITHINGS"); + + const res = await callRoute<{ + entries: Array<{ index: number; status: string }>; + }>(postBatch, { + method: "POST", + route: "/api/workouts/batch", + body: { + workouts: [ + { + sportType: "running", + startedAt: at("10:02:00"), + endedAt: at("10:32:00"), + source: "APPLE_HEALTH", + externalId: "apple-1002", + }, + { + sportType: "running", + startedAt: at("10:06:00"), + endedAt: at("10:36:00"), + source: "MANUAL", + externalId: "manual-1006", + }, + ], + }, + auth, + }); + + expect(res.status).toBe(200); + expect(res.data!.entries.map((e) => e.status)).toEqual([ + "inserted", + "inserted", + ]); + const stored = await prisma.workout.findMany({ + where: { userId: user.id }, + orderBy: { startedAt: "asc" }, + select: { id: true, externalId: true }, + }); + expect(await list(auth)).toEqual([stored[2]!.id, stored[1]!.id]); + }); + + it("still drops the twin a batch carries of its own winner", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + + const res = await callRoute<{ + entries: Array<{ index: number; status: string }>; + }>(postBatch, { + method: "POST", + route: "/api/workouts/batch", + body: { + workouts: [ + { + sportType: "running", + startedAt: at("08:04:50"), + endedAt: at("08:34:50"), + source: "APPLE_HEALTH", + externalId: "apple-0804", + }, + { + sportType: "running", + startedAt: at("08:05:20"), + endedAt: at("08:35:20"), + source: "MANUAL", + externalId: "manual-0805", + }, + ], + }, + auth, + }); + + expect(res.status).toBe(200); + expect(res.data!.entries.map((e) => e.status)).toEqual([ + "inserted", + "duplicate", + ]); + }); +}); From c15c1d18f3f843512db93695bbef4b89b7787913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 18:22:36 +0200 Subject: [PATCH 02/13] fix(coach): read one lab marker's history in full With an analyte, the Coach's labs block read the newest 4,000 lab rows of every marker and matched the asked-for one in memory afterwards. An account with that many newer readings of other markers lost the marker's older readings, so its count and first date named a shorter history than the record holds, or the marker vanished. The match now runs in SQL before the row cap, on the biomarker's name for a linked row and the row's own name otherwise, and the history compares units with sameLabUnit, so a reading stored as "MG/DL" beside "mg/dL" stays in the series. --- src/lib/ai/coach/labs-snapshot.ts | 23 ++++++- .../coach-labs-analyte-history.test.ts | 66 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/integration/coach-labs-analyte-history.test.ts diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index a28effb09..bb030f516 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -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, diff --git a/tests/integration/coach-labs-analyte-history.test.ts b/tests/integration/coach-labs-analyte-history.test.ts new file mode 100644 index 000000000..8a64acb14 --- /dev/null +++ b/tests/integration/coach-labs-analyte-history.test.ts @@ -0,0 +1,66 @@ +/** + * The Coach's history of one lab marker is read in full, whatever else the + * account has measured since. + * + * The block used to read the newest 4,000 lab rows of every marker and match + * the asked-for one in memory afterwards. An account with more than that many + * newer readings of other markers (a home glucose or INR log is enough) lost + * the asked-for marker's older readings, so its `count` and `firstTakenAt` + * named a shorter history than the record holds, or the marker vanished. The + * same history also left out a reading whose unit was written another way + * ("MG/DL" beside "mg/dL"). + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot"; + +import { makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const DAY_MS = 24 * 60 * 60 * 1000; + +beforeEach(() => truncateAllTables(prisma)); + +describe("buildLabsSnapshotBlock with an analyte", () => { + it("reads the marker's whole history past thousands of newer readings of other markers", async () => { + const user = await makeUser(); + const now = new Date(); + const daysAgo = (days: number) => new Date(now.getTime() - days * DAY_MS); + + // Three LDL readings, the oldest two years back; one of them stored with + // the unit written in capitals. + await prisma.labResult.createMany({ + data: [ + { takenAt: daysAgo(730), value: 151, unit: "MG/DL" }, + { takenAt: daysAgo(400), value: 142, unit: "mg/dL" }, + { takenAt: daysAgo(200), value: 128, unit: "mg/dL" }, + ].map((reading) => ({ + userId: user.id, + analyte: "LDL", + ...reading, + })), + }); + // 4,500 newer readings of another marker, all after the last LDL. + await prisma.labResult.createMany({ + data: Array.from({ length: 4_500 }, (_, index) => ({ + userId: user.id, + analyte: "Glucose", + value: 90 + (index % 20), + unit: "mg/dL", + takenAt: new Date(daysAgo(199).getTime() + index * 60_000), + })), + }); + + const block = await buildLabsSnapshotBlock(user.id, now, undefined, { + analyte: "ldl", + }); + + expect(block?.recent).toHaveLength(1); + const ldl = block!.recent[0]!; + expect(ldl.analyte).toBe("LDL"); + expect(ldl.value).toBe(128); + expect(ldl.history?.count).toBe(3); + expect(ldl.history?.firstTakenAt).toBe(daysAgo(730).toISOString()); + }); +}); From ac31130bc0dc8e35ec347390c159100e5ac9636d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 18:37:25 +0200 Subject: [PATCH 03/13] fix(cache): mark measurement caches stale after every bulk write Closed analytics blocks survive a write that says it reached no further back, so a writer that never invalidated left them on their pre-write values. The Apple Health and Health Connect imports, the CSV and JSON imports, the score engines, the WHOOP webhook, the Withings activity, sleep and ECG legs, the Oura history backfill and the sleep-segment sweep wrote measurements without touching the read caches; a long-history import showed the old all-time figures until the entries aged out. The shared post-mutation tail now marks the caches stale back to the oldest row it is handed, and the Withings and Oura legs that hand-rolled the same tail run through it. The two export imports share one tail that refolds the rollups, marks the caches stale back to the oldest sample written (all of them when none was) and re-warms the status assessments. A sweep that tombstones a sleep segment drops every cached block. --- .../baselines/source-file-lines.json | 8 +- .../baselines/source-function-lines.json | 10 +- .../baselines/version-stamp-comments.json | 8 +- src/lib/cache/closed-block.ts | 17 ++ src/lib/jobs/apple-health-import-worker.ts | 80 +------- src/lib/jobs/health-connect-import-worker.ts | 52 +----- src/lib/jobs/import-tail.ts | 82 ++++++++ src/lib/oura/sync-heart-rate.ts | 32 +--- src/lib/oura/sync.ts | 29 +-- src/lib/rollups/after-measurement-mutation.ts | 13 ++ src/lib/sleep/sweep-stale-segments.ts | 5 + src/lib/withings/sync-activity.ts | 32 +--- src/lib/withings/sync-ecg.ts | 29 +-- src/lib/withings/sync-sleep.ts | 33 +--- .../bulk-writer-cache-invalidation.test.ts | 176 ++++++++++++++++++ 15 files changed, 360 insertions(+), 246 deletions(-) create mode 100644 src/lib/jobs/import-tail.ts create mode 100644 tests/integration/bulk-writer-cache-invalidation.test.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 901c7d2aa..7f8fa627f 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 123, - "total": 145651, + "total": 145555, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -91,7 +91,7 @@ "lib/insights/mood-status.ts": 814, "lib/insights/status-cache.ts": 812, "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, @@ -114,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, @@ -123,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 } diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index c20379ec3..d977290b1 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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": 148324, + "total": 148227, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -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 } diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index aa461064a..df40d555c 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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": 2959, - "total": 10520, + "total": 10517, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2346,7 +2346,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, @@ -2953,9 +2953,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, diff --git a/src/lib/cache/closed-block.ts b/src/lib/cache/closed-block.ts index 6a4728e94..fa1408306 100644 --- a/src/lib/cache/closed-block.ts +++ b/src/lib/cache/closed-block.ts @@ -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; +} diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index 1023cc0f8..f2cbfe0d9 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -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. diff --git a/src/lib/jobs/health-connect-import-worker.ts b/src/lib/jobs/health-connect-import-worker.ts index b4928e022..08850c72a 100644 --- a/src/lib/jobs/health-connect-import-worker.ts +++ b/src/lib/jobs/health-connect-import-worker.ts @@ -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" }, diff --git a/src/lib/jobs/import-tail.ts b/src/lib/jobs/import-tail.ts new file mode 100644 index 000000000..a63dbf71d --- /dev/null +++ b/src/lib/jobs/import-tail.ts @@ -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; + }, + 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 }; +} diff --git a/src/lib/oura/sync-heart-rate.ts b/src/lib/oura/sync-heart-rate.ts index 17597a23e..48f6d66b3 100644 --- a/src/lib/oura/sync-heart-rate.ts +++ b/src/lib/oura/sync-heart-rate.ts @@ -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 { 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", + }); } /** diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index c205e05e5..fe4f89984 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -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; } diff --git a/src/lib/rollups/after-measurement-mutation.ts b/src/lib/rollups/after-measurement-mutation.ts index aa501d1d6..c96bbecdf 100644 --- a/src/lib/rollups/after-measurement-mutation.ts +++ b/src/lib/rollups/after-measurement-mutation.ts @@ -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. diff --git a/src/lib/sleep/sweep-stale-segments.ts b/src/lib/sleep/sweep-stale-segments.ts index e1503d692..b5607c1d4 100644 --- a/src/lib/sleep/sweep-stale-segments.ts +++ b/src/lib/sleep/sweep-stale-segments.ts @@ -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; } diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 9fa6732c5..b5199cb0a 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -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. diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts index 865532dc5..477a8de07 100644 --- a/src/lib/withings/sync-ecg.ts +++ b/src/lib/withings/sync-ecg.ts @@ -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( diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 25b7abaca..c8b99eaf6 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -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 diff --git a/tests/integration/bulk-writer-cache-invalidation.test.ts b/tests/integration/bulk-writer-cache-invalidation.test.ts new file mode 100644 index 000000000..81ce584cc --- /dev/null +++ b/tests/integration/bulk-writer-cache-invalidation.test.ts @@ -0,0 +1,176 @@ +/** + * Every bulk writer of measurements marks the cached reads stale back to the + * oldest reading it wrote. + * + * A closed analytics block (a value over readings before a fixed instant) + * survives every write that says it reached no further back, so a writer that + * did not invalidate at all left the block on its pre-write value: an Apple + * Health export carrying years of history showed the old all-time figures + * until the entry aged out, and the next sync, reaching back only a day, + * spared it. Pinned here for the writers that had no invalidation: the Apple + * Health import worker, the shared post-mutation tail the CSV and JSON + * imports and the score engines rely on, the Oura upsert a history backfill + * writes through, and the sleep-segment sweep. + */ +import { createHash } from "node:crypto"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { strToU8, zipSync } from "fflate"; +import type { Job } from "pg-boss"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { closedBlockKeyPart } from "@/lib/cache/closed-block"; +import { caches, type ServerCache } from "@/lib/cache/server-cache"; +import { + APPLE_HEALTH_IMPORT_PARSER_REVISION, + handleAppleHealthImport, + _setWorkerPrismaForTests, + type AppleHealthImportPayload, +} from "@/lib/jobs/apple-health-import-worker"; +import { upsertOuraMeasurements } from "@/lib/oura/sync"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; +import { sweepStaleSleepSegments } from "@/lib/sleep/sweep-stale-segments"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const prisma = getPrismaClient(); +const analytics = caches.analytics as ServerCache<{ v: number }>; +const USER = "user-bulk-writer-cache"; +const DAY_MS = 86_400_000; +const NOW = Date.now(); +const yearsAgo = (years: number) => new Date(NOW - years * 365 * DAY_MS); + +/** A closed block over the readings before `before`, and an open entry. */ +function seedCache(before: Date) { + const closed = `${USER}|pre-fold-remainder|${closedBlockKeyPart(before)}|x`; + const open = `${USER}|dashboard-snapshot|en`; + analytics.deleteByPrefix(`${USER}|`); + analytics.set(closed, { v: 1 }); + analytics.set(open, { v: 2 }); + return { closed, open }; +} + +beforeEach(async () => { + await truncateAllTables(prisma); + _setWorkerPrismaForTests(prisma); + await prisma.user.create({ + data: { + id: USER, + username: USER, + email: `${USER}@example.test`, + timezone: "UTC", + }, + }); +}); + +function record(at: Date, value: number): string { + const stamp = at.toISOString().replace("T", " ").slice(0, 19) + " +0000"; + return ``; +} + +async function runImport(xmlRecords: string[]) { + const xml = ` + + ${xmlRecords.join("\n ")} +`; + const dir = mkdtempSync(join(tmpdir(), "hl-import-cache-")); + const path = join(dir, "export.zip"); + const bytes = Buffer.from( + zipSync({ "apple_health_export/export.xml": strToU8(xml) }), + ); + writeFileSync(path, bytes); + await prisma.importJob.create({ + data: { + userId: USER, + pgBossJobId: "cache-job", + status: "queued", + uploadBytes: bytes.length, + uploadSha256: createHash("sha256").update(bytes).digest("hex"), + parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION, + }, + }); + const outcome = await handleAppleHealthImport({ + id: "cache-job", + data: { + userId: USER, + uploadPath: path, + uploadBytes: bytes.length, + enqueuedAt: new Date().toISOString(), + }, + } as unknown as Job); + expect(outcome.ok).toBe(true); +} + +describe("bulk writers invalidate the measurement read caches", () => { + it("the Apple Health import marks stale every block its oldest sample reaches", async () => { + const reached = seedCache(yearsAgo(1)); + const older = `${USER}|pre-fold-remainder|${closedBlockKeyPart(yearsAgo(9))}|x`; + analytics.set(older, { v: 3 }); + + await runImport([record(yearsAgo(7), 88), record(yearsAgo(0.1), 84)]); + + expect(analytics.get(reached.closed)).toBeNull(); + expect(analytics.get(reached.open)).toBeNull(); + // A block of readings older than anything imported stays. + expect(analytics.get(older)).toEqual({ v: 3 }); + }); + + it("the shared post-mutation tail invalidates back to its oldest row", async () => { + const cache = seedCache(yearsAgo(1)); + await afterMeasurementMutation( + USER, + [{ type: "WEIGHT", measuredAt: yearsAgo(3) }], + { source: "manual" }, + ); + expect(analytics.get(cache.closed)).toBeNull(); + expect(analytics.get(cache.open)).toBeNull(); + }); + + it("the Oura upsert a history backfill writes through invalidates", async () => { + const cache = seedCache(yearsAgo(1)); + const written = await upsertOuraMeasurements( + USER, + [ + { + type: "RESTING_HEART_RATE", + value: 52, + unit: "bpm", + measuredAt: yearsAgo(2), + externalId: "oura:test:resting:1", + }, + ], + { emitArrivals: false }, + ); + expect(written).toBe(1); + expect(analytics.get(cache.closed)).toBeNull(); + }); + + it("a sleep-segment sweep that tombstones a row invalidates", async () => { + await prisma.measurement.create({ + data: { + userId: USER, + type: "SLEEP_DURATION", + value: 60, + unit: "min", + source: "OURA", + measuredAt: yearsAgo(2), + externalId: "night-1:stale", + }, + }); + const cache = seedCache(yearsAgo(1)); + const removed = await sweepStaleSleepSegments(USER, "OURA", [ + { prefix: "night-1:", keepIds: ["night-1:fresh"] }, + ]); + expect(removed).toBe(1); + expect(analytics.get(cache.closed)).toBeNull(); + }); +}); From 7fda400a8c7d8f8545a5d904c233eac19ac1e68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 18:43:03 +0200 Subject: [PATCH 04/13] fix(i18n): serve English for an unreadable catalog, refuse to boot without it The server reads messages/.json from disk on first use. A missing or corrupt file threw, and since the matchers iterate every locale, one bad non-English catalog turned every request that touched them into a 500. That locale is now served the English catalog, and the failure is logged once as i18n.catalog.fallback with the locale and the reason. English is the fallback itself: a process whose messages/en.json is missing or does not parse refuses to start, with the file and the reason in the message. The image build checks the catalogs twice with scripts/verify-message-catalogs.mjs: in the builder against the locale list in the source, and in the runner stage against the copies the image carries. --- Dockerfile | 11 ++ scripts/verify-message-catalogs.mjs | 98 +++++++++++++++++ .../verify-message-catalogs-script.test.ts | 63 +++++++++++ src/instrumentation.ts | 8 ++ .../server-catalogs-fallback.test.ts | 91 ++++++++++++++++ src/lib/i18n/server-catalogs.ts | 101 ++++++++++++++++-- 6 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 scripts/verify-message-catalogs.mjs create mode 100644 src/__tests__/verify-message-catalogs-script.test.ts create mode 100644 src/lib/i18n/__tests__/server-catalogs-fallback.test.ts diff --git a/Dockerfile b/Dockerfile index ecc726d6a..db86834e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. diff --git a/scripts/verify-message-catalogs.mjs b/scripts/verify-message-catalogs.mjs new file mode 100644 index 000000000..0e8dd79b5 --- /dev/null +++ b/scripts/verify-message-catalogs.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Checks that every shipped locale has a message catalog that parses. + * + * The server reads `messages/.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 (--config | --locales ) [--write ]", + ); + 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}`); diff --git a/src/__tests__/verify-message-catalogs-script.test.ts b/src/__tests__/verify-message-catalogs-script.test.ts new file mode 100644 index 000000000..06628fb27 --- /dev/null +++ b/src/__tests__/verify-message-catalogs-script.test.ts @@ -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/); + }); +}); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 35eddfef6..d75f9f8e5 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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 diff --git a/src/lib/i18n/__tests__/server-catalogs-fallback.test.ts b/src/lib/i18n/__tests__/server-catalogs-fallback.test.ts new file mode 100644 index 000000000..cd709fccf --- /dev/null +++ b/src/lib/i18n/__tests__/server-catalogs-fallback.test.ts @@ -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 })[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/); + }); +}); diff --git a/src/lib/i18n/server-catalogs.ts b/src/lib/i18n/server-catalogs.ts index cd9f1b555..138cd9f29 100644 --- a/src/lib/i18n/server-catalogs.ts +++ b/src/lib/i18n/server-catalogs.ts @@ -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; @@ -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) { + 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). */ From 6ab1f2d8a28d23cfb7675469a76dcad530f3a071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 18:44:44 +0200 Subject: [PATCH 05/13] docs(deploy): order a split deploy for the job-queue schema upgrade in v1.45 A web-only process opens its queue connection without migrating and refuses a job-queue schema older than its own, so in a web/worker split the new worker has to start, and upgrade the schema from 45 to 46, before the new web containers. The v1.45.0 notes say so, name the one additive migration, state that a single-container install needs no action, and point a forked compose file at the new stop grace period. --- docs/ops/deploy.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index c57c738ce..14a790232 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -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()`. 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 From faedcbba93bf88a3cfb99d2ac620c471ca62341b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 18:55:56 +0200 Subject: [PATCH 06/13] test(backup): pin seeded timestamps, tombstones, self-references and key ids The parity harness normalised any timestamp written during the run to , and the seeded rows' createdAt and updatedAt were themselves written during the run, so a restore that dropped a row's createdAt still matched its golden copy. Every run-time timestamp of the seeded account is now pinned to a fixed historical value, and the seeded snapshot is asserted to hold none, so a lost createdAt shows as against a historical value. The account also carries a tombstone of every model that keeps them, a flare under its condition and a superseded profile fact, so the second restore pass resolves real pointers. The fixture is sealed under the legacy key and a second key is active from the export on; sealed values render as the key id they name and the text they open to, so a value the restore re-encrypts instead of carrying over differs. The golden copies are written from the backup code as it stood before the export and restore moved into per-domain modules (64fa65b46), and the current code matches them. --- .../export-disaster-recovery.json | 2160 ++++++++++------ .../backup-parity/export-portable.json | 626 ++--- .../restore-disaster-recovery-over-live.json | 148 +- .../restore-disaster-recovery.json | 2194 +++++++++++------ .../backup-parity/restore-portable.json | 785 +++--- .../backup-parity/restore-skips.json | 4 +- .../backup-parity/restore-version-1.json | 36 +- .../backup-parity/seeded-account.json | 1352 ++++++---- tests/integration/backup-parity.test.ts | 447 +++- 9 files changed, 5078 insertions(+), 2674 deletions(-) diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index b217a23f5..c664c3195 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -12,7 +12,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -26,12 +26,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -75,7 +75,7 @@ "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", "insuranceNumber": null, - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber", "lastReportPracticeName": "rt-User-lastReportPracticeName", "avatarBytes": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", @@ -83,7 +83,7 @@ "avatarUpdatedAt": "2026-06-30T12:34:56.789Z", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "timezone": "America/Chicago", "locale": "fr", @@ -196,8 +196,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -219,8 +219,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -242,8 +242,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -255,7 +255,7 @@ "measuredAt": "2026-07-01T07:00:00.000Z", "source": "MANUAL", "notes": "rt-Measurement-notes", - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -265,8 +265,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" }, { "id": "", @@ -288,8 +288,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" } ], "customMedicationCategories": [ @@ -298,7 +298,7 @@ "label": "Heart and circulation", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z" } ], "medications": [ @@ -328,7 +328,7 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T11:51:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", "active": false, @@ -336,7 +336,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "id": "", @@ -381,8 +381,8 @@ { "id": "", "notes": "rt-MedicationSideEffect-notes", - "notesEncrypted": "", - "createdAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z", "occurredAt": "2026-07-01T21:00:00.000Z", "category": "GI", "entry": "NAUSEA", @@ -394,13 +394,13 @@ "id": "", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "id": "", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -412,7 +412,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "courses": [ @@ -420,24 +420,24 @@ "startsOn": "2026-03-01", "endsOn": "2026-03-07", "note": "the March flu", - "createdAt": "" + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "createdAt": "2026-05-02T00:46:01.000Z" } ], "doseChanges": [ { "id": "", "note": "rt-MedicationDoseChange-note", - "noteEncrypted": "", + "noteEncrypted": "", "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "id": "", @@ -446,26 +446,26 @@ "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ { "id": "", "notes": "rt-MedicationInventoryItem-notes", - "notesEncrypted": "", + "notesEncrypted": "", "state": "ACTIVE", "containerType": "BLISTER", "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "id": "", @@ -481,8 +481,8 @@ "purchasedAt": null, "manufacturer": null, "doseStrength": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "phaseConfig": { @@ -524,7 +524,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "id": "", @@ -539,7 +539,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "id": "", @@ -554,14 +554,14 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "efficacyTargets": [ { "id": "", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z", "measurementType": "WEIGHT", "biomarkerName": "Ferritin", "primary": false @@ -573,17 +573,38 @@ { "id": "", "medicationId": "", + "autoMissed": false, + "attributionSource": "AUTO", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "", + "medication": "Round-trip tablet", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "source": "WEB", + "autoRecorded": false + }, + { + "id": "", + "medicationId": "", "autoMissed": true, "attributionSource": "USER_PIN", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null, "medication": "Round-trip tablet", @@ -595,14 +616,40 @@ } ], "moodEntries": [ + { + "note": null, + "noteEncrypted": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "syncVersion": 0, + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "id": "", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "a1": null, + "a2": null, + "a3": null, + "a4": null, + "a5": null, + "tags": null, + "source": "MOODLOG", + "loggedAt": "2026-07-03T20:00:00.000Z", + "externalId": null, + "tz": null, + "deletedAt": "", + "factors": [], + "structuredTags": [], + "context": null + }, { "note": "rt-MoodEntry-note", - "noteEncrypted": "", - "syncedAt": "", + "noteEncrypted": "", + "syncedAt": "2026-05-02T04:17:00.000Z", "syncVersion": 3, - "createdAt": "", - "updatedAt": "", - "id": "", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", + "id": "", "date": "2026-07-01", "mood": "GUT", "score": 4, @@ -625,7 +672,7 @@ ], "structuredTags": [], "context": { - "notesEncrypted": "", + "notesEncrypted": "", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -648,41 +695,41 @@ ], "customMoodTagCategories": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ { - "id": "", + "id": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", - "categoryId": "", + "categoryId": "", "kind": "RATED", "isActive": false, "icon": "rt-MoodTag-icon", "sortOrder": 3, - "labelEncrypted": "", + "labelEncrypted": "", "scaleMin": 3, "scaleMax": 3, "inverse": true } ], "cycleProfile": { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, "typicalCycleLength": 3, @@ -696,13 +743,29 @@ }, "cycles": [ { - "id": "", + "id": "", + "isPredicted": false, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "tz": null + }, + { + "id": "", "isPredicted": true, "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", "periodEndDate": "rt-MenstrualCycle-periodEndDate", @@ -712,13 +775,13 @@ "tz": "rt-MenstrualCycle-tz" }, { - "id": "", + "id": "", "isPredicted": false, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "", + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z", "startDate": "2026-06-03", "endDate": null, "periodEndDate": null, @@ -730,12 +793,12 @@ ], "cycleDayLogs": [ { - "id": "", - "cycleId": "", + "id": "", + "cycleId": "", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z", "date": "2026-06-02", "flow": "HEAVY", "intermenstrualBleeding": true, @@ -751,8 +814,8 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", @@ -765,27 +828,57 @@ "severity": 3 } ] + }, + { + "id": "", + "cycleId": "", + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z", + "date": "2026-06-03", + "flow": "LIGHT", + "intermenstrualBleeding": false, + "basalBodyTempC": null, + "temperatureExcluded": false, + "ovulationTest": null, + "cervicalMucus": null, + "cervixPosition": null, + "cervixFirmness": null, + "cervixOpening": null, + "sexualActivity": false, + "protectedSex": null, + "pregnancyTest": null, + "progesteroneTest": null, + "contraceptive": null, + "sensitiveEncrypted": null, + "notesEncrypted": null, + "source": "MANUAL", + "externalId": null, + "tz": null, + "symptomKeys": [], + "symptomSeverities": [] } ], "customSymptoms": [ { - "id": "", + "id": "", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", "categoryId": "csc_physical", "icon": "rt-CycleSymptom-icon", "sortOrder": 3, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "labResults": [ { - "id": "", - "biomarkerId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "biomarkerId": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "updatedAt": "2026-05-02T10:59:00.000Z", "deletedAt": null, "note": null, "panel": "rt-LabResult-panel", @@ -803,13 +896,37 @@ "takenAt": "2026-06-30T09:00:00.000Z", "source": "rt-LabResult-source", "biomarkerName": "Ferritin" + }, + { + "id": "", + "biomarkerId": null, + "noteEncrypted": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "updatedAt": "2026-05-02T10:59:01.000Z", + "deletedAt": "", + "note": null, + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "source": "MANUAL", + "biomarkerName": null } ], "biomarkers": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, @@ -822,26 +939,44 @@ { "name": "Ferr.", "source": "IMPORT_CHOICE", - "id": "", - "createdAt": "" + "id": "", + "createdAt": "2026-05-06T11:26:00.000Z" } ] } ], "biomarkerMerges": [ { - "id": "", - "targetBiomarkerId": "", + "id": "", + "targetBiomarkerId": "", "targetName": "Ferritin", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { - "id": "", + "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "deletedAt": null, + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -849,25 +984,25 @@ "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySite": null, "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "deletedAt": null, "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "tz": "rt-IllnessDayLog-tz", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T19:08:00.000Z", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null, "symptoms": [ { @@ -875,11 +1010,43 @@ "severity": 2 } ] + }, + { + "id": "", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "note": null, + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "updatedAt": "2026-05-02T19:09:01.000Z", + "deletedAt": "", + "symptoms": [] } ] }, { - "id": "", + "id": "", + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, + "note": null, + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "updatedAt": "2026-05-02T17:32:02.000Z", + "deletedAt": "", + "dayLogs": [] + }, + { + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -889,17 +1056,33 @@ "note": null, "noteEncrypted": null, "bodySite": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null, "dayLogs": [] } ], "allergies": [ { - "id": "", + "id": "", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reaction": null, + "note": null, + "reactionEncrypted": null, + "notesEncrypted": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "updatedAt": "2026-05-02T14:11:01.000Z", + "deletedAt": "" + }, + { + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -908,29 +1091,29 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": null, "note": null, - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], "familyHistory": [ { - "id": "", + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z" } ], "workouts": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z", "metadata": { "roundTrip": "rt-Workout-metadata" }, @@ -953,7 +1136,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -965,14 +1148,14 @@ "sourceSystem": "PAPERLESS", "sourceId": "4711", "sourceInstance": "https://paperless.example.test", - "createdAt": "", - "updatedAt": "", - "contentEncrypted": "", + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "providerType": "rt-InboundDocument-providerType", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -999,7 +1182,7 @@ } }, "healthProfile": { - "id": "", + "id": "", "aboutMe": null, "conditions": null, "allergies": null, @@ -1016,35 +1199,46 @@ "emergencyContacts": null, "emergencyImplants": null, "emergencyNote": null, - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", - "createdAt": "", - "updatedAt": "" + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" }, "healthProfileFacts": [ { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "kind": "SMOKING_STATUS", + "value": null, + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "customMetrics": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null, "name": "Grip strength", "unit": "kg", @@ -1055,8 +1249,18 @@ "correlationEnabled": true, "entries": [ { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "", + "noteEncrypted": null, + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null + }, + { + "id": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null, "noteEncrypted": null, "value": 44, @@ -1065,23 +1269,37 @@ "note": "left hand, after the long ride" }, { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null, - "noteEncrypted": "", + "noteEncrypted": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null } ] + }, + { + "id": "", + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "entries": [] } ], "correlationPatterns": [ { - "id": "", - "updatedAt": "", - "createdAt": "", + "id": "", + "updatedAt": "2026-05-03T18:39:00.000Z", + "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -1103,9 +1321,9 @@ "decryptFailures": [], "intradayProfiles": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z", "type": "ACTIVITY_STEPS", "dateKey": "2026-07-01", "hourlyCumulative": [ @@ -1169,8 +1387,8 @@ ], "healthScoreRecords": [ { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T20:13:00.000Z", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", "composite": 71, @@ -1187,13 +1405,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z", "needs": { "recordTarget": "me", "areas": [ @@ -1258,61 +1476,102 @@ }, "practitioners": [ { - "id": "", + "id": "", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -1320,14 +1579,14 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z", "deletedAt": null } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -1335,26 +1594,44 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -1371,46 +1648,68 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -1421,35 +1720,35 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" } ], "attachments": [] }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", "documentScoped": true, "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -1462,7 +1761,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -1470,61 +1769,124 @@ ], "coachFacts": [ { - "id": "", - "factEncrypted": "", + "id": "", + "factEncrypted": "", "deletedAt": null, "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" + }, + { + "id": "", + "factEncrypted": "", + "deletedAt": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null, + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", - "outcomeEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", + "outcomeEncrypted": "", "deletedAt": null, "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" + }, + { + "id": "", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "outcomeEncrypted": null, + "deletedAt": "", + "status": "active", + "reviewDate": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z" } ], "coachReminders": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "deletedAt": null, "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" + }, + { + "id": "", + "noteEncrypted": "", + "deletedAt": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z" } ], "mentalHealthAssessments": [ { - "id": "", + "id": "", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -1535,49 +1897,49 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "consentReceipts": [ { - "id": "", + "id": "", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "committedRecordId": "", + "committedRecordId": "", "committedRecordType": "labResult", - "dataEncrypted": "", - "provenanceEncrypted": "", - "createdAt": "", - "updatedAt": "" + "dataEncrypted": "", + "provenanceEncrypted": "", + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "personalRecords": [ @@ -1591,7 +1953,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -1603,7 +1965,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -1615,19 +1977,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -1635,7 +1997,7 @@ "date": "2026-06-15", "lat": null, "lon": null, - "locationEncrypted": "", + "locationEncrypted": "", "locationLabel": null, "source": "TRAVEL", "tempMin": 19.4, @@ -1673,14 +2035,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", "lat": null, "lon": null, - "locationEncrypted": "", + "locationEncrypted": "", "locationLabel": null, "source": "HOME", "tempMin": 13.1, @@ -1718,8 +2080,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -1729,9 +2091,9 @@ "lat": null, "lon": null, "label": null, - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -1743,17 +2105,17 @@ "placesVisited": 4, "awayFromHomeMinutes": 1440, "countryCode": null, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ { - "id": "", - "waveformEncrypted": "", + "id": "", + "waveformEncrypted": "", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", @@ -1764,120 +2126,132 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "symptomDefinitions": [ { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } ], "lifeEvents": [ { - "id": "", - "titleEncrypted": "", - "noteEncrypted": "", + "id": "", + "titleEncrypted": "", + "noteEncrypted": "", "category": "HOME", "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", "deletedAt": null }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", + "noteEncrypted": null, + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "titleEncrypted": "", "noteEncrypted": null, "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "labResultDocumentLinks": [ { - "labResultId": "", - "documentId": "", - "createdAt": "" + "labResultId": "", + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "nutrientDays": [ @@ -1888,14 +2262,14 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z" } ] }, "builtCounts": { "measurements": 5, "medications": 1, - "intakeEvents": 1, + "intakeEvents": 2, "medicationSideEffects": 1, "medicationPauseEras": 2, "medicationDoseChanges": 2, @@ -1904,43 +2278,43 @@ "reminderPhaseConfigs": 1, "medicationEfficacyTargets": 1, "medicationScheduleRevisions": 3, - "moodEntries": 1, + "moodEntries": 2, "customMoodTagCategories": 1, "hiddenMoodTags": 1, - "cycles": 2, - "cycleDayLogs": 1, + "cycles": 3, + "cycleDayLogs": 2, "nutrientDays": 1, - "labResults": 1, + "labResults": 2, "biomarkers": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, "familyHistory": 1, "workouts": 1, "documents": 1, "healthProfile": 1, - "customMetrics": 1, - "healthProfileFactRevisions": 1, - "customMetricEntries": 2, + "customMetrics": 2, + "healthProfileFactRevisions": 2, + "customMetricEntries": 3, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "customVaccines": 1, - "vaccinations": 1, + "customVaccines": 2, + "vaccinations": 2, "vaccinationLinks": 1, - "measurementReminders": 1, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, "coachConversationDocuments": 1, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "documentConditionLinks": 1, "extractedFacts": 1, @@ -1952,13 +2326,13 @@ "ecgRecordings": 1, "symptomDefinitions": 2, "symptomEvents": 3, - "lifeEvents": 2, + "lifeEvents": 3, "relationLinks": 5 }, "streamedCounts": { "measurements": 5, "medications": 1, - "intakeEvents": 1, + "intakeEvents": 2, "medicationSideEffects": 1, "medicationPauseEras": 2, "medicationDoseChanges": 2, @@ -1967,43 +2341,43 @@ "reminderPhaseConfigs": 1, "medicationEfficacyTargets": 1, "medicationScheduleRevisions": 3, - "moodEntries": 1, + "moodEntries": 2, "customMoodTagCategories": 1, "hiddenMoodTags": 1, - "cycles": 2, - "cycleDayLogs": 1, + "cycles": 3, + "cycleDayLogs": 2, "nutrientDays": 1, - "labResults": 1, + "labResults": 2, "biomarkers": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, "familyHistory": 1, "workouts": 1, "documents": 1, "healthProfile": 1, - "customMetrics": 1, - "healthProfileFactRevisions": 1, - "customMetricEntries": 2, + "customMetrics": 2, + "healthProfileFactRevisions": 2, + "customMetricEntries": 3, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "customVaccines": 1, - "vaccinations": 1, + "customVaccines": 2, + "vaccinations": 2, "vaccinationLinks": 1, - "measurementReminders": 1, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, "coachConversationDocuments": 1, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "documentConditionLinks": 1, "extractedFacts": 1, @@ -2015,7 +2389,7 @@ "ecgRecordings": 1, "symptomDefinitions": 2, "symptomEvents": 3, - "lifeEvents": 2, + "lifeEvents": 3, "relationLinks": 5 }, "parsed": { @@ -2031,7 +2405,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2042,12 +2416,12 @@ "glitchtipEnvironment": "rt-AppSettings-glitchtipEnvironment", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -2094,7 +2468,7 @@ "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", "insuranceNumber": null, - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber", "lastReportPracticeName": "rt-User-lastReportPracticeName", "avatarBytes": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", @@ -2102,7 +2476,7 @@ "avatarUpdatedAt": "2026-06-30T12:34:56.789Z", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "timezone": "America/Chicago", "locale": "fr", @@ -2215,8 +2589,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -2238,8 +2612,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -2261,8 +2635,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -2274,7 +2648,7 @@ "measuredAt": "2026-07-01T07:00:00.000Z", "source": "MANUAL", "notes": "rt-Measurement-notes", - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -2284,8 +2658,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" }, { "id": "", @@ -2307,8 +2681,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" } ], "medications": [ @@ -2345,8 +2719,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", + "updatedAt": "2026-05-01T11:51:00.000Z", "schedules": [ { "id": "", @@ -2395,8 +2769,8 @@ "entry": "NAUSEA", "severity": 3, "notes": "rt-MedicationSideEffect-notes", - "notesEncrypted": "", - "createdAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z" } ], "pauseEras": [ @@ -2404,13 +2778,13 @@ "id": "", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "id": "", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -2422,7 +2796,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "doseChanges": [ @@ -2432,8 +2806,8 @@ "doseValue": 5, "doseUnit": "mg", "note": "rt-MedicationDoseChange-note", - "noteEncrypted": "", - "createdAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-01T19:47:00.000Z" }, { "id": "", @@ -2442,7 +2816,7 @@ "doseUnit": "mg", "note": null, "noteEncrypted": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ @@ -2453,15 +2827,15 @@ "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": "rt-MedicationInventoryItem-notes", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "id": "", @@ -2477,8 +2851,8 @@ "doseStrength": null, "notes": null, "notesEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "inventoryEvents": [ @@ -2512,8 +2886,8 @@ "measurementType": "WEIGHT", "biomarkerName": "Ferritin", "primary": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z" } ], "scheduleRevisions": [ @@ -2530,7 +2904,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "id": "", @@ -2545,7 +2919,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "id": "", @@ -2560,7 +2934,7 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "courses": [ @@ -2568,13 +2942,13 @@ "startsOn": "2026-03-01", "endsOn": "2026-03-07", "note": "the March flu", - "createdAt": "" + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "createdAt": "2026-05-02T00:46:01.000Z" } ] } @@ -2585,7 +2959,7 @@ "label": "Heart and circulation", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z" } ], "intakeEvents": [ @@ -2593,6 +2967,27 @@ "id": "", "medicationId": "", "medication": "Round-trip tablet", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", + "source": "WEB", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "" + }, + { + "id": "", + "medicationId": "", + "medication": "Round-trip tablet", "scheduledFor": "2026-07-01T08:00:00.000Z", "takenAt": "2026-07-01T08:04:00.000Z", "skipped": true, @@ -2601,21 +2996,47 @@ "attributionSource": "USER_PIN", "source": "API", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null } ], "moodEntries": [ { - "id": "", + "id": "", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "a1": null, + "a2": null, + "a3": null, + "a4": null, + "a5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MOODLOG", + "externalId": null, + "loggedAt": "2026-07-03T20:00:00.000Z", + "tz": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "factors": [], + "structuredTags": [], + "context": null + }, + { + "id": "", "date": "2026-07-01", "mood": "GUT", "score": 4, @@ -2626,16 +3047,16 @@ "a5": 3, "tags": "[\"round-trip-tag\"]", "note": "rt-MoodEntry-note", - "noteEncrypted": "", + "noteEncrypted": "", "source": "rt-MoodEntry-source", "externalId": "rt-MoodEntry-externalId", "loggedAt": "2026-07-01T20:00:00.000Z", "tz": "rt-MoodEntry-tz", - "syncedAt": "", + "syncedAt": "2026-05-02T04:17:00.000Z", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", "factors": [ { "key": "round_trip_factor", @@ -2661,12 +3082,12 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "" + "notesEncrypted": "" } } ], "cycleProfile": { - "id": "", + "id": "", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, "typicalCycleLength": 3, @@ -2677,12 +3098,28 @@ "rawChartMode": true, "discreetNotifications": true, "sensitiveCategoryEncryption": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z" }, "cycles": [ { - "id": "", + "id": "", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "isPredicted": false, + "tz": null, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z" + }, + { + "id": "", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", "periodEndDate": "rt-MenstrualCycle-periodEndDate", @@ -2694,11 +3131,11 @@ "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z" }, { - "id": "", + "id": "", "startDate": "2026-06-03", "endDate": null, "periodEndDate": null, @@ -2709,16 +3146,16 @@ "tz": null, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "" + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z" } ], "cycleDayLogs": [ { - "id": "", + "id": "", "date": "2026-06-02", - "cycleId": "", + "cycleId": "", "flow": "HEAVY", "intermenstrualBleeding": true, "basalBodyTempC": 2.5, @@ -2733,15 +3170,15 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z", "symptomKeys": [ "custom:round-trip-cramps" ], @@ -2751,48 +3188,78 @@ "severity": 3 } ] + }, + { + "id": "", + "date": "2026-06-03", + "cycleId": "", + "flow": "LIGHT", + "intermenstrualBleeding": false, + "basalBodyTempC": null, + "temperatureExcluded": false, + "ovulationTest": null, + "cervicalMucus": null, + "cervixPosition": null, + "cervixFirmness": null, + "cervixOpening": null, + "sexualActivity": false, + "protectedSex": null, + "pregnancyTest": null, + "progesteroneTest": null, + "contraceptive": null, + "sensitiveEncrypted": null, + "notesEncrypted": null, + "source": "MANUAL", + "externalId": null, + "tz": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z", + "symptomKeys": [], + "symptomSeverities": [] } ], "customSymptoms": [ { - "id": "", + "id": "", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", "categoryId": "csc_physical", "icon": "rt-CycleSymptom-icon", "sortOrder": 3, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "customMoodTagCategories": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ { - "id": "", + "id": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", - "categoryId": "", + "categoryId": "", "kind": "RATED", "isActive": false, "icon": "rt-MoodTag-icon", "sortOrder": 3, - "labelEncrypted": "", + "labelEncrypted": "", "scaleMin": 3, "scaleMax": 3, "inverse": true @@ -2800,7 +3267,7 @@ ], "labResults": [ { - "id": "", + "id": "", "panel": "rt-LabResult-panel", "analyte": "Ferritin", "value": 91, @@ -2816,17 +3283,41 @@ "takenAt": "2026-06-30T09:00:00.000Z", "source": "rt-LabResult-source", "biomarkerName": "Ferritin", - "biomarkerId": "", + "biomarkerId": "", "note": null, - "createdAt": "", - "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "noteEncrypted": "", "deletedAt": null, - "updatedAt": "" + "updatedAt": "2026-05-02T10:59:00.000Z" + }, + { + "id": "", + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "source": "MANUAL", + "biomarkerName": null, + "biomarkerId": null, + "note": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "noteEncrypted": null, + "deletedAt": "", + "updatedAt": "2026-05-02T10:59:01.000Z" } ], "biomarkers": [ { - "id": "", + "id": "", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, @@ -2837,30 +3328,48 @@ "context": "fasted, morning draw", "aliases": [ { - "id": "", + "id": "", "name": "Ferr.", "source": "IMPORT_CHOICE", - "createdAt": "" + "createdAt": "2026-05-06T11:26:00.000Z" } ], - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z" } ], "biomarkerMerges": [ { - "id": "", - "targetBiomarkerId": "", + "id": "", + "targetBiomarkerId": "", "targetName": "Ferritin", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { - "id": "", + "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "deletedAt": null, + "updatedAt": "2026-05-02T17:32:03.000Z", + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -2868,17 +3377,17 @@ "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, "note": null, - "createdAt": "", - "noteEncrypted": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "noteEncrypted": "", "bodySite": null, "bodySiteEncrypted": null, "laterality": null, "deletedAt": null, - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:00.000Z", "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, @@ -2889,16 +3398,48 @@ } ], "note": null, - "updatedAt": "", - "noteEncrypted": "", + "updatedAt": "2026-05-02T19:09:00.000Z", + "noteEncrypted": "", "tz": "rt-IllnessDayLog-tz", - "createdAt": "", + "createdAt": "2026-05-02T19:08:00.000Z", "deletedAt": null + }, + { + "id": "", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "symptoms": [], + "note": null, + "updatedAt": "2026-05-02T19:09:01.000Z", + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "deletedAt": "" } ] }, { - "id": "", + "id": "", + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, + "note": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "deletedAt": "", + "updatedAt": "2026-05-02T17:32:02.000Z", + "dayLogs": [] + }, + { + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -2906,96 +3447,124 @@ "resolvedAt": null, "parentConditionId": null, "note": null, - "createdAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", "noteEncrypted": null, "bodySite": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", "deletedAt": null, - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:01.000Z", "dayLogs": [] } ], "symptomDefinitions": [ { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { - "id": "", + "id": "", "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, "noteEncrypted": null, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "noteEncrypted": null, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { - "id": "", + "id": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, - "noteEncrypted": "", + "noteEncrypted": "", "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } ], "lifeEvents": [ { - "id": "", + "id": "", "category": "HOME", "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "titleEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "titleEncrypted": "", + "noteEncrypted": null, + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "titleEncrypted": "", + "titleEncrypted": "", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "allergies": [ { - "id": "", + "id": "", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reaction": null, + "note": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "reactionEncrypted": null, + "notesEncrypted": null, + "deletedAt": "", + "updatedAt": "2026-05-02T14:11:01.000Z" + }, + { + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -3004,27 +3573,27 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": null, "note": null, - "createdAt": "", - "reactionEncrypted": "", - "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "reactionEncrypted": "", + "notesEncrypted": "", "deletedAt": null, - "updatedAt": "" + "updatedAt": "2026-05-02T14:11:00.000Z" } ], "familyHistory": [ { - "id": "", + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z" } ], "workouts": [ { - "id": "", + "id": "", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", "endedAt": "2026-06-29T06:30:00.000Z", @@ -3043,13 +3612,13 @@ "metadata": { "roundTrip": "rt-Workout-metadata" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z" } ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -3058,12 +3627,12 @@ "status": "EXTRACTING", "reportDate": "2026-06-30T12:34:56.789Z", "documentDate": "2026-06-30T12:34:56.789Z", - "contentEncrypted": "", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "providerType": "rt-InboundDocument-providerType", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -3072,31 +3641,31 @@ "sourceId": "4711", "sourceInstance": "https://paperless.example.test", "aiReadDeferred": true, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z" } ], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "committedRecordId": "", + "committedRecordId": "", "committedRecordType": "labResult", - "dataEncrypted": "", - "provenanceEncrypted": "", - "createdAt": "", - "updatedAt": "" + "dataEncrypted": "", + "provenanceEncrypted": "", + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "nutrientDays": [ @@ -3107,11 +3676,11 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z" } ], "healthProfile": { - "id": "", + "id": "", "aboutMe": null, "conditions": null, "allergies": null, @@ -3128,33 +3697,44 @@ "emergencyContacts": null, "emergencyImplants": null, "emergencyNote": null, - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", - "createdAt": "", - "updatedAt": "" + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" }, "healthProfileFacts": [ { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "kind": "SMOKING_STATUS", + "value": null, + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "customMetrics": [ { - "id": "", + "id": "", "name": "Grip strength", "unit": "kg", "targetLow": 2.5, @@ -3162,36 +3742,60 @@ "decimals": 3, "description": "rt-CustomMetric-description", "correlationEnabled": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null, "entries": [ { - "id": "", + "id": "", + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null, + "noteEncrypted": null, + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "" + }, + { + "id": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": "left hand, after the long ride", "noteEncrypted": null, - "createdAt": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null, - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null } ] + }, + { + "id": "", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "", + "entries": [] } ], "correlationPatterns": [ { - "id": "", + "id": "", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -3208,13 +3812,13 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T18:38:00.000Z", + "updatedAt": "2026-05-03T18:39:00.000Z" } ], "intradayProfiles": [ { - "id": "", + "id": "", "type": "ACTIVITY_STEPS", "dateKey": "2026-07-01", "hourlyCumulative": [ @@ -3274,13 +3878,13 @@ "basis": "HOUR_BUCKETS", "bucketCount": 3, "timezone": "Europe/Berlin", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z" } ], "healthScoreRecords": [ { - "id": "", + "id": "", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", "composite": 71, @@ -3297,12 +3901,12 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "", - "createdAt": "" + "computedAt": "2026-05-03T20:12:00.000Z", + "createdAt": "2026-05-03T20:13:00.000Z" } ], "onboardingRecord": { - "id": "", + "id": "", "needs": { "recordTarget": "me", "areas": [ @@ -3364,66 +3968,107 @@ }, "modulesDerivedAt": "2026-07-01T08:00:00.000Z", "completedAt": "2026-07-01T08:00:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z" }, "practitioners": [ { - "id": "", + "id": "", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -3431,14 +4076,14 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z", "deletedAt": null } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -3446,73 +4091,91 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "labResultDocumentLinks": [ { - "labResultId": "", - "documentId": "", - "createdAt": "" + "labResultId": "", + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -3523,35 +4186,35 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" } ], "attachments": [] }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", "documentScoped": true, "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -3564,7 +4227,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -3572,11 +4235,30 @@ ], "mentalHealthAssessments": [ { - "id": "", + "id": "", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -3587,81 +4269,125 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "consentReceipts": [ { - "id": "", + "id": "", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "coachFacts": [ { - "id": "", - "factEncrypted": "", + "id": "", + "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "", + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null + }, + { + "id": "", + "factEncrypted": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null, + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z", + "deletedAt": "" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", - "outcomeEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", + "outcomeEncrypted": "", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null + }, + { + "id": "", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "outcomeEncrypted": null, + "status": "active", + "reviewDate": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z", + "deletedAt": "" } ], "coachReminders": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z", "deletedAt": null + }, + { + "id": "", + "noteEncrypted": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z", + "deletedAt": "" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -3678,29 +4404,51 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "personalRecords": [ @@ -3714,7 +4462,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -3726,7 +4474,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -3738,19 +4486,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -3759,7 +4507,7 @@ "lat": null, "lon": null, "locationLabel": null, - "locationEncrypted": "", + "locationEncrypted": "", "source": "TRAVEL", "tempMin": 19.4, "tempMax": 28.1, @@ -3796,15 +4544,15 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", "lat": null, "lon": null, "locationLabel": null, - "locationEncrypted": "", + "locationEncrypted": "", "source": "HOME", "tempMin": 13.1, "tempMax": 22.7, @@ -3841,8 +4589,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -3852,9 +4600,9 @@ "lat": null, "lon": null, "label": null, - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -3866,20 +4614,20 @@ "placesVisited": 4, "awayFromHomeMinutes": 1440, "countryCode": null, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ { - "id": "", + "id": "", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", - "waveformEncrypted": "", + "waveformEncrypted": "", "samplingFrequency": 300, "sampleCount": 8, "durationSeconds": 0.02666666666666667, @@ -3887,8 +4635,8 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "manifest": { @@ -3917,47 +4665,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, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, "familyHistory": 1, "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, @@ -3973,47 +4721,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, - "labResults": 1, + "moodEntries": 2, + "cycles": 3, + "cycleDayLogs": 2, + "labResults": 2, "nutrientDays": 1, "biomarkers": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, "familyHistory": 1, "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, diff --git a/tests/integration/__golden__/backup-parity/export-portable.json b/tests/integration/__golden__/backup-parity/export-portable.json index 35ffc7afc..b7076c404 100644 --- a/tests/integration/__golden__/backup-parity/export-portable.json +++ b/tests/integration/__golden__/backup-parity/export-portable.json @@ -162,7 +162,7 @@ "label": "Heart and circulation", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z" } ], "medications": [ @@ -175,7 +175,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "windowStart": "08:00", @@ -203,12 +203,12 @@ { "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -219,7 +219,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "courses": [ @@ -227,13 +227,13 @@ "startsOn": "2026-03-01", "endsOn": "2026-03-07", "note": "the March flu", - "createdAt": "" + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "createdAt": "2026-05-02T00:46:01.000Z" } ], "doseChanges": [ @@ -242,14 +242,14 @@ "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "note": null, "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ @@ -260,13 +260,13 @@ "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "notes": null, @@ -280,8 +280,8 @@ "purchasedAt": null, "manufacturer": null, "doseStrength": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "phaseConfig": { @@ -319,7 +319,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "validFrom": "2026-02-01T00:00:00.000Z", @@ -333,7 +333,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "validFrom": "2026-03-01T00:00:00.000Z", @@ -347,7 +347,7 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "efficacyTargets": [ @@ -430,7 +430,7 @@ "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ @@ -550,12 +550,27 @@ "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "bodySite": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -565,12 +580,12 @@ "note": "rt-IllnessEpisode-noteEncrypted", "bodySite": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, @@ -581,12 +596,12 @@ } ], "note": "rt-IllnessDayLog-noteEncrypted", - "updatedAt": "" + "updatedAt": "2026-05-02T19:09:00.000Z" } ] }, { - "id": "", + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -596,14 +611,14 @@ "note": null, "bodySite": "Stomach", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "dayLogs": [] } ], "allergies": [ { - "id": "", + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -612,19 +627,19 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": "rt-Allergy-reactionEncrypted", "note": "rt-Allergy-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z" } ], "familyHistory": [ { - "id": "", + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z" } ], "workouts": [ @@ -647,7 +662,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -659,7 +674,7 @@ "sourceSystem": "PAPERLESS", "sourceId": "4711", "sourceInstance": "https://paperless.example.test", - "createdAt": "", + "createdAt": "2026-05-04T01:24:00.000Z", "summary": "rt-InboundDocument-summaryEncrypted" } ], @@ -727,7 +742,7 @@ ], "correlationPatterns": [ { - "createdAt": "", + "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -828,7 +843,7 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { @@ -896,57 +911,57 @@ }, "practitioners": [ { - "id": "", + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": "rt-Practitioner-locationEncrypted", "phone": "rt-Practitioner-phoneEncrypted", "note": "ring the upper bell", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z" } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reason": "annual check", "outcome": "ferritin back in range, recheck in a year", "bodySite": "rt-Encounter-bodySiteEncrypted", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "" + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", - "createdAt": "" + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -954,13 +969,13 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -968,25 +983,25 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "note": "sore arm for a day, nothing else", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -1003,43 +1018,43 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, "summary": "earlier turns: weight trend and evening walks", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "my readings look higher this week, is that real?", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -1052,7 +1067,7 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", "content": "the last seven mornings average 4 mmHg above the fortnight before", "metricSourceJson": null, @@ -1068,17 +1083,17 @@ "attachments": [] }, { - "id": "", + "id": "", "title": "About my June labs", "documentScoped": true, "summary": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "What does the ferritin mean?", "metricSourceJson": null, @@ -1093,7 +1108,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -1101,21 +1116,21 @@ ], "coachFacts": [ { - "id": "", + "id": "", "fact": "does not tolerate ACE inhibitors", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", "ifCue": "if the evening reading is over 140", "thenAction": "then walk twenty minutes before dinner tomorrow", @@ -1123,42 +1138,42 @@ "outcome": "rt-CoachPlan-outcomeEncrypted", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" } ], "coachReminders": [ { - "id": "", + "id": "", "note": "ask how the evening walks are going", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" } ], "mentalHealthAssessments": [], "consentReceipts": [], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, @@ -1183,8 +1198,8 @@ "page": 2, "confidence": 0.94 }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "personalRecords": [ @@ -1198,7 +1213,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -1210,7 +1225,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -1222,19 +1237,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -1279,8 +1294,8 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", @@ -1323,8 +1338,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -1334,8 +1349,8 @@ "lat": 41.4, "lon": 2.2, "label": "Barcelona", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -1349,8 +1364,8 @@ "countryCode": "ES", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ @@ -1375,8 +1390,8 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "symptomDefinitions": [ @@ -1385,24 +1400,24 @@ "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { "note": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { "note": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, @@ -1411,16 +1426,16 @@ "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { "note": "left side, behind the eye", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } @@ -1433,8 +1448,8 @@ "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z" }, { "title": "Parental leave", @@ -1443,43 +1458,43 @@ "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z" } ], "labResultDocumentLinks": [ { "labResultId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "nutrientDays": [ @@ -1490,7 +1505,7 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z" } ] }, @@ -1514,7 +1529,7 @@ "nutrientDays": 1, "labResults": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -1577,7 +1592,7 @@ "nutrientDays": 1, "labResults": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -1787,7 +1802,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "windowStart": "08:00", @@ -1815,12 +1830,12 @@ { "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -1831,7 +1846,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "doseChanges": [ @@ -1840,14 +1855,14 @@ "doseValue": 5, "doseUnit": "mg", "note": "titration note, encrypted at rest", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", "note": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ @@ -1857,14 +1872,14 @@ "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": "second pack, kept in the kitchen drawer", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "state": "USED_UP", @@ -1878,8 +1893,8 @@ "manufacturer": null, "doseStrength": null, "notes": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "inventoryEvents": [ @@ -1924,7 +1939,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "validFrom": "2026-02-01T00:00:00.000Z", @@ -1938,7 +1953,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "validFrom": "2026-03-01T00:00:00.000Z", @@ -1952,7 +1967,7 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "courses": [ @@ -1960,13 +1975,13 @@ "startsOn": "2026-03-01", "endsOn": "2026-03-07", "note": "the March flu", - "createdAt": "" + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "createdAt": "2026-05-02T00:46:01.000Z" } ] } @@ -1977,7 +1992,7 @@ "label": "Heart and circulation", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z" } ], "intakeEvents": [ @@ -2108,7 +2123,7 @@ "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ @@ -2171,12 +2186,27 @@ "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "bodySite": null, + "laterality": null, + "updatedAt": "2026-05-02T17:32:03.000Z", + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -2184,14 +2214,14 @@ "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, "note": "rt-IllnessEpisode-noteEncrypted", - "createdAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", "bodySite": null, "laterality": null, - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:00.000Z", "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, @@ -2202,12 +2232,12 @@ } ], "note": "rt-IllnessDayLog-noteEncrypted", - "updatedAt": "" + "updatedAt": "2026-05-02T19:09:00.000Z" } ] }, { - "id": "", + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -2215,10 +2245,10 @@ "resolvedAt": null, "parentConditionId": null, "note": null, - "createdAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", "bodySite": "Stomach", "laterality": "BOTH", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:01.000Z", "dayLogs": [] } ], @@ -2228,24 +2258,24 @@ "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, "note": null, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "note": null, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, @@ -2254,16 +2284,16 @@ "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "note": "left side, behind the eye", "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } @@ -2276,8 +2306,8 @@ "precision": "MONTH", "title": "Moved to the coast", "note": "the flat with the long hallway", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z" }, { "category": "WORK", @@ -2286,13 +2316,13 @@ "precision": "DAY", "title": "Parental leave", "note": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z" } ], "allergies": [ { - "id": "", + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -2301,19 +2331,19 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": "rt-Allergy-reactionEncrypted", "note": "rt-Allergy-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z" } ], "familyHistory": [ { - "id": "", + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z" } ], "workouts": [ @@ -2336,7 +2366,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -2349,20 +2379,20 @@ "sourceId": "4711", "sourceInstance": "https://paperless.example.test", "summary": "rt-InboundDocument-summaryEncrypted", - "createdAt": "" + "createdAt": "2026-05-04T01:24:00.000Z" } ], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, @@ -2387,8 +2417,8 @@ "page": 2, "confidence": 0.94 }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "nutrientDays": [ @@ -2399,7 +2429,7 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z" } ], "healthProfile": { @@ -2464,7 +2494,7 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "" + "createdAt": "2026-05-03T18:38:00.000Z" } ], "intradayProfiles": [ @@ -2548,7 +2578,7 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { @@ -2616,57 +2646,57 @@ }, "practitioners": [ { - "id": "", + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": "rt-Practitioner-locationEncrypted", "phone": "rt-Practitioner-phoneEncrypted", "note": "ring the upper bell", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z" } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reason": "annual check", "outcome": "ferritin back in range, recheck in a year", "bodySite": "rt-Encounter-bodySiteEncrypted", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "" + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", - "createdAt": "" + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -2674,13 +2704,13 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -2688,70 +2718,70 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "note": "sore arm for a day, nothing else", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "labResultDocumentLinks": [ { "labResultId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, "summary": "earlier turns: weight trend and evening walks", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "my readings look higher this week, is that real?", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -2764,7 +2794,7 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", "content": "the last seven mornings average 4 mmHg above the fortnight before", "metricSourceJson": null, @@ -2780,17 +2810,17 @@ "attachments": [] }, { - "id": "", + "id": "", "title": "About my June labs", "documentScoped": true, "summary": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "What does the ferritin mean?", "metricSourceJson": null, @@ -2805,7 +2835,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -2815,21 +2845,21 @@ "consentReceipts": [], "coachFacts": [ { - "id": "", + "id": "", "fact": "does not tolerate ACE inhibitors", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", "ifCue": "if the evening reading is over 140", "thenAction": "then walk twenty minutes before dinner tomorrow", @@ -2837,32 +2867,32 @@ "outcome": "rt-CoachPlan-outcomeEncrypted", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" } ], "coachReminders": [ { - "id": "", + "id": "", "note": "ask how the evening walks are going", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -2879,28 +2909,28 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "personalRecords": [ @@ -2914,7 +2944,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -2926,7 +2956,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -2938,19 +2968,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -2995,8 +3025,8 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", @@ -3039,8 +3069,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -3050,8 +3080,8 @@ "lat": 41.4, "lon": 2.2, "label": "Barcelona", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -3065,8 +3095,8 @@ "countryCode": "ES", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ @@ -3091,8 +3121,8 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "manifest": { @@ -3131,7 +3161,7 @@ "nutrientDays": 1, "labResults": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -3187,7 +3217,7 @@ "labResults": 1, "nutrientDays": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json index 29326a9cd..6b196cc9e 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json @@ -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, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, "familyHistory": 1, "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, + "illnessEpisodes": 4, + "allergies": 2, "familyHistory": 1, "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, + "illnessEpisodes": 4, + "allergies": 2, "familyHistory": 1, "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, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, "familyHistory": 1, "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 } } diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index c254dae87..e66469817 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -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, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, "familyHistory": 1, "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, @@ -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, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, "familyHistory": 1, "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 } } @@ -427,8 +427,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -451,8 +451,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" }, { "id": "", @@ -475,8 +475,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -499,8 +499,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -513,7 +513,7 @@ "source": "MANUAL", "measuredAt": "2026-07-01T07:00:00.000Z", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -523,8 +523,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" } ], "IntradayCumulativeProfile": [ @@ -590,8 +590,8 @@ "basis": "HOUR_BUCKETS", "bucketCount": 3, "timezone": "Europe/Berlin", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z" } ], "Medication": [ @@ -628,7 +628,7 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "updatedAt": "" } ], @@ -687,21 +687,42 @@ "attributionSource": "USER_PIN", "source": "API", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "medicationId": "", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", + "source": "WEB", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "" } ], "MedicationSideEffect": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "occurredAt": "2026-07-01T21:00:00.000Z", @@ -709,48 +730,48 @@ "entry": "NAUSEA", "severity": 3, "notes": null, - "notesEncrypted": "", - "createdAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z" } ], "MedicationPauseEra": [ { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "MedicationDoseChange": [ { - "id": "", + "id": "", "medicationId": "", "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", "note": null, - "noteEncrypted": "", - "createdAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-01T19:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", "note": null, "noteEncrypted": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "MedicationCategoryAssignment": [ @@ -762,41 +783,41 @@ ], "MedicationCategoryLabel": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "labelEncrypted": "", + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "", + "createdAt": "2026-05-01T23:06:00.000Z", "updatedAt": "" } ], "MedicationCourse": [ { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", "updatedAt": "" }, { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", "noteEncrypted": null, - "createdAt": "", + "createdAt": "2026-05-02T00:46:01.000Z", "updatedAt": "" } ], "MedicationIngredient": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "nutrient": "vitamin_d", @@ -805,13 +826,13 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "", + "createdAt": "2026-05-02T02:29:00.000Z", "updatedAt": "" } ], "MoodEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", "mood": "GUT", @@ -823,21 +844,45 @@ "stabilityA5": 3, "tags": "[\"round-trip-tag\"]", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "source": "rt-MoodEntry-source", "externalId": "rt-MoodEntry-externalId", "moodLoggedAt": "2026-07-01T20:00:00.000Z", "tz": "rt-MoodEntry-tz", - "syncedAt": "", - "createdAt": "", - "updatedAt": "", + "syncedAt": "2026-05-02T04:17:00.000Z", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", "syncVersion": 3, "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "moodA1": null, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MOODLOG", + "externalId": null, + "moodLoggedAt": "2026-07-03T20:00:00.000Z", + "tz": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "syncVersion": 0, + "deletedAt": "" } ], "MoodContext": [ { - "moodEntryId": "", + "moodEntryId": "", "userId": "round-trip-owner", "workStatus": "regular", "workMinutes": 3, @@ -856,23 +901,23 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "", + "notesEncrypted": "", "createdAt": "", "updatedAt": "" } ], "MoodEntryTagLink": [ { - "moodEntryId": "", - "moodTagId": "", + "moodEntryId": "", + "moodTagId": "", "createdAt": "", "rating": 4 } ], "MoodTag": [ { - "id": "", - "categoryId": "", + "id": "", + "categoryId": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", "icon": "rt-MoodTag-icon", @@ -883,12 +928,12 @@ "scaleMax": 3, "inverse": true, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "LabResult": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "panel": "rt-LabResult-panel", "analyte": "Ferritin", @@ -903,33 +948,71 @@ "sourceValue": 2.5, "sourceUnit": "rt-LabResult-sourceUnit", "takenAt": "2026-06-30T09:00:00.000Z", - "biomarkerId": "", + "biomarkerId": "", "source": "rt-LabResult-source", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "updatedAt": "2026-05-02T10:59:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "biomarkerId": null, + "source": "MANUAL", + "noteEncrypted": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "updatedAt": "2026-05-02T10:59:01.000Z", + "deletedAt": "" } ], "Biomarker": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z" } ], "Allergy": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reactionEncrypted": null, + "notesEncrypted": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "updatedAt": "2026-05-02T14:11:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "substance": "Penicillin", "category": "FOOD", @@ -937,29 +1020,29 @@ "severity": "MILD", "status": "INACTIVE", "onsetAt": "2026-06-30T12:34:56.789Z", - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], "FamilyHistoryEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z", "deletedAt": null } ], "IllnessEpisode": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Cold", "type": "INFECTION", @@ -967,15 +1050,31 @@ "onsetAt": "2026-06-20T00:00:00.000Z", "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", + "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "noteEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", "updatedAt": "", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Iron deficiency", "type": "CHRONIC", @@ -984,162 +1083,215 @@ "resolvedAt": null, "parentConditionId": null, "noteEncrypted": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, + "noteEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "updatedAt": "2026-05-02T17:32:02.000Z", + "deletedAt": "" } ], "IllnessDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "episodeId": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, - "noteEncrypted": "", + "noteEncrypted": "", "tz": "rt-IllnessDayLog-tz", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T19:08:00.000Z", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "updatedAt": "2026-05-02T19:09:01.000Z", + "deletedAt": "" } ], "IllnessSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 2, "createdAt": "" } ], "SymptomDefinition": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "labelEncrypted": "", + "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "labelEncrypted": "", + "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z" } ], "SymptomEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, "noteEncrypted": null, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, - "noteEncrypted": "", + "noteEncrypted": "", "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "noteEncrypted": null, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ], "LifeEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "category": "HOME", "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "titleEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", + "userId": "round-trip-owner", + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "titleEncrypted": "", + "noteEncrypted": null, + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "titleEncrypted": "", + "titleEncrypted": "", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "UserHealthProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", "aiIncludedSections": [ "ABOUT_ME" ], "emergencyBloodType": "A_POS", "organDonorStatus": "YES", "advanceDirectiveStatus": "EXISTS", - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", "lifeConfirmedJson": { "roundTrip": "rt-UserHealthProfile-lifeConfirmedJson" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" } ], "HealthProfileFactRevision": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "SMOKING_STATUS", - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "kind": "SMOKING_STATUS", + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "CycleProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, @@ -1151,13 +1303,30 @@ "rawChartMode": true, "discreetNotifications": true, "sensitiveCategoryEncryption": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z" } ], "MenstrualCycle": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "isPredicted": false, + "tz": null, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", @@ -1170,11 +1339,11 @@ "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-03", "endDate": null, @@ -1186,17 +1355,17 @@ "tz": null, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "" + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z" } ], "CycleDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-02", - "cycleId": "", + "cycleId": "", "flow": "HEAVY", "intermenstrualBleeding": true, "basalBodyTempC": 2.5, @@ -1211,20 +1380,49 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "date": "2026-06-03", + "cycleId": "", + "flow": "LIGHT", + "intermenstrualBleeding": false, + "basalBodyTempC": null, + "temperatureExcluded": false, + "ovulationTest": null, + "cervicalMucus": null, + "cervixPosition": null, + "cervixFirmness": null, + "cervixOpening": null, + "sexualActivity": false, + "protectedSex": null, + "pregnancyTest": null, + "progesteroneTest": null, + "contraceptive": null, + "sensitiveEncrypted": null, + "notesEncrypted": null, + "source": "MANUAL", + "externalId": null, + "tz": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z" } ], "CycleSymptom": [ { - "id": "", + "id": "", "categoryId": "csc_physical", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", @@ -1232,20 +1430,20 @@ "sortOrder": 3, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "CycleSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 3, "createdAt": "" } ], "CustomMetric": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Grip strength", "unit": "kg", @@ -1254,40 +1452,66 @@ "decimals": 3, "description": "rt-CustomMetric-description", "correlationEnabled": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "" } ], "CustomMetricEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null, + "noteEncrypted": null, + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", + "customMetricId": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": null, - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null, - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null } ], "CorrelationPattern": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", @@ -1305,13 +1529,13 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T18:38:00.000Z", + "updatedAt": "2026-05-03T18:39:00.000Z" } ], "HealthScoreRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", @@ -1329,13 +1553,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "", - "createdAt": "" + "computedAt": "2026-05-03T20:12:00.000Z", + "createdAt": "2026-05-03T20:13:00.000Z" } ], "Workout": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", @@ -1355,8 +1579,8 @@ "metadata": { "roundTrip": "rt-Workout-metadata" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z" } ], "NutrientIntakeDay": [ @@ -1368,20 +1592,20 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "", + "createdAt": "2026-05-03T23:27:00.000Z", "updatedAt": "" } ], "InboundDocument": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", "mimeType": "application/pdf", "byteSize": 57, - "contentEncrypted": "", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "status": "EXTRACTING", @@ -1389,7 +1613,7 @@ "reportDate": "2026-06-30T12:34:56.789Z", "documentDate": "2026-06-30T12:34:56.789Z", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -1398,76 +1622,126 @@ "sourceId": "4711", "sourceInstance": "https://paperless.example.test", "aiReadDeferred": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z", "deletedAt": null } ], "Practitioner": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "Encounter": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null } ], "EncounterDocumentLink": [ { - "id": "", - "encounterId": "", - "documentId": "", + "id": "", + "encounterId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T06:04:00.000Z" } ], "EncounterLabLink": [ { - "id": "", - "encounterId": "", - "labResultId": "", + "id": "", + "encounterId": "", + "labResultId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T07:44:00.000Z" } ], "EncounterConditionLink": [ { - "id": "", - "encounterId": "", - "episodeId": "", + "id": "", + "encounterId": "", + "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T09:24:00.000Z" } ], "VaccinationRecord": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", @@ -1476,28 +1750,41 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null } ], "VaccinationDocumentLink": [ { - "id": "", - "vaccinationId": "", - "documentId": "", + "id": "", + "vaccinationId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T12:44:00.000Z" } ], "CustomVaccine": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "deletedAt": "", + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "name": "Round-trip travel combo", "components": [ @@ -1507,13 +1794,13 @@ "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "MeasurementReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", @@ -1532,54 +1819,78 @@ "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, "lastNotifiedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "lastNotifiedAt": null, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "MeasurementReminderEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" } ], "CoachConversation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, "documentScoped": true }, { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", - "summaryEncrypted": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, "documentScoped": false @@ -1587,24 +1898,24 @@ ], "CoachMessage": [ { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "assistant", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -1615,10 +1926,10 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -1631,67 +1942,114 @@ ], "CoachConversationDocument": [ { - "conversationId": "", - "documentId": "", + "conversationId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ], "CoachFact": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "factEncrypted": "", + "factEncrypted": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z", + "deletedAt": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null + }, + { + "id": "", + "userId": "round-trip-owner", + "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null, "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "" + "sourceMessageId": "" } ], "CoachPlan": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "outcomeEncrypted": "", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "outcomeEncrypted": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "status": "active", + "reviewDate": null, + "outcomeEncrypted": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z", + "deletedAt": "" } ], "CoachReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "noteEncrypted": "", + "noteEncrypted": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", + "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z", "deletedAt": null } ], "MedicationInventoryItem": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "ACTIVE", @@ -1699,18 +2057,18 @@ "unitsTotal": "decimal:30", "unitsRemaining": "decimal:21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": null, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "USED_UP", @@ -1725,20 +2083,20 @@ "doseStrength": null, "notes": null, "notesEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "MedicationInventoryEvent": [ { - "id": "", + "id": "", "medicationId": "", "delta": -8, "reason": "consumed", "occurredAt": "2026-07-09T07:00:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "delta": 30, "reason": "purchased", @@ -1747,26 +2105,26 @@ ], "MoodTagCategory": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "MoodTagHidden": [ { "userId": "round-trip-owner", "moodTagId": "mt_grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "ReminderPhaseConfig": [ { - "id": "", + "id": "", "medicationId": "", "greenValue": 45, "greenMode": "PERCENT", @@ -1780,12 +2138,32 @@ ], "MentalHealthAssessment": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -1796,59 +2174,59 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "ConsentReceipt": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "DocumentConditionLink": [ { - "id": "", - "documentId": "", - "episodeId": "", + "id": "", + "documentId": "", + "episodeId": "", "userId": "round-trip-owner", "createdAt": "2026-07-02T09:00:00.000Z" } ], "ExtractedFact": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "userId": "round-trip-owner", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "dataEncrypted": "", - "provenanceEncrypted": "", - "committedRecordId": "", + "dataEncrypted": "", + "provenanceEncrypted": "", + "committedRecordId": "", "committedRecordType": "labResult", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "PersonalRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_10km_time", @@ -1859,10 +2237,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_5km_time", @@ -1873,10 +2251,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WEIGHT", "metricSlot": null, @@ -1887,31 +2265,31 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "UserAchievement": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" } ], "EnvironmentContext": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", - "locationEncrypted": "", + "locationEncrypted": "", "source": "TRAVEL", "tempMin": 19.4, "tempMax": 28.1, @@ -1948,14 +2326,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", - "locationEncrypted": "", + "locationEncrypted": "", "source": "HOME", "tempMin": 13.1, "tempMax": 22.7, @@ -1992,24 +2370,24 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "EnvironmentTravelLocation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-10", "endDate": "2026-06-20", - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "DawarichDay": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", "totalDistanceM": 48300, @@ -2017,16 +2395,16 @@ "trainDistanceM": 30500, "placesVisited": 4, "awayFromHomeMinutes": 1440, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "MedicationScheduleRevision": [ { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -2039,10 +2417,10 @@ ], "source": "ARCHIVED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-02-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -2054,11 +2432,11 @@ } ], "source": "ARCHIVED", - "supersededByRevisionId": "", - "createdAt": "" + "supersededByRevisionId": "", + "createdAt": "2026-05-06T04:47:02.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-03-01T00:00:00.000Z", "validUntil": "2026-05-01T00:00:00.000Z", @@ -2071,28 +2449,28 @@ ], "source": "MANUAL", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "MedicationEfficacyTarget": [ { - "id": "", + "id": "", "medicationId": "", "measurementType": "WEIGHT", - "biomarkerId": "", + "biomarkerId": "", "primary": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z" } ], "EcgRecording": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", - "waveformEncrypted": "", + "waveformEncrypted": "", "samplingFrequency": 300, "sampleCount": 8, "durationSeconds": 0.02666666666666667, @@ -2100,13 +2478,13 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "OnboardingRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "needsJson": { "areas": [ @@ -2169,80 +2547,80 @@ }, "modulesDerivedAt": "2026-07-01T08:00:00.000Z", "completedAt": "2026-07-01T08:00:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z" } ], "BiomarkerAlias": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "biomarkerId": "", + "biomarkerId": "", "name": "Ferr.", "nameFolded": "ferr.", "source": "IMPORT_CHOICE", - "createdAt": "" + "createdAt": "2026-05-06T11:26:00.000Z" } ], "BiomarkerMerge": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "targetBiomarkerId": "", + "targetBiomarkerId": "", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "LabResultDocumentLink": [ { - "id": "", - "labResultId": "", - "documentId": "", + "id": "", + "labResultId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T14:44:00.000Z" } ], "EncounterMedicationLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "medicationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "EncounterVaccinationLink": [ { - "id": "", - "encounterId": "", - "vaccinationId": "", + "id": "", + "encounterId": "", + "vaccinationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T18:04:00.000Z" } ], "MedicationDocumentLink": [ { - "id": "", + "id": "", "medicationId": "", - "documentId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T19:44:00.000Z" } ], "MedicationConditionLink": [ { - "id": "", + "id": "", "medicationId": "", - "episodeId": "", + "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T21:24:00.000Z" } ], "AccountGrant": [ { - "id": "", + "id": "", "grantorId": "round-trip-owner", "granteeId": "parity-delegate", "access": "READ", @@ -2273,7 +2651,7 @@ "timezone": "America/Chicago", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "environmentAqHistoryJson": null, "codexAccessTokenEncrypted": null, @@ -2438,7 +2816,7 @@ "passkeyUpgradeNudgeDismissed": true, "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber" }, "AppSettings": { @@ -2450,7 +2828,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2464,12 +2842,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -2518,7 +2896,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2532,12 +2910,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -2581,7 +2959,7 @@ "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", "insuranceNumber": null, - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber", "lastReportPracticeName": "rt-User-lastReportPracticeName", "avatarBytes": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", @@ -2589,7 +2967,7 @@ "avatarUpdatedAt": "2026-06-30T12:34:56.789Z", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "timezone": "America/Chicago", "locale": "fr", @@ -2702,8 +3080,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -2725,8 +3103,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -2748,8 +3126,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -2761,7 +3139,7 @@ "measuredAt": "2026-07-01T07:00:00.000Z", "source": "MANUAL", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -2771,8 +3149,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" }, { "id": "", @@ -2794,8 +3172,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" } ], "customMedicationCategories": [ @@ -2804,7 +3182,7 @@ "label": "Heart and circulation", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z" } ], "medications": [ @@ -2842,7 +3220,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "id": "", @@ -2887,8 +3265,8 @@ { "id": "", "notes": null, - "notesEncrypted": "", - "createdAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z", "occurredAt": "2026-07-01T21:00:00.000Z", "category": "GI", "entry": "NAUSEA", @@ -2900,13 +3278,13 @@ "id": "", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "id": "", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -2918,7 +3296,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "courses": [ @@ -2926,24 +3304,24 @@ "startsOn": "2026-03-01", "endsOn": "2026-03-07", "note": "the March flu", - "createdAt": "" + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "createdAt": "2026-05-02T00:46:01.000Z" } ], "doseChanges": [ { "id": "", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "id": "", @@ -2952,26 +3330,26 @@ "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ { "id": "", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "state": "ACTIVE", "containerType": "BLISTER", "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "id": "", @@ -2987,8 +3365,8 @@ "purchasedAt": null, "manufacturer": null, "doseStrength": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "phaseConfig": { @@ -3030,7 +3408,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "id": "", @@ -3045,7 +3423,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "id": "", @@ -3060,14 +3438,14 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "efficacyTargets": [ { "id": "", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z", "measurementType": "WEIGHT", "biomarkerName": "Ferritin", "primary": false @@ -3079,17 +3457,38 @@ { "id": "", "medicationId": "", + "autoMissed": false, + "attributionSource": "AUTO", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "", + "medication": "Round-trip tablet", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "source": "WEB", + "autoRecorded": false + }, + { + "id": "", + "medicationId": "", "autoMissed": true, "attributionSource": "USER_PIN", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null, "medication": "Round-trip tablet", @@ -3103,12 +3502,38 @@ "moodEntries": [ { "note": null, - "noteEncrypted": "", - "syncedAt": "", + "noteEncrypted": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "syncVersion": 0, + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "id": "", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "a1": null, + "a2": null, + "a3": null, + "a4": null, + "a5": null, + "tags": null, + "source": "MOODLOG", + "loggedAt": "2026-07-03T20:00:00.000Z", + "externalId": null, + "tz": null, + "deletedAt": "", + "factors": [], + "structuredTags": [], + "context": null + }, + { + "note": null, + "noteEncrypted": "", + "syncedAt": "2026-05-02T04:17:00.000Z", "syncVersion": 3, - "createdAt": "", - "updatedAt": "", - "id": "", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", + "id": "", "date": "2026-07-01", "mood": "GUT", "score": 4, @@ -3131,7 +3556,7 @@ ], "structuredTags": [], "context": { - "notesEncrypted": "", + "notesEncrypted": "", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -3154,41 +3579,41 @@ ], "customMoodTagCategories": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ { - "id": "", + "id": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", - "categoryId": "", + "categoryId": "", "kind": "RATED", "isActive": false, "icon": "rt-MoodTag-icon", "sortOrder": 3, - "labelEncrypted": "", + "labelEncrypted": "", "scaleMin": 3, "scaleMax": 3, "inverse": true } ], "cycleProfile": { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, "typicalCycleLength": 3, @@ -3202,13 +3627,29 @@ }, "cycles": [ { - "id": "", + "id": "", + "isPredicted": false, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "tz": null + }, + { + "id": "", "isPredicted": true, "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", "periodEndDate": "rt-MenstrualCycle-periodEndDate", @@ -3218,13 +3659,13 @@ "tz": "rt-MenstrualCycle-tz" }, { - "id": "", + "id": "", "isPredicted": false, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "", + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z", "startDate": "2026-06-03", "endDate": null, "periodEndDate": null, @@ -3236,12 +3677,12 @@ ], "cycleDayLogs": [ { - "id": "", - "cycleId": "", + "id": "", + "cycleId": "", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z", "date": "2026-06-02", "flow": "HEAVY", "intermenstrualBleeding": true, @@ -3257,8 +3698,8 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", @@ -3271,27 +3712,57 @@ "severity": 3 } ] + }, + { + "id": "", + "cycleId": "", + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z", + "date": "2026-06-03", + "flow": "LIGHT", + "intermenstrualBleeding": false, + "basalBodyTempC": null, + "temperatureExcluded": false, + "ovulationTest": null, + "cervicalMucus": null, + "cervixPosition": null, + "cervixFirmness": null, + "cervixOpening": null, + "sexualActivity": false, + "protectedSex": null, + "pregnancyTest": null, + "progesteroneTest": null, + "contraceptive": null, + "sensitiveEncrypted": null, + "notesEncrypted": null, + "source": "MANUAL", + "externalId": null, + "tz": null, + "symptomKeys": [], + "symptomSeverities": [] } ], "customSymptoms": [ { - "id": "", + "id": "", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", "categoryId": "csc_physical", "icon": "rt-CycleSymptom-icon", "sortOrder": 3, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "labResults": [ { - "id": "", - "biomarkerId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "biomarkerId": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "updatedAt": "2026-05-02T10:59:00.000Z", "deletedAt": null, "note": null, "panel": "rt-LabResult-panel", @@ -3309,13 +3780,37 @@ "takenAt": "2026-06-30T09:00:00.000Z", "source": "rt-LabResult-source", "biomarkerName": "Ferritin" + }, + { + "id": "", + "biomarkerId": null, + "noteEncrypted": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "updatedAt": "2026-05-02T10:59:01.000Z", + "deletedAt": "", + "note": null, + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "source": "MANUAL", + "biomarkerName": null } ], "biomarkers": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, @@ -3328,26 +3823,44 @@ { "name": "Ferr.", "source": "IMPORT_CHOICE", - "id": "", - "createdAt": "" + "id": "", + "createdAt": "2026-05-06T11:26:00.000Z" } ] } ], "biomarkerMerges": [ { - "id": "", - "targetBiomarkerId": "", + "id": "", + "targetBiomarkerId": "", "targetName": "Ferritin", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { - "id": "", + "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "", + "deletedAt": null, + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -3355,25 +3868,25 @@ "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySite": null, "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "deletedAt": null, "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "tz": "rt-IllnessDayLog-tz", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T19:08:00.000Z", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null, "symptoms": [ { @@ -3381,11 +3894,43 @@ "severity": 2 } ] + }, + { + "id": "", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "note": null, + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "updatedAt": "2026-05-02T19:09:01.000Z", + "deletedAt": "", + "symptoms": [] } ] }, { - "id": "", + "id": "", + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, + "note": null, + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "updatedAt": "2026-05-02T17:32:02.000Z", + "deletedAt": "", + "dayLogs": [] + }, + { + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -3395,17 +3940,33 @@ "note": null, "noteEncrypted": null, "bodySite": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null, "dayLogs": [] } ], "allergies": [ { - "id": "", + "id": "", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reaction": null, + "note": null, + "reactionEncrypted": null, + "notesEncrypted": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "updatedAt": "2026-05-02T14:11:01.000Z", + "deletedAt": "" + }, + { + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -3414,29 +3975,29 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": null, "note": null, - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], "familyHistory": [ { - "id": "", + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z" } ], "workouts": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z", "metadata": { "roundTrip": "rt-Workout-metadata" }, @@ -3459,7 +4020,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -3471,14 +4032,14 @@ "sourceSystem": "PAPERLESS", "sourceId": "4711", "sourceInstance": "https://paperless.example.test", - "createdAt": "", - "updatedAt": "", - "contentEncrypted": "", + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "providerType": "rt-InboundDocument-providerType", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -3505,7 +4066,7 @@ } }, "healthProfile": { - "id": "", + "id": "", "aboutMe": null, "conditions": null, "allergies": null, @@ -3522,35 +4083,46 @@ "emergencyContacts": null, "emergencyImplants": null, "emergencyNote": null, - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", - "createdAt": "", - "updatedAt": "" + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" }, "healthProfileFacts": [ { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "kind": "SMOKING_STATUS", + "value": null, + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "customMetrics": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null, "name": "Grip strength", "unit": "kg", @@ -3561,33 +4133,57 @@ "correlationEnabled": true, "entries": [ { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "", + "noteEncrypted": null, + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null + }, + { + "id": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null, - "noteEncrypted": "", + "noteEncrypted": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": null }, { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null, - "noteEncrypted": "", + "noteEncrypted": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null } ] + }, + { + "id": "", + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "entries": [] } ], "correlationPatterns": [ { - "id": "", - "updatedAt": "", - "createdAt": "", + "id": "", + "updatedAt": "2026-05-03T18:39:00.000Z", + "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -3609,9 +4205,9 @@ "decryptFailures": [], "intradayProfiles": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z", "type": "ACTIVITY_STEPS", "dateKey": "2026-07-01", "hourlyCumulative": [ @@ -3675,8 +4271,8 @@ ], "healthScoreRecords": [ { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T20:13:00.000Z", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", "composite": 71, @@ -3693,13 +4289,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z", "needs": { "recordTarget": "me", "areas": [ @@ -3764,61 +4360,102 @@ }, "practitioners": [ { - "id": "", + "id": "", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -3826,14 +4463,14 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z", "deletedAt": null } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -3841,26 +4478,44 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -3877,46 +4532,68 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "coachConversations": [ { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", "documentScoped": false, - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -3927,35 +4604,35 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" } ], "attachments": [] }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", "documentScoped": true, "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -3968,7 +4645,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -3976,61 +4653,124 @@ ], "coachFacts": [ { - "id": "", - "factEncrypted": "", + "id": "", + "factEncrypted": "", "deletedAt": null, "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" + }, + { + "id": "", + "factEncrypted": "", + "deletedAt": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null, + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", - "outcomeEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", + "outcomeEncrypted": "", "deletedAt": null, "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" + }, + { + "id": "", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "outcomeEncrypted": null, + "deletedAt": "", + "status": "active", + "reviewDate": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z" } ], "coachReminders": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "deletedAt": null, "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" + }, + { + "id": "", + "noteEncrypted": "", + "deletedAt": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z" } ], "mentalHealthAssessments": [ { - "id": "", + "id": "", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -4041,49 +4781,49 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "consentReceipts": [ { - "id": "", + "id": "", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "committedRecordId": "", + "committedRecordId": "", "committedRecordType": "labResult", - "dataEncrypted": "", - "provenanceEncrypted": "", - "createdAt": "", - "updatedAt": "" + "dataEncrypted": "", + "provenanceEncrypted": "", + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "personalRecords": [ @@ -4097,7 +4837,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -4109,7 +4849,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -4121,19 +4861,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -4141,7 +4881,7 @@ "date": "2026-06-15", "lat": null, "lon": null, - "locationEncrypted": "", + "locationEncrypted": "", "locationLabel": null, "source": "TRAVEL", "tempMin": 19.4, @@ -4179,14 +4919,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", "lat": null, "lon": null, - "locationEncrypted": "", + "locationEncrypted": "", "locationLabel": null, "source": "HOME", "tempMin": 13.1, @@ -4224,8 +4964,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -4235,9 +4975,9 @@ "lat": null, "lon": null, "label": null, - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -4249,17 +4989,17 @@ "placesVisited": 4, "awayFromHomeMinutes": 1440, "countryCode": null, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ { - "id": "", - "waveformEncrypted": "", + "id": "", + "waveformEncrypted": "", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", @@ -4270,120 +5010,132 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "symptomDefinitions": [ { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } ], "lifeEvents": [ { - "id": "", - "titleEncrypted": "", - "noteEncrypted": "", + "id": "", + "titleEncrypted": "", + "noteEncrypted": "", "category": "HOME", "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", "deletedAt": null }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", + "noteEncrypted": null, + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "titleEncrypted": "", "noteEncrypted": null, "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "labResultDocumentLinks": [ { - "labResultId": "", - "documentId": "", - "createdAt": "" + "labResultId": "", + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "nutrientDays": [ @@ -4394,7 +5146,7 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z" } ] } diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index f3554e295..479f19de8 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -19,7 +19,7 @@ "nutrientDays": 1, "labResults": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -439,7 +439,7 @@ "labResults": 1, "biomarkers": 1, "nutrientDays": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -581,7 +581,7 @@ "source": "MANUAL", "measuredAt": "2026-07-01T07:00:00.000Z", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": null, "aggregationProvenance": null, @@ -694,7 +694,7 @@ "reorderLeadDays": null, "externalSource": null, "externalId": null, - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "updatedAt": "" } ], @@ -769,7 +769,7 @@ "entry": "NAUSEA", "severity": 3, "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "createdAt": "" } ], @@ -780,7 +780,7 @@ "userId": "round-trip-owner", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "id": "", @@ -788,7 +788,7 @@ "userId": "round-trip-owner", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "MedicationDoseChange": [ @@ -799,8 +799,8 @@ "doseValue": 5, "doseUnit": "mg", "note": null, - "noteEncrypted": "", - "createdAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-01T19:47:00.000Z" }, { "id": "", @@ -810,7 +810,7 @@ "doseUnit": "mg", "note": null, "noteEncrypted": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "MedicationCategoryAssignment": [ @@ -825,10 +825,10 @@ "id": "", "userId": "round-trip-owner", "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "labelEncrypted": "", + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "", + "createdAt": "2026-05-01T23:06:00.000Z", "updatedAt": "" } ], @@ -839,8 +839,8 @@ "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", "updatedAt": "" }, { @@ -850,7 +850,7 @@ "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", "noteEncrypted": null, - "createdAt": "", + "createdAt": "2026-05-02T00:46:01.000Z", "updatedAt": "" } ], @@ -865,7 +865,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "", + "createdAt": "2026-05-02T02:29:00.000Z", "updatedAt": "" } ], @@ -883,7 +883,7 @@ "stabilityA5": 3, "tags": "[\"round-trip-tag\"]", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "source": "rt-MoodEntry-source", "externalId": "rt-MoodEntry-externalId", "moodLoggedAt": "2026-07-01T20:00:00.000Z", @@ -916,7 +916,7 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "", + "notesEncrypted": "", "createdAt": "", "updatedAt": "" } @@ -943,7 +943,7 @@ "scaleMax": 3, "inverse": true, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "LabResult": [ @@ -965,7 +965,7 @@ "takenAt": "2026-06-30T09:00:00.000Z", "biomarkerId": "", "source": "rt-LabResult-source", - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null @@ -979,7 +979,7 @@ "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", @@ -997,10 +997,10 @@ "severity": "MILD", "status": "INACTIVE", "onsetAt": "2026-06-30T12:34:56.789Z", - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], @@ -1011,9 +1011,9 @@ "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z", "deletedAt": null } ], @@ -1027,16 +1027,32 @@ "onsetAt": "2026-06-20T00:00:00.000Z", "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "deletedAt": null }, { "id": "", "userId": "round-trip-owner", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "noteEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "", + "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -1044,129 +1060,129 @@ "resolvedAt": null, "parentConditionId": null, "noteEncrypted": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null } ], "IllnessDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, - "noteEncrypted": "", + "noteEncrypted": "", "tz": null, "createdAt": "", - "updatedAt": "", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null } ], "IllnessSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 2, "createdAt": "" } ], "SymptomDefinition": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "labelEncrypted": "", + "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "labelEncrypted": "", + "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z" } ], "SymptomEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, "noteEncrypted": null, "episodeId": "", - "createdAt": "", - "updatedAt": "" - }, - { - "id": "", - "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-06-21T11:00:00.000Z", - "intensity": 8, - "noteEncrypted": "", - "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", + "occurredAt": "2026-06-21T11:00:00.000Z", + "intensity": 8, + "noteEncrypted": "", + "episodeId": null, + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "noteEncrypted": null, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ], "LifeEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "category": "HOME", "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "titleEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "titleEncrypted": "", + "titleEncrypted": "", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "UserHealthProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", "pendingQuestionsEncrypted": null, "aiIncludedSections": [ "ABOUT_ME" @@ -1174,9 +1190,9 @@ "emergencyBloodType": "A_POS", "organDonorStatus": "YES", "advanceDirectiveStatus": "EXISTS", - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", "lifeConfirmedJson": { "roundTrip": "rt-UserHealthProfile-lifeConfirmedJson" }, @@ -1187,7 +1203,7 @@ "HealthProfileFactRevision": [], "CycleProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, @@ -1206,7 +1222,7 @@ "MenstrualCycle": [], "CycleDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-02", "cycleId": null, @@ -1225,7 +1241,7 @@ "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", "sensitiveEncrypted": null, - "notesEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", @@ -1237,7 +1253,7 @@ ], "CycleSymptom": [ { - "id": "", + "id": "", "categoryId": "csc_physical", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", @@ -1245,20 +1261,20 @@ "sortOrder": 3, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "CycleSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 3, "createdAt": "" } ], "CustomMetric": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Grip strength", "unit": "kg", @@ -1274,33 +1290,33 @@ ], "CustomMetricEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "deletedAt": null } ], "CorrelationPattern": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", @@ -1318,13 +1334,13 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "", + "createdAt": "2026-05-03T18:38:00.000Z", "updatedAt": "" } ], "HealthScoreRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", @@ -1342,13 +1358,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "", + "computedAt": "2026-05-03T20:12:00.000Z", "createdAt": "" } ], "Workout": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", @@ -1379,43 +1395,43 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "", + "createdAt": "2026-05-03T23:27:00.000Z", "updatedAt": "" } ], "InboundDocument": [], "Practitioner": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "Encounter": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null } ], @@ -1423,16 +1439,16 @@ "EncounterLabLink": [], "EncounterConditionLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T09:24:00.000Z" } ], "VaccinationRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", @@ -1441,20 +1457,20 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null } ], "VaccinationDocumentLink": [], "CustomVaccine": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Round-trip travel combo", "components": [ @@ -1464,13 +1480,13 @@ "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "MeasurementReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", @@ -1489,54 +1505,54 @@ "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, "lastNotifiedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null } ], "MeasurementReminderEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" } ], "CoachConversation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, "documentScoped": true }, { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", - "summaryEncrypted": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, "documentScoped": false @@ -1544,24 +1560,24 @@ ], "CoachMessage": [ { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "assistant", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -1572,10 +1588,10 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -1589,60 +1605,60 @@ "CoachConversationDocument": [], "CoachFact": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "factEncrypted": "", + "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null, "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "" + "sourceMessageId": "" } ], "CoachPlan": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "outcomeEncrypted": "", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "outcomeEncrypted": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null } ], "CoachReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "noteEncrypted": "", + "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z", "deletedAt": null } ], "MedicationInventoryItem": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "ACTIVE", @@ -1650,18 +1666,18 @@ "unitsTotal": "decimal:30", "unitsRemaining": "decimal:21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": null, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "USED_UP", @@ -1676,20 +1692,20 @@ "doseStrength": null, "notes": null, "notesEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "MedicationInventoryEvent": [ { - "id": "", + "id": "", "medicationId": "", "delta": -8, "reason": "consumed", "occurredAt": "2026-07-09T07:00:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "delta": 30, "reason": "purchased", @@ -1705,19 +1721,19 @@ "sortOrder": 90, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "MoodTagHidden": [ { "userId": "round-trip-owner", "moodTagId": "mt_grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "ReminderPhaseConfig": [ { - "id": "", + "id": "", "medicationId": "", "greenValue": 45, "greenMode": "PERCENT", @@ -1735,7 +1751,7 @@ "ExtractedFact": [], "PersonalRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_10km_time", @@ -1746,10 +1762,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_5km_time", @@ -1760,10 +1776,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WEIGHT", "metricSlot": null, @@ -1774,31 +1790,31 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "UserAchievement": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" } ], "EnvironmentContext": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", - "locationEncrypted": "", + "locationEncrypted": "", "source": "TRAVEL", "tempMin": 19.4, "tempMax": 28.1, @@ -1835,14 +1851,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", - "locationEncrypted": "", + "locationEncrypted": "", "source": "HOME", "tempMin": 13.1, "tempMax": 22.7, @@ -1879,24 +1895,24 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "EnvironmentTravelLocation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-10", "endDate": "2026-06-20", - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "DawarichDay": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", "totalDistanceM": 48300, @@ -1904,16 +1920,16 @@ "trainDistanceM": 30500, "placesVisited": 4, "awayFromHomeMinutes": 1440, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "MedicationScheduleRevision": [ { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -1926,10 +1942,10 @@ ], "source": "ARCHIVED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-02-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -1941,11 +1957,11 @@ } ], "source": "ARCHIVED", - "supersededByRevisionId": "", - "createdAt": "" + "supersededByRevisionId": "", + "createdAt": "2026-05-06T04:47:02.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-03-01T00:00:00.000Z", "validUntil": "2026-05-01T00:00:00.000Z", @@ -1958,12 +1974,12 @@ ], "source": "MANUAL", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "MedicationEfficacyTarget": [ { - "id": "", + "id": "", "medicationId": "", "measurementType": "WEIGHT", "biomarkerId": "", @@ -1974,12 +1990,12 @@ ], "EcgRecording": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", - "waveformEncrypted": "", + "waveformEncrypted": "", "samplingFrequency": 300, "sampleCount": 8, "durationSeconds": 0.02666666666666667, @@ -1987,13 +2003,13 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "OnboardingRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "needsJson": { "areas": [ @@ -2062,7 +2078,7 @@ ], "BiomarkerAlias": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "biomarkerId": "", "name": "Ferr.", @@ -2073,47 +2089,47 @@ ], "BiomarkerMerge": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "targetBiomarkerId": "", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "LabResultDocumentLink": [], "EncounterMedicationLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "medicationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "EncounterVaccinationLink": [ { - "id": "", - "encounterId": "", - "vaccinationId": "", + "id": "", + "encounterId": "", + "vaccinationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T18:04:00.000Z" } ], "MedicationDocumentLink": [], "MedicationConditionLink": [ { - "id": "", + "id": "", "medicationId": "", "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T21:24:00.000Z" } ], "AccountGrant": [ { - "id": "", + "id": "", "grantorId": "round-trip-owner", "granteeId": "parity-delegate", "access": "READ", @@ -2144,7 +2160,7 @@ "timezone": "America/Chicago", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "environmentAqHistoryJson": null, "codexAccessTokenEncrypted": null, @@ -2309,7 +2325,7 @@ "passkeyUpgradeNudgeDismissed": true, "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber" }, "AppSettings": { @@ -2321,7 +2337,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2335,12 +2351,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -2539,7 +2555,7 @@ "label": "Heart and circulation", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z" } ], "medications": [ @@ -2552,7 +2568,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "windowStart": "08:00", @@ -2580,12 +2596,12 @@ { "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -2596,7 +2612,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "courses": [ @@ -2604,13 +2620,13 @@ "startsOn": "2026-03-01", "endsOn": "2026-03-07", "note": "the March flu", - "createdAt": "" + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "createdAt": "2026-05-02T00:46:01.000Z" } ], "doseChanges": [ @@ -2619,14 +2635,14 @@ "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "note": null, "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ @@ -2637,13 +2653,13 @@ "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "notes": null, @@ -2657,8 +2673,8 @@ "purchasedAt": null, "manufacturer": null, "doseStrength": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "phaseConfig": { @@ -2696,7 +2712,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "validFrom": "2026-02-01T00:00:00.000Z", @@ -2710,7 +2726,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "validFrom": "2026-03-01T00:00:00.000Z", @@ -2724,7 +2740,7 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "efficacyTargets": [ @@ -2807,7 +2823,7 @@ "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ @@ -2927,12 +2943,27 @@ "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "bodySite": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "", + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -2942,12 +2973,12 @@ "note": "rt-IllnessEpisode-noteEncrypted", "bodySite": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, @@ -2958,12 +2989,12 @@ } ], "note": "rt-IllnessDayLog-noteEncrypted", - "updatedAt": "" + "updatedAt": "2026-05-02T19:09:00.000Z" } ] }, { - "id": "", + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -2973,14 +3004,14 @@ "note": null, "bodySite": "Stomach", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "dayLogs": [] } ], "allergies": [ { - "id": "", + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -2989,19 +3020,19 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": "rt-Allergy-reactionEncrypted", "note": "rt-Allergy-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z" } ], "familyHistory": [ { - "id": "", + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z" } ], "workouts": [ @@ -3087,7 +3118,7 @@ ], "correlationPatterns": [ { - "createdAt": "", + "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -3188,7 +3219,7 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { @@ -3256,45 +3287,45 @@ }, "practitioners": [ { - "id": "", + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": "rt-Practitioner-locationEncrypted", "phone": "rt-Practitioner-phoneEncrypted", "note": "ring the upper bell", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z" } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reason": "annual check", "outcome": "ferritin back in range, recheck in a year", "bodySite": "rt-Encounter-bodySiteEncrypted", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "" + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z" } ], "encounterDocumentLinks": [], "encounterLabLinks": [], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -3302,13 +3333,13 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -3316,19 +3347,19 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "note": "sore arm for a day, nothing else", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z" } ], "vaccinationDocumentLinks": [], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -3345,43 +3376,43 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, "summary": "earlier turns: weight trend and evening walks", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "my readings look higher this week, is that real?", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -3394,7 +3425,7 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", "content": "the last seven mornings average 4 mmHg above the fortnight before", "metricSourceJson": null, @@ -3410,17 +3441,17 @@ "attachments": [] }, { - "id": "", + "id": "", "title": "About my June labs", "documentScoped": true, "summary": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "What does the ferritin mean?", "metricSourceJson": null, @@ -3438,21 +3469,21 @@ ], "coachFacts": [ { - "id": "", + "id": "", "fact": "does not tolerate ACE inhibitors", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", "ifCue": "if the evening reading is over 140", "thenAction": "then walk twenty minutes before dinner tomorrow", @@ -3460,27 +3491,27 @@ "outcome": "rt-CoachPlan-outcomeEncrypted", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" } ], "coachReminders": [ { - "id": "", + "id": "", "note": "ask how the evening walks are going", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" } ], "mentalHealthAssessments": [], @@ -3498,7 +3529,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -3510,7 +3541,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -3522,19 +3553,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -3579,8 +3610,8 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", @@ -3623,8 +3654,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -3634,8 +3665,8 @@ "lat": 41.4, "lon": 2.2, "label": "Barcelona", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -3649,8 +3680,8 @@ "countryCode": "ES", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ @@ -3675,8 +3706,8 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "symptomDefinitions": [ @@ -3685,24 +3716,24 @@ "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { "note": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { "note": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, @@ -3711,16 +3742,16 @@ "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { "note": "left side, behind the eye", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } @@ -3733,8 +3764,8 @@ "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z" }, { "title": "Parental leave", @@ -3743,31 +3774,31 @@ "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z" } ], "labResultDocumentLinks": [], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "nutrientDays": [ @@ -3778,7 +3809,7 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z" } ] } diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json index 339fe8c13..0861a6e8b 100644 --- a/tests/integration/__golden__/backup-parity/restore-skips.json +++ b/tests/integration/__golden__/backup-parity/restore-skips.json @@ -641,7 +641,7 @@ "scaleMax": 5, "inverse": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "LabResult": [], @@ -726,7 +726,7 @@ "sortOrder": 0, "isActive": true, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "MoodTagHidden": [], diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index cdc01f836..1223a636a 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -487,7 +487,7 @@ "source": "MANUAL", "measuredAt": "2026-07-01T07:00:00.000Z", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": null, "aggregationProvenance": null, @@ -668,7 +668,7 @@ "stabilityA5": null, "tags": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "source": "MOODLOG", "externalId": null, "moodLoggedAt": "2026-07-02T20:00:00.000Z", @@ -726,7 +726,7 @@ "takenAt": "2026-07-05T08:00:00.000Z", "biomarkerId": "", "source": "MANUAL", - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null @@ -740,7 +740,7 @@ "unit": "ng/mL", "lowerBound": 30, "upperBound": 400, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "Iron", "hidden": false, "analyteKey": null, @@ -758,8 +758,8 @@ "severity": "SEVERE", "status": "ACTIVE", "onsetAt": null, - "reactionEncrypted": "", - "notesEncrypted": "", + "reactionEncrypted": "", + "notesEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null @@ -772,7 +772,7 @@ "relationship": "FATHER", "condition": "Diabetes", "ageAtOnset": 55, - "notesEncrypted": "", + "notesEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null @@ -789,7 +789,7 @@ "resolvedAt": "2026-07-02T00:00:00.000Z", "parentConditionId": "legacy-episode-parent", "noteEncrypted": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": null, "createdAt": "", "updatedAt": "", @@ -804,7 +804,7 @@ "onsetAt": "2025-01-01T00:00:00.000Z", "resolvedAt": null, "parentConditionId": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySiteEncrypted": null, "laterality": null, "createdAt": "", @@ -820,7 +820,7 @@ "date": "2026-07-01", "functionalImpact": 2, "feverC": null, - "noteEncrypted": "", + "noteEncrypted": "", "tz": null, "createdAt": "", "updatedAt": "", @@ -1350,7 +1350,7 @@ "measuredAt": "2026-07-01T07:00:00.000Z", "source": "MANUAL", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": null, "aggregationProvenance": null, @@ -1555,7 +1555,7 @@ }, { "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "syncedAt": "", "syncVersion": 0, "createdAt": "", @@ -1591,7 +1591,7 @@ { "id": "", "biomarkerId": "", - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null, @@ -1642,7 +1642,7 @@ "note": null, "noteEncrypted": null, "bodySite": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": null, "createdAt": "", "updatedAt": "", @@ -1655,7 +1655,7 @@ "functionalImpact": 2, "feverC": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "tz": null, "createdAt": "", "updatedAt": "", @@ -1673,7 +1673,7 @@ "resolvedAt": null, "parentConditionId": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySite": null, "bodySiteEncrypted": null, "laterality": null, @@ -1694,8 +1694,8 @@ "onsetAt": null, "reaction": null, "note": null, - "reactionEncrypted": "", - "notesEncrypted": "", + "reactionEncrypted": "", + "notesEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index 32712c200..1622db7ff 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -25,8 +25,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -49,8 +49,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" }, { "id": "", @@ -73,8 +73,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -97,8 +97,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -111,7 +111,7 @@ "source": "MANUAL", "measuredAt": "2026-07-01T07:00:00.000Z", "notes": "rt-Measurement-notes", - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -121,8 +121,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" } ], "IntradayCumulativeProfile": [ @@ -188,8 +188,8 @@ "basis": "HOUR_BUCKETS", "bucketCount": 3, "timezone": "Europe/Berlin", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z" } ], "Medication": [ @@ -226,8 +226,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T11:50:00.000Z", + "updatedAt": "2026-05-01T11:51:00.000Z" } ], "MedicationSchedule": [ @@ -285,21 +285,42 @@ "attributionSource": "USER_PIN", "source": "API", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "medicationId": "", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", + "source": "WEB", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "" } ], "MedicationSideEffect": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "occurredAt": "2026-07-01T21:00:00.000Z", @@ -307,94 +328,94 @@ "entry": "NAUSEA", "severity": 3, "notes": "rt-MedicationSideEffect-notes", - "notesEncrypted": "", - "createdAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z" } ], "MedicationPauseEra": [ { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "MedicationDoseChange": [ { - "id": "", + "id": "", "medicationId": "", "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", "note": "rt-MedicationDoseChange-note", - "noteEncrypted": "", - "createdAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-01T19:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", "note": null, "noteEncrypted": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "MedicationCategoryAssignment": [ { "medicationId": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "updatedAt": "" + "updatedAt": "2026-05-01T21:22:00.000Z" } ], "MedicationCategoryLabel": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "labelEncrypted": "", + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "MedicationCourse": [ { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", + "updatedAt": "2026-05-02T00:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T00:46:01.000Z", + "updatedAt": "2026-05-02T00:47:01.000Z" } ], "MedicationIngredient": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "nutrient": "vitamin_d", @@ -403,13 +424,13 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T02:29:00.000Z", + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "MoodEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", "mood": "GUT", @@ -421,21 +442,45 @@ "stabilityA5": 3, "tags": "[\"round-trip-tag\"]", "note": "rt-MoodEntry-note", - "noteEncrypted": "", + "noteEncrypted": "", "source": "rt-MoodEntry-source", "externalId": "rt-MoodEntry-externalId", "moodLoggedAt": "2026-07-01T20:00:00.000Z", "tz": "rt-MoodEntry-tz", - "syncedAt": "", - "createdAt": "", - "updatedAt": "", + "syncedAt": "2026-05-02T04:17:00.000Z", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", "syncVersion": 3, "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "moodA1": null, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MOODLOG", + "externalId": null, + "moodLoggedAt": "2026-07-03T20:00:00.000Z", + "tz": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "syncVersion": 0, + "deletedAt": "" } ], "MoodContext": [ { - "moodEntryId": "", + "moodEntryId": "", "userId": "round-trip-owner", "workStatus": "regular", "workMinutes": 3, @@ -454,23 +499,23 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } ], "MoodEntryTagLink": [ { - "moodEntryId": "", - "moodTagId": "", - "createdAt": "", + "moodEntryId": "", + "moodTagId": "", + "createdAt": "2026-05-02T07:22:00.000Z", "rating": 4 } ], "MoodTag": [ { - "id": "", - "categoryId": "", + "id": "", + "categoryId": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", "icon": "rt-MoodTag-icon", @@ -481,12 +526,12 @@ "scaleMax": 3, "inverse": true, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "LabResult": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "panel": "rt-LabResult-panel", "analyte": "Ferritin", @@ -501,33 +546,71 @@ "sourceValue": 2.5, "sourceUnit": "rt-LabResult-sourceUnit", "takenAt": "2026-06-30T09:00:00.000Z", - "biomarkerId": "", + "biomarkerId": "", "source": "rt-LabResult-source", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "updatedAt": "2026-05-02T10:59:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "biomarkerId": null, + "source": "MANUAL", + "noteEncrypted": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "updatedAt": "2026-05-02T10:59:01.000Z", + "deletedAt": "" } ], "Biomarker": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z" } ], "Allergy": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reactionEncrypted": null, + "notesEncrypted": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "updatedAt": "2026-05-02T14:11:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "substance": "Penicillin", "category": "FOOD", @@ -535,29 +618,40 @@ "severity": "MILD", "status": "INACTIVE", "onsetAt": "2026-06-30T12:34:56.789Z", - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], "FamilyHistoryEntry": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z", "deletedAt": null } ], "IllnessEpisode": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Cold", "type": "INFECTION", @@ -565,15 +659,31 @@ "onsetAt": "2026-06-20T00:00:00.000Z", "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", + "userId": "round-trip-owner", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "noteEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "deletedAt": null + }, + { + "id": "", "userId": "round-trip-owner", "label": "Iron deficiency", "type": "CHRONIC", @@ -582,162 +692,215 @@ "resolvedAt": null, "parentConditionId": null, "noteEncrypted": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null - } - ], - "IllnessDayLog": [ - { - "id": "", - "userId": "round-trip-owner", - "episodeId": "", - "date": "2026-06-21", - "functionalImpact": 3, - "feverC": 2.5, - "noteEncrypted": "", - "tz": "rt-IllnessDayLog-tz", - "createdAt": "", - "updatedAt": "", - "deletedAt": null - } - ], - "IllnessSymptomLink": [ - { - "dayLogId": "", - "symptomId": "", - "severity": 2, - "createdAt": "" - } - ], - "SymptomDefinition": [ - { - "id": "", - "userId": "round-trip-owner", - "labelEncrypted": "", - "icon": "Zap", - "sortOrder": 0, - "isActive": true, - "createdAt": "", - "updatedAt": "" - }, - { - "id": "", - "userId": "round-trip-owner", - "labelEncrypted": "", - "icon": "Brain", - "sortOrder": 1, - "isActive": false, - "createdAt": "", - "updatedAt": "" - } - ], - "SymptomEvent": [ - { - "id": "", - "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-06-21T09:00:00.000Z", - "intensity": 4, - "noteEncrypted": null, - "episodeId": "", - "createdAt": "", - "updatedAt": "" - }, - { - "id": "", - "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-06-21T11:00:00.000Z", - "intensity": 8, - "noteEncrypted": "", - "episodeId": null, - "createdAt": "", - "updatedAt": "" }, { "id": "", "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-07-10T18:30:00.000Z", - "intensity": 2, + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, "noteEncrypted": null, - "episodeId": null, - "createdAt": "", - "updatedAt": "" + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "updatedAt": "2026-05-02T17:32:02.000Z", + "deletedAt": "" } ], - "LifeEvent": [ + "IllnessDayLog": [ { "id": "", "userId": "round-trip-owner", - "category": "HOME", - "startDate": "2023-09-01", - "endDate": null, - "precision": "MONTH", - "titleEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "episodeId": "", + "date": "2026-06-21", + "functionalImpact": 3, + "feverC": 2.5, + "noteEncrypted": "", + "tz": "rt-IllnessDayLog-tz", + "createdAt": "2026-05-02T19:08:00.000Z", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null }, { "id": "", "userId": "round-trip-owner", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "updatedAt": "2026-05-02T19:09:01.000Z", + "deletedAt": "" + } + ], + "IllnessSymptomLink": [ + { + "dayLogId": "", + "symptomId": "", + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" + } + ], + "SymptomDefinition": [ + { + "id": "", + "userId": "round-trip-owner", + "labelEncrypted": "", + "icon": "Zap", + "sortOrder": 0, + "isActive": true, + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "labelEncrypted": "", + "icon": "Brain", + "sortOrder": 1, + "isActive": false, + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z" + } + ], + "SymptomEvent": [ + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", + "occurredAt": "2026-06-21T09:00:00.000Z", + "intensity": 4, + "noteEncrypted": null, + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", + "occurredAt": "2026-06-21T11:00:00.000Z", + "intensity": 8, + "noteEncrypted": "", + "episodeId": null, + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", + "occurredAt": "2026-07-10T18:30:00.000Z", + "intensity": 2, + "noteEncrypted": null, + "episodeId": null, + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" + } + ], + "LifeEvent": [ + { + "id": "", + "userId": "round-trip-owner", + "category": "HOME", + "startDate": "2023-09-01", + "endDate": null, + "precision": "MONTH", + "titleEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", + "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "titleEncrypted": "", + "noteEncrypted": null, + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "titleEncrypted": "", + "titleEncrypted": "", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "UserHealthProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", "aiIncludedSections": [ "ABOUT_ME" ], "emergencyBloodType": "A_POS", "organDonorStatus": "YES", "advanceDirectiveStatus": "EXISTS", - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", "lifeConfirmedJson": { "roundTrip": "rt-UserHealthProfile-lifeConfirmedJson" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" } ], "HealthProfileFactRevision": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "SMOKING_STATUS", - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "kind": "SMOKING_STATUS", + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "CycleProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, @@ -749,13 +912,30 @@ "rawChartMode": true, "discreetNotifications": true, "sensitiveCategoryEncryption": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z" } ], "MenstrualCycle": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "isPredicted": false, + "tz": null, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", @@ -768,11 +948,11 @@ "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-03", "endDate": null, @@ -784,17 +964,17 @@ "tz": null, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "" + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z" } ], "CycleDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-02", - "cycleId": "", + "cycleId": "", "flow": "HEAVY", "intermenstrualBleeding": true, "basalBodyTempC": 2.5, @@ -809,20 +989,49 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "date": "2026-06-03", + "cycleId": "", + "flow": "LIGHT", + "intermenstrualBleeding": false, + "basalBodyTempC": null, + "temperatureExcluded": false, + "ovulationTest": null, + "cervicalMucus": null, + "cervixPosition": null, + "cervixFirmness": null, + "cervixOpening": null, + "sexualActivity": false, + "protectedSex": null, + "pregnancyTest": null, + "progesteroneTest": null, + "contraceptive": null, + "sensitiveEncrypted": null, + "notesEncrypted": null, + "source": "MANUAL", + "externalId": null, + "tz": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z" } ], "CycleSymptom": [ { - "id": "", + "id": "", "categoryId": "csc_physical", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", @@ -830,20 +1039,20 @@ "sortOrder": 3, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "CycleSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 3, - "createdAt": "" + "createdAt": "2026-05-03T13:23:00.000Z" } ], "CustomMetric": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Grip strength", "unit": "kg", @@ -852,40 +1061,66 @@ "decimals": 3, "description": "rt-CustomMetric-description", "correlationEnabled": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "" } ], "CustomMetricEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null, + "noteEncrypted": null, + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", + "customMetricId": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": "left hand, after the long ride", "noteEncrypted": null, - "createdAt": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null, - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null } ], "CorrelationPattern": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", @@ -903,13 +1138,13 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T18:38:00.000Z", + "updatedAt": "2026-05-03T18:39:00.000Z" } ], "HealthScoreRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", @@ -927,13 +1162,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "", - "createdAt": "" + "computedAt": "2026-05-03T20:12:00.000Z", + "createdAt": "2026-05-03T20:13:00.000Z" } ], "Workout": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", @@ -953,8 +1188,8 @@ "metadata": { "roundTrip": "rt-Workout-metadata" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z" } ], "NutrientIntakeDay": [ @@ -966,20 +1201,20 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "InboundDocument": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", "mimeType": "application/pdf", "byteSize": 57, - "contentEncrypted": "", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "status": "EXTRACTING", @@ -987,7 +1222,7 @@ "reportDate": "2026-06-30T12:34:56.789Z", "documentDate": "2026-06-30T12:34:56.789Z", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -996,76 +1231,155 @@ "sourceId": "4711", "sourceInstance": "https://paperless.example.test", "aiReadDeferred": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "kind": "OTHER", + "title": "Deleted scan", + "filename": "deleted-scan.pdf", + "mimeType": "application/pdf", + "byteSize": 57, + "contentEncrypted": "", + "contentSha256": null, + "contentCodec": "binary2", + "status": "STORED", + "providerType": null, + "reportDate": null, + "documentDate": null, + "errorReason": null, + "summaryEncrypted": null, + "summaryGeneratedAt": null, + "summaryState": "NONE", + "lastIndexAttemptAt": null, + "lastIndexOutcome": null, + "sourceSystem": null, + "sourceId": null, + "sourceInstance": null, + "aiReadDeferred": false, + "createdAt": "2026-05-04T01:24:01.000Z", + "updatedAt": "2026-05-04T01:25:01.000Z", + "deletedAt": "" } ], "Practitioner": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": "rt-Practitioner-location", "phone": "rt-Practitioner-phone", - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "Encounter": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null } ], "EncounterDocumentLink": [ { - "id": "", - "encounterId": "", - "documentId": "", + "id": "", + "encounterId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T06:04:00.000Z" } ], "EncounterLabLink": [ { - "id": "", - "encounterId": "", - "labResultId": "", + "id": "", + "encounterId": "", + "labResultId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T07:44:00.000Z" } ], "EncounterConditionLink": [ { - "id": "", - "encounterId": "", - "episodeId": "", + "id": "", + "encounterId": "", + "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T09:24:00.000Z" } ], "VaccinationRecord": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", @@ -1074,28 +1388,41 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null } ], "VaccinationDocumentLink": [ { - "id": "", - "vaccinationId": "", - "documentId": "", + "id": "", + "vaccinationId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T12:44:00.000Z" } ], "CustomVaccine": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "deletedAt": "", + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "name": "Round-trip travel combo", "components": [ @@ -1105,13 +1432,13 @@ "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "MeasurementReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", @@ -1130,53 +1457,77 @@ "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, "lastNotifiedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "lastNotifiedAt": null, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "MeasurementReminderEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" } ], "CoachConversation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "title": "How is my blood pressure trending?", "titleEncrypted": null, - "createdAt": "", - "updatedAt": "", - "summaryEncrypted": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, "documentScoped": false }, { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, @@ -1185,24 +1536,24 @@ ], "CoachMessage": [ { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "assistant", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -1213,10 +1564,10 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -1229,67 +1580,114 @@ ], "CoachConversationDocument": [ { - "conversationId": "", - "documentId": "", + "conversationId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ], "CoachFact": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "factEncrypted": "", + "factEncrypted": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z", + "deletedAt": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null + }, + { + "id": "", + "userId": "round-trip-owner", + "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null, "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "" + "sourceMessageId": "" } ], "CoachPlan": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "outcomeEncrypted": "", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "outcomeEncrypted": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "status": "active", + "reviewDate": null, + "outcomeEncrypted": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z", + "deletedAt": "" } ], "CoachReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "noteEncrypted": "", + "noteEncrypted": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", + "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z", "deletedAt": null } ], "MedicationInventoryItem": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "ACTIVE", @@ -1297,18 +1695,18 @@ "unitsTotal": "decimal:30", "unitsRemaining": "decimal:21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": "rt-MedicationInventoryItem-notes", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "USED_UP", @@ -1323,20 +1721,20 @@ "doseStrength": null, "notes": null, "notesEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "MedicationInventoryEvent": [ { - "id": "", + "id": "", "medicationId": "", "delta": -8, "reason": "consumed", "occurredAt": "2026-07-09T07:00:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "delta": 30, "reason": "purchased", @@ -1345,26 +1743,26 @@ ], "MoodTagCategory": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "MoodTagHidden": [ { "userId": "round-trip-owner", "moodTagId": "mt_grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "ReminderPhaseConfig": [ { - "id": "", + "id": "", "medicationId": "", "greenValue": 45, "greenMode": "PERCENT", @@ -1378,12 +1776,32 @@ ], "MentalHealthAssessment": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -1394,59 +1812,59 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "ConsentReceipt": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "DocumentConditionLink": [ { - "id": "", - "documentId": "", - "episodeId": "", + "id": "", + "documentId": "", + "episodeId": "", "userId": "round-trip-owner", "createdAt": "2026-07-02T09:00:00.000Z" } ], "ExtractedFact": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "userId": "round-trip-owner", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "dataEncrypted": "", - "provenanceEncrypted": "", - "committedRecordId": "", + "dataEncrypted": "", + "provenanceEncrypted": "", + "committedRecordId": "", "committedRecordType": "labResult", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "PersonalRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_10km_time", @@ -1457,10 +1875,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_5km_time", @@ -1471,10 +1889,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WEIGHT", "metricSlot": null, @@ -1485,31 +1903,31 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "UserAchievement": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" } ], "EnvironmentContext": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", - "locationEncrypted": "", + "locationEncrypted": "", "source": "TRAVEL", "tempMin": 19.4, "tempMax": 28.1, @@ -1546,14 +1964,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", - "locationEncrypted": "", + "locationEncrypted": "", "source": "HOME", "tempMin": 13.1, "tempMax": 22.7, @@ -1590,24 +2008,24 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "EnvironmentTravelLocation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-10", "endDate": "2026-06-20", - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "DawarichDay": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", "totalDistanceM": 48300, @@ -1615,16 +2033,16 @@ "trainDistanceM": 30500, "placesVisited": 4, "awayFromHomeMinutes": 1440, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "MedicationScheduleRevision": [ { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -1637,10 +2055,10 @@ ], "source": "ARCHIVED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-02-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -1652,11 +2070,11 @@ } ], "source": "ARCHIVED", - "supersededByRevisionId": "", - "createdAt": "" + "supersededByRevisionId": "", + "createdAt": "2026-05-06T04:47:02.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-03-01T00:00:00.000Z", "validUntil": "2026-05-01T00:00:00.000Z", @@ -1669,28 +2087,28 @@ ], "source": "MANUAL", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "MedicationEfficacyTarget": [ { - "id": "", + "id": "", "medicationId": "", "measurementType": "WEIGHT", - "biomarkerId": "", + "biomarkerId": "", "primary": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z" } ], "EcgRecording": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", - "waveformEncrypted": "", + "waveformEncrypted": "", "samplingFrequency": 300, "sampleCount": 8, "durationSeconds": 0.02666666666666667, @@ -1698,13 +2116,13 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "OnboardingRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "needsJson": { "areas": [ @@ -1743,80 +2161,80 @@ }, "modulesDerivedAt": "2026-07-01T08:00:00.000Z", "completedAt": "2026-07-01T08:00:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z" } ], "BiomarkerAlias": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "biomarkerId": "", + "biomarkerId": "", "name": "Ferr.", "nameFolded": "ferr.", "source": "IMPORT_CHOICE", - "createdAt": "" + "createdAt": "2026-05-06T11:26:00.000Z" } ], "BiomarkerMerge": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "targetBiomarkerId": "", + "targetBiomarkerId": "", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "LabResultDocumentLink": [ { - "id": "", - "labResultId": "", - "documentId": "", + "id": "", + "labResultId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T14:44:00.000Z" } ], "EncounterMedicationLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "medicationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "EncounterVaccinationLink": [ { - "id": "", - "encounterId": "", - "vaccinationId": "", + "id": "", + "encounterId": "", + "vaccinationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T18:04:00.000Z" } ], "MedicationDocumentLink": [ { - "id": "", + "id": "", "medicationId": "", - "documentId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T19:44:00.000Z" } ], "MedicationConditionLink": [ { - "id": "", + "id": "", "medicationId": "", - "episodeId": "", + "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T21:24:00.000Z" } ], "AccountGrant": [ { - "id": "", + "id": "", "grantorId": "round-trip-owner", "granteeId": "parity-delegate", "access": "WRITE", @@ -1825,13 +2243,13 @@ "measurements" ] }, - "invitedAt": "", + "invitedAt": "2026-05-07T02:25:00.000Z", "acceptedAt": "2026-07-02T09:00:00.000Z", "revokedAt": null, "revokedBy": null, "expiresAt": null, "lastUsedAt": null, - "createdAt": "" + "createdAt": "2026-05-07T02:31:00.000Z" } ], "User": { @@ -1843,7 +2261,7 @@ "oidcIssuer": "rt-User-oidcIssuer", "oidcSub": "rt-User-oidcSub", "createdAt": "2026-06-30T12:34:56.789Z", - "updatedAt": "", + "updatedAt": "2026-05-06T23:08:00.000Z", "managedProfileAt": "2026-06-30T12:34:56.789Z", "heightCm": 2.5, "dateOfBirth": "2026-06-30T12:34:56.789Z", @@ -1851,20 +2269,20 @@ "timezone": "America/Chicago", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "environmentAqHistoryJson": { "roundTrip": "rt-User-environmentAqHistoryJson" }, - "codexAccessTokenEncrypted": "", - "codexRefreshTokenEncrypted": "", + "codexAccessTokenEncrypted": "", + "codexRefreshTokenEncrypted": "", "codexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "codexConnectedAt": "2026-06-30T12:34:56.789Z", "codexConnectionStatus": "rt-User-codexConnectionStatus", "useCentralCodex": true, "insightsPrivacyMode": "raw", "insightsCachedAt": "2026-06-30T12:34:56.789Z", - "insightsCachedTextEncrypted": "", + "insightsCachedTextEncrypted": "", "insightsCachedLocale": "rt-User-insightsCachedLocale", "insightsSnapshotHash": "rt-User-insightsSnapshotHash", "insightsWarmFailedAt": "2026-06-30T12:34:56.789Z", @@ -1879,29 +2297,29 @@ "telegramBotToken": "rt-User-telegramBotToken", "telegramChatId": "rt-User-telegramChatId", "telegramEnabled": true, - "withingsClientIdEncrypted": "", - "withingsClientSecretEncrypted": "", - "whoopClientIdEncrypted": "", - "whoopClientSecretEncrypted": "", - "fitbitClientIdEncrypted": "", - "fitbitClientSecretEncrypted": "", - "googleHealthClientIdEncrypted": "", - "googleHealthClientSecretEncrypted": "", - "nightscoutUrlEncrypted": "", - "nightscoutTokenEncrypted": "", + "withingsClientIdEncrypted": "", + "withingsClientSecretEncrypted": "", + "whoopClientIdEncrypted": "", + "whoopClientSecretEncrypted": "", + "fitbitClientIdEncrypted": "", + "fitbitClientSecretEncrypted": "", + "googleHealthClientIdEncrypted": "", + "googleHealthClientSecretEncrypted": "", + "nightscoutUrlEncrypted": "", + "nightscoutTokenEncrypted": "", "nightscoutAllowPrivateHost": true, - "dawarichUrlEncrypted": "", - "dawarichApiKeyEncrypted": "", - "dawarichPrefsEncrypted": "", + "dawarichUrlEncrypted": "", + "dawarichApiKeyEncrypted": "", + "dawarichPrefsEncrypted": "", "dawarichAllowPrivateHost": true, - "polarAccessTokenEncrypted": "", - "polarUserIdEncrypted": "", - "polarClientIdEncrypted": "", - "polarClientSecretEncrypted": "", - "ouraAccessTokenEncrypted": "", - "ouraRefreshTokenEncrypted": "", - "ouraClientIdEncrypted": "", - "ouraClientSecretEncrypted": "", + "polarAccessTokenEncrypted": "", + "polarUserIdEncrypted": "", + "polarClientIdEncrypted": "", + "polarClientSecretEncrypted": "", + "ouraAccessTokenEncrypted": "", + "ouraRefreshTokenEncrypted": "", + "ouraClientIdEncrypted": "", + "ouraClientSecretEncrypted": "", "ouraScopeDenied": { "roundTrip": "rt-User-ouraScopeDenied" }, @@ -1913,10 +2331,10 @@ "clientAppSharedBy": { "roundTrip": "rt-User-clientAppSharedBy" }, - "stravaClientIdEncrypted": "", - "stravaClientSecretEncrypted": "", - "stravaAccessTokenEncrypted": "", - "stravaRefreshTokenEncrypted": "", + "stravaClientIdEncrypted": "", + "stravaClientSecretEncrypted": "", + "stravaAccessTokenEncrypted": "", + "stravaRefreshTokenEncrypted": "", "stravaAthleteId": "rt-User-stravaAthleteId", "stravaLastActivityAt": "2026-06-30T12:34:56.789Z", "stravaBackfillCompletedAt": "2026-06-30T12:34:56.789Z", @@ -1963,11 +2381,11 @@ "aiProvider": "LOCAL", "aiModel": "rt-User-aiModel", "aiBaseUrl": "https://llm.example.com/v1", - "aiAnthropicKeyEncrypted": "", - "aiLocalKeyEncrypted": "", - "aiOpenaiKeyEncrypted": "", + "aiAnthropicKeyEncrypted": "", + "aiLocalKeyEncrypted": "", + "aiOpenaiKeyEncrypted": "", "aiCompatBaseUrl": "https://gateway.example.com/v1", - "aiCompatKeyEncrypted": "", + "aiCompatKeyEncrypted": "", "aiCompatModel": "rt-User-aiCompatModel", "aiProviderChain": { "roundTrip": "rt-User-aiProviderChain" @@ -2017,14 +2435,14 @@ "avatarBytes": "", "avatarContentType": "image/png", "avatarUpdatedAt": "2026-06-30T12:34:56.789Z", - "totpSecretEncrypted": "", + "totpSecretEncrypted": "", "totpConfirmedAt": "2026-06-30T12:34:56.789Z", "totpLastStep": "bigint:5", "mfaEnforced": true, "passkeyUpgradeNudgeDismissed": true, "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber" }, "AppSettings": { @@ -2036,7 +2454,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2050,12 +2468,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", diff --git a/tests/integration/backup-parity.test.ts b/tests/integration/backup-parity.test.ts index 0d6903442..3e83e614c 100644 --- a/tests/integration/backup-parity.test.ts +++ b/tests/integration/backup-parity.test.ts @@ -22,15 +22,29 @@ * 5. Hand the restore malformed and unresolvable files and pin each refusal * and each skip report, and the schema's issue list for a bad file. * + * The account carries a tombstone of every model that keeps them and both + * self-references (a flare under its condition, a superseded profile fact), + * so the second restore pass has something to resolve. Every timestamp the + * database stamped while the fixture ran is pinned to a fixed historical + * value, and the seeded account is asserted to hold none of this run's: a + * restore that drops a row's `createdAt` then shows up as `` against a + * historical value instead of hiding behind a seeded ``. The fixture is + * sealed under the legacy key `v1`, and a second key is active from the + * export on, so a value the restore re-encrypts instead of carrying over + * comes back under `v2`. + * * Values that legitimately differ between two runs are normalised, never * dropped: ids become `` in order of first appearance, ciphertext - * becomes the text it opens to (or its length when no codec opens it), and a - * timestamp written during the run becomes ``. Key order is kept, so a - * file that carries the same values in another order differs. + * becomes the key id it names and the text it opens to (or its length when no + * codec opens it), and a timestamp written during the run becomes ``. + * Key order is kept, so a file that carries the same values in another order + * differs. * * `BACKUP_PARITY_WRITE=1` rewrites the golden copies. Only do that for a * change that means to alter the backup, and review the diff of - * `tests/integration/__golden__/backup-parity/` as part of it. + * `tests/integration/__golden__/backup-parity/` as part of it. A change to + * this harness itself writes them from the backup code as it was before the + * change under test, never from the code being checked. */ import { Buffer } from "node:buffer"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -39,8 +53,16 @@ import { join, resolve } from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { PrismaClient } from "@/generated/prisma/client"; -import { decrypt, decryptBytes, encrypt } from "@/lib/crypto"; -import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { + _resetCryptoCacheForTests, + decrypt, + decryptBytes, + encrypt, + encryptBytes, + extractKeyId, + extractKeyIdFromBytes, +} from "@/lib/crypto"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { openDayCountry } from "@/lib/dawarich/connection"; import { openLocation } from "@/lib/environment/location-cipher"; import { decryptFactData, decryptFactProvenance } from "@/lib/documents/store"; @@ -68,6 +90,7 @@ import { syntheticValue, } from "./backup-column-audit"; import { + AT, COLUMN_PRESENCE_ONLY, FILL_OVERRIDES, OWNER_ID, @@ -187,6 +210,34 @@ function openBytes(value: Uint8Array): string | null { return null; } +/** + * The key id of a sealed byte value: the header of the binary codec, or the + * `.` prefix of a string-codec ciphertext stored as its UTF-8 bytes. + * `-` for neither. + */ +function bytesKeyId(value: Uint8Array): string { + const buffer = Buffer.from(value); + return ( + extractKeyIdFromBytes(buffer) ?? + extractKeyId(buffer.toString("utf8")) ?? + "-" + ); +} + +/** + * The key id of a sealed string: the `.` prefix of the string codec, or + * the header of a sealed byte value carried as base64. `-` for neither (the + * unversioned legacy string form, or another codec). + */ +function stringKeyId(value: string): string { + const id = extractKeyId(value); + if (id !== null) return id; + if (value.length % 4 === 0 && BASE64.test(value)) { + return bytesKeyId(Buffer.from(value, "base64")); + } + return "-"; +} + function openString(value: string): string | null { try { return decrypt(value); @@ -234,11 +285,18 @@ class Normaliser { return DAY.test(out) && this.days.has(out) ? "" : out; } + /** + * A sealed string, as the key it was sealed under and the text it opens to. + * The key id is part of the comparison: a restore that re-encrypted a value + * the file carried as ciphertext opens to the same text under another key, + * and only the id shows it. + */ sealed(value: string): string { const opened = openString(value); + const kid = stringKeyId(value); return opened === null - ? `` - : ``; + ? `` + : ``; } /** A JSON value, key order kept. */ @@ -267,7 +325,7 @@ class Normaliser { const opened = openBytes(value); return opened === null ? `` - : ``; + : ``; } if (typeof value === "bigint") return `bigint:${value}`; if ( @@ -515,7 +573,369 @@ async function seedMaximalAccount(prisma: PrismaClient) { acceptedAt: new Date("2026-07-02T09:00:00.000Z"), }, }); - return { admin, unfillable: unfillable.sort() }; + await seedTombstones(prisma); + await seedSelfReferences(prisma); + const unpinned = await pinRunTimeTimestamps(prisma); + rotateActiveKey(); + return { admin, unfillable: unfillable.sort(), unpinned }; +} + +/** + * One soft-deleted row of every model that keeps tombstones, beside the live + * rows the fixture seeds. A disaster-recovery file carries the tombstones a + * device sync needs to keep a deleted row deleted, and the golden copies pin + * which models' tombstones the file carries and that each comes back deleted. + * Seeded after the column fill so the fill still lands on the live rows. + */ +async function seedTombstones(prisma: PrismaClient) { + const deletedAt = AT(TOMBSTONE_DELETED_AT); + const userId = OWNER_ID; + const medication = await prisma.medication.findFirstOrThrow({ + where: { userId, name: "Round-trip tablet" }, + }); + const episode = await prisma.illnessEpisode.findFirstOrThrow({ + where: { userId, label: "Cold" }, + }); + const cycle = await prisma.menstrualCycle.findFirstOrThrow({ + where: { userId, startDate: "2026-06-01" }, + }); + const metric = await prisma.customMetric.findFirstOrThrow({ + where: { userId, name: "Grip strength" }, + }); + const thread = await prisma.coachConversation.findFirstOrThrow({ + where: { userId, documentScoped: false }, + }); + + await prisma.medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: AT("2026-07-02T08:00:00.000Z"), + takenAt: AT("2026-07-02T08:03:00.000Z"), + deletedAt, + }, + }); + await prisma.mentalHealthAssessment.create({ + data: { + userId, + instrument: "GAD7", + locale: "en", + version: "standard", + responsesEncrypted: encryptToBytes( + JSON.stringify({ items: [1, 1, 0, 1, 0, 1, 0], schema: 1 }), + ), + totalScore: 4, + severityBand: "minimal", + takenAt: AT("2026-06-05T18:00:00.000Z"), + tz: "Europe/Berlin", + source: "WEB", + deletedAt, + }, + }); + await prisma.moodEntry.create({ + data: { + userId, + date: "2026-07-03", + mood: "SCHLECHT", + score: 2, + moodLoggedAt: AT("2026-07-03T20:00:00.000Z"), + deletedAt, + }, + }); + await prisma.measurementReminder.create({ + data: { + userId, + label: "Gewicht (deleted)", + measurementType: "WEIGHT", + intervalDays: 14, + deletedAt, + }, + }); + await prisma.labResult.create({ + data: { + userId, + analyte: "TSH", + value: 2.1, + unit: "mU/L", + takenAt: AT("2026-05-30T09:00:00.000Z"), + deletedAt, + }, + }); + await prisma.customMetric.create({ + data: { userId, name: "Plank (deleted)", unit: "s", deletedAt }, + }); + await prisma.customMetricEntry.create({ + data: { + userId, + customMetricId: metric.id, + value: 40, + unit: "kg", + measuredAt: AT("2026-06-28T18:00:00.000Z"), + deletedAt, + }, + }); + await prisma.coachFact.create({ + data: { + userId, + factEncrypted: encryptToBytes("a fact the person deleted"), + category: "preference", + sourceConversationId: thread.id, + source: "user", + deletedAt, + }, + }); + const plan = await prisma.coachPlan.create({ + data: { + userId, + metric: "WEIGHT", + ifCueEncrypted: encryptToBytes("if the scale reads over 75"), + thenActionEncrypted: encryptToBytes("then skip the second helping"), + status: "active", + deletedAt, + }, + }); + await prisma.coachReminder.create({ + data: { + userId, + noteEncrypted: encryptToBytes("a reminder the person deleted"), + relatedPlanId: plan.id, + triggerKind: "date", + dueAt: AT("2026-09-15T07:00:00.000Z"), + status: "active", + source: "extractor", + deletedAt, + }, + }); + await prisma.menstrualCycle.create({ + data: { userId, startDate: "2026-05-04", deletedAt }, + }); + await prisma.cycleDayLog.create({ + data: { + userId, + cycleId: cycle.id, + date: "2026-06-03", + flow: "LIGHT", + deletedAt, + }, + }); + await prisma.illnessEpisode.create({ + data: { + userId, + label: "Sprained ankle (deleted)", + type: "INJURY", + onsetAt: AT("2026-04-10T00:00:00.000Z"), + deletedAt, + }, + }); + await prisma.illnessDayLog.create({ + data: { userId, episodeId: episode.id, date: "2026-06-22", deletedAt }, + }); + await prisma.allergy.create({ + data: { userId, substance: "Latex (deleted)", deletedAt }, + }); + await prisma.familyHistoryEntry.create({ + data: { + userId, + relationship: "FATHER", + condition: "Hypertension (deleted)", + notesEncrypted: encryptToBytes("diagnosed at fifty"), + deletedAt, + }, + }); + const bytes = encryptBytes(Buffer.from("a page the person deleted")); + const contentEncrypted = new Uint8Array(new ArrayBuffer(bytes.byteLength)); + contentEncrypted.set(bytes); + await prisma.inboundDocument.create({ + data: { + userId, + kind: "OTHER", + title: "Deleted scan", + filename: "deleted-scan.pdf", + mimeType: "application/pdf", + byteSize: bytes.byteLength, + contentEncrypted, + contentCodec: "binary2", + deletedAt, + }, + }); + const practitioner = await prisma.practitioner.create({ + data: { userId, name: "Closed practice (deleted)", deletedAt }, + }); + await prisma.encounter.create({ + data: { + userId, + occurredAt: AT("2026-04-02T08:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + practitionerId: practitioner.id, + deletedAt, + }, + }); + await prisma.customVaccine.create({ + data: { userId, name: "Deleted combo", components: ["rabies"], deletedAt }, + }); + await prisma.vaccinationRecord.create({ + data: { + userId, + occurredAt: AT("2026-04-14T00:00:00.000Z"), + antigenSlug: "influenza", + vaccineName: "Influenza (deleted)", + deletedAt, + }, + }); + await prisma.lifeEvent.create({ + data: { + userId, + category: "OTHER", + startDate: "2025-12-01", + precision: "DAY", + titleEncrypted: encryptToBytes("an event the person deleted"), + deletedAt, + }, + }); +} + +/** + * The two models that point at another row of themselves. The restore writes + * those pointers in a second pass once every row of the model is back; with + * the pointers null, nothing in the file exercised it. + */ +async function seedSelfReferences(prisma: PrismaClient) { + const userId = OWNER_ID; + const chronic = await prisma.illnessEpisode.findFirstOrThrow({ + where: { userId, label: "Iron deficiency" }, + }); + await prisma.illnessEpisode.create({ + data: { + userId, + label: "Iron deficiency flare", + type: "CHRONIC", + lifecycle: "FLARE", + onsetAt: AT("2026-06-25T00:00:00.000Z"), + parentConditionId: chronic.id, + }, + }); + const first = await prisma.healthProfileFactRevision.findFirstOrThrow({ + where: { userId, kind: "SMOKING_STATUS" }, + }); + const correction = await prisma.healthProfileFactRevision.create({ + data: { + userId, + kind: "SMOKING_STATUS", + valueEncrypted: encryptToBytes("former"), + validFrom: AT("2026-02-01T00:00:00.000Z"), + provenance: "USER_REPORTED", + }, + }); + await prisma.healthProfileFactRevision.update({ + where: { id: first.id }, + data: { supersededByRevisionId: correction.id }, + }); +} + +/** + * Give every timestamp the database stamped while the fixture ran a fixed + * historical value. A restore that loses a row's `createdAt` writes the + * restore's own time; while the seeded value was itself "now", both read as + * `` after normalisation and the loss could not show. Pinned, the seeded + * account holds no run-time timestamp at all (asserted below), so a `` in + * a restored row is the restore's own doing and is pinned as such. Returns the + * columns that would not take a value. + */ +async function pinRunTimeTimestamps(prisma: PrismaClient): Promise { + const from = RUN_STARTED - 5 * 60_000; + const runTime = (value: unknown) => + value instanceof Date && value.getTime() >= from; + const base = Date.UTC(2026, 4, 1, 8, 0, 0); + const unpinned: string[] = []; + const pin = async ( + model: AuditModel, + rows: Record[], + modelIndex: number, + ) => { + for (const [rowIndex, row] of rows.entries()) { + const data: Record = {}; + model.columns.forEach((column, columnIndex) => { + if (column.type !== "DateTime" || !runTime(row[column.name])) return; + data[column.name] = new Date( + base + (modelIndex * 100 + columnIndex) * 60_000 + rowIndex * 1000, + ); + }); + if (Object.keys(data).length === 0) continue; + try { + await delegateOf(prisma, model).update({ + where: primaryKeyWhere(model, row), + data, + }); + } catch { + unpinned.push(`${model.name}:${Object.keys(data).join(",")}`); + } + } + }; + const models = auditModels(prisma); + const byName = new Map(models.map((m) => [m.name, m])); + for (const [index, model] of models.entries()) { + const rows = await delegateOf(prisma, model).findMany({ + where: ownerWhere(byName, prisma, model.name, OWNER_ID), + orderBy: model.primaryKey.map((key) => ({ [key]: "asc" as const })), + }); + await pin(model, rows, index); + } + const extra = readAuditModels(prisma, [ + "User", + "AppSettings", + "AccountGrant", + ]); + const [user, settings, grant] = extra; + await pin( + user, + [await prisma.user.findUniqueOrThrow({ where: { id: OWNER_ID } })], + models.length, + ); + await pin( + settings, + [ + await prisma.appSettings.findUniqueOrThrow({ + where: { id: "singleton" }, + }), + ], + models.length + 1, + ); + await pin( + grant, + (await prisma.accountGrant.findMany({ + where: { grantorId: OWNER_ID }, + orderBy: { id: "asc" }, + })) as Record[], + models.length + 2, + ); + return unpinned.sort(); +} + +/** + * Everything above was sealed under the legacy key, which the environment + * names `v1`. From here on a second key is the active one, so a value the + * restore re-encrypts instead of carrying over comes back under `v2`, and the + * key id in the normalised copies shows it. + */ +const ORIGINAL_KEY_ENV = { + keys: process.env.ENCRYPTION_KEYS, + active: process.env.ENCRYPTION_ACTIVE_KEY_ID, +}; +function rotateActiveKey() { + process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "11".repeat(32) }); + process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2"; + _resetCryptoCacheForTests(); +} +function restoreKeyEnv() { + for (const [name, value] of [ + ["ENCRYPTION_KEYS", ORIGINAL_KEY_ENV.keys], + ["ENCRYPTION_ACTIVE_KEY_ID", ORIGINAL_KEY_ENV.active], + ] as const) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + _resetCryptoCacheForTests(); } /** Every row the account holds, model by model, normalised. */ @@ -705,15 +1125,20 @@ describe("backup parity", () => { }, SLOW); afterAll(async () => { + restoreKeyEnv(); await truncateAllTables(prisma); }, SLOW); it( "exports the maximal account as it did", async () => { - const { admin, unfillable } = await seedMaximalAccount(prisma); + const { admin, unfillable, unpinned } = await seedMaximalAccount(prisma); adminId = admin.id; const before = await snapshotAccount(prisma, OWNER_ID); + // The seeded account holds no timestamp of this run, so every `` + // further down is one a restore or an export wrote. + expect(unpinned).toEqual([]); + expect(JSON.stringify(before)).not.toContain(""); const dr = await exportFile(prisma, "disaster-recovery"); const portable = await exportFile(prisma, "portable-export"); drJson = dr.json; From 3b2c2c45f3c7199e440a6ffb04d5500642ef309f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 18:59:07 +0200 Subject: [PATCH 07/13] fix(backup): carry deleted family-history entries in the disaster-recovery file Every record with a tombstone (allergies, labs, conditions, vaccinations, life events, measurements and the rest) rides the disaster-recovery file deleted rows included, so a restore gives back the account as it was. The family history read only live entries for both files, so a restore lost its deletions, and it carried the note as readable text that the restore sealed again under the active key. The disaster-recovery slice now carries every entry with its deletion and the note as the ciphertext at rest, and the restore writes both back verbatim; the portable file keeps the readable live entries. The parity golden copies change for exactly that: the deleted entry, its note's key and the shifted ids after it. --- .../export/family-history-backup-schema.ts | 4 +- src/lib/export/family-history-backup.ts | 58 +- src/lib/export/records-backup.ts | 2 +- .../export-disaster-recovery.json | 370 +++++++------ .../restore-disaster-recovery-over-live.json | 8 +- .../restore-disaster-recovery.json | 498 +++++++++--------- .../backup-parity/restore-version-1.json | 6 +- .../family-history-backup-tombstones.test.ts | 53 ++ 8 files changed, 576 insertions(+), 423 deletions(-) create mode 100644 tests/integration/family-history-backup-tombstones.test.ts diff --git a/src/lib/export/family-history-backup-schema.ts b/src/lib/export/family-history-backup-schema.ts index 2ee8f32ff..eca2a52fd 100644 --- a/src/lib/export/family-history-backup-schema.ts +++ b/src/lib/export/family-history-backup-schema.ts @@ -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(); diff --git a/src/lib/export/family-history-backup.ts b/src/lib/export/family-history-backup.ts index d24407eb6..6a597711d 100644 --- a/src/lib/export/family-history-backup.ts +++ b/src/lib/export/family-history-backup.ts @@ -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, 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 { 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) } : {}), diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 756b89f37..485aa97fe 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -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, diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index c664c3195..81e8ccae0 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -1101,17 +1101,30 @@ "familyHistory": [ { "id": "", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "note": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "note": "rt-FamilyHistoryEntry-notesEncrypted", + "note": null, + "notesEncrypted": "", "createdAt": "2026-05-02T15:46:00.000Z", - "updatedAt": "2026-05-02T15:47:00.000Z" + "updatedAt": "2026-05-02T15:47:00.000Z", + "deletedAt": null } ], "workouts": [ { - "id": "", + "id": "", "createdAt": "2026-05-03T21:58:00.000Z", "updatedAt": "2026-05-03T21:59:00.000Z", "metadata": { @@ -1136,7 +1149,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -1182,7 +1195,7 @@ } }, "healthProfile": { - "id": "", + "id": "", "aboutMe": null, "conditions": null, "allergies": null, @@ -1212,18 +1225,18 @@ }, "healthProfileFacts": [ { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", - "supersededByRevisionId": "", + "supersededByRevisionId": "", "createdAt": "2026-05-03T05:08:00.000Z" }, { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, "valueEncrypted": "", @@ -1236,7 +1249,7 @@ ], "customMetrics": [ { - "id": "", + "id": "", "createdAt": "2026-05-03T15:09:00.000Z", "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null, @@ -1249,7 +1262,7 @@ "correlationEnabled": true, "entries": [ { - "id": "", + "id": "", "createdAt": "2026-05-03T16:48:02.000Z", "deletedAt": "", "noteEncrypted": null, @@ -1259,7 +1272,7 @@ "note": null }, { - "id": "", + "id": "", "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null, "noteEncrypted": null, @@ -1269,7 +1282,7 @@ "note": "left hand, after the long ride" }, { - "id": "", + "id": "", "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null, "noteEncrypted": "", @@ -1281,7 +1294,7 @@ ] }, { - "id": "", + "id": "", "createdAt": "2026-05-03T15:09:01.000Z", "updatedAt": "2026-05-03T15:10:01.000Z", "deletedAt": "", @@ -1297,7 +1310,7 @@ ], "correlationPatterns": [ { - "id": "", + "id": "", "updatedAt": "2026-05-03T18:39:00.000Z", "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", @@ -1321,7 +1334,7 @@ "decryptFailures": [], "intradayProfiles": [ { - "id": "", + "id": "", "createdAt": "2026-05-01T09:51:00.000Z", "updatedAt": "2026-05-01T09:52:00.000Z", "type": "ACTIVITY_STEPS", @@ -1387,7 +1400,7 @@ ], "healthScoreRecords": [ { - "id": "", + "id": "", "createdAt": "2026-05-03T20:13:00.000Z", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", @@ -1409,7 +1422,7 @@ } ], "onboardingRecord": { - "id": "", + "id": "", "createdAt": "2026-05-06T09:47:00.000Z", "updatedAt": "2026-05-06T09:48:00.000Z", "needs": { @@ -1476,7 +1489,7 @@ }, "practitioners": [ { - "id": "", + "id": "", "name": "Closed practice (deleted)", "specialty": null, "practice": null, @@ -1490,7 +1503,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", @@ -1506,26 +1519,26 @@ ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reasonEncrypted": "", "outcomeEncrypted": "", "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", + "reminderId": "", "createdAt": "2026-05-04T04:31:00.000Z", "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "occurredAt": "2026-04-02T08:00:00.000Z", "status": "DONE", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reasonEncrypted": null, "outcomeEncrypted": null, "bodySiteEncrypted": null, @@ -1538,28 +1551,28 @@ ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", + "encounterId": "", + "targetId": "", "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", "name": "Deleted combo", "components": [ "rabies" @@ -1571,7 +1584,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -1586,7 +1599,7 @@ ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -1594,17 +1607,17 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "noteEncrypted": "", "createdAt": "2026-05-04T11:14:00.000Z", "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "occurredAt": "2026-04-14T00:00:00.000Z", "antigenSlug": "influenza", "vaccineName": "Influenza (deleted)", @@ -1624,14 +1637,14 @@ ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", + "vaccinationId": "", + "targetId": "", "createdAt": "2026-05-04T12:44:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -1653,7 +1666,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "label": "Gewicht (deleted)", "measurementType": "WEIGHT", "intervalDays": 14, @@ -1677,8 +1690,8 @@ ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, @@ -1686,8 +1699,8 @@ "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, @@ -1697,7 +1710,7 @@ ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, "summaryEncrypted": "", @@ -1707,7 +1720,7 @@ "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "contentEncrypted": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -1720,7 +1733,7 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", "contentEncrypted": "", "metricSourceJson": null, @@ -1736,7 +1749,7 @@ "attachments": [] }, { - "id": "", + "id": "", "titleEncrypted": "", "documentScoped": true, "summaryEncrypted": null, @@ -1746,7 +1759,7 @@ "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "contentEncrypted": "", "metricSourceJson": null, @@ -1761,7 +1774,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -1769,25 +1782,25 @@ ], "coachFacts": [ { - "id": "", + "id": "", "factEncrypted": "", "deletedAt": null, "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", + "sourceMessageId": "", "createdAt": "2026-05-05T00:26:00.000Z", "updatedAt": "2026-05-05T00:27:00.000Z" }, { - "id": "", + "id": "", "factEncrypted": "", "deletedAt": "", "category": "preference", "confidence": 50, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": null, "sourceMessageId": null, @@ -1797,7 +1810,7 @@ ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", "ifCueEncrypted": "", "thenActionEncrypted": "", @@ -1806,12 +1819,12 @@ "deletedAt": null, "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", + "sourceConversationId": "", "createdAt": "2026-05-05T02:10:00.000Z", "updatedAt": "2026-05-05T02:11:00.000Z" }, { - "id": "", + "id": "", "metric": "WEIGHT", "ifCueEncrypted": "", "thenActionEncrypted": "", @@ -1827,28 +1840,28 @@ ], "coachReminders": [ { - "id": "", + "id": "", "noteEncrypted": "", "deletedAt": null, "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, "createdAt": "2026-05-05T03:53:00.000Z", "updatedAt": "2026-05-05T03:54:00.000Z" }, { - "id": "", + "id": "", "noteEncrypted": "", "deletedAt": "", "metric": null, - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "date", "dueAt": "2026-09-15T07:00:00.000Z", "contextCue": null, @@ -1863,7 +1876,7 @@ ], "mentalHealthAssessments": [ { - "id": "", + "id": "", "instrument": "GAD7", "locale": "en", "version": "standard", @@ -1882,7 +1895,7 @@ "updatedAt": "2026-05-05T13:57:01.000Z" }, { - "id": "", + "id": "", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", @@ -1903,7 +1916,7 @@ ], "consentReceipts": [ { - "id": "", + "id": "", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", @@ -1911,7 +1924,7 @@ "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", @@ -1921,15 +1934,15 @@ ], "documentConditionLinks": [ { - "documentId": "", + "documentId": "", "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, @@ -2114,7 +2127,7 @@ ], "ecgRecordings": [ { - "id": "", + "id": "", "waveformEncrypted": "", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", @@ -2132,7 +2145,7 @@ ], "symptomDefinitions": [ { - "id": "", + "id": "", "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, @@ -2141,7 +2154,7 @@ "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, @@ -2150,7 +2163,7 @@ "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, @@ -2161,7 +2174,7 @@ ] }, { - "id": "", + "id": "", "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, @@ -2170,7 +2183,7 @@ "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { - "id": "", + "id": "", "noteEncrypted": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, @@ -2183,7 +2196,7 @@ ], "lifeEvents": [ { - "id": "", + "id": "", "titleEncrypted": "", "noteEncrypted": "", "category": "HOME", @@ -2195,7 +2208,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "titleEncrypted": "", "noteEncrypted": null, "category": "OTHER", @@ -2207,7 +2220,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "titleEncrypted": "", "noteEncrypted": null, "category": "WORK", @@ -2222,28 +2235,28 @@ "labResultDocumentLinks": [ { "labResultId": "", - "documentId": "", + "documentId": "", "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", + "encounterId": "", + "vaccinationId": "", "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", + "documentId": "", "createdAt": "2026-05-06T19:44:00.000Z" } ], @@ -2289,7 +2302,7 @@ "illnessEpisodes": 4, "illnessDayLogs": 2, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "healthProfile": 1, @@ -2352,7 +2365,7 @@ "illnessEpisodes": 4, "illnessDayLogs": 2, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "healthProfile": 1, @@ -3459,7 +3472,7 @@ ], "symptomDefinitions": [ { - "id": "", + "id": "", "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, @@ -3468,7 +3481,7 @@ "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { - "id": "", + "id": "", "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, "noteEncrypted": null, @@ -3477,7 +3490,7 @@ "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "noteEncrypted": null, @@ -3488,7 +3501,7 @@ ] }, { - "id": "", + "id": "", "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, @@ -3497,7 +3510,7 @@ "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { - "id": "", + "id": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "noteEncrypted": "", @@ -3510,7 +3523,7 @@ ], "lifeEvents": [ { - "id": "", + "id": "", "category": "HOME", "startDate": "2023-09-01", "endDate": null, @@ -3522,7 +3535,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "category": "OTHER", "startDate": "2025-12-01", "endDate": null, @@ -3534,7 +3547,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", @@ -3583,17 +3596,30 @@ "familyHistory": [ { "id": "", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "note": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "note": "rt-FamilyHistoryEntry-notesEncrypted", + "note": null, + "notesEncrypted": "", "createdAt": "2026-05-02T15:46:00.000Z", - "updatedAt": "2026-05-02T15:47:00.000Z" + "updatedAt": "2026-05-02T15:47:00.000Z", + "deletedAt": null } ], "workouts": [ { - "id": "", + "id": "", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", "endedAt": "2026-06-29T06:30:00.000Z", @@ -3618,7 +3644,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -3647,15 +3673,15 @@ ], "documentConditionLinks": [ { - "documentId": "", + "documentId": "", "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, @@ -3680,7 +3706,7 @@ } ], "healthProfile": { - "id": "", + "id": "", "aboutMe": null, "conditions": null, "allergies": null, @@ -3710,18 +3736,18 @@ }, "healthProfileFacts": [ { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", - "supersededByRevisionId": "", + "supersededByRevisionId": "", "createdAt": "2026-05-03T05:08:00.000Z" }, { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, "valueEncrypted": "", @@ -3734,7 +3760,7 @@ ], "customMetrics": [ { - "id": "", + "id": "", "name": "Grip strength", "unit": "kg", "targetLow": 2.5, @@ -3747,7 +3773,7 @@ "deletedAt": null, "entries": [ { - "id": "", + "id": "", "value": 40, "unit": "kg", "measuredAt": "2026-06-28T18:00:00.000Z", @@ -3757,7 +3783,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", @@ -3767,7 +3793,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", @@ -3779,7 +3805,7 @@ ] }, { - "id": "", + "id": "", "name": "Plank (deleted)", "unit": "s", "targetLow": null, @@ -3795,7 +3821,7 @@ ], "correlationPatterns": [ { - "id": "", + "id": "", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -3818,7 +3844,7 @@ ], "intradayProfiles": [ { - "id": "", + "id": "", "type": "ACTIVITY_STEPS", "dateKey": "2026-07-01", "hourlyCumulative": [ @@ -3884,7 +3910,7 @@ ], "healthScoreRecords": [ { - "id": "", + "id": "", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", "composite": 71, @@ -3906,7 +3932,7 @@ } ], "onboardingRecord": { - "id": "", + "id": "", "needs": { "recordTarget": "me", "areas": [ @@ -3973,7 +3999,7 @@ }, "practitioners": [ { - "id": "", + "id": "", "name": "Closed practice (deleted)", "specialty": null, "practice": null, @@ -3987,7 +4013,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", @@ -4003,26 +4029,26 @@ ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reasonEncrypted": "", "outcomeEncrypted": "", "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", + "reminderId": "", "createdAt": "2026-05-04T04:31:00.000Z", "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "occurredAt": "2026-04-02T08:00:00.000Z", "status": "DONE", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reasonEncrypted": null, "outcomeEncrypted": null, "bodySiteEncrypted": null, @@ -4035,28 +4061,28 @@ ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", + "encounterId": "", + "targetId": "", "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", "name": "Deleted combo", "components": [ "rabies" @@ -4068,7 +4094,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -4083,7 +4109,7 @@ ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -4091,17 +4117,17 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "noteEncrypted": "", "createdAt": "2026-05-04T11:14:00.000Z", "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "occurredAt": "2026-04-14T00:00:00.000Z", "antigenSlug": "influenza", "vaccineName": "Influenza (deleted)", @@ -4121,36 +4147,36 @@ ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", + "vaccinationId": "", + "targetId": "", "createdAt": "2026-05-04T12:44:00.000Z" } ], "labResultDocumentLinks": [ { "labResultId": "", - "documentId": "", + "documentId": "", "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", + "encounterId": "", + "vaccinationId": "", "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", + "documentId": "", "createdAt": "2026-05-06T19:44:00.000Z" } ], @@ -4163,7 +4189,7 @@ ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, "summaryEncrypted": "", @@ -4173,7 +4199,7 @@ "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "contentEncrypted": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -4186,7 +4212,7 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", "contentEncrypted": "", "metricSourceJson": null, @@ -4202,7 +4228,7 @@ "attachments": [] }, { - "id": "", + "id": "", "titleEncrypted": "", "documentScoped": true, "summaryEncrypted": null, @@ -4212,7 +4238,7 @@ "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "contentEncrypted": "", "metricSourceJson": null, @@ -4227,7 +4253,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -4235,7 +4261,7 @@ ], "mentalHealthAssessments": [ { - "id": "", + "id": "", "instrument": "GAD7", "locale": "en", "version": "standard", @@ -4254,7 +4280,7 @@ "updatedAt": "2026-05-05T13:57:01.000Z" }, { - "id": "", + "id": "", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", @@ -4275,7 +4301,7 @@ ], "consentReceipts": [ { - "id": "", + "id": "", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", @@ -4283,7 +4309,7 @@ "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", @@ -4293,24 +4319,24 @@ ], "coachFacts": [ { - "id": "", + "id": "", "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", + "sourceMessageId": "", "createdAt": "2026-05-05T00:26:00.000Z", "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "factEncrypted": "", "category": "preference", "confidence": 50, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": null, "sourceMessageId": null, @@ -4321,7 +4347,7 @@ ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", "ifCueEncrypted": "", "thenActionEncrypted": "", @@ -4329,13 +4355,13 @@ "outcomeEncrypted": "", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", + "sourceConversationId": "", "createdAt": "2026-05-05T02:10:00.000Z", "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "metric": "WEIGHT", "ifCueEncrypted": "", "thenActionEncrypted": "", @@ -4351,16 +4377,16 @@ ], "coachReminders": [ { - "id": "", + "id": "", "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, "createdAt": "2026-05-05T03:53:00.000Z", @@ -4368,10 +4394,10 @@ "deletedAt": null }, { - "id": "", + "id": "", "noteEncrypted": "", "metric": null, - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "date", "dueAt": "2026-09-15T07:00:00.000Z", "contextCue": null, @@ -4387,7 +4413,7 @@ ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -4409,7 +4435,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "label": "Gewicht (deleted)", "measurementType": "WEIGHT", "intervalDays": 14, @@ -4433,8 +4459,8 @@ ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, @@ -4442,8 +4468,8 @@ "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, @@ -4623,7 +4649,7 @@ ], "ecgRecordings": [ { - "id": "", + "id": "", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", @@ -4678,7 +4704,7 @@ "illnessEpisodes": 4, "illnessDayLogs": 2, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 1, @@ -4734,7 +4760,7 @@ "illnessEpisodes": 4, "illnessDayLogs": 2, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 1, diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json index 6b196cc9e..77fa33c88 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json @@ -22,7 +22,7 @@ "illnessEpisodes": 4, "illnessDayLogs": 2, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 1, @@ -78,7 +78,7 @@ "nutrientDays": 1, "illnessEpisodes": 4, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 0, @@ -294,7 +294,7 @@ "nutrientDays": 1, "illnessEpisodes": 4, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 0, @@ -350,7 +350,7 @@ "illnessEpisodes": 4, "illnessDayLogs": 2, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "healthProfile": 1, diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index e66469817..852833970 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -22,7 +22,7 @@ "illnessEpisodes": 4, "illnessDayLogs": 2, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 1, @@ -350,7 +350,7 @@ "illnessEpisodes": 4, "illnessDayLogs": 2, "allergies": 2, - "familyHistory": 1, + "familyHistory": 2, "workouts": 1, "documents": 1, "healthProfile": 1, @@ -1031,10 +1031,21 @@ { "id": "", "userId": "round-trip-owner", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "notesEncrypted": "", + "notesEncrypted": "", "createdAt": "2026-05-02T15:46:00.000Z", "updatedAt": "2026-05-02T15:47:00.000Z", "deletedAt": null @@ -1042,7 +1053,7 @@ ], "IllnessEpisode": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Cold", "type": "INFECTION", @@ -1058,14 +1069,14 @@ "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Iron deficiency flare", "type": "CHRONIC", "lifecycle": "FLARE", "onsetAt": "2026-06-25T00:00:00.000Z", "resolvedAt": null, - "parentConditionId": "", + "parentConditionId": "", "noteEncrypted": null, "bodySiteEncrypted": null, "laterality": null, @@ -1074,7 +1085,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Iron deficiency", "type": "CHRONIC", @@ -1090,7 +1101,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Sprained ankle (deleted)", "type": "INJURY", @@ -1108,9 +1119,9 @@ ], "IllnessDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "episodeId": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, @@ -1121,9 +1132,9 @@ "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "episodeId": "", + "episodeId": "", "date": "2026-06-22", "functionalImpact": null, "feverC": null, @@ -1136,15 +1147,15 @@ ], "IllnessSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 2, "createdAt": "" } ], "SymptomDefinition": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "labelEncrypted": "", "icon": "Zap", @@ -1154,7 +1165,7 @@ "updatedAt": "2026-05-02T22:27:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "labelEncrypted": "", "icon": "Brain", @@ -1166,20 +1177,20 @@ ], "SymptomEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, "noteEncrypted": null, - "episodeId": "", + "episodeId": "", "createdAt": "2026-05-03T00:07:00.000Z", "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "noteEncrypted": "", @@ -1188,9 +1199,9 @@ "updatedAt": "2026-05-03T00:08:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "noteEncrypted": null, @@ -1201,7 +1212,7 @@ ], "LifeEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "category": "HOME", "startDate": "2023-09-01", @@ -1214,7 +1225,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "category": "OTHER", "startDate": "2025-12-01", @@ -1227,7 +1238,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "category": "WORK", "startDate": "2026-03-02", @@ -1242,7 +1253,7 @@ ], "UserHealthProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "aboutMeEncrypted": "", "conditionsEncrypted": "", @@ -1267,18 +1278,18 @@ ], "HealthProfileFactRevision": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "SMOKING_STATUS", "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", - "supersededByRevisionId": "", + "supersededByRevisionId": "", "createdAt": "2026-05-03T05:08:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "SMOKING_STATUS", "valueEncrypted": "", @@ -1291,7 +1302,7 @@ ], "CycleProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, @@ -1309,7 +1320,7 @@ ], "MenstrualCycle": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-05-04", "endDate": null, @@ -1326,7 +1337,7 @@ "updatedAt": "2026-05-03T08:34:02.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", @@ -1343,7 +1354,7 @@ "updatedAt": "2026-05-03T08:34:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-03", "endDate": null, @@ -1355,17 +1366,17 @@ "tz": null, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", + "absorbedIntoId": "", "createdAt": "2026-05-03T08:33:01.000Z", "updatedAt": "2026-05-03T08:34:01.000Z" } ], "CycleDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-02", - "cycleId": "", + "cycleId": "", "flow": "HEAVY", "intermenstrualBleeding": true, "basalBodyTempC": 2.5, @@ -1391,10 +1402,10 @@ "updatedAt": "2026-05-03T10:26:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-03", - "cycleId": "", + "cycleId": "", "flow": "LIGHT", "intermenstrualBleeding": false, "basalBodyTempC": null, @@ -1422,7 +1433,7 @@ ], "CycleSymptom": [ { - "id": "", + "id": "", "categoryId": "csc_physical", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", @@ -1435,15 +1446,15 @@ ], "CycleSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 3, "createdAt": "" } ], "CustomMetric": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Grip strength", "unit": "kg", @@ -1457,7 +1468,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Plank (deleted)", "unit": "s", @@ -1473,9 +1484,9 @@ ], "CustomMetricEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 40, "unit": "kg", "measuredAt": "2026-06-28T18:00:00.000Z", @@ -1485,9 +1496,9 @@ "deletedAt": "" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", @@ -1497,9 +1508,9 @@ "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", @@ -1511,7 +1522,7 @@ ], "CorrelationPattern": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", @@ -1535,7 +1546,7 @@ ], "HealthScoreRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", @@ -1559,7 +1570,7 @@ ], "Workout": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", @@ -1598,7 +1609,7 @@ ], "InboundDocument": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "LAB_RESULT", "title": "June labs", @@ -1629,7 +1640,7 @@ ], "Practitioner": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Closed practice (deleted)", "specialty": null, @@ -1644,7 +1655,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Round-trip practice", "specialty": "Internal medicine", @@ -1661,12 +1672,12 @@ ], "Encounter": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-04-02T08:00:00.000Z", "status": "DONE", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reasonEncrypted": null, "outcomeEncrypted": null, "bodySiteEncrypted": null, @@ -1677,17 +1688,17 @@ "deletedAt": "" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reasonEncrypted": "", "outcomeEncrypted": "", "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", + "reminderId": "", "createdAt": "2026-05-04T04:31:00.000Z", "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null @@ -1695,17 +1706,17 @@ ], "EncounterDocumentLink": [ { - "id": "", - "encounterId": "", - "documentId": "", + "id": "", + "encounterId": "", + "documentId": "", "userId": "round-trip-owner", "createdAt": "2026-05-04T06:04:00.000Z" } ], "EncounterLabLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "labResultId": "", "userId": "round-trip-owner", "createdAt": "2026-05-04T07:44:00.000Z" @@ -1713,16 +1724,16 @@ ], "EncounterConditionLink": [ { - "id": "", - "encounterId": "", - "episodeId": "", + "id": "", + "encounterId": "", + "episodeId": "", "userId": "round-trip-owner", "createdAt": "2026-05-04T09:24:00.000Z" } ], "VaccinationRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-04-14T00:00:00.000Z", "antigenSlug": "influenza", @@ -1741,7 +1752,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", @@ -1750,10 +1761,10 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "noteEncrypted": "", "createdAt": "2026-05-04T11:14:00.000Z", "updatedAt": "2026-05-04T11:15:00.000Z", @@ -1762,16 +1773,16 @@ ], "VaccinationDocumentLink": [ { - "id": "", - "vaccinationId": "", - "documentId": "", + "id": "", + "vaccinationId": "", + "documentId": "", "userId": "round-trip-owner", "createdAt": "2026-05-04T12:44:00.000Z" } ], "CustomVaccine": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Deleted combo", "components": [ @@ -1784,7 +1795,7 @@ "updatedAt": "2026-05-04T14:28:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Round-trip travel combo", "components": [ @@ -1800,7 +1811,7 @@ ], "MeasurementReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", @@ -1824,7 +1835,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Gewicht (deleted)", "measurementType": "WEIGHT", @@ -1850,9 +1861,9 @@ ], "MeasurementReminderEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, @@ -1860,9 +1871,9 @@ "createdAt": "2026-05-04T17:47:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, @@ -1872,7 +1883,7 @@ ], "CoachConversation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, "titleEncrypted": "", @@ -1884,7 +1895,7 @@ "documentScoped": true }, { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, "titleEncrypted": "", @@ -1898,8 +1909,8 @@ ], "CoachMessage": [ { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "assistant", "encryptedContent": "", "metricSourceJson": null, @@ -1912,8 +1923,8 @@ "createdAt": "2026-07-20T09:59:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", "encryptedContent": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -1926,8 +1937,8 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", "encryptedContent": "", "metricSourceJson": null, @@ -1942,19 +1953,19 @@ ], "CoachConversationDocument": [ { - "conversationId": "", - "documentId": "", + "conversationId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ], "CoachFact": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "factEncrypted": "", "category": "preference", "confidence": 50, - "sourceConversationId": "", + "sourceConversationId": "", "createdAt": "2026-05-05T00:26:01.000Z", "updatedAt": "2026-05-05T00:27:01.000Z", "deletedAt": "", @@ -1963,23 +1974,23 @@ "sourceMessageId": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "createdAt": "2026-05-05T00:26:00.000Z", "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null, "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "" + "sourceMessageId": "" } ], "CoachPlan": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metric": "BLOOD_PRESSURE", "ifCueEncrypted": "", @@ -1988,13 +1999,13 @@ "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", "outcomeEncrypted": "", - "sourceConversationId": "", + "sourceConversationId": "", "createdAt": "2026-05-05T02:10:00.000Z", "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metric": "WEIGHT", "ifCueEncrypted": "", @@ -2011,11 +2022,11 @@ ], "CoachReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "noteEncrypted": "", "metric": null, - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "date", "dueAt": "2026-09-15T07:00:00.000Z", "contextCue": null, @@ -2029,17 +2040,17 @@ "deletedAt": "" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, "createdAt": "2026-05-05T03:53:00.000Z", @@ -2049,7 +2060,7 @@ ], "MedicationInventoryItem": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "ACTIVE", @@ -2068,7 +2079,7 @@ "updatedAt": "2026-05-05T05:36:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "USED_UP", @@ -2089,14 +2100,14 @@ ], "MedicationInventoryEvent": [ { - "id": "", + "id": "", "medicationId": "", "delta": -8, "reason": "consumed", "occurredAt": "2026-07-09T07:00:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "delta": 30, "reason": "purchased", @@ -2124,7 +2135,7 @@ ], "ReminderPhaseConfig": [ { - "id": "", + "id": "", "medicationId": "", "greenValue": 45, "greenMode": "PERCENT", @@ -2138,7 +2149,7 @@ ], "MentalHealthAssessment": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "instrument": "GAD7", "locale": "en", @@ -2158,7 +2169,7 @@ "updatedAt": "2026-05-05T13:57:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "instrument": "PHQ9", "locale": "de", @@ -2180,7 +2191,7 @@ ], "ConsentReceipt": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", @@ -2189,7 +2200,7 @@ "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", @@ -2200,17 +2211,17 @@ ], "DocumentConditionLink": [ { - "id": "", - "documentId": "", - "episodeId": "", + "id": "", + "documentId": "", + "episodeId": "", "userId": "round-trip-owner", "createdAt": "2026-07-02T09:00:00.000Z" } ], "ExtractedFact": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "userId": "round-trip-owner", "factType": "OBSERVATION", "status": "APPROVED", @@ -2226,7 +2237,7 @@ ], "PersonalRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_10km_time", @@ -2240,7 +2251,7 @@ "createdAt": "2026-05-05T20:31:02.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_5km_time", @@ -2254,7 +2265,7 @@ "createdAt": "2026-05-05T20:31:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WEIGHT", "metricSlot": null, @@ -2270,14 +2281,14 @@ ], "UserAchievement": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", "createdAt": "2026-05-05T22:04:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", @@ -2286,7 +2297,7 @@ ], "EnvironmentContext": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", "locationEncrypted": "", @@ -2330,7 +2341,7 @@ "updatedAt": "2026-05-06T00:21:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", "locationEncrypted": "", @@ -2376,7 +2387,7 @@ ], "EnvironmentTravelLocation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-10", "endDate": "2026-06-20", @@ -2387,7 +2398,7 @@ ], "DawarichDay": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", "totalDistanceM": 48300, @@ -2404,7 +2415,7 @@ ], "MedicationScheduleRevision": [ { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -2420,7 +2431,7 @@ "createdAt": "2026-05-06T04:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-02-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -2432,11 +2443,11 @@ } ], "source": "ARCHIVED", - "supersededByRevisionId": "", + "supersededByRevisionId": "", "createdAt": "2026-05-06T04:47:02.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-03-01T00:00:00.000Z", "validUntil": "2026-05-01T00:00:00.000Z", @@ -2454,7 +2465,7 @@ ], "MedicationEfficacyTarget": [ { - "id": "", + "id": "", "medicationId": "", "measurementType": "WEIGHT", "biomarkerId": "", @@ -2465,7 +2476,7 @@ ], "EcgRecording": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", @@ -2484,7 +2495,7 @@ ], "OnboardingRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "needsJson": { "areas": [ @@ -2553,7 +2564,7 @@ ], "BiomarkerAlias": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "biomarkerId": "", "name": "Ferr.", @@ -2564,7 +2575,7 @@ ], "BiomarkerMerge": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "targetBiomarkerId": "", "sourceBiomarkerId": "merged-away-marker", @@ -2575,17 +2586,17 @@ ], "LabResultDocumentLink": [ { - "id": "", + "id": "", "labResultId": "", - "documentId": "", + "documentId": "", "userId": "round-trip-owner", "createdAt": "2026-05-06T14:44:00.000Z" } ], "EncounterMedicationLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "medicationId": "", "userId": "round-trip-owner", "createdAt": "2026-05-06T16:24:00.000Z" @@ -2593,34 +2604,34 @@ ], "EncounterVaccinationLink": [ { - "id": "", - "encounterId": "", - "vaccinationId": "", + "id": "", + "encounterId": "", + "vaccinationId": "", "userId": "round-trip-owner", "createdAt": "2026-05-06T18:04:00.000Z" } ], "MedicationDocumentLink": [ { - "id": "", + "id": "", "medicationId": "", - "documentId": "", + "documentId": "", "userId": "round-trip-owner", "createdAt": "2026-05-06T19:44:00.000Z" } ], "MedicationConditionLink": [ { - "id": "", + "id": "", "medicationId": "", - "episodeId": "", + "episodeId": "", "userId": "round-trip-owner", "createdAt": "2026-05-06T21:24:00.000Z" } ], "AccountGrant": [ { - "id": "", + "id": "", "grantorId": "round-trip-owner", "granteeId": "parity-delegate", "access": "READ", @@ -3985,17 +3996,30 @@ "familyHistory": [ { "id": "", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "note": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "note": "rt-FamilyHistoryEntry-notesEncrypted", + "note": null, + "notesEncrypted": "", "createdAt": "2026-05-02T15:46:00.000Z", - "updatedAt": "2026-05-02T15:47:00.000Z" + "updatedAt": "2026-05-02T15:47:00.000Z", + "deletedAt": null } ], "workouts": [ { - "id": "", + "id": "", "createdAt": "2026-05-03T21:58:00.000Z", "updatedAt": "2026-05-03T21:59:00.000Z", "metadata": { @@ -4020,7 +4044,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -4066,7 +4090,7 @@ } }, "healthProfile": { - "id": "", + "id": "", "aboutMe": null, "conditions": null, "allergies": null, @@ -4096,18 +4120,18 @@ }, "healthProfileFacts": [ { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", - "supersededByRevisionId": "", + "supersededByRevisionId": "", "createdAt": "2026-05-03T05:08:00.000Z" }, { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, "valueEncrypted": "", @@ -4120,7 +4144,7 @@ ], "customMetrics": [ { - "id": "", + "id": "", "createdAt": "2026-05-03T15:09:00.000Z", "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null, @@ -4133,7 +4157,7 @@ "correlationEnabled": true, "entries": [ { - "id": "", + "id": "", "createdAt": "2026-05-03T16:48:02.000Z", "deletedAt": "", "noteEncrypted": null, @@ -4143,7 +4167,7 @@ "note": null }, { - "id": "", + "id": "", "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null, "noteEncrypted": "", @@ -4153,7 +4177,7 @@ "note": null }, { - "id": "", + "id": "", "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null, "noteEncrypted": "", @@ -4165,7 +4189,7 @@ ] }, { - "id": "", + "id": "", "createdAt": "2026-05-03T15:09:01.000Z", "updatedAt": "2026-05-03T15:10:01.000Z", "deletedAt": "", @@ -4181,7 +4205,7 @@ ], "correlationPatterns": [ { - "id": "", + "id": "", "updatedAt": "2026-05-03T18:39:00.000Z", "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", @@ -4205,7 +4229,7 @@ "decryptFailures": [], "intradayProfiles": [ { - "id": "", + "id": "", "createdAt": "2026-05-01T09:51:00.000Z", "updatedAt": "2026-05-01T09:52:00.000Z", "type": "ACTIVITY_STEPS", @@ -4271,7 +4295,7 @@ ], "healthScoreRecords": [ { - "id": "", + "id": "", "createdAt": "2026-05-03T20:13:00.000Z", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", @@ -4293,7 +4317,7 @@ } ], "onboardingRecord": { - "id": "", + "id": "", "createdAt": "2026-05-06T09:47:00.000Z", "updatedAt": "2026-05-06T09:48:00.000Z", "needs": { @@ -4360,7 +4384,7 @@ }, "practitioners": [ { - "id": "", + "id": "", "name": "Closed practice (deleted)", "specialty": null, "practice": null, @@ -4374,7 +4398,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", @@ -4390,26 +4414,26 @@ ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reasonEncrypted": "", "outcomeEncrypted": "", "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", + "reminderId": "", "createdAt": "2026-05-04T04:31:00.000Z", "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "occurredAt": "2026-04-02T08:00:00.000Z", "status": "DONE", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reasonEncrypted": null, "outcomeEncrypted": null, "bodySiteEncrypted": null, @@ -4422,28 +4446,28 @@ ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", + "encounterId": "", + "targetId": "", "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", "name": "Deleted combo", "components": [ "rabies" @@ -4455,7 +4479,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -4470,7 +4494,7 @@ ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -4478,17 +4502,17 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "noteEncrypted": "", "createdAt": "2026-05-04T11:14:00.000Z", "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "occurredAt": "2026-04-14T00:00:00.000Z", "antigenSlug": "influenza", "vaccineName": "Influenza (deleted)", @@ -4508,14 +4532,14 @@ ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", + "vaccinationId": "", + "targetId": "", "createdAt": "2026-05-04T12:44:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -4537,7 +4561,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "label": "Gewicht (deleted)", "measurementType": "WEIGHT", "intervalDays": 14, @@ -4561,8 +4585,8 @@ ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, @@ -4570,8 +4594,8 @@ "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, @@ -4581,7 +4605,7 @@ ], "coachConversations": [ { - "id": "", + "id": "", "titleEncrypted": "", "documentScoped": false, "summaryEncrypted": "", @@ -4591,7 +4615,7 @@ "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "contentEncrypted": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -4604,7 +4628,7 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", "contentEncrypted": "", "metricSourceJson": null, @@ -4620,7 +4644,7 @@ "attachments": [] }, { - "id": "", + "id": "", "titleEncrypted": "", "documentScoped": true, "summaryEncrypted": null, @@ -4630,7 +4654,7 @@ "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "contentEncrypted": "", "metricSourceJson": null, @@ -4645,7 +4669,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -4653,25 +4677,25 @@ ], "coachFacts": [ { - "id": "", + "id": "", "factEncrypted": "", "deletedAt": null, "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", + "sourceMessageId": "", "createdAt": "2026-05-05T00:26:00.000Z", "updatedAt": "2026-05-05T00:27:00.000Z" }, { - "id": "", + "id": "", "factEncrypted": "", "deletedAt": "", "category": "preference", "confidence": 50, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": null, "sourceMessageId": null, @@ -4681,7 +4705,7 @@ ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", "ifCueEncrypted": "", "thenActionEncrypted": "", @@ -4690,12 +4714,12 @@ "deletedAt": null, "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", + "sourceConversationId": "", "createdAt": "2026-05-05T02:10:00.000Z", "updatedAt": "2026-05-05T02:11:00.000Z" }, { - "id": "", + "id": "", "metric": "WEIGHT", "ifCueEncrypted": "", "thenActionEncrypted": "", @@ -4711,28 +4735,28 @@ ], "coachReminders": [ { - "id": "", + "id": "", "noteEncrypted": "", "deletedAt": null, "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, "createdAt": "2026-05-05T03:53:00.000Z", "updatedAt": "2026-05-05T03:54:00.000Z" }, { - "id": "", + "id": "", "noteEncrypted": "", "deletedAt": "", "metric": null, - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "date", "dueAt": "2026-09-15T07:00:00.000Z", "contextCue": null, @@ -4747,7 +4771,7 @@ ], "mentalHealthAssessments": [ { - "id": "", + "id": "", "instrument": "GAD7", "locale": "en", "version": "standard", @@ -4766,7 +4790,7 @@ "updatedAt": "2026-05-05T13:57:01.000Z" }, { - "id": "", + "id": "", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", @@ -4787,7 +4811,7 @@ ], "consentReceipts": [ { - "id": "", + "id": "", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", @@ -4795,7 +4819,7 @@ "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", @@ -4805,15 +4829,15 @@ ], "documentConditionLinks": [ { - "documentId": "", + "documentId": "", "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, @@ -4998,7 +5022,7 @@ ], "ecgRecordings": [ { - "id": "", + "id": "", "waveformEncrypted": "", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", @@ -5016,7 +5040,7 @@ ], "symptomDefinitions": [ { - "id": "", + "id": "", "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, @@ -5025,7 +5049,7 @@ "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, @@ -5034,7 +5058,7 @@ "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, @@ -5045,7 +5069,7 @@ ] }, { - "id": "", + "id": "", "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, @@ -5054,7 +5078,7 @@ "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { - "id": "", + "id": "", "noteEncrypted": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, @@ -5067,7 +5091,7 @@ ], "lifeEvents": [ { - "id": "", + "id": "", "titleEncrypted": "", "noteEncrypted": "", "category": "HOME", @@ -5079,7 +5103,7 @@ "deletedAt": null }, { - "id": "", + "id": "", "titleEncrypted": "", "noteEncrypted": null, "category": "OTHER", @@ -5091,7 +5115,7 @@ "deletedAt": "" }, { - "id": "", + "id": "", "titleEncrypted": "", "noteEncrypted": null, "category": "WORK", @@ -5106,28 +5130,28 @@ "labResultDocumentLinks": [ { "labResultId": "", - "documentId": "", + "documentId": "", "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", + "encounterId": "", + "vaccinationId": "", "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", + "documentId": "", "createdAt": "2026-05-06T19:44:00.000Z" } ], diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index 1223a636a..f079e68ac 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -1707,9 +1707,11 @@ "relationship": "FATHER", "condition": "Diabetes", "ageAtOnset": 55, - "note": "type 2", + "note": null, + "notesEncrypted": "", "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "deletedAt": null } ], "workouts": [ diff --git a/tests/integration/family-history-backup-tombstones.test.ts b/tests/integration/family-history-backup-tombstones.test.ts new file mode 100644 index 000000000..e83853055 --- /dev/null +++ b/tests/integration/family-history-backup-tombstones.test.ts @@ -0,0 +1,53 @@ +/** + * The disaster-recovery file carries deleted family-history entries, as it + * carries every other record's tombstones, and carries their note as the + * ciphertext at rest. The portable file carries the live entries only, with + * the note readable. The full round trip, the restored tombstone included, is + * pinned by the backup parity golden copies. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { buildFamilyHistoryBackupSlice } from "@/lib/export/family-history-backup"; + +import { makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +beforeEach(() => truncateAllTables(prisma)); + +describe("family history in the backup", () => { + it("keeps a deleted entry in the disaster-recovery file and out of the portable one", async () => { + const user = await makeUser(); + const notes = encryptToBytes("diagnosed at fifty"); + await prisma.familyHistoryEntry.create({ + data: { userId: user.id, relationship: "MOTHER", condition: "Asthma" }, + }); + const deleted = await prisma.familyHistoryEntry.create({ + data: { + userId: user.id, + relationship: "FATHER", + condition: "Hypertension", + notesEncrypted: notes, + deletedAt: new Date("2026-09-30T10:00:00.000Z"), + }, + }); + + const dr = await buildFamilyHistoryBackupSlice(prisma, user.id, true); + expect(dr.familyHistory).toHaveLength(2); + const tombstone = dr.familyHistory.find((e) => e.id === deleted.id); + expect(tombstone?.deletedAt).toBe("2026-09-30T10:00:00.000Z"); + expect(tombstone?.note).toBeNull(); + expect(tombstone?.notesEncrypted).toBe( + Buffer.from(notes).toString("base64"), + ); + + const portable = await buildFamilyHistoryBackupSlice( + prisma, + user.id, + false, + ); + expect(portable.familyHistory.map((e) => e.condition)).toEqual(["Asthma"]); + }); +}); From 422adf29861db26b56398c46a87895e4d4c8364e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 19:00:54 +0200 Subject: [PATCH 08/13] test(measurements): pin that a stats re-post with a new source version, device or sleep stage still writes The unchanged-stats shortcut skips the write when every overwritten column matches the stored row. The kit test covered a new value and a new device; it now also re-posts a step total that only names a new HealthKit source version and a sleep row that only changes its stage, and checks each is written with its syncVersion bumped and the tail started. --- ...measurements-batch-unchanged-stats.test.ts | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/tests/integration/routes/measurements-batch-unchanged-stats.test.ts b/tests/integration/routes/measurements-batch-unchanged-stats.test.ts index d4a0b6cd2..367c40dd4 100644 --- a/tests/integration/routes/measurements-batch-unchanged-stats.test.ts +++ b/tests/integration/routes/measurements-batch-unchanged-stats.test.ts @@ -9,8 +9,9 @@ * `updatedAt`), and starts nothing: no rollup refold, no cache * invalidation, no PR detection; * - goes the old way as soon as one column differs (the value, the unit, - * the device), and for a tombstoned row (a resurrection), so those are - * written and start the tail as before; + * the device, the source version, the sleep stage), and for a tombstoned + * row (a resurrection), so those are written and start the tail as + * before; * - costs a handful of statements for a batch of them, instead of a * reconciler transaction each. */ @@ -72,8 +73,14 @@ const prisma = getPrismaClient(); const ROUTE = "/api/measurements/batch"; const DAYS = ["2026-10-01", "2026-10-02", "2026-10-03", "2026-10-04"]; -function stepsEntry(day: string, value: number, deviceType = "watch") { +function stepsEntry( + day: string, + value: number, + deviceType = "watch", + externalSourceVersion?: string, +) { return { + ...(externalSourceVersion ? { externalSourceVersion } : {}), hkIdentifier: "HKQuantityTypeIdentifierStepCount", value, unit: "count", @@ -166,6 +173,65 @@ describe("an unchanged stats: re-post", () => { expect(tail.prs).toBe(1); }); + it("writes a re-post that changes only the source version, the device or the sleep stage", async () => { + // The overwrite contract of a `stats:` row covers these columns as much as + // the value: a re-post that only names a new HealthKit source version, a + // new device or a new sleep stage replaces the stored one. + const night = { + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + value: 95, + unit: "category", + startDate: "2026-10-05T01:00:00.000Z", + endDate: "2026-10-05T02:35:00.000Z", + externalId: "stats:HKCategoryTypeIdentifierSleepAnalysis:2026-10-05", + sleepStage: 3, + }; + const seeded = await post([night]); + expect(seeded.data.inserted).toBe(1); + const sleepRow = () => + prisma.measurement.findFirstOrThrow({ + where: { userId, externalId: night.externalId }, + select: { syncVersion: true, sleepStage: true }, + }); + const sleepBefore = await sleepRow(); + const before = (await storedVersions()).map((r) => r.syncVersion); + resetTail(); + + const res = await post([ + stepsEntry(DAYS[0]!, 1000, "watch", "17.1"), // new source version + stepsEntry(DAYS[1]!, 1001, "phone"), // new device + { ...night, sleepStage: 4 }, // new sleep stage + ]); + expect(res.status).toBe(200); + expect(res.data.entries.map((e) => e.status)).toEqual([ + "updated", + "updated", + "updated", + ]); + + const after = await prisma.measurement.findMany({ + where: { userId, externalId: { startsWith: "stats:HKQuantity" } }, + orderBy: { externalId: "asc" }, + select: { + syncVersion: true, + externalSourceVersion: true, + deviceType: true, + }, + }); + expect(after.map((r) => r.syncVersion)).toEqual([ + before[0]! + 1, + before[1]! + 1, + before[2], + before[3], + ]); + expect(after[0]!.externalSourceVersion).toBe("17.1"); + expect(after[1]!.deviceType).toBe("phone"); + const sleepAfter = await sleepRow(); + expect(sleepAfter.sleepStage).toBe("DEEP"); + expect(sleepAfter.syncVersion).toBe(sleepBefore.syncVersion + 1); + expect(tail.rollups).toBe(1); + }); + it("resurrects a tombstoned row the old way", async () => { await prisma.measurement.updateMany({ where: { From 298515d156a786a8781a8a98f2c1cf6cb1a5b133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 19:04:20 +0200 Subject: [PATCH 09/13] ci: fail the mutation floors step, and scale nightly property runs by a factor The floors step piped scripts/mutation-floors.mjs into tee without pipefail, so it took tee's exit status and a module under its floor could never fail the run. It now runs under bash with pipefail, as do the other steps that pipe into tee, and a guard reads every workflow and fails on a tee pipe without it. The other piped steps were checked: each either validates what the pipe produced or pipes a command that cannot fail. The nightly runs set FC_NUM_RUNS, an absolute count that replaced every property's own: the database backup round trip, which states twelve cases for its cost, ran a hundred times against a timeout sized for twelve. FC_RUNS_FACTOR now multiplies each property's count (five for the unit suite, four for integration), propertyTimeout grows a test's timeout by the same factor, and the backup round trip uses it. --- .github/workflows/mutation.yml | 7 +- .github/workflows/test-nightly.yml | 13 ++- docs/contributing/testing.md | 8 +- src/__tests__/fast-check-runs-factor.test.ts | 39 +++++++ src/__tests__/workflow-pipefail-guard.test.ts | 96 ++++++++++++++++ .../backup-random-account.property.test.ts | 106 +++++++++--------- tests/property/fast-check.ts | 34 +++++- 7 files changed, 242 insertions(+), 61 deletions(-) create mode 100644 src/__tests__/fast-check-runs-factor.test.ts create mode 100644 src/__tests__/workflow-pipefail-guard.test.ts diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index cc92b01ad..a49998145 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -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() diff --git a/.github/workflows/test-nightly.yml b/.github/workflows/test-nightly.yml index b33b9b5c8..88c1e0a27 100644 --- a/.github/workflows/test-nightly.yml +++ b/.github/workflows/test-nightly.yml @@ -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) diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 100e9a043..fd0a7d434 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -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= pnpm test ` 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= pnpm test ` 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 diff --git a/src/__tests__/fast-check-runs-factor.test.ts b/src/__tests__/fast-check-runs-factor.test.ts new file mode 100644 index 000000000..a9fa55ed2 --- /dev/null +++ b/src/__tests__/fast-check-runs-factor.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/workflow-pipefail-guard.test.ts b/src/__tests__/workflow-pipefail-guard.test.ts new file mode 100644 index 000000000..a7fa32b27 --- /dev/null +++ b/src/__tests__/workflow-pipefail-guard.test.ts @@ -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([]); + }); +}); diff --git a/tests/integration/backup-random-account.property.test.ts b/tests/integration/backup-random-account.property.test.ts index f88553000..33f7c3040 100644 --- a/tests/integration/backup-random-account.property.test.ts +++ b/tests/integration/backup-random-account.property.test.ts @@ -21,7 +21,7 @@ import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; import type { Prisma } from "@/generated/prisma/client"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; -import { assertAsyncProperty } from "../property/fast-check"; +import { assertAsyncProperty, propertyTimeout } from "../property/fast-check"; import { callRoute } from "../kit/call-route"; import { makeAdmin, makeSession, makeUser } from "../kit/factories"; import { POST as restoreBackup } from "./restore-job-driver"; @@ -264,56 +264,62 @@ beforeEach(async () => { }); describe("a generated account", () => { - it("comes back from a disaster-recovery backup exactly as it left", async () => { - await assertAsyncProperty( - fc.asyncProperty(accountArb, async (account) => { - await truncateAllTables(prisma); - await seed(account); - const before = await dump(); + it( + "comes back from a disaster-recovery backup exactly as it left", + async () => { + await assertAsyncProperty( + fc.asyncProperty(accountArb, async (account) => { + await truncateAllTables(prisma); + await seed(account); + const before = await dump(); - let json = ""; - await streamFullBackupJson( - prisma, - OWNER_ID, - (chunk) => { - json += chunk; - }, - { - purpose: "disaster-recovery", - exportedAt: new Date("2026-10-01T00:00:00.000Z"), - }, - ); + let json = ""; + await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + json += chunk; + }, + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-10-01T00:00:00.000Z"), + }, + ); - await prisma.user.delete({ where: { id: OWNER_ID } }); - await makeUser({ id: OWNER_ID }); - const backup = await prisma.dataBackup.create({ - data: { - userId: OWNER_ID, - type: "RANDOM_ACCOUNT_ROUND_TRIP", - data: await legacyStreamedBlobFrom(async (write) => { - await write(json); - }), - }, - }); - const operator = await makeSession(await makeAdmin()); - const restored = await callRoute(restoreBackup, { - method: "POST", - route: "/api/admin/backups/{id}/restore", - params: { id: backup.id }, - body: { confirm: "RESTORE" }, - auth: operator, - contract: { - skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", - }, - }); - expect( - restored.status, - JSON.stringify(restored.body).slice(0, 300), - ).toBe(200); + await prisma.user.delete({ where: { id: OWNER_ID } }); + await makeUser({ id: OWNER_ID }); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "RANDOM_ACCOUNT_ROUND_TRIP", + data: await legacyStreamedBlobFrom(async (write) => { + await write(json); + }), + }, + }); + const operator = await makeSession(await makeAdmin()); + const restored = await callRoute(restoreBackup, { + method: "POST", + route: "/api/admin/backups/{id}/restore", + params: { id: backup.id }, + body: { confirm: "RESTORE" }, + auth: operator, + contract: { + skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", + }, + }); + expect( + restored.status, + JSON.stringify(restored.body).slice(0, 300), + ).toBe(200); - expect(await dump()).toEqual(before); - }), - 12, - ); - }); + expect(await dump()).toEqual(before); + }), + 12, + ); + // Twelve round trips take a few seconds; the nightly factor multiplies + // both the cases and this budget. + }, + propertyTimeout(60_000), + ); }); diff --git a/tests/property/fast-check.ts b/tests/property/fast-check.ts index f5dd632da..283198c96 100644 --- a/tests/property/fast-check.ts +++ b/tests/property/fast-check.ts @@ -8,8 +8,13 @@ * prints the seed and the shrunk counterexample, and * `FC_SEED= pnpm test ` replays it exactly. * - * `FC_NUM_RUNS` scales the number of generated cases (the nightly run uses - * more). The default keeps every property file well under a second or two. + * `FC_RUNS_FACTOR` multiplies every property's own case count (the nightly + * run sets it above 1). A factor rather than a count: each property states + * the number of cases its cost allows, a cheap law a few hundred and a + * database round trip a dozen, and a nightly count applied to all of them + * alike ran the round trip a hundred times against a timeout sized for + * twelve. `propertyTimeout` grows a test's own timeout by the same factor. + * The defaults keep every property file well under a second or two. */ import fc from "fast-check"; @@ -21,22 +26,41 @@ function positiveInt(raw: string | undefined): number | null { return Number.isInteger(value) && value > 0 ? value : null; } +function positiveNumber(raw: string | undefined): number | null { + if (!raw) return null; + const value = Number(raw); + return Number.isFinite(value) && value > 0 ? value : null; +} + export const PROPERTY_SEED = positiveInt(process.env.FC_SEED) ?? DEFAULT_SEED; -const SCALE = positiveInt(process.env.FC_NUM_RUNS); +/** The multiplier on every property's case count; 1 on a pull request. */ +export const RUNS_FACTOR = positiveNumber(process.env.FC_RUNS_FACTOR) ?? 1; -/** Parameters for `fc.assert`; `numRuns` is the PR-run default per property. */ +/** + * Parameters for `fc.assert`; `numRuns` is the property's own case count on a + * pull request, multiplied by `FC_RUNS_FACTOR` when it is set. + */ export function propertyParameters( numRuns = 100, overrides: fc.Parameters = {}, ): fc.Parameters { return { seed: PROPERTY_SEED, - numRuns: SCALE ?? numRuns, + numRuns: Math.max(1, Math.round(numRuns * RUNS_FACTOR)), ...overrides, }; } +/** + * A test timeout for a property whose cases are expensive, grown by the same + * factor as its case count, so a nightly run with more cases does not run + * into a timeout sized for the pull-request count. + */ +export function propertyTimeout(baseMs: number): number { + return Math.ceil(baseMs * Math.max(1, RUNS_FACTOR)); +} + /** Run a property with the shared parameters. */ export function assertProperty( property: fc.IProperty, From 760f261a5fdb1c1892fdd98445954df5c0fe15ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 19:08:15 +0200 Subject: [PATCH 10/13] test(guards): count fixed-path reads through readSourceText in the source-read ratchet The ratchet on tests that read a source file by path matched readFileSync and readFile only, so a guard that called readSourceText with a fixed path, directly or through a local read(rel) wrapper, read the file without the anchors readSource demands and was never counted. The classifier now counts both shapes, and leaves alone a readSource call with anchors and a sweep that feeds the reader from a walk. The twelve guards that read a fixed file that way now go through readSource with an anchor each read asserts on, so the baseline stays where it was. --- .../acting-account-boundary-guard.test.ts | 14 +++-- .../bearer-scope-enforcement-guard.test.ts | 33 +++++++--- ...lation-single-implementation-guard.test.ts | 11 +++- ...cycle-verdict-single-decider-guard.test.ts | 12 ++-- .../external-id-stability-guard.test.ts | 12 ++-- ...ar-fit-single-implementation-guard.test.ts | 13 +++- .../memory-block-egress-guard.test.ts | 15 +++-- .../recent-proof-surface-guard.test.ts | 11 +++- .../record-session-fence-guard.test.ts | 23 +++++-- ...spread-single-implementation-guard.test.ts | 14 ++++- src/__tests__/sql-now-utc-guard.test.ts | 4 +- src/__tests__/step-up-elevation-guard.test.ts | 26 ++++++-- src/__tests__/test-realism-ratchet.test.ts | 60 ++++++++++++++++++- 13 files changed, 200 insertions(+), 48 deletions(-) diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index 5a3dc2bef..ec8ebafb7 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -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); diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index 21ab2d10a..8f38b2dc1 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -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\)/); }); diff --git a/src/__tests__/correlation-single-implementation-guard.test.ts b/src/__tests__/correlation-single-implementation-guard.test.ts index c796e97d6..6a6b1d808 100644 --- a/src/__tests__/correlation-single-implementation-guard.test.ts +++ b/src/__tests__/correlation-single-implementation-guard.test.ts @@ -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*\(/, diff --git a/src/__tests__/cycle-verdict-single-decider-guard.test.ts b/src/__tests__/cycle-verdict-single-decider-guard.test.ts index 48a19d200..fe0d9101e 100644 --- a/src/__tests__/cycle-verdict-single-decider-guard.test.ts +++ b/src/__tests__/cycle-verdict-single-decider-guard.test.ts @@ -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?:"); }); diff --git a/src/__tests__/external-id-stability-guard.test.ts b/src/__tests__/external-id-stability-guard.test.ts index 09b03fc17..3d3d27425 100644 --- a/src/__tests__/external-id-stability-guard.test.ts +++ b/src/__tests__/external-id-stability-guard.test.ts @@ -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", () => { diff --git a/src/__tests__/linear-fit-single-implementation-guard.test.ts b/src/__tests__/linear-fit-single-implementation-guard.test.ts index ca5b7888a..2f149383e 100644 --- a/src/__tests__/linear-fit-single-implementation-guard.test.ts +++ b/src/__tests__/linear-fit-single-implementation-guard.test.ts @@ -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); }); diff --git a/src/__tests__/memory-block-egress-guard.test.ts b/src/__tests__/memory-block-egress-guard.test.ts index 7c0fa8bd7..22ae40339 100644 --- a/src/__tests__/memory-block-egress-guard.test.ts +++ b/src/__tests__/memory-block-egress-guard.test.ts @@ -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. diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index 9e1838b78..4b0acd796 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -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)); diff --git a/src/__tests__/record-session-fence-guard.test.ts b/src/__tests__/record-session-fence-guard.test.ts index e6cacd6a0..eed7928bc 100644 --- a/src/__tests__/record-session-fence-guard.test.ts +++ b/src/__tests__/record-session-fence-guard.test.ts @@ -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)"); diff --git a/src/__tests__/spread-single-implementation-guard.test.ts b/src/__tests__/spread-single-implementation-guard.test.ts index 815f3cb9c..434e49f7d 100644 --- a/src/__tests__/spread-single-implementation-guard.test.ts +++ b/src/__tests__/spread-single-implementation-guard.test.ts @@ -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); diff --git a/src/__tests__/sql-now-utc-guard.test.ts b/src/__tests__/sql-now-utc-guard.test.ts index 096f85055..ce1f2e423 100644 --- a/src/__tests__/sql-now-utc-guard.test.ts +++ b/src/__tests__/sql-now-utc-guard.test.ts @@ -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); diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts index e295dba3b..250f9bb83 100644 --- a/src/__tests__/step-up-elevation-guard.test.ts +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -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("), ); diff --git a/src/__tests__/test-realism-ratchet.test.ts b/src/__tests__/test-realism-ratchet.test.ts index e35e78a9c..9fbd4c0dc 100644 --- a/src/__tests__/test-realism-ratchet.test.ts +++ b/src/__tests__/test-realism-ratchet.test.ts @@ -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( From cec7ce2ad5f03318cbb2d69b2a282f383a8d481f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 19:10:55 +0200 Subject: [PATCH 11/13] docs(api): regenerate the OpenAPI contract for the family-history backup fields The backup payload schema the admin restore routes publish now declares notesEncrypted and deletedAt on a family-history entry, which the disaster-recovery file carries since the previous change. --- docs/api/openapi.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b2d97d4bd..c271ca188 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -43008,12 +43008,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 From 6c4c097517a9494106aca841ecb4e6b86e8c7191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 19:15:58 +0200 Subject: [PATCH 12/13] fix(workouts): start the windowed workout read after the plain reads beside it The windowed reader is async, so a Promise.all that listed it first held a rejected promise of it whenever a later element threw while the array was built, and nobody handled that rejection. It now comes last in each of those arrays. --- src/lib/day/records.ts | 10 +++++----- src/lib/insights/correlation-channel-series.ts | 18 +++++++++--------- src/lib/insights/feature-blocks.ts | 10 +++++----- src/lib/insights/strain-score.ts | 10 +++++----- src/lib/workouts/canonical-read.ts | 5 +++++ src/lib/workouts/list-read.ts | 10 +++++----- 6 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index f490f35b1..35138b89d 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -909,7 +909,11 @@ async function assessmentsPart(frame: DayFrame): Promise { /* ─── workouts, cycle ─────────────────────────────────────────────────────── */ async function workoutsPart(frame: DayFrame): Promise { - const [stored, account] = await Promise.all([ + 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 }, @@ -919,10 +923,6 @@ async function workoutsPart(frame: DayFrame): Promise { select: WORKOUT_PICK_SELECT, }), ), - prisma.user.findUnique({ - where: { id: frame.userId }, - select: { sourcePriorityJson: true }, - }), ]); // One session synced by two services is one workout on the day, as in the // workout list it links to, a twin across midnight included. diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index ed386cc69..25f971c18 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -1260,15 +1260,7 @@ export async function fetchTrainingSeries( tz: string, since: Date, ): Promise { - const [workouts, account, trimp] = await Promise.all([ - 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 [account, trimp, workouts] = await Promise.all([ prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, @@ -1280,6 +1272,14 @@ 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 = canonicalWorkoutsInRange( workouts, diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index f501f1825..acd01bf57 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -539,7 +539,11 @@ export async function readWorkoutsBlock( now: number, ): Promise { const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY); - const [stored, account] = await Promise.all([ + 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 }, @@ -555,10 +559,6 @@ export async function readWorkoutsBlock( }, }), ), - prisma.user.findUnique({ - where: { id: userId }, - select: { sourcePriorityJson: 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 diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index b5a5240b2..526a2527d 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -432,7 +432,11 @@ 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([ + 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) => @@ -450,10 +454,6 @@ export async function computeStrainScore( take: 100, }), ), - prisma.user.findUnique({ - where: { id: userId }, - select: { sourcePriorityJson: true }, - }), ]); // The watch and the phone relay (or a ring) both write the day's active diff --git a/src/lib/workouts/canonical-read.ts b/src/lib/workouts/canonical-read.ts index 22777a630..055eae3bb 100644 --- a/src/lib/workouts/canonical-read.ts +++ b/src/lib/workouts/canonical-read.ts @@ -20,6 +20,11 @@ * 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. diff --git a/src/lib/workouts/list-read.ts b/src/lib/workouts/list-read.ts index ff4f896d0..5c3181c56 100644 --- a/src/lib/workouts/list-read.ts +++ b/src/lib/workouts/list-read.ts @@ -151,7 +151,11 @@ export async function buildWorkoutsProjection( // 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 [around, userRow] = await Promise.all([ + const [userRow, around] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), readWorkoutsAroundRange(range, (bounds) => prisma.workout.findMany({ where: { @@ -179,10 +183,6 @@ export async function buildWorkoutsProjection( }, }), ), - prisma.user.findUnique({ - where: { id: userId }, - select: { sourcePriorityJson: true }, - }), ]); const canonical: CanonicalWorkoutRow[] = canonicalWorkoutsInRange( From 811fcf55092eb5d0b6a6e51a4c86900bd418f9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 19:57:47 +0200 Subject: [PATCH 13/13] fix(backup): restore row stamps and carry sealed labels, course notes and marker context as ciphertext A disaster-recovery restore wrote these rows stamped with the restore's own time: the medication's updatedAt (moved again by the course window), the category assignment, custom category labels, courses and composition, the mood day context and tag links, illness and cycle symptom links, nutrient days, and a flare's updatedAt after the parent pointer pass. The recovery file now carries each stamp (additive fields: linkCreatedAt on a mood entry, symptomLinkCreatedAt on a cycle day log, createdAt on an illness symptom, categoryUpdatedAt on a medication, updatedAt where missing) and the restore writes it back. A file without them restores as before. A custom category label, a course note and a marker's context rode the recovery file as readable text and were sealed again under the active key. They now ride as ciphertext (labelEncrypted, noteEncrypted, contextEncrypted) and come back as the same bytes; a file that carries the readable value instead is sealed on the way in, as before. The portable file keeps the readable values. The rotation script and the encrypted-column writer guard already cover all three columns. The parity golden copies change for exactly this: the new fields in the recovery file, the restored stamps in place of , and the three values coming back under the key they were sealed with. The legacy readable coach title and custom metric note are still sealed on restore, since the row holds no ciphertext to carry. The round-trip column audit no longer excludes the four link stamps. --- docs/api/openapi.yaml | 74 +++- .../baselines/source-function-lines.json | 4 +- .../mood-backup-completeness.test.ts | 6 - src/lib/cycle/__tests__/backup.test.ts | 17 +- src/lib/cycle/backup-schema.ts | 3 + src/lib/cycle/backup.ts | 79 +++-- .../__tests__/full-backup-payload.test.ts | 78 +++-- .../export/__tests__/records-backup.test.ts | 24 +- .../restore-backup-round-trip.test.ts | 7 +- src/lib/export/illness-backup-schema.ts | 3 + src/lib/export/illness-backup.ts | 20 +- src/lib/export/labs-backup-schema.ts | 3 + src/lib/export/labs-backup.ts | 36 +- src/lib/export/medications-backup-schema.ts | 23 +- src/lib/export/medications-backup.ts | 34 +- src/lib/export/medications-restore.ts | 43 ++- src/lib/export/mood-backup-schema.ts | 9 + src/lib/export/mood-backup.ts | 15 +- src/lib/export/mood-restore.ts | 14 +- src/lib/export/nutrients-backup-schema.ts | 3 + src/lib/export/nutrients-backup.ts | 7 + src/lib/medication-category.ts | 17 +- .../export-disaster-recovery.json | 90 ++++- .../restore-disaster-recovery.json | 77 +++-- .../backup-parity/restore-portable.json | 4 +- .../backup-parity/restore-version-1.json | 12 +- .../backup-dr-stamps-and-ciphertext.test.ts | 316 ++++++++++++++++++ tests/integration/backup-fixture.ts | 11 - tests/integration/backup-round-trip.test.ts | 26 +- 29 files changed, 901 insertions(+), 154 deletions(-) create mode 100644 tests/integration/backup-dr-stamps-and-ciphertext.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c271ca188..bba317fee 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -40862,6 +40862,11 @@ components: category: type: string maxLength: 64 + categoryUpdatedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" deliveryForm: type: string enum: @@ -41138,6 +41143,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - nutrient - amountPerUnit @@ -41492,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 @@ -41522,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 @@ -41536,9 +41557,11 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - key - - label - sortOrder - isActive additionalProperties: {} @@ -41749,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 @@ -41851,6 +41889,12 @@ components: - type: string minLength: 1 - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 additionalProperties: {} - type: "null" required: @@ -42214,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 @@ -42515,6 +42574,11 @@ components: type: - string - "null" + contextEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" aliases: type: array items: @@ -42705,6 +42769,9 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" + createdAt: + type: string + minLength: 1 required: - key additionalProperties: {} @@ -43381,6 +43448,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - day - nutrient diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index d977290b1..d984004ea 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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": 148227, + "total": 148208, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -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, diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts index 050953e4f..041112000 100644 --- a/src/__tests__/mood-backup-completeness.test.ts +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -55,18 +55,12 @@ const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record = { 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 = { 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 { diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index cd7318a86..88d644c62 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -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" }, + ], }); }); }); diff --git a/src/lib/cycle/backup-schema.ts b/src/lib/cycle/backup-schema.ts index 4dae4e9ca..66dd41e35 100644 --- a/src/lib/cycle/backup-schema.ts +++ b/src/lib/cycle/backup-schema.ts @@ -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). */ diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index c04859d87..6bf55b3d4 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -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( : 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, +) { + const severityByKey = new Map(); + 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(); - 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 diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 0f5f486d5..d8270f380 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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" } + : {}), }, ]); }, diff --git a/src/lib/export/__tests__/records-backup.test.ts b/src/lib/export/__tests__/records-backup.test.ts index c0d68ee1b..406b60bc1 100644 --- a/src/lib/export/__tests__/records-backup.test.ts +++ b/src/lib/export/__tests__/records-backup.test.ts @@ -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", + }, + ], }, ], }); diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index b647ccbf4..45266aa47 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -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"), + }, + ], }, ]), }, diff --git a/src/lib/export/illness-backup-schema.ts b/src/lib/export/illness-backup-schema.ts index 09f6de025..2022d5ced 100644 --- a/src/lib/export/illness-backup-schema.ts +++ b/src/lib/export/illness-backup-schema.ts @@ -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 diff --git a/src/lib/export/illness-backup.ts b/src/lib/export/illness-backup.ts index 64486bb95..19eb4423c 100644 --- a/src/lib/export/illness-backup.ts +++ b/src/lib/export/illness-backup.ts @@ -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 { 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 { 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({ diff --git a/src/lib/export/labs-backup-schema.ts b/src/lib/export/labs-backup-schema.ts index b28577007..df15b82e6 100644 --- a/src/lib/export/labs-backup-schema.ts +++ b/src/lib/export/labs-backup-schema.ts @@ -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 diff --git a/src/lib/export/labs-backup.ts b/src/lib/export/labs-backup.ts index f2e5291d5..2205385d9 100644 --- a/src/lib/export/labs-backup.ts +++ b/src/lib/export/labs-backup.ts @@ -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 { 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) } diff --git a/src/lib/export/medications-backup-schema.ts b/src/lib/export/medications-backup-schema.ts index eade10b55..4b43396fd 100644 --- a/src/lib/export/medications-backup-schema.ts +++ b/src/lib/export/medications-backup-schema.ts @@ -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:`, 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(), diff --git a/src/lib/export/medications-backup.ts b/src/lib/export/medications-backup.ts index 2ffbfd80c..990d579e5 100644 --- a/src/lib/export/medications-backup.ts +++ b/src/lib/export/medications-backup.ts @@ -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 diff --git a/src/lib/export/medications-restore.ts b/src/lib/export/medications-restore.ts index 90c7d5cb2..d71a2505f 100644 --- a/src/lib/export/medications-restore.ts +++ b/src/lib/export/medications-restore.ts @@ -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 { 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 { 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 }); } diff --git a/src/lib/export/mood-backup-schema.ts b/src/lib/export/mood-backup-schema.ts index 4f571f411..a61ea130c 100644 --- a/src/lib/export/mood-backup-schema.ts +++ b/src/lib/export/mood-backup-schema.ts @@ -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() diff --git a/src/lib/export/mood-backup.ts b/src/lib/export/mood-backup.ts index 439a3f4d9..69f4cc380 100644 --- a/src/lib/export/mood-backup.ts +++ b/src/lib/export/mood-backup.ts @@ -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, diff --git a/src/lib/export/mood-restore.ts b/src/lib/export/mood-restore.ts index 938195124..26596eb76 100644 --- a/src/lib/export/mood-restore.ts +++ b/src/lib/export/mood-restore.ts @@ -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(); + 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 }); } diff --git a/src/lib/export/nutrients-backup-schema.ts b/src/lib/export/nutrients-backup-schema.ts index edd766e67..b7b701e93 100644 --- a/src/lib/export/nutrients-backup-schema.ts +++ b/src/lib/export/nutrients-backup-schema.ts @@ -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(); diff --git a/src/lib/export/nutrients-backup.ts b/src/lib/export/nutrients-backup.ts index 09693fc4d..ecfef114a 100644 --- a/src/lib/export/nutrients-backup.ts +++ b/src/lib/export/nutrients-backup.ts @@ -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 { source: n.source, externalSourceVersion: n.externalSourceVersion ?? null, ...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}), + ...(n.updatedAt ? { updatedAt: new Date(n.updatedAt) } : {}), })), }); } diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index f6240db5a..00ef279ee 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -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() } + : {}), })); } diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index 81e8ccae0..48a526e97 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -295,10 +295,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "2026-05-01T23:06:00.000Z" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "medications": [ @@ -329,6 +331,7 @@ "externalSource": "WEB", "externalId": "rt-Medication-externalId", "updatedAt": "2026-05-01T11:51:00.000Z", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", "active": false, @@ -406,6 +409,7 @@ "ingredients": [ { "id": "", + "updatedAt": "2026-05-02T02:30:00.000Z", "nutrient": "vitamin_d", "amountPerUnit": 25, "unit": "ug", @@ -419,13 +423,17 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", + "note": null, + "noteEncrypted": "", + "updatedAt": "2026-05-02T00:47:00.000Z", "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, + "noteEncrypted": null, + "updatedAt": "2026-05-02T00:47:01.000Z", "createdAt": "2026-05-02T00:46:01.000Z" } ], @@ -623,6 +631,7 @@ "syncVersion": 0, "createdAt": "2026-05-02T04:18:01.000Z", "updatedAt": "2026-05-02T04:19:01.000Z", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "SCHLECHT", @@ -649,6 +658,12 @@ "syncVersion": 3, "createdAt": "2026-05-02T04:18:00.000Z", "updatedAt": "2026-05-02T04:19:00.000Z", + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "id": "", "date": "2026-07-01", "mood": "GUT", @@ -673,6 +688,8 @@ "structuredTags": [], "context": { "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -827,6 +844,12 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] }, { @@ -857,7 +880,8 @@ "externalId": null, "tz": null, "symptomKeys": [], - "symptomSeverities": [] + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ @@ -933,7 +957,8 @@ "upperBound": 2.5, "panel": "rt-Biomarker-panel", "hidden": true, - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "analyteKey": "rt-Biomarker-analyteKey", "aliases": [ { @@ -1007,7 +1032,8 @@ "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ] }, @@ -2275,7 +2301,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "2026-05-03T23:27:00.000Z" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ] }, @@ -2718,6 +2745,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "deliveryForm": "INJECTION", "trackInjectionSites": true, "allowedInjectionSites": [ @@ -2809,7 +2837,8 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "2026-05-02T02:29:00.000Z" + "createdAt": "2026-05-02T02:29:00.000Z", + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "doseChanges": [ @@ -2954,14 +2983,18 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", - "createdAt": "2026-05-02T00:46:00.000Z" + "note": null, + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", + "updatedAt": "2026-05-02T00:47:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "2026-05-02T00:46:01.000Z" + "noteEncrypted": null, + "createdAt": "2026-05-02T00:46:01.000Z", + "updatedAt": "2026-05-02T00:47:01.000Z" } ] } @@ -2969,10 +3002,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "2026-05-01T23:06:00.000Z" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "intakeEvents": [ @@ -3046,6 +3081,7 @@ "updatedAt": "2026-05-02T04:19:01.000Z", "factors": [], "structuredTags": [], + "linkCreatedAt": [], "context": null }, { @@ -3077,6 +3113,12 @@ } ], "structuredTags": [], + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "context": { "workStatus": "regular", "workMinutes": 3, @@ -3095,7 +3137,9 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "" + "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } } ], @@ -3200,6 +3244,12 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] }, { @@ -3230,7 +3280,8 @@ "createdAt": "2026-05-03T10:25:01.000Z", "updatedAt": "2026-05-03T10:26:01.000Z", "symptomKeys": [], - "symptomSeverities": [] + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ @@ -3338,7 +3389,8 @@ "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "aliases": [ { "id": "", @@ -3407,7 +3459,8 @@ "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ], "note": null, @@ -3702,7 +3755,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "2026-05-03T23:27:00.000Z" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "healthProfile": { diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index 852833970..439fe95bc 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -629,7 +629,7 @@ "externalSource": "WEB", "externalId": "rt-Medication-externalId", "createdAt": "2026-05-01T11:50:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-01T11:51:00.000Z" } ], "MedicationSchedule": [ @@ -778,7 +778,7 @@ { "medicationId": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "updatedAt": "" + "updatedAt": "2026-05-01T21:22:00.000Z" } ], "MedicationCategoryLabel": [ @@ -786,11 +786,11 @@ "id": "", "userId": "round-trip-owner", "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "labelEncrypted": "", + "labelEncrypted": "", "sortOrder": 2, "isActive": false, "createdAt": "2026-05-01T23:06:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "MedicationCourse": [ @@ -800,9 +800,9 @@ "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "2026-05-02T00:46:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-02T00:47:00.000Z" }, { "id": "", @@ -812,7 +812,7 @@ "endsOn": "2026-06-16T00:00:00.000Z", "noteEncrypted": null, "createdAt": "2026-05-02T00:46:01.000Z", - "updatedAt": "" + "updatedAt": "2026-05-02T00:47:01.000Z" } ], "MedicationIngredient": [ @@ -827,7 +827,7 @@ "enteredUnit": "IU", "source": "LABEL_AI", "createdAt": "2026-05-02T02:29:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "MoodEntry": [ @@ -902,15 +902,15 @@ "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } ], "MoodEntryTagLink": [ { "moodEntryId": "", "moodTagId": "", - "createdAt": "", + "createdAt": "2026-05-02T07:22:00.000Z", "rating": 4 } ], @@ -987,7 +987,7 @@ "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", @@ -1081,7 +1081,7 @@ "bodySiteEncrypted": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "deletedAt": null }, { @@ -1150,7 +1150,7 @@ "dayLogId": "", "symptomId": "", "severity": 2, - "createdAt": "" + "createdAt": "2026-05-02T20:43:00.000Z" } ], "SymptomDefinition": [ @@ -1449,7 +1449,7 @@ "dayLogId": "", "symptomId": "", "severity": 3, - "createdAt": "" + "createdAt": "2026-05-03T13:23:00.000Z" } ], "CustomMetric": [ @@ -1604,7 +1604,7 @@ "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", "createdAt": "2026-05-03T23:27:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "InboundDocument": [ @@ -3190,10 +3190,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "2026-05-01T23:06:00.000Z" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "medications": [ @@ -3223,7 +3225,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T11:51:00.000Z", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", "active": false, @@ -3301,6 +3304,7 @@ "ingredients": [ { "id": "", + "updatedAt": "2026-05-02T02:30:00.000Z", "nutrient": "vitamin_d", "amountPerUnit": 25, "unit": "ug", @@ -3314,13 +3318,17 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", + "note": null, + "noteEncrypted": "", + "updatedAt": "2026-05-02T00:47:00.000Z", "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, + "noteEncrypted": null, + "updatedAt": "2026-05-02T00:47:01.000Z", "createdAt": "2026-05-02T00:46:01.000Z" } ], @@ -3518,6 +3526,7 @@ "syncVersion": 0, "createdAt": "2026-05-02T04:18:01.000Z", "updatedAt": "2026-05-02T04:19:01.000Z", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "SCHLECHT", @@ -3544,6 +3553,12 @@ "syncVersion": 3, "createdAt": "2026-05-02T04:18:00.000Z", "updatedAt": "2026-05-02T04:19:00.000Z", + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "id": "", "date": "2026-07-01", "mood": "GUT", @@ -3568,6 +3583,8 @@ "structuredTags": [], "context": { "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -3722,6 +3739,12 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] }, { @@ -3752,7 +3775,8 @@ "externalId": null, "tz": null, "symptomKeys": [], - "symptomSeverities": [] + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ @@ -3828,7 +3852,8 @@ "upperBound": 2.5, "panel": "rt-Biomarker-panel", "hidden": true, - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "analyteKey": "rt-Biomarker-analyteKey", "aliases": [ { @@ -3866,7 +3891,7 @@ "bodySiteEncrypted": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "deletedAt": null, "dayLogs": [] }, @@ -3902,7 +3927,8 @@ "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ] }, @@ -5170,7 +5196,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "2026-05-03T23:27:00.000Z" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ] } diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index 479f19de8..5b81b82e2 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -1047,7 +1047,7 @@ "bodySiteEncrypted": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "deletedAt": null }, { @@ -2959,7 +2959,7 @@ "bodySite": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "dayLogs": [] }, { diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index f079e68ac..7128d4c26 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -1391,6 +1391,7 @@ "externalSource": null, "externalId": null, "updatedAt": "", + "categoryUpdatedAt": null, "name": "Ramipril", "dose": "5 mg", "active": true, @@ -1454,6 +1455,7 @@ "externalSource": null, "externalId": null, "updatedAt": "", + "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": "", "createdAt": "" } ], @@ -1534,6 +1538,7 @@ "syncVersion": 0, "createdAt": "", "updatedAt": "", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "OKAY", @@ -1560,6 +1565,7 @@ "syncVersion": 0, "createdAt": "", "updatedAt": "", + "linkCreatedAt": [], "id": "", "date": "2026-07-02", "mood": "GOOD", @@ -1624,7 +1630,8 @@ "upperBound": 400, "panel": "Iron", "hidden": false, - "context": "fasted", + "context": null, + "contextEncrypted": "", "analyteKey": null, "aliases": [] } @@ -1802,7 +1809,8 @@ "unit": "mL", "source": "MANUAL", "externalSourceVersion": null, - "createdAt": "" + "createdAt": "", + "updatedAt": "" } ] } diff --git a/tests/integration/backup-dr-stamps-and-ciphertext.test.ts b/tests/integration/backup-dr-stamps-and-ciphertext.test.ts new file mode 100644 index 000000000..512f70636 --- /dev/null +++ b/tests/integration/backup-dr-stamps-and-ciphertext.test.ts @@ -0,0 +1,316 @@ +/** + * A disaster-recovery restore gives back the timestamps and the ciphertext the + * account had, and still takes a recovery file written before it did. + * + * - The row stamps of the medication rows (the drug, its courses, + * composition, category and category label), of the mood day context and + * tag links, of the illness and cycle symptom links and of the nutrient + * days used to come back as the restore's own time. The file now carries + * them and the restore writes them back. + * - A custom category's label, a course note and a marker's context rode a + * recovery file as readable text and were sealed again under whatever key + * was active; the file now carries the ciphertext and the restore writes + * the bytes back unchanged. + * - A recovery file without the new fields (any written before) restores + * the way it always did: the readable values sealed here, the stamps the + * restore's own. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; + +import { + createOwner, + OWNER_ID, + seedAdminSession, + seedEveryTwoEndedModel, +} from "./backup-fixture"; +import { cookieJar, headerJar } from "./mock-next-headers"; +import { POST } from "./restore-job-driver"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const CREATED = new Date("2026-03-03T03:03:03.000Z"); +const UPDATED = new Date("2026-04-04T04:04:04.000Z"); +const CONTEXT = "fasted, morning draw"; + +beforeEach(async () => { + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); +}); + +/** Give every tracked row a historical stamp, and the marker a context. */ +async function seed(): Promise { + await seedAdminSession(prisma); + await createOwner(prisma); + await seedEveryTwoEndedModel(prisma); + const owner = { userId: OWNER_ID }; + const both = { createdAt: CREATED, updatedAt: UPDATED }; + await prisma.biomarker.updateMany({ + where: owner, + data: { contextEncrypted: encryptContextToBytes(CONTEXT), ...both }, + }); + await prisma.medicationCategoryLabel.updateMany({ where: owner, data: both }); + await prisma.medicationCourse.updateMany({ where: owner, data: both }); + await prisma.medicationIngredient.updateMany({ where: owner, data: both }); + await prisma.medicationCategoryAssignment.updateMany({ + where: { medication: owner }, + data: { updatedAt: UPDATED }, + }); + await prisma.moodContext.updateMany({ where: owner, data: both }); + await prisma.moodEntryTagLink.updateMany({ + where: { moodEntry: owner }, + data: { createdAt: CREATED }, + }); + await prisma.illnessSymptomLink.updateMany({ + where: { dayLog: owner }, + data: { createdAt: CREATED }, + }); + await prisma.cycleSymptomLink.updateMany({ + where: { dayLog: owner }, + data: { createdAt: CREATED }, + }); + await prisma.nutrientIntakeDay.updateMany({ where: owner, data: both }); + // Last: the writes above do not touch the drug, this pins its own stamp. + await prisma.medication.updateMany({ + where: owner, + data: { updatedAt: UPDATED }, + }); +} + +const b64 = (bytes: Uint8Array | null) => + bytes ? Buffer.from(bytes).toString("base64") : null; + +/** The tracked stamps and ciphertext of the account, in a stable order. */ +async function tracked(client: PrismaClient) { + const owner = { userId: OWNER_ID }; + return { + medication: await client.medication.findMany({ + where: owner, + orderBy: { name: "asc" }, + select: { updatedAt: true }, + }), + assignment: await client.medicationCategoryAssignment.findMany({ + where: { medication: owner }, + select: { updatedAt: true }, + }), + labels: ( + await client.medicationCategoryLabel.findMany({ + where: owner, + select: { labelEncrypted: true, createdAt: true, updatedAt: true }, + }) + ).map((r) => ({ ...r, labelEncrypted: b64(r.labelEncrypted) })), + courses: ( + await client.medicationCourse.findMany({ + where: owner, + orderBy: { startsOn: "asc" }, + select: { noteEncrypted: true, createdAt: true, updatedAt: true }, + }) + ).map((r) => ({ ...r, noteEncrypted: b64(r.noteEncrypted) })), + ingredients: await client.medicationIngredient.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + moodContexts: await client.moodContext.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + moodLinks: await client.moodEntryTagLink.findMany({ + where: { moodEntry: owner }, + select: { createdAt: true }, + }), + illnessLinks: await client.illnessSymptomLink.findMany({ + where: { dayLog: owner }, + select: { createdAt: true }, + }), + cycleLinks: await client.cycleSymptomLink.findMany({ + where: { dayLog: owner }, + select: { createdAt: true }, + }), + nutrients: await client.nutrientIntakeDay.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + markers: ( + await client.biomarker.findMany({ + where: owner, + select: { contextEncrypted: true }, + }) + ).map((r) => ({ contextEncrypted: b64(r.contextEncrypted) })), + }; +} + +async function restore(payload: unknown): Promise { + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "DR_STAMPS", + data: encrypt(JSON.stringify(payload)), + }, + }); + const startedAt = new Date(); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + return startedAt; +} + +type Payload = Record & { + customMedicationCategories: Array>; + medications: Array>; + moodEntries: Array>; + illnessEpisodes: Array>; + cycleDayLogs: Array>; + nutrientDays: Array>; + biomarkers: Array>; +}; + +/** The same file as a release before this one wrote it. */ +function asOlderRecoveryFile(payload: Payload): Payload { + const older = structuredClone(payload); + for (const c of older.customMedicationCategories) { + c.label = decryptFromBytes( + Buffer.from(c.labelEncrypted as string, "base64"), + ); + delete c.labelEncrypted; + delete c.updatedAt; + } + for (const m of older.medications) { + delete m.categoryUpdatedAt; + for (const i of m.ingredients as Array>) { + delete i.updatedAt; + } + for (const c of m.courses as Array>) { + c.note = c.noteEncrypted + ? decryptFromBytes(Buffer.from(c.noteEncrypted as string, "base64")) + : null; + delete c.noteEncrypted; + delete c.updatedAt; + } + } + for (const e of older.moodEntries) { + delete e.linkCreatedAt; + const context = e.context as Record | null; + if (context) { + delete context.createdAt; + delete context.updatedAt; + } + } + for (const episode of older.illnessEpisodes) { + for (const day of episode.dayLogs as Array>) { + for (const s of day.symptoms as Array>) { + delete s.createdAt; + } + } + } + for (const day of older.cycleDayLogs) delete day.symptomLinkCreatedAt; + for (const n of older.nutrientDays) delete n.updatedAt; + for (const marker of older.biomarkers) { + marker.context = marker.contextEncrypted + ? decryptContextFromBytes( + Buffer.from(marker.contextEncrypted as string, "base64"), + ) + : null; + delete marker.contextEncrypted; + } + return older; +} + +describe("a disaster-recovery restore", () => { + it("gives back the row stamps and the ciphertext as they were", async () => { + await seed(); + const before = await tracked(prisma); + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const file = JSON.stringify(payload); + // None of the three sealed values rides the file readable. + expect(file).not.toContain("Heart and circulation"); + expect(file).not.toContain("the March flu"); + expect(file).not.toContain(CONTEXT); + + await restore(payload); + const after = await tracked(prisma); + expect(after).toEqual(before); + // The comparison saw rows, not two empty lists. + for (const rows of Object.values(after)) + expect(rows.length).toBeGreaterThan(0); + }); + + it("takes an older recovery file the way it always did", async () => { + await seed(); + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const startedAt = await restore(asOlderRecoveryFile(payload as Payload)); + + const label = await prisma.medicationCategoryLabel.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptFromBytes(label.labelEncrypted)).toBe( + "Heart and circulation", + ); + const course = await prisma.medicationCourse.findFirstOrThrow({ + where: { userId: OWNER_ID, noteEncrypted: { not: null } }, + }); + expect(decryptFromBytes(course.noteEncrypted!)).toBe("the March flu"); + const marker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptContextFromBytes(marker.contextEncrypted!)).toBe(CONTEXT); + + const after = await tracked(prisma); + const restamped = [ + ...after.labels.map((r) => r.updatedAt), + ...after.courses.map((r) => r.updatedAt), + ...after.ingredients.map((r) => r.updatedAt), + ...after.assignment.map((r) => r.updatedAt), + ...after.moodContexts.map((r) => r.createdAt), + ...after.moodLinks.map((r) => r.createdAt), + ...after.illnessLinks.map((r) => r.createdAt), + ...after.cycleLinks.map((r) => r.createdAt), + ...after.nutrients.map((r) => r.updatedAt), + ]; + expect(restamped.length).toBeGreaterThan(8); + for (const at of restamped) { + expect(at.getTime()).toBeGreaterThanOrEqual(startedAt.getTime() - 1000); + } + }); +}); diff --git a/tests/integration/backup-fixture.ts b/tests/integration/backup-fixture.ts index 1ce127aee..bfe84a6c5 100644 --- a/tests/integration/backup-fixture.ts +++ b/tests/integration/backup-fixture.ts @@ -1374,17 +1374,6 @@ export async function seedAdminSession(prisma: PrismaClient) { export const COLUMN_EXCLUSIONS: Readonly> = { "MeasurementReminder.lastNotifiedAt": "operational repeat cursor, documented on the column as not in the backup: a restored reminder re-reminds at most once, on its next notify hour", - // When a join row was written. Nothing reads these: the links are read for - // which tag, symptom or context applies, never for when the row appeared, - // and the parent entry carries its own timestamps, which do travel. - "MoodContext.createdAt": - "insert stamp of the per-entry context row; no reader, the mood entry's own timestamps travel", - "MoodEntryTagLink.createdAt": - "insert stamp of a tag link; no reader, the mood entry's own timestamps travel", - "CycleSymptomLink.createdAt": - "insert stamp of a symptom link; no reader, the day log's own timestamps travel", - "IllnessSymptomLink.createdAt": - "insert stamp of a symptom link; no reader, the illness day log's own timestamps travel", }; /** diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index fcd577d42..5b7c100da 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1924,14 +1924,26 @@ describe("every model the plan claims two-ended survives a real restore", () => Unfiled: "OTHER", Tamiflu: ROUND_TRIP_CATEGORY_KEY, }); + // A portable file carries the label readable; a disaster-recovery file + // carries its ciphertext, like every other sealed column. expect(payload.customMedicationCategories).toEqual([ - { - key: ROUND_TRIP_CATEGORY_KEY, - label: "Travel kit", - sortOrder: 0, - isActive: false, - createdAt: expect.any(String), - }, + purpose === "disaster-recovery" + ? { + key: ROUND_TRIP_CATEGORY_KEY, + label: null, + labelEncrypted: expect.any(String), + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + updatedAt: expect.any(String), + } + : { + key: ROUND_TRIP_CATEGORY_KEY, + label: "Travel kit", + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + }, ]); await prisma.user.delete({ where: { id: OWNER_ID } });