mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Seed one account as far as the schema allows (a row of every two-ended model, every column of those rows and of the account row, the instance settings, a sharing grant), export it both ways and restore each file into a fresh account, over the live account, and from a version-1 file in the old shapes. The files, counts, parsed payload, preview, restore outcome, reported phases, audit rows, wide-event fields and every row the account holds afterwards are compared with golden copies after ids, ciphertext and run-time timestamps are normalised. Refusals, skip reports and the schema's issue list for malformed files are pinned the same way. The seed moves out of the round-trip test into a shared fixture module so both files fill the same account.
1574 lines
52 KiB
TypeScript
1574 lines
52 KiB
TypeScript
/**
|
|
* The account the backup tests fill: one row of every two-ended model, the
|
|
* shapes a CHECK constraint ties to a particular row, and the values the
|
|
* column fill uses where a type alone would produce one the restore refuses.
|
|
*
|
|
* Shared by `backup-round-trip.test.ts`, which proves each model and column
|
|
* comes back, and `backup-parity.test.ts`, which pins the whole file and the
|
|
* restored account against a golden copy.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import { encrypt, encryptBytes } from "@/lib/crypto";
|
|
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { sealDayCountry } from "@/lib/dawarich/connection";
|
|
import { sealLocation } from "@/lib/environment/location-cipher";
|
|
import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store";
|
|
import { encryptWaveformToBytes } from "@/lib/withings/ecg-waveform-codec";
|
|
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
|
import type { AuditColumn, AuditModel } from "./backup-column-audit";
|
|
import { cookieJar } from "./mock-next-headers";
|
|
|
|
export const OWNER_ID = "round-trip-owner";
|
|
export const AT = (iso: string) => new Date(iso);
|
|
// The restore drops a measurement tombstone older than
|
|
// `TOMBSTONE_RETENTION_DAYS` on purpose, so the fixture's deletion has to stay
|
|
// recent on whatever day the suite runs. A fixed date aged out on 2026-10-03.
|
|
export const TOMBSTONE_DELETED_AT = new Date(
|
|
Math.floor((Date.now() - 10 * 86_400_000) / 1000) * 1000,
|
|
).toISOString();
|
|
export const PHQ9_RESPONSES = JSON.stringify({
|
|
items: [2, 1, 2, 2, 1, 1, 2, 1, 2],
|
|
schema: 1,
|
|
});
|
|
export const CONSENT_ARTEFACT = "JVBERi0xLjQKJWZpeHR1cmU=";
|
|
export const INVENTORY_NOTE = "second pack, kept in the kitchen drawer";
|
|
export const COACH_FACT = "does not tolerate ACE inhibitors";
|
|
// v1.41 — the model text of a turn's trail, stored encrypted beside the turn.
|
|
export const COACH_TRAIL_JSON = JSON.stringify({
|
|
entries: [{ id: "a1", title: "Checking the trend", text: "Read 30 days." }],
|
|
});
|
|
export const COACH_PLAN_CUE = "if the evening reading is over 140";
|
|
export const COACH_PLAN_ACTION =
|
|
"then walk twenty minutes before dinner tomorrow";
|
|
export const COACH_REMINDER_NOTE = "ask how the evening walks are going";
|
|
export const COACH_SUMMARY = "earlier turns: weight trend and evening walks";
|
|
export const METRIC_LEGACY_NOTE = "left hand, after the long ride";
|
|
export const METRIC_SEALED_NOTE = "right hand, rested";
|
|
export const COACH_USER_TURN =
|
|
"my readings look higher this week, is that real?";
|
|
export const COACH_ASSISTANT_TURN =
|
|
"the last seven mornings average 4 mmHg above the fortnight before";
|
|
/** The table of values the assistant turn read: ciphertext at rest. */
|
|
export const COACH_RESULTS_JSON = JSON.stringify([
|
|
{
|
|
ref: "r1",
|
|
source: {
|
|
tool: "get_metric_series",
|
|
domain: "bp",
|
|
window: "last7days",
|
|
period: "current",
|
|
granularity: "day",
|
|
},
|
|
shape: "timeSeries",
|
|
titleKey: "coach.result.title.metricByPeriod",
|
|
title: "Blood pressure by day",
|
|
rowCount: 2,
|
|
chartKind: "line",
|
|
displayed: true,
|
|
columns: [
|
|
{
|
|
key: "period",
|
|
kind: "period",
|
|
labelKey: "coach.result.column.day",
|
|
label: "Day",
|
|
},
|
|
{
|
|
key: "sys",
|
|
kind: "number",
|
|
labelKey: "coach.result.column.systolic",
|
|
label: "Systolic",
|
|
unit: "mmHg",
|
|
},
|
|
],
|
|
rows: [
|
|
["2026-07-19", 131],
|
|
["2026-07-20", null],
|
|
],
|
|
truncated: false,
|
|
chart: { kind: "line", x: "period", series: ["sys"] },
|
|
},
|
|
]);
|
|
export const ROUND_TRIP_CATEGORY_KEY =
|
|
"custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10";
|
|
export const DOSE_CHANGE_NOTE = "titration note, encrypted at rest";
|
|
export const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)";
|
|
export const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
|
|
/** A short micro-volt strip. Long enough that a defaulted empty array shows. */
|
|
export const ECG_SAMPLES = [-12, 4, 118, -33, 7, 2, -5, 61];
|
|
|
|
/**
|
|
* One row of every two-ended model, for one account.
|
|
*
|
|
* Minimal on purpose: this file asks whether a row survives the round trip, and
|
|
* a fat fixture only makes the answer slower to get. The mood factor and the
|
|
* cycle symptom are the account's OWN custom rows rather than seeded catalogue
|
|
* entries, because a catalogue row survives the account being deleted and would
|
|
* make the link tables look restored when nothing restored them.
|
|
*/
|
|
export async function seedEveryTwoEndedModel(
|
|
prisma: PrismaClient,
|
|
): Promise<void> {
|
|
// Held rather than discarded: the personal best below points at this row
|
|
// through a column that is a real foreign key in the database, so the
|
|
// fixture needs the id to build the reference the restore has to resolve.
|
|
const weightMeasurement = await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "WEIGHT",
|
|
value: 74.2,
|
|
unit: "kg",
|
|
measuredAt: AT("2026-07-01T07:00:00.000Z"),
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
// A DELETED reading, kept as a tombstone. It is here because the obvious way
|
|
// to make the weekly backup cheaper — stop exporting soft-deleted rows, which
|
|
// on a real record is four rows in five — is wrong, and nothing in the tree
|
|
// said so out loud. A tombstone inside the retention window is what tells a
|
|
// synced client the row is gone (`/api/sync/changes` reads exactly these) and
|
|
// what makes a re-sent Apple Health sample a no-op instead of a resurrection.
|
|
// Drop them from a disaster-recovery file and a restore quietly brings back
|
|
// every measurement the account ever deleted on the next device sync.
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "PULSE",
|
|
value: 61,
|
|
unit: "bpm",
|
|
measuredAt: AT("2026-07-02T06:30:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "round-trip-deleted-sample",
|
|
syncVersion: 4,
|
|
deletedAt: AT(TOMBSTONE_DELETED_AT),
|
|
},
|
|
});
|
|
// The EVENT row an ECG strip is filed against, and the strip itself. The
|
|
// pairing is the point: `EcgRecording.measurementId` is a real foreign key,
|
|
// so this is the reference whose remap has to survive the round trip rather
|
|
// than fail the transaction.
|
|
const rhythmEvent = await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "IRREGULAR_RHYTHM_NOTIFICATION",
|
|
value: 1,
|
|
unit: "event",
|
|
measuredAt: AT("2026-06-15T14:22:00.000Z"),
|
|
source: "WITHINGS",
|
|
rhythmClassification: "IRREGULAR",
|
|
},
|
|
});
|
|
await prisma.ecgRecording.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
source: "WITHINGS",
|
|
externalRecordingId: "round-trip-signal-1",
|
|
recordedAt: AT("2026-06-15T14:22:00.000Z"),
|
|
waveformEncrypted: encryptWaveformToBytes(ECG_SAMPLES),
|
|
samplingFrequency: 300,
|
|
sampleCount: ECG_SAMPLES.length,
|
|
durationSeconds: ECG_SAMPLES.length / 300,
|
|
lead: "I",
|
|
averageHeartRate: 88,
|
|
rhythmClassification: "IRREGULAR",
|
|
measurementId: rhythmEvent.id,
|
|
},
|
|
});
|
|
await prisma.intradayCumulativeProfile.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "ACTIVITY_STEPS",
|
|
dateKey: "2026-07-01",
|
|
// One slot per hour — the restore refuses a profile of any other length,
|
|
// because a short row lands in the table and is dropped on read.
|
|
hourlyCumulative: Array.from({ length: 24 }, (_, hour) => hour * 350),
|
|
dayTotal: 8050,
|
|
sampleCount: 24,
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
|
|
// Two courses with a gap, and the window the row projects from them (the
|
|
// latest). The earlier course exists nowhere else once it has ended.
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Round-trip tablet",
|
|
dose: "5 mg",
|
|
startsOn: AT("2026-06-10T00:00:00.000Z"),
|
|
endsOn: AT("2026-06-16T00:00:00.000Z"),
|
|
schedules: {
|
|
create: { windowStart: "08:00", windowEnd: "09:00", label: "Morning" },
|
|
},
|
|
courses: {
|
|
create: [
|
|
{
|
|
userId: OWNER_ID,
|
|
startsOn: AT("2026-03-01T00:00:00.000Z"),
|
|
endsOn: AT("2026-03-07T00:00:00.000Z"),
|
|
noteEncrypted: encryptToBytes("the March flu"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
startsOn: AT("2026-06-10T00:00:00.000Z"),
|
|
endsOn: AT("2026-06-16T00:00:00.000Z"),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
scheduledFor: AT("2026-07-01T08:00:00.000Z"),
|
|
takenAt: AT("2026-07-01T08:04:00.000Z"),
|
|
},
|
|
});
|
|
// Not OTHER: a missing row already reads as OTHER, so only a chosen value
|
|
// proves the category came back. A category the person named, so the
|
|
// assignment can only resolve if the label came back before it.
|
|
await prisma.medicationCategoryLabel.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
key: ROUND_TRIP_CATEGORY_KEY,
|
|
labelEncrypted: encryptToBytes("Heart and circulation"),
|
|
sortOrder: 2,
|
|
},
|
|
});
|
|
await prisma.medicationCategoryAssignment.create({
|
|
data: { medicationId: medication.id, category: ROUND_TRIP_CATEGORY_KEY },
|
|
});
|
|
// The one row in this fixture that is also checked field by field after the
|
|
// restore — see the assertion at the end of the test for why.
|
|
await prisma.medicationSideEffect.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
occurredAt: AT("2026-07-01T21:00:00.000Z"),
|
|
category: "GI",
|
|
entry: "NAUSEA",
|
|
severity: 3,
|
|
notesEncrypted: encryptToBytes(SIDE_EFFECT_NOTE),
|
|
},
|
|
});
|
|
// Two eras, and the open one carries the weight. `resumedAt: null` means the
|
|
// drug is paused right now; a restore that closed it at restore time would
|
|
// still count one row back here and read as recovered. The field-level
|
|
// assertion at the end of the test is what holds the null.
|
|
// A two-step ramp. The ORDER is the content here: same drug, same unit,
|
|
// 5 mg then 10 mg. A restore that returned them reversed would still count
|
|
// two rows back and would describe the opposite clinical story.
|
|
// One open pack and one used up, plus the two ledger rows behind the open
|
|
// one. The count on the pack is deliberately NOT the sum of its events: 30
|
|
// in, 8 consumed, and the remaining count says 21 because the person threw
|
|
// one away without recording it. A restore that recomputed the count from
|
|
// the ledger would "fix" that to 22 and disagree with every low-stock
|
|
// reminder the account has already received.
|
|
// A PHQ-9 administration whose item 9 was answered above zero. The flag and
|
|
// the encrypted answers are seeded to DISAGREE with a naive recomputation:
|
|
// the flag says true, and a restore that decided to recompute it would have
|
|
// to decrypt the blob to do so, which is the handling this data is kept away
|
|
// from. The assertion below reads the flag and the ciphertext separately.
|
|
await prisma.mentalHealthAssessment.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
instrument: "PHQ9",
|
|
locale: "de",
|
|
version: "standard",
|
|
responsesEncrypted: encryptToBytes(PHQ9_RESPONSES),
|
|
totalScore: 14,
|
|
severityBand: "moderate",
|
|
item9Flagged: true,
|
|
crisisShownAt: AT("2026-07-05T18:02:00.000Z"),
|
|
takenAt: AT("2026-07-05T18:00:00.000Z"),
|
|
tz: "Europe/Berlin",
|
|
source: "WEB",
|
|
},
|
|
});
|
|
|
|
// One active consent and one revoked. The revocation is the part a restore
|
|
// can quietly lose: dropping `revokedAt` turns a withdrawn agreement back
|
|
// into a standing one, and the latest-per-user lookup shortcuts on exactly
|
|
// that column.
|
|
await prisma.consentReceipt.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
kind: "ai-processing",
|
|
artefact: CONSENT_ARTEFACT,
|
|
signedAt: AT("2026-06-01T09:00:00.000Z"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
kind: "research-sharing",
|
|
artefact: "eyJhbGciOiJub25lIn0.e30.",
|
|
signedAt: AT("2026-06-02T09:00:00.000Z"),
|
|
revokedAt: AT("2026-07-10T11:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
// Tuned away from every default, and one threshold in PERCENT rather than
|
|
// MINUTES: a restore that fell back to the schema defaults would return four
|
|
// plausible numbers and silently change when this drug turns orange.
|
|
await prisma.reminderPhaseConfig.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
greenValue: 45,
|
|
greenMode: "MINUTES",
|
|
yellowValue: 20,
|
|
yellowMode: "PERCENT",
|
|
orangeValue: 5,
|
|
orangeMode: "MINUTES",
|
|
redValue: 180,
|
|
redMode: "MINUTES",
|
|
},
|
|
});
|
|
|
|
const openPack = await prisma.medicationInventoryItem.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
state: "ACTIVE",
|
|
containerType: "BLISTER",
|
|
unitsTotal: "30",
|
|
unitsRemaining: "21",
|
|
firstUseAt: AT("2026-07-01T08:00:00.000Z"),
|
|
expiresAt: AT("2027-01-31T00:00:00.000Z"),
|
|
manufacturer: "Fixture Pharma",
|
|
doseStrength: "10 mg",
|
|
notesEncrypted: encryptToBytes(INVENTORY_NOTE),
|
|
},
|
|
});
|
|
await prisma.medicationInventoryItem.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
state: "USED_UP",
|
|
containerType: "BOTTLE",
|
|
unitsTotal: "60",
|
|
unitsRemaining: "0",
|
|
},
|
|
});
|
|
await prisma.medicationInventoryEvent.createMany({
|
|
data: [
|
|
{
|
|
medicationId: medication.id,
|
|
delta: 30,
|
|
reason: "purchased",
|
|
occurredAt: AT("2026-07-01T07:00:00.000Z"),
|
|
},
|
|
{
|
|
medicationId: medication.id,
|
|
delta: -8,
|
|
reason: "consumed",
|
|
occurredAt: AT("2026-07-09T07:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
await prisma.medicationDoseChange.createMany({
|
|
data: [
|
|
{
|
|
medicationId: medication.id,
|
|
effectiveFrom: AT("2026-04-01T08:00:00.000Z"),
|
|
doseValue: 5,
|
|
doseUnit: "mg",
|
|
noteEncrypted: encryptToBytes(DOSE_CHANGE_NOTE),
|
|
},
|
|
{
|
|
medicationId: medication.id,
|
|
effectiveFrom: AT("2026-06-01T08:00:00.000Z"),
|
|
doseValue: 10,
|
|
doseUnit: "mg",
|
|
noteEncrypted: null,
|
|
},
|
|
],
|
|
});
|
|
// Three archived eras, and the CHAIN through them is the content. The
|
|
// middle one was corrected, so it points at the third; the first and the
|
|
// third stand on their own. A restore that returned three rows with every
|
|
// link null satisfies any count and has destroyed the only thing the
|
|
// pointer says — every era consumer skips a superseded row, so the
|
|
// corrected middle era would come back live beside the correction that
|
|
// replaced it and the timeline would show the same window twice.
|
|
await prisma.medicationScheduleRevision.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
validFrom: AT("2026-01-01T00:00:00.000Z"),
|
|
validUntil: AT("2026-03-01T00:00:00.000Z"),
|
|
payload: [{ windowStart: "07:00", windowEnd: "09:00", dose: "5 mg" }],
|
|
source: "ARCHIVED",
|
|
},
|
|
});
|
|
const correctionEra = await prisma.medicationScheduleRevision.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
validFrom: AT("2026-03-01T00:00:00.000Z"),
|
|
validUntil: AT("2026-05-01T00:00:00.000Z"),
|
|
payload: [{ windowStart: "08:00", windowEnd: "10:00", dose: "10 mg" }],
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
await prisma.medicationScheduleRevision.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
validFrom: AT("2026-02-01T00:00:00.000Z"),
|
|
validUntil: AT("2026-03-01T00:00:00.000Z"),
|
|
payload: [{ windowStart: "12:00", windowEnd: "13:00", dose: "10 mg" }],
|
|
source: "ARCHIVED",
|
|
supersededByRevisionId: correctionEra.id,
|
|
},
|
|
});
|
|
|
|
// v1.43 — a composition entered in a non-canonical unit, so the entered
|
|
// amount and unit are set too.
|
|
await prisma.medicationIngredient.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
nutrient: "vitamin_d",
|
|
amountPerUnit: 25,
|
|
unit: "ug",
|
|
enteredAmount: 1000,
|
|
enteredUnit: "IU",
|
|
source: "LABEL_AI",
|
|
},
|
|
});
|
|
|
|
await prisma.medicationPauseEra.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
pausedAt: AT("2026-05-02T08:00:00.000Z"),
|
|
resumedAt: AT("2026-05-20T08:00:00.000Z"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
pausedAt: AT("2026-07-15T08:00:00.000Z"),
|
|
resumedAt: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
// A RATED tag the account defined itself — the export carries only rated
|
|
// links, so a BINARY tag would leave `MoodEntryTagLink` empty.
|
|
const moodCategory = await prisma.moodTagCategory.findFirstOrThrow({
|
|
where: { userId: null },
|
|
});
|
|
// A category the account created itself, not a seeded one. It cascades away
|
|
// with the user, and `MoodTag.categoryId` is a real foreign key — so before
|
|
// the category travelled, this one row made the restore violate
|
|
// `mood_tags_category_id_fkey` and answer 500 with the account left empty.
|
|
const ownCategory = await prisma.moodTagCategory.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
key: "round_trip_own_category",
|
|
labelKey: "mood.category.roundTripOwn",
|
|
sortOrder: 90,
|
|
},
|
|
});
|
|
const moodTag = await prisma.moodTag.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
categoryId: ownCategory.id,
|
|
key: "round_trip_factor",
|
|
labelKey: "mood.tag.roundTripFactor",
|
|
kind: "RATED",
|
|
scaleMin: 1,
|
|
scaleMax: 5,
|
|
},
|
|
});
|
|
// A SEEDED tag the account chose to hide. The id differs per instance, so
|
|
// this is the row that proves the hidden set travels by key.
|
|
const seededTagToHide = await prisma.moodTag.findFirstOrThrow({
|
|
where: { userId: null },
|
|
});
|
|
await prisma.moodTagHidden.create({
|
|
data: { userId: OWNER_ID, moodTagId: seededTagToHide.id },
|
|
});
|
|
|
|
await prisma.moodEntry.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
date: "2026-07-01",
|
|
mood: "GUT",
|
|
score: 4,
|
|
source: "MOODLOG",
|
|
moodLoggedAt: AT("2026-07-01T20:00:00.000Z"),
|
|
tagLinks: { create: { moodTagId: moodTag.id, rating: 4 } },
|
|
context: {
|
|
create: {
|
|
userId: OWNER_ID,
|
|
workStatus: "regular",
|
|
contactCircles: JSON.stringify(["family"]),
|
|
leisureJoy: 6,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const biomarker = await prisma.biomarker.create({
|
|
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
|
|
});
|
|
// Another spelling of the marker, and the ledger row of a marker that was
|
|
// merged into it: both ride the marker (D1223).
|
|
await prisma.biomarkerAlias.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
biomarkerId: biomarker.id,
|
|
name: "Ferr.",
|
|
nameFolded: "ferr.",
|
|
source: "IMPORT_CHOICE",
|
|
},
|
|
});
|
|
await prisma.biomarkerMerge.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
targetBiomarkerId: biomarker.id,
|
|
sourceBiomarkerId: "merged-away-marker",
|
|
sourceName: "Ferritin (Serum)",
|
|
readingCount: 3,
|
|
},
|
|
});
|
|
const labResult = await prisma.labResult.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
biomarkerId: biomarker.id,
|
|
analyte: "Ferritin",
|
|
value: 91,
|
|
unit: "ng/mL",
|
|
takenAt: AT("2026-06-30T09:00:00.000Z"),
|
|
},
|
|
});
|
|
// What the drug above is FOR, pinned to the analyte just created. The two
|
|
// rows are seeded at opposite ends of this fixture on purpose: the drug is
|
|
// restored hundreds of lines before the analyte, so this pairing is what
|
|
// proves the second pass runs late enough to resolve the name.
|
|
await prisma.medicationEfficacyTarget.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
biomarkerId: biomarker.id,
|
|
primary: true,
|
|
},
|
|
});
|
|
await prisma.allergy.create({
|
|
data: { userId: OWNER_ID, substance: "Penicillin" },
|
|
});
|
|
await prisma.familyHistoryEntry.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
relationship: "MOTHER",
|
|
condition: "Type 2 diabetes",
|
|
},
|
|
});
|
|
|
|
const episode = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Cold",
|
|
type: "INFECTION",
|
|
onsetAt: AT("2026-06-20T00:00:00.000Z"),
|
|
},
|
|
});
|
|
// A SECOND condition, and the one the document below is actually filed
|
|
// against. Two episodes is what makes the filing assertion mean something:
|
|
// with one, a restore that pointed every link at the first condition it
|
|
// found would return the right count and read as correct.
|
|
const filedEpisode = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Iron deficiency",
|
|
type: "CHRONIC",
|
|
onsetAt: AT("2026-03-01T00:00:00.000Z"),
|
|
// v1.39.2 — a condition's body site and side travel too.
|
|
bodySiteEncrypted: encryptToBytes("Stomach"),
|
|
laterality: "BOTH",
|
|
},
|
|
});
|
|
const illnessSymptom = await prisma.illnessSymptom.create({
|
|
data: { key: "round_trip_cough", labelKey: "illness.symptom.roundTrip" },
|
|
});
|
|
await prisma.illnessDayLog.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
episodeId: episode.id,
|
|
date: "2026-06-21",
|
|
symptomLinks: { create: { symptomId: illnessSymptom.id, severity: 2 } },
|
|
},
|
|
});
|
|
|
|
// v1.40 — two of the person's own symptoms and three occurrences: one filed
|
|
// against the open cold, one with a note, one on its own in the gap.
|
|
const aura = await prisma.symptomDefinition.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
labelEncrypted: encryptToBytes("Aura"),
|
|
icon: "Zap",
|
|
sortOrder: 0,
|
|
},
|
|
});
|
|
const headache = await prisma.symptomDefinition.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
labelEncrypted: encryptToBytes("Headache"),
|
|
icon: "Brain",
|
|
sortOrder: 1,
|
|
isActive: false,
|
|
},
|
|
});
|
|
await prisma.symptomEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
definitionId: aura.id,
|
|
occurredAt: AT("2026-06-21T09:00:00.000Z"),
|
|
intensity: 4,
|
|
episodeId: episode.id,
|
|
},
|
|
});
|
|
await prisma.symptomEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
definitionId: headache.id,
|
|
occurredAt: AT("2026-06-21T11:00:00.000Z"),
|
|
intensity: 8,
|
|
noteEncrypted: encryptToBytes("left side, behind the eye"),
|
|
},
|
|
});
|
|
await prisma.symptomEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
definitionId: aura.id,
|
|
occurredAt: AT("2026-07-10T18:30:00.000Z"),
|
|
intensity: 2,
|
|
},
|
|
});
|
|
|
|
// v1.42 — two life events: a move known to the month, with a note, and a
|
|
// period known to the day.
|
|
await prisma.lifeEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
category: "HOME",
|
|
startDate: "2023-09-01",
|
|
precision: "MONTH",
|
|
titleEncrypted: encryptToBytes("Moved to the coast"),
|
|
noteEncrypted: encryptToBytes("the flat with the long hallway"),
|
|
},
|
|
});
|
|
await prisma.lifeEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
category: "WORK",
|
|
startDate: "2026-03-02",
|
|
endDate: "2026-05-29",
|
|
precision: "DAY",
|
|
titleEncrypted: encryptToBytes("Parental leave"),
|
|
},
|
|
});
|
|
|
|
await prisma.userHealthProfile.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
aboutMeEncrypted: encryptToBytes("Runs three times a week"),
|
|
},
|
|
});
|
|
await prisma.healthProfileFactRevision.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
kind: "SMOKING_STATUS",
|
|
valueEncrypted: encryptToBytes("never"),
|
|
validFrom: AT("2026-01-01T00:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
await prisma.cycleProfile.create({
|
|
data: { userId: OWNER_ID, cycleTrackingEnabled: true },
|
|
});
|
|
const cycle = await prisma.menstrualCycle.create({
|
|
data: { userId: OWNER_ID, startDate: "2026-06-01" },
|
|
});
|
|
const cycleCategory = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
|
const cycleSymptom = await prisma.cycleSymptom.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
categoryId: cycleCategory.id,
|
|
key: "custom:round-trip-cramps",
|
|
labelKey: "cycle.symptom.custom",
|
|
labelEncrypted: encrypt("Round-trip cramps"),
|
|
},
|
|
});
|
|
await prisma.cycleDayLog.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
cycleId: cycle.id,
|
|
date: "2026-06-02",
|
|
flow: "HEAVY",
|
|
symptomLinks: { create: { symptomId: cycleSymptom.id } },
|
|
},
|
|
});
|
|
|
|
await prisma.customMetric.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Grip strength",
|
|
unit: "kg",
|
|
// v1.39.3 — one reading whose note is still readable (a row the
|
|
// free-text backfill has not reached) and one sealed the current way.
|
|
// Both must come back sealed, and neither readable.
|
|
entries: {
|
|
create: [
|
|
{
|
|
userId: OWNER_ID,
|
|
value: 44,
|
|
unit: "kg",
|
|
measuredAt: AT("2026-07-01T18:00:00.000Z"),
|
|
note: METRIC_LEGACY_NOTE,
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
value: 45,
|
|
unit: "kg",
|
|
measuredAt: AT("2026-07-02T18:00:00.000Z"),
|
|
noteEncrypted: encryptToBytes(METRIC_SEALED_NOTE),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
await prisma.correlationPattern.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
canonicalKey: `p1:${"a1".repeat(32)}`,
|
|
family: "sleep",
|
|
factorKey: "sleep_duration",
|
|
outcomeKey: "weight",
|
|
lagDays: 1,
|
|
sampleSize: 30,
|
|
effectSize: 0.42,
|
|
pValue: 0.01,
|
|
evidenceHash: "b2".repeat(32),
|
|
lastComputedAt: AT("2026-07-01T00:00:00.000Z"),
|
|
},
|
|
});
|
|
await prisma.onboardingRecord.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
needsJson: {
|
|
recordTarget: "me",
|
|
areas: ["blood-pressure", "sleep"],
|
|
medication: "yes",
|
|
sources: ["withings"],
|
|
visit: "within-a-month",
|
|
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
|
|
},
|
|
stepsJson: [
|
|
{ id: "who", status: "done" },
|
|
{ id: "areas", status: "done" },
|
|
{ id: "sources", status: "skipped" },
|
|
],
|
|
firstResultJson: {
|
|
task: "connect-source",
|
|
target: "withings",
|
|
completedAt: "2026-07-01T08:30:00.000Z",
|
|
},
|
|
// The once-only latch on the module derivation. Seeded non-null so the
|
|
// assertion after the restore can show it came back rather than
|
|
// defaulting — a null here would hand the next confirm permission to
|
|
// re-apply the questionnaire over every module decision made since.
|
|
modulesDerivedAt: AT("2026-07-01T08:00:00.000Z"),
|
|
completedAt: AT("2026-07-01T08:00:00.000Z"),
|
|
},
|
|
});
|
|
await prisma.healthScoreRecord.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
dayKey: "2026-07-01",
|
|
timezone: "Europe/Berlin",
|
|
composite: 71,
|
|
band: "green",
|
|
scoreVersion: 1,
|
|
composition: ["activity", "sleep"],
|
|
pillarScores: { activity: 70, sleep: 72 },
|
|
inputFingerprint: "c3".repeat(32),
|
|
},
|
|
});
|
|
|
|
await prisma.workout.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
sportType: "RUNNING",
|
|
startedAt: AT("2026-06-29T06:00:00.000Z"),
|
|
endedAt: AT("2026-06-29T06:30:00.000Z"),
|
|
durationSec: 1800,
|
|
},
|
|
});
|
|
await prisma.nutrientIntakeDay.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
day: "2026-07-01",
|
|
nutrient: "WATER",
|
|
amount: 2100,
|
|
unit: "ml",
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
|
|
const documentBytes = encryptBytes(Buffer.from("round-trip document bytes"));
|
|
const contentEncrypted = new Uint8Array(
|
|
new ArrayBuffer(documentBytes.byteLength),
|
|
);
|
|
contentEncrypted.set(documentBytes);
|
|
const document = await prisma.inboundDocument.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
kind: "LAB_RESULT",
|
|
title: "June labs",
|
|
filename: "june-labs.pdf",
|
|
mimeType: "application/pdf",
|
|
byteSize: documentBytes.byteLength,
|
|
contentEncrypted,
|
|
contentCodec: "binary2",
|
|
// Imported (#1038): the source key has to survive the trip, or a
|
|
// re-run of the importer after a restore stores the page twice.
|
|
sourceSystem: "PAPERLESS",
|
|
sourceId: "4711",
|
|
// Held back from automatic AI reading; a restore that dropped the
|
|
// marker would hand the page to the summary catch-up.
|
|
aiReadDeferred: true,
|
|
},
|
|
});
|
|
|
|
// The page filed under the SECOND condition, not the first. A restore that
|
|
// kept the count and lost the pairing would leave the vault sorted wrongly
|
|
// rather than visibly unsorted, which is harder to notice and worse.
|
|
await prisma.documentConditionLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
documentId: document.id,
|
|
episodeId: filedEpisode.id,
|
|
createdAt: AT("2026-07-02T09:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
// One staged fact, already reviewed, approved and committed to the ferritin
|
|
// lab row above. Every column that records the decision is set AWAY from its
|
|
// schema default on purpose: `PENDING`, `needsReview: true` and two NULL
|
|
// commitment columns are what a restore that ignores them writes, and the
|
|
// assertion after the restore is what catches that. A fact handed back as
|
|
// PENDING is offered for review again, and approving it a second time writes
|
|
// a second lab result for a reading the account already has.
|
|
await prisma.extractedFact.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
documentId: document.id,
|
|
factType: "OBSERVATION",
|
|
status: "APPROVED",
|
|
confidence: 0.94,
|
|
needsReview: false,
|
|
committedRecordId: labResult.id,
|
|
committedRecordType: "labResult",
|
|
dataEncrypted: encryptFactData({
|
|
label: "Ferritin",
|
|
code: null,
|
|
codeSystem: null,
|
|
value: 91,
|
|
valueText: null,
|
|
unit: "ng/mL",
|
|
referenceLow: 30,
|
|
referenceHigh: 400,
|
|
effectiveDate: "2026-06-30",
|
|
}),
|
|
provenanceEncrypted: encryptFactProvenance({
|
|
sourceText: EXTRACTED_FACT_SPAN,
|
|
anchored: true,
|
|
sourceOffset: 412,
|
|
page: 2,
|
|
confidence: 0.94,
|
|
}),
|
|
},
|
|
});
|
|
|
|
// Two Coach threads, and the pair is the point. One is an ordinary health
|
|
// conversation; the other is FENCED — `documentScoped` true, grounded in the
|
|
// document seeded above. A restore that let the flag default to false would
|
|
// return both threads, satisfy every count, and quietly hand the tool loop
|
|
// back to the one conversation whose history may contain document-derived
|
|
// text. That is why the assertion below reads the flag per thread rather
|
|
// than counting rows.
|
|
await prisma.coachConversation.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
title: "How is my blood pressure trending?",
|
|
documentScoped: false,
|
|
summaryEncrypted: encryptToBytes(COACH_SUMMARY),
|
|
summaryUpdatedAt: AT("2026-07-20T10:00:00.000Z"),
|
|
summaryTurnCount: 4,
|
|
messages: {
|
|
create: [
|
|
{
|
|
role: "user",
|
|
encryptedContent: encryptToBytes(COACH_USER_TURN),
|
|
createdAt: AT("2026-07-20T09:58:00.000Z"),
|
|
},
|
|
{
|
|
role: "assistant",
|
|
encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
|
|
resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
|
|
trailEncrypted: encryptToBytes(COACH_TRAIL_JSON),
|
|
providerType: "anthropic",
|
|
model: "claude-opus-5",
|
|
tokensUsed: 812,
|
|
createdAt: AT("2026-07-20T09:59:00.000Z"),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
await prisma.coachConversation.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
// v1.39.3 — sealed the current way; the thread above keeps a readable
|
|
// legacy title, the shape of a row the backfill has not reached.
|
|
titleEncrypted: encryptToBytes("About my June labs"),
|
|
documentScoped: true,
|
|
messages: {
|
|
create: [
|
|
{
|
|
role: "user",
|
|
encryptedContent: encryptToBytes("What does the ferritin mean?"),
|
|
createdAt: AT("2026-07-21T08:00:00.000Z"),
|
|
},
|
|
],
|
|
},
|
|
attachments: {
|
|
create: [
|
|
{ documentId: document.id, addedAt: AT("2026-07-21T07:59:00.000Z") },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
// What the Coach keeps between threads, seeded so both references are
|
|
// exercised: the fact and the plan point back at the thread they came out
|
|
// of, and the reminder points at the plan.
|
|
const healthThread = await prisma.coachConversation.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, documentScoped: false },
|
|
});
|
|
const healthAnswer = await prisma.coachMessage.findFirstOrThrow({
|
|
where: { conversationId: healthThread.id, role: "assistant" },
|
|
});
|
|
await prisma.coachFact.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
factEncrypted: encryptToBytes(COACH_FACT),
|
|
category: "constraint",
|
|
confidence: 80,
|
|
sourceConversationId: healthThread.id,
|
|
source: "user",
|
|
lastUsedAt: AT("2026-07-20T10:00:00.000Z"),
|
|
sourceMessageId: healthAnswer.id,
|
|
},
|
|
});
|
|
const plan = await prisma.coachPlan.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
metric: "BLOOD_PRESSURE",
|
|
ifCueEncrypted: encryptToBytes(COACH_PLAN_CUE),
|
|
thenActionEncrypted: encryptToBytes(COACH_PLAN_ACTION),
|
|
targetEncrypted: encryptToBytes("under 130 by October"),
|
|
status: "active",
|
|
reviewDate: AT("2026-10-01T00:00:00.000Z"),
|
|
sourceConversationId: healthThread.id,
|
|
},
|
|
});
|
|
await prisma.coachReminder.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
noteEncrypted: encryptToBytes(COACH_REMINDER_NOTE),
|
|
metric: "BLOOD_PRESSURE",
|
|
relatedPlanId: plan.id,
|
|
triggerKind: "date",
|
|
dueAt: AT("2026-09-01T07:00:00.000Z"),
|
|
status: "active",
|
|
source: "extractor",
|
|
sourceConversationId: healthThread.id,
|
|
surfaceCount: 2,
|
|
},
|
|
});
|
|
|
|
// One visit, the practice it was at, and one link of each of the three
|
|
// kinds. The links reach the document, the lab result and the condition
|
|
// episode seeded above rather than fresh rows, because a link to something
|
|
// the restore did not put back is the exact case the restore reports as a
|
|
// skip — seeding it against a row that does come back is what makes a
|
|
// returning count mean the filing survived.
|
|
const practitioner = await prisma.practitioner.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Round-trip practice",
|
|
specialty: "Internal medicine",
|
|
noteEncrypted: encryptToBytes("ring the upper bell"),
|
|
},
|
|
});
|
|
|
|
// One Vorsorge reminder with the v1.37.20 skip/snooze columns all set, and
|
|
// two completion-ledger rows — one honest satisfy, one honest skip. Fat like
|
|
// the vaccination fixture below and for the same reason: the count-back
|
|
// proves the rows returned, the column-by-column assertion after the restore
|
|
// proves they returned WHOLE. The encounter and the dose below point their
|
|
// `reminderId` here, because both references now remap against the restored
|
|
// reminders and this fixture is what proves they survive rather than drop.
|
|
const reminder = await prisma.measurementReminder.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Blutdruck messen",
|
|
measurementType: "BLOOD_PRESSURE_SYS",
|
|
intervalDays: 7,
|
|
notifyHour: 8,
|
|
location: "Zuhause",
|
|
nextDueAt: AT("2026-07-08T06:00:00.000Z"),
|
|
lastSatisfiedAt: AT("2026-07-01T06:05:00.000Z"),
|
|
vaccinationAntigen: "tetanus",
|
|
snoozedUntil: AT("2026-07-08T06:00:00.000Z"),
|
|
lastSkippedAt: AT("2026-06-24T06:00:00.000Z"),
|
|
skipCount: 2,
|
|
},
|
|
});
|
|
await prisma.measurementReminderEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
reminderId: reminder.id,
|
|
kind: "SATISFIED",
|
|
occurredAt: AT("2026-07-01T06:05:00.000Z"),
|
|
onTime: true,
|
|
source: "manual",
|
|
},
|
|
});
|
|
await prisma.measurementReminderEvent.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
reminderId: reminder.id,
|
|
kind: "SKIPPED",
|
|
occurredAt: AT("2026-06-24T06:00:00.000Z"),
|
|
onTime: false,
|
|
source: "skip",
|
|
},
|
|
});
|
|
|
|
const encounter = await prisma.encounter.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
occurredAt: AT("2026-06-30T08:00:00.000Z"),
|
|
status: "DONE",
|
|
kind: "ROUTINE",
|
|
practitionerId: practitioner.id,
|
|
reasonEncrypted: encryptToBytes("annual check"),
|
|
outcomeEncrypted: encryptToBytes(
|
|
"ferritin back in range, recheck in a year",
|
|
),
|
|
reminderId: reminder.id,
|
|
},
|
|
});
|
|
await prisma.encounterDocumentLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
encounterId: encounter.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
await prisma.encounterLabLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
encounterId: encounter.id,
|
|
labResultId: labResult.id,
|
|
},
|
|
});
|
|
await prisma.encounterConditionLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
encounterId: encounter.id,
|
|
episodeId: episode.id,
|
|
},
|
|
});
|
|
|
|
// One dose with every optional column filled, and the page it was
|
|
// transcribed from. Fat on purpose, against this file's own minimal-fixture
|
|
// rule: a count-back proves the row returned, and the assertion after the
|
|
// restore reads each column back so it also proves the row returned WHOLE.
|
|
// The practitioner, the encounter and the reminder are the ones seeded
|
|
// above, because all three are remapped on the way back and a reference to
|
|
// something the restore did not put back is a different case (covered by
|
|
// the skip test below). `reminderId` used to be deliberately absent here —
|
|
// it was the one reference that could never resolve — and is deliberately
|
|
// present since v1.37.20, because resolving is now the behaviour under test.
|
|
// v1.42 (#1005) — the person's own vaccine definition the dose also names.
|
|
// The catalogue slug still wins on read; the reference is what has to
|
|
// survive the trip, and it can only if the definition comes back first.
|
|
const customVaccine = await prisma.customVaccine.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
name: "Round-trip travel combo",
|
|
components: ["typhoid", "hepatitis-a"],
|
|
typicalSeriesDoses: 2,
|
|
boosterIntervalMonths: 36,
|
|
},
|
|
});
|
|
const vaccination = await prisma.vaccinationRecord.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
occurredAt: AT("2026-05-14T00:00:00.000Z"),
|
|
customVaccineId: customVaccine.id,
|
|
antigenSlug: "tdap",
|
|
vaccineName: "Tetanus, diphtheria and pertussis",
|
|
doseNumber: 3,
|
|
seriesDoses: 3,
|
|
lotNumber: "RT-4471",
|
|
site: "LEFT_ARM",
|
|
practitionerId: practitioner.id,
|
|
encounterId: encounter.id,
|
|
reminderId: reminder.id,
|
|
noteEncrypted: encryptToBytes("sore arm for a day, nothing else"),
|
|
},
|
|
});
|
|
await prisma.vaccinationDocumentLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
vaccinationId: vaccination.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
|
|
// v1.43 — one row in each of the five relation link tables, joining the
|
|
// records seeded above.
|
|
await prisma.labResultDocumentLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
labResultId: labResult.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
await prisma.encounterMedicationLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
encounterId: encounter.id,
|
|
medicationId: medication.id,
|
|
},
|
|
});
|
|
await prisma.encounterVaccinationLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
encounterId: encounter.id,
|
|
vaccinationId: vaccination.id,
|
|
},
|
|
});
|
|
await prisma.medicationDocumentLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
await prisma.medicationConditionLink.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
medicationId: medication.id,
|
|
episodeId: episode.id,
|
|
},
|
|
});
|
|
|
|
// Three bests, chosen for what a count cannot see.
|
|
//
|
|
// The first points at the measurement it was found in. That column is a
|
|
// foreign key in the database while the Prisma schema declares no relation
|
|
// for it, so this row is what proves the pointer resolves against the
|
|
// measurements the restore wrote, and would prove the opposite loudly, by
|
|
// failing the constraint and taking the whole restore with it.
|
|
//
|
|
// The other two share a metric type AND an instant and differ only in the
|
|
// sport slot: the same 5 km and 10 km bests a runner has. A restore that
|
|
// dropped the slot would return two rows describing one record twice, and
|
|
// the faster time would be presented as the 10 km best.
|
|
await prisma.personalRecord.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
metricType: "WEIGHT",
|
|
direction: "MIN",
|
|
value: 74.2,
|
|
unit: "kg",
|
|
achievedAt: AT("2026-07-01T07:00:00.000Z"),
|
|
sourceMeasurementId: weightMeasurement.id,
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
await prisma.personalRecord.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
metricType: "WALKING_RUNNING_DISTANCE",
|
|
metricSlot: "running_5km_time",
|
|
direction: "MIN",
|
|
value: 1512,
|
|
unit: "s",
|
|
achievedAt: AT("2026-05-18T09:20:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "workout-pr-5km",
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
metricType: "WALKING_RUNNING_DISTANCE",
|
|
metricSlot: "running_10km_time",
|
|
direction: "MIN",
|
|
value: 3184,
|
|
unit: "s",
|
|
achievedAt: AT("2026-05-18T09:20:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "workout-pr-10km",
|
|
},
|
|
],
|
|
});
|
|
|
|
// Two badges, both earned long before this fixture runs. The second names a
|
|
// definition no catalogue in this build ships: a file can be older than the
|
|
// release reading it, and the restore must carry the row rather than judge
|
|
// it, exactly as it carries a retired antigen slug.
|
|
await prisma.userAchievement.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
achievementId: "intake-total-10",
|
|
unlockedAt: AT("2026-02-21T10:15:00.000Z"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
achievementId: "retired-badge-from-an-older-release",
|
|
unlockedAt: AT("2025-11-05T08:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
// A fortnight in Barcelona, and one day of it recorded as a reading.
|
|
//
|
|
// The pair is the fixture. The home day and the trip day carry different
|
|
// coordinates and different weather, and the trip day's reading only makes
|
|
// sense next to the period that explains it: with the period gone, the next
|
|
// refresh re-resolves 2026-06-15 to Berlin and upserts Berlin's weather over
|
|
// it. The assertion after the restore reads the two back TOGETHER. The
|
|
// coordinates are at 1 decimal, the precision the app stores since v1.39.4;
|
|
// a restore rounds anything finer, so finer values would not round-trip.
|
|
// Since v1.42 the location is stored sealed (`locationEncrypted`), the way
|
|
// the writers store it; it is the only copy since v1.43.
|
|
// v1.43 — one Dawarich day abroad, with its sealed country.
|
|
await prisma.dawarichDay.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
date: "2026-06-15",
|
|
totalDistanceM: 48_300,
|
|
drivingDistanceM: 12_000,
|
|
trainDistanceM: 30_500,
|
|
placesVisited: 4,
|
|
awayFromHomeMinutes: 1440,
|
|
countryCodeEncrypted: sealDayCountry("ES"),
|
|
isTravelDay: true,
|
|
fetchedAt: new Date("2026-06-16T03:20:00.000Z"),
|
|
},
|
|
});
|
|
await prisma.environmentTravelLocation.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
startDate: "2026-06-10",
|
|
endDate: "2026-06-20",
|
|
locationEncrypted: sealLocation({
|
|
lat: 41.4,
|
|
lon: 2.2,
|
|
label: "Barcelona",
|
|
}),
|
|
},
|
|
});
|
|
await prisma.environmentContext.createMany({
|
|
data: [
|
|
{
|
|
userId: OWNER_ID,
|
|
date: "2026-06-15",
|
|
locationEncrypted: sealLocation({
|
|
lat: 41.4,
|
|
lon: 2.2,
|
|
label: "Barcelona",
|
|
}),
|
|
source: "TRAVEL",
|
|
tempMin: 19.4,
|
|
tempMax: 28.1,
|
|
tempMean: 23.6,
|
|
apparentMean: 25.2,
|
|
sunshineSec: 39_600,
|
|
daylightSec: 52_800,
|
|
precipSum: 0,
|
|
pressureMean: 1016.4,
|
|
pressureDelta: 3.2,
|
|
humidityMean: 63,
|
|
cloudMean: 12,
|
|
weatherCode: 1,
|
|
fetchedAt: AT("2026-06-16T03:15:00.000Z"),
|
|
},
|
|
{
|
|
userId: OWNER_ID,
|
|
date: "2026-07-01",
|
|
locationEncrypted: sealLocation({
|
|
lat: 52.5,
|
|
lon: 13.4,
|
|
label: "Berlin",
|
|
}),
|
|
source: "HOME",
|
|
tempMin: 13.1,
|
|
tempMax: 22.7,
|
|
tempMean: 17.9,
|
|
apparentMean: 17.1,
|
|
sunshineSec: 28_800,
|
|
daylightSec: 59_400,
|
|
precipSum: 4.6,
|
|
pressureMean: 1008.9,
|
|
pressureDelta: 7.8,
|
|
humidityMean: 74,
|
|
cloudMean: 68,
|
|
weatherCode: 61,
|
|
fetchedAt: AT("2026-07-02T03:15:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
export async function createOwner(prisma: PrismaClient) {
|
|
return prisma.user.create({
|
|
data: {
|
|
id: OWNER_ID,
|
|
username: "round-trip-owner",
|
|
email: "round-trip-owner@example.test",
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function seedAdminSession(prisma: PrismaClient) {
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
username: "round-trip-admin",
|
|
email: "round-trip-admin@example.test",
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return admin;
|
|
}
|
|
|
|
/**
|
|
* Columns the restore is right not to bring back verbatim, by `Model.column`.
|
|
* Every other column of every two-ended model must come back with the value
|
|
* it left with. An entry here whose column no longer exists fails the run.
|
|
*/
|
|
export const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
|
|
"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",
|
|
};
|
|
|
|
/**
|
|
* Columns the restore carries on purpose as "set where it was set" rather
|
|
* than the same value, each with the reason. A presence check still fails a
|
|
* column that stops coming back at all.
|
|
*/
|
|
export const COLUMN_PRESENCE_ONLY: Readonly<Record<string, string>> = {
|
|
"Medication.autoMarkTakenSince":
|
|
"the auto-take floor restarts at the restore, so doses a portable file cannot carry as removed are not filled again; pinned in the restore test for it",
|
|
};
|
|
|
|
/**
|
|
* Values for columns the fixture leaves empty where the type alone does not
|
|
* produce one the export and the restore accept.
|
|
*/
|
|
export const FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
|
|
"MedicationSchedule.rrule": "FREQ=WEEKLY;BYDAY=MO,TH",
|
|
"InboundDocument.sourceInstance": "https://paperless.example.test",
|
|
// The digest of the fixture's own document bytes, so a restore that checks
|
|
// it against the content finds them consistent.
|
|
"InboundDocument.contentSha256": createHash("sha256")
|
|
.update("round-trip document bytes")
|
|
.digest("hex"),
|
|
"Biomarker.contextEncrypted": () =>
|
|
encryptContextToBytes("fasted, morning draw"),
|
|
"MoodEntry.tags": JSON.stringify(["round-trip-tag"]),
|
|
"CorrelationPattern.qValue": 0.04,
|
|
"CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32),
|
|
// CHECK in migration 0408: the profile is built from samples or from hour buckets.
|
|
"IntradayCumulativeProfile.basis": "HOUR_BUCKETS",
|
|
// The posted hours are a whole day of slots (-1 for an hour not posted);
|
|
// the restore refuses any other length, as it does for the curve.
|
|
"IntradayCumulativeProfile.hourlyIncrements": Array.from(
|
|
{ length: 24 },
|
|
(_, hour) => (hour < 6 ? -1 : 350),
|
|
),
|
|
};
|
|
|
|
/**
|
|
* Rows for the columns a CHECK constraint ties to a shape the base fixture
|
|
* does not have: a stage only on a sleep reading, a context only on a glucose
|
|
* reading, a rolling interval only on a schedule without a calendar rule.
|
|
*/
|
|
export async function seedColumnShapes(prisma: PrismaClient): Promise<void> {
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 5.4,
|
|
unit: "mmol/L",
|
|
measuredAt: AT("2026-07-03T06:45:00.000Z"),
|
|
source: "MANUAL",
|
|
glucoseContext: "FASTING",
|
|
},
|
|
});
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "SLEEP_DURATION",
|
|
value: 95,
|
|
unit: "min",
|
|
measuredAt: AT("2026-07-03T02:00:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "round-trip-deep-sleep",
|
|
sleepStage: "DEEP",
|
|
},
|
|
});
|
|
// A start logged out of order and folded into the one before it: soft
|
|
// deleted, and pointing at the start that absorbed it so that removing that
|
|
// start can bring this one back.
|
|
const absorbing = await prisma.menstrualCycle.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, startDate: "2026-06-01" },
|
|
});
|
|
await prisma.menstrualCycle.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
startDate: "2026-06-03",
|
|
deletedAt: AT("2026-06-04T07:00:00.000Z"),
|
|
absorbedIntoId: absorbing.id,
|
|
},
|
|
});
|
|
const medication = await prisma.medication.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
await prisma.medicationSchedule.create({
|
|
data: {
|
|
medicationId: medication.id,
|
|
windowStart: "20:00",
|
|
windowEnd: "21:00",
|
|
label: "Every third evening",
|
|
rollingIntervalDays: 3,
|
|
},
|
|
});
|
|
}
|
|
|
|
export type Delegate = {
|
|
findMany(args: {
|
|
where: Record<string, unknown>;
|
|
}): Promise<Record<string, unknown>[]>;
|
|
update(args: {
|
|
where: Record<string, unknown>;
|
|
data: Record<string, unknown>;
|
|
}): Promise<unknown>;
|
|
};
|
|
|
|
export function delegateOf(prisma: PrismaClient, model: AuditModel): Delegate {
|
|
return (prisma as unknown as Record<string, Delegate>)[model.delegate];
|
|
}
|
|
|
|
export function primaryKeyWhere(
|
|
model: AuditModel,
|
|
row: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
if (model.primaryKey.length === 1) {
|
|
return { [model.primaryKey[0]]: row[model.primaryKey[0]] };
|
|
}
|
|
return {
|
|
[model.primaryKey.join("_")]: Object.fromEntries(
|
|
model.primaryKey.map((k) => [k, row[k]]),
|
|
),
|
|
};
|
|
}
|
|
|
|
/** How a column is compared, or why it is not. */
|
|
export function comparison(
|
|
column: AuditColumn,
|
|
): "value" | "presence" | { excluded: string } {
|
|
const key = `${column.model}.${column.name}`;
|
|
if (key in COLUMN_EXCLUSIONS) return { excluded: COLUMN_EXCLUSIONS[key] };
|
|
if (column.isId) {
|
|
return { excluded: "primary key; the restore may mint a fresh one" };
|
|
}
|
|
if (column.name === "userId") {
|
|
return { excluded: "owner; re-derived from the restoring account" };
|
|
}
|
|
if (column.isUpdatedAt) {
|
|
return { excluded: "@updatedAt; Prisma stamps it on the restore's write" };
|
|
}
|
|
// A declared foreign key may be re-pointed at a row the restore minted, so
|
|
// it is held to being set where it was set, not to the same id.
|
|
if (column.isForeignKey) return "presence";
|
|
if (key in COLUMN_PRESENCE_ONLY) return "presence";
|
|
return "value";
|
|
}
|
|
|
|
/**
|
|
* A 1x1 PNG: the smallest picture the avatar upload, and so the restore,
|
|
* accepts.
|
|
*/
|
|
export const AVATAR_PNG = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
|
"base64",
|
|
);
|
|
|
|
/**
|
|
* Values for `User` columns where the type alone would produce one the
|
|
* restore rightly refuses: a vocabulary, a time zone, an AI endpoint, a
|
|
* threshold band, an image.
|
|
*/
|
|
export const USER_FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
|
|
"User.gender": "FEMALE",
|
|
"User.locale": "fr",
|
|
"User.timezone": "America/Chicago",
|
|
"User.homeTimezone": "Asia/Tokyo",
|
|
"User.unitPreference": "imperial",
|
|
"User.glucoseUnit": "mmol/L",
|
|
"User.insightsPrivacyMode": "raw",
|
|
"User.aiProvider": "LOCAL",
|
|
"User.aiBaseUrl": "https://llm.example.com/v1",
|
|
"User.aiCompatBaseUrl": "https://gateway.example.com/v1",
|
|
"User.thresholdsJson": { WEIGHT: { min: 60, max: 80 } },
|
|
// The restore admits goal by goal against the goals schema, so the generic
|
|
// `{ roundTrip }` blob would be refused as an unknown key, not carried.
|
|
"User.activityGoalsJson": { workoutsPerWeek: 4, stepsPerMonth: 250_000 },
|
|
"User.globalExcludedInjectionSites": ["THIGH_LEFT"],
|
|
"User.avatarBytes": () => {
|
|
const bytes = new Uint8Array(new ArrayBuffer(AVATAR_PNG.byteLength));
|
|
bytes.set(AVATAR_PNG);
|
|
return bytes;
|
|
},
|
|
"User.avatarContentType": "image/png",
|
|
// v1.42 — a sealed home the way the writers seal it, so it opens.
|
|
"User.homeLocationEncrypted": () =>
|
|
sealLocation({ lat: 2.5, lon: 2.5, label: "round-trip home" }),
|
|
};
|