Merge pull request #819 from MBombeck/work/backup-docs

Carry the vault's filing and its staged facts through backup and restore
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 10:58:28 +02:00
commit 974a90f28f
21 files changed
+1108 -39

No files matched your search

+24 -7
View File
@@ -291,15 +291,32 @@ test.describe.serial("scoped sharing browser journeys", () => {
let corruptAuthPayload = true;
await page.route("**/api/auth/me", async (route) => {
const response = await route.fetch();
if (!corruptAuthPayload) {
await route.fulfill({ response });
// The switch this test drives ends in a full reload, and the reload
// cancels whatever `/api/auth/me` call is in flight. Playwright then
// considers the route handled, and the `fulfill` below lands on it with
// "Route is already handled!" — a failure of the teardown, not of the
// thing under test, and one that turns the whole shard red under
// `failOnFlakyTests`.
//
// Same guard as `v137-record-session-fence.spec.ts` and the a11y sibling
// of this file. The fetch is guarded too: a request cancelled mid-flight
// rejects there rather than at the fulfill.
let response;
try {
response = await route.fetch();
} catch {
return;
}
await route.fulfill({
response,
json: withDivergentActiveAccountAccess(await response.json()),
});
if (!corruptAuthPayload) {
await route.fulfill({ response }).catch(() => {});
return;
}
await route
.fulfill({
response,
json: withDivergentActiveAccountAccess(await response.json()),
})
.catch(() => {});
});
const ownerOnlyReads: string[] = [];
+6
View File
@@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Besuchsverknüpfung",
"restoreSkippedVaccinationReference": "Impfverknüpfung",
"restoreSkippedCheckupClosure": "Vorsorge-Abschluss",
"restoreSkippedReminderReference": "Erinnerungsverknüpfung",
"restoreSkippedCoachAttachment": "Coach-Dokumentverknüpfung",
"restoreSkippedCoachReference": "Coach-Verweis",
"restoreSkippedDocumentConditionLink": "Dokumentzuordnung",
"restoreSkippedExtractedFact": "Dokumentbefund",
"restoreSkippedFactCommitment": "Befundübernahme",
"docsLink": "Dokumentation"
},
"danger-zone": {
+6
View File
@@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Visit reference",
"restoreSkippedVaccinationReference": "Vaccination reference",
"restoreSkippedCheckupClosure": "Checkup closure",
"restoreSkippedReminderReference": "Reminder reference",
"restoreSkippedCoachAttachment": "Coach document link",
"restoreSkippedCoachReference": "Coach reference",
"restoreSkippedDocumentConditionLink": "Document filing",
"restoreSkippedExtractedFact": "Document fact",
"restoreSkippedFactCommitment": "Fact commitment",
"docsLink": "Documentation"
},
"danger-zone": {
+6
View File
@@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Referencia de visita",
"restoreSkippedVaccinationReference": "Referencia de vacunación",
"restoreSkippedCheckupClosure": "Cierre de revisión",
"restoreSkippedReminderReference": "Referencia de recordatorio",
"restoreSkippedCoachAttachment": "Enlace de documento del Coach",
"restoreSkippedCoachReference": "Referencia del Coach",
"restoreSkippedDocumentConditionLink": "Clasificación de documento",
"restoreSkippedExtractedFact": "Dato del documento",
"restoreSkippedFactCommitment": "Registro del dato",
"docsLink": "Documentación"
},
"danger-zone": {
+6
View File
@@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Référence de visite",
"restoreSkippedVaccinationReference": "Référence de vaccination",
"restoreSkippedCheckupClosure": "Clôture d'un examen",
"restoreSkippedReminderReference": "Référence de rappel",
"restoreSkippedCoachAttachment": "Lien de document du Coach",
"restoreSkippedCoachReference": "Référence du Coach",
"restoreSkippedDocumentConditionLink": "Classement du document",
"restoreSkippedExtractedFact": "Donnée du document",
"restoreSkippedFactCommitment": "Enregistrement de la donnée",
"docsLink": "Documentation"
},
"danger-zone": {
+6
View File
@@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Riferimento alla visita",
"restoreSkippedVaccinationReference": "Riferimento alla vaccinazione",
"restoreSkippedCheckupClosure": "Chiusura del controllo",
"restoreSkippedReminderReference": "Riferimento al promemoria",
"restoreSkippedCoachAttachment": "Collegamento documento del Coach",
"restoreSkippedCoachReference": "Riferimento del Coach",
"restoreSkippedDocumentConditionLink": "Classificazione del documento",
"restoreSkippedExtractedFact": "Dato del documento",
"restoreSkippedFactCommitment": "Registrazione del dato",
"docsLink": "Documentazione"
},
"danger-zone": {
+6
View File
@@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Odwołanie wizyty",
"restoreSkippedVaccinationReference": "Odwołanie szczepienia",
"restoreSkippedCheckupClosure": "Zamknięcie badania",
"restoreSkippedReminderReference": "Odwołanie do przypomnienia",
"restoreSkippedCoachAttachment": "Powiązanie dokumentu z Coachem",
"restoreSkippedCoachReference": "Odwołanie Coacha",
"restoreSkippedDocumentConditionLink": "Przypisanie dokumentu",
"restoreSkippedExtractedFact": "Fakt z dokumentu",
"restoreSkippedFactCommitment": "Zapis faktu",
"docsLink": "Dokumentacja"
},
"danger-zone": {
@@ -304,6 +304,10 @@ function sourceClient() {
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
@@ -56,6 +56,7 @@ import {
restoreCoachMemoryData,
} from "@/lib/export/coach-backup";
import { restoreRemindersData } from "@/lib/export/reminders-backup";
import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -90,6 +91,8 @@ interface RestoreResponse {
familyHistory: number;
workouts: number;
documents: number;
documentConditionLinks: number;
extractedFacts: number;
healthProfile: number;
healthProfileFactRevisions: number;
customMetrics: number;
@@ -1215,6 +1218,10 @@ const handler = apiHandler(
restoredBiomarkerIds.add(created.id);
}
// Collected for the staged facts further down: an approved fact's
// `committedRecordId` names the lab result it was committed to, and
// that reference is resolved against the rows this loop writes.
const restoredLabResultIds = new Set<string>();
for (const lab of payload.labResults) {
const biomarkerId =
lab.biomarkerId !== undefined
@@ -1231,7 +1238,7 @@ const handler = apiHandler(
`Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`,
);
}
await tx.labResult.create({
const createdLab = await tx.labResult.create({
data: {
...(lab.id ? { id: lab.id } : {}),
userId: ownerId,
@@ -1265,6 +1272,7 @@ const handler = apiHandler(
: {}),
},
});
restoredLabResultIds.add(createdLab.id);
}
const episodeIds = new Set(
@@ -1538,6 +1546,34 @@ const handler = apiHandler(
});
}
// What the vault was filed against, and what was read out of it.
// AFTER the documents and the condition episodes because both are
// foreign keys here — a filing written before either exists does not
// drop quietly, it violates a constraint and costs the operator the
// whole restore. AFTER the lab results and the medications for a
// second reason: an approved fact's `committedRecordId` is resolved
// against the rows those branches wrote, and resolving before they
// exist would null every commitment and still report success. Both
// ends of this section live in
// `src/lib/export/document-filing-backup.ts`.
const documentFilingCleared = await restoreDocumentFilingData(
tx,
ownerId,
payload,
{
documentIds: new Set(
payload.documents.map((document) => document.id),
),
episodeIds,
committedRecordIds: new Set([
...restoredLabResultIds,
...episodeIds,
...restoredMedicationIds,
]),
},
skips,
);
// The Vorsorge reminders and their completion ledger (v1.37.20,
// #223 / iOS #68), BEFORE the visits and the vaccinations: both of
// those remap a `reminderId` against the reminders in the database,
@@ -1637,6 +1673,9 @@ const handler = apiHandler(
familyHistory: familyHistory.count,
workouts: workouts.count,
documents: documents.count,
documentConditionLinks:
documentFilingCleared.documentConditionLinks,
extractedFacts: documentFilingCleared.extractedFacts,
healthProfile: profileCleared.healthProfile,
healthProfileFactRevisions:
profileCleared.healthProfileFactRevisions,
@@ -50,6 +50,10 @@ vi.mock("@/lib/db", () => ({
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -147,6 +151,10 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -60,6 +60,9 @@ vi.mock("@/lib/db", () => ({
familyHistoryEntry: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
inboundDocument: { findMany: vi.fn() },
// What a document was filed against, and what was read out of it.
documentConditionLink: { findMany: vi.fn() },
extractedFact: { findMany: vi.fn() },
},
}));
@@ -167,6 +170,10 @@ beforeEach(() => {
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -48,6 +48,10 @@ vi.mock("@/lib/db", () => ({
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
+28 -1
View File
@@ -332,7 +332,34 @@ function catalogueLabel(
if (catalogue === "checkupClosure") {
return t("admin.section.backups.restoreSkippedCheckupClosure");
}
return t("admin.section.backups.restoreSkippedMoodFactor");
if (catalogue === "reminderReference") {
return t("admin.section.backups.restoreSkippedReminderReference");
}
if (catalogue === "coachAttachment") {
return t("admin.section.backups.restoreSkippedCoachAttachment");
}
if (catalogue === "coachReference") {
return t("admin.section.backups.restoreSkippedCoachReference");
}
if (catalogue === "documentConditionLink") {
return t("admin.section.backups.restoreSkippedDocumentConditionLink");
}
if (catalogue === "extractedFact") {
return t("admin.section.backups.restoreSkippedExtractedFact");
}
if (catalogue === "factCommitment") {
return t("admin.section.backups.restoreSkippedFactCommitment");
}
if (catalogue === "moodFactor") {
return t("admin.section.backups.restoreSkippedMoodFactor");
}
// Not a fallback: the chain above is exhaustive and this line is what makes
// the compiler say so. A catalogue added without a label here now stops the
// build instead of shipping under a label that belongs to something else,
// which is what three of them were already doing — a dropped Coach
// attachment reached the operator's screen labelled "mood factor".
const unlabelled: never = catalogue;
return unlabelled;
}
/**
@@ -363,6 +363,10 @@ function makePrisma() {
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
// against these, so the assertions below exercise the builder rather than
// a stand-in that would agree with whatever the payload happened to do.
+30 -10
View File
@@ -226,6 +226,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
// The screener history and the consent record, disaster-recovery only. The
// reasons for that live in the module itself.
"src/lib/export/sensitive-backup.ts",
// What a document was filed against and what was read out of it. The
// documents themselves are read by `records-backup.ts`; these two read
// through their OWN delegates here, for the reason the visits comment gives.
"src/lib/export/document-filing-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -239,6 +243,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/lib/export/reminders-backup.ts",
"src/lib/export/coach-backup.ts",
"src/lib/export/sensitive-backup.ts",
"src/lib/export/document-filing-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -304,12 +309,13 @@ export const TWO_ENDED_MODELS = [
"EncounterLabLink",
"EncounterConditionLink",
// Doses travel both ways from the release that introduces them, and the
// link with them. `DocumentConditionLink` one list down says what the
// alternative costs — documents and conditions both restore, the filing
// between them does not. Repeating that here would return a restored
// Impfpass scan and a restored dose with nothing between them, which is the
// same regret against a record a person cannot reconstruct from anywhere
// else.
// link with them. `DocumentConditionLink` was the register entry that said
// what the alternative costs — documents and conditions both restore, the
// filing between them does not — and it has since landed carried itself, at
// the bottom of this list. Repeating that debt here would have returned a
// restored Impfpass scan and a restored dose with nothing between them,
// which is the same regret against a record a person cannot reconstruct
// from anywhere else.
"VaccinationRecord",
"VaccinationDocumentLink",
// The Vorsorge reminders, off the debt register at last (v1.37.20, #223 /
@@ -388,6 +394,24 @@ export const TWO_ENDED_MODELS = [
// neither.
"MentalHealthAssessment",
"ConsentReceipt",
// What the vault was sorted into, and what was read out of it. The register
// called the first "a restored vault is unsorted" and the second
// "re-derivable only by re-running the extraction against a provider, at the
// operator's cost". The second understated it: what a re-run cannot produce
// is the REVIEW DECISION on each fact, and every column holding one defaults
// to "nobody has looked at this yet". The confirm endpoint acts on a PENDING
// fact by committing it into the structured store, so a restore that
// defaulted them would offer an already-committed reading for approval a
// second time and write a second lab result behind it.
//
// Both references are real foreign keys, unlike the Coach's, so the failure
// they guard against is not a dead pointer but an aborted transaction: the
// builder carries a filing or a fact only when both of its ends are carried,
// and the restore drops and names what a hand-edited file still cannot
// place. `committedRecordId` is the one bare id column here, and it is
// nulled together with its type when it resolves to nothing.
"DocumentConditionLink",
"ExtractedFact",
] as const;
/** One model claimed to travel both ways. */
@@ -442,10 +466,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
"Per-day environmental readings joined to the record. Re-fetchable for recent days only; older history is gone once the provider window closes.",
EnvironmentTravelLocation:
"Where the person was on a given day, which is what makes the environmental readings mean anything. Never re-derivable.",
DocumentConditionLink:
"Which documents were filed against which condition. Documents and conditions both restore; the filing between them does not, so a restored vault is unsorted.",
ExtractedFact:
"Facts read out of a document by the AI pass, with their provenance back to the page. Re-derivable only by re-running the extraction against a provider, at the operator's cost.",
};
/**
+444
View File
@@ -0,0 +1,444 @@
/**
* What a stored document was filed against, and what was read out of it, with
* both backup ends in one file.
*
* Same arrangement as `coach-backup.ts`, `reminders-backup.ts` and
* `vaccinations-backup.ts`, for the same reason: a reader asking "is this
* carried at both ends?" answers it here, and a reader who greps only the
* restore ROUTE gets a false negative because the route delegates.
*
* Two models ride, and they are the two halves of the same sentence about a
* document. `DocumentConditionLink` says which conditions a page belongs to.
* `ExtractedFact` says what the extraction pass transcribed out of it, with the
* span it came from. Both sat on the coverage-pending register, where the
* entries read "a restored vault is unsorted" and "re-derivable only by
* re-running the extraction against a provider, at the operator's cost".
*
* ## The state on a fact is worth more than the fact
*
* `status`, `needsReview` and the pair `committedRecordId` /
* `committedRecordType` are the record of a DECISION a person already made:
* this fact was reviewed, approved, and committed to that lab result. Every one
* of them has a schema default that reads as "nobody has looked at this yet"
* (`PENDING`, `needsReview: true`, both commitment columns NULL), so a restore
* that let them default hands back the same rows with their history erased.
*
* That is not only lost history. `POST /api/documents/inbound/[id]/confirm`
* accepts a decision only on a `PENDING` fact and commits it through the normal
* field-by-field create — so a fact that comes back PENDING is offered for
* review a second time, and approving it writes a SECOND lab result, condition
* or medication for a reading the account already has. The count of facts would
* be right, the vault would look right, and the structured store would start
* doubling. So the four columns are carried and restored verbatim, and asserted
* by name in the round trip.
*
* ## Two references that behave very differently
*
* `documentId` is a real foreign key on both models, and `episodeId` is one on
* the link. A value that cannot resolve does not quietly stop meaning
* something: it aborts the transaction and costs the operator the WHOLE
* restore. So the builder carries a row only when both of its ends are
* carried — a document tombstoned out of the file takes its filings and its
* facts with it — and the restore still checks against what it actually wrote
* and drops what it cannot place, because a hand-edited or truncated file is
* the case the write-time filter cannot cover.
*
* `committedRecordId` is the opposite: a bare id column with no relation
* declared, pointing into one of three different tables depending on
* `committedRecordType`. Nothing in the database refuses a value that points at
* nothing, which makes it more dangerous rather than less. It is resolved
* against the lab results, condition episodes and medications the restore has
* just written, and a value that resolves to nothing is nulled TOGETHER with
* its type and reported — a fact that still claimed to have been committed
* somewhere, with no somewhere, would send a reader looking for a row that is
* not there.
*
* ## Ciphertext follows the contract every other encrypted column here uses
*
* A disaster-recovery payload carries the stored bytes verbatim as base64,
* because the same instance's key reads them back unchanged. A portable export
* carries the decrypted JSON, because a portable export exists to be readable
* by the person who owns it, exactly as it already decrypts medication notes,
* mood notes and document summaries.
*
* The portable arm of the RESTORE is there for the reader's file, not for a
* path this release can reach: a fact cannot exist without a document, and the
* route refuses a portable file that carries any document ahead of the wipe
* because document ciphertext is not in it. It is written the same way as the
* coach turns rather than as a special case, so the next person reading either
* file finds one contract instead of two.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
recordUnknownKeys,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
export interface DocumentFilingBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** One document filed against one condition episode. */
export interface DocumentConditionLinkBackupEntry {
documentId: string;
episodeId: string;
createdAt: string;
}
/** One fact the extraction pass staged against a document. */
export interface ExtractedFactBackupEntry {
id: string;
documentId: string;
factType: string;
/**
* The review state and the commitment it produced. Carried verbatim; see the
* file header for why letting these default re-opens a decided fact and lets
* a second approval double the structured row behind it.
*/
status: string;
confidence: number;
needsReview: boolean;
committedRecordId: string | null;
committedRecordType: string | null;
/** Ciphertext as base64. Present on a disaster-recovery payload only. */
dataEncrypted?: string;
provenanceEncrypted?: string;
/** Decrypted JSON. Present on a portable payload only. */
data?: unknown;
provenance?: unknown;
createdAt: string;
updatedAt: string;
}
export interface DocumentFilingBackupSection {
documentConditionLinks: DocumentConditionLinkBackupEntry[];
extractedFacts: ExtractedFactBackupEntry[];
}
export interface DocumentFilingBackupCounts {
documentConditionLinks: number;
extractedFacts: number;
}
/**
* Every restorable column of both models.
*
* Named rather than inlined so a column added to either model shows up as a
* diff here rather than as a silent omission from the file.
*/
const DOCUMENT_CONDITION_LINK_BACKUP_SELECT = {
documentId: true,
episodeId: true,
createdAt: true,
} satisfies Prisma.DocumentConditionLinkSelect;
const EXTRACTED_FACT_BACKUP_SELECT = {
id: true,
documentId: true,
factType: true,
status: true,
confidence: true,
needsReview: true,
committedRecordId: true,
committedRecordType: true,
dataEncrypted: true,
provenanceEncrypted: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.ExtractedFactSelect;
/**
* Decrypt one JSON column, or say plainly that this one could not be read.
*
* A single row encrypted under a key the instance has since dropped must not
* take the export down with it: the rest of the vault is still the person's.
* The placeholder is an object rather than `null` because `null` reads as "the
* extraction found nothing here", and that is not what happened.
*/
function decryptJsonSoft(bytes: Uint8Array): unknown {
try {
return JSON.parse(decryptFromBytes(bytes));
} catch {
return { unreadable: "encrypted with a key this instance no longer holds" };
}
}
export async function buildDocumentFilingBackupSection(
prisma: Pick<PrismaClient, "documentConditionLink" | "extractedFact">,
userId: string,
options: DocumentFilingBackupOptions = {},
): Promise<DocumentFilingBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
// Both parents are foreign keys, so a row whose parent the file does not
// carry could only ever restore as a reported loss. The document reader
// skips tombstoned documents in BOTH purposes, and the episode reader skips
// tombstoned episodes in a portable export only — so the filters here mirror
// exactly what `records-backup.ts` decided to carry.
const [linkRows, factRows] = await Promise.all([
prisma.documentConditionLink.findMany({
where: {
userId,
document: { deletedAt: null },
...(disasterRecovery ? {} : { episode: { deletedAt: null } }),
},
orderBy: { createdAt: "asc" },
select: DOCUMENT_CONDITION_LINK_BACKUP_SELECT,
}),
prisma.extractedFact.findMany({
where: { userId, document: { deletedAt: null } },
orderBy: { createdAt: "asc" },
select: EXTRACTED_FACT_BACKUP_SELECT,
}),
]);
return {
documentConditionLinks: linkRows.map((row) => ({
documentId: row.documentId,
episodeId: row.episodeId,
createdAt: row.createdAt.toISOString(),
})),
extractedFacts: factRows.map((row) => ({
id: row.id,
documentId: row.documentId,
factType: row.factType,
status: row.status,
confidence: row.confidence,
needsReview: row.needsReview,
committedRecordId: row.committedRecordId,
committedRecordType: row.committedRecordType,
...(disasterRecovery
? {
dataEncrypted: Buffer.from(row.dataEncrypted).toString("base64"),
provenanceEncrypted: Buffer.from(row.provenanceEncrypted).toString(
"base64",
),
}
: {
data: decryptJsonSoft(row.dataEncrypted),
provenance: decryptJsonSoft(row.provenanceEncrypted),
}),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countDocumentFilingBackupSection(
section: DocumentFilingBackupSection,
): DocumentFilingBackupCounts {
return {
documentConditionLinks: section.documentConditionLinks.length,
extractedFacts: section.extractedFacts.length,
};
}
/** Counts this restore wiped, for the audit trail. */
export interface DocumentFilingRestoreCleared {
documentConditionLinks: number;
extractedFacts: number;
}
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
/**
* What the parser hands over, which is looser than what the builder writes.
*
* The wire schema defaults or leaves optional every field an older file might
* not carry. The three kept strictly required on a fact are `status`,
* `needsReview` and `confidence`: a file that does not state a review decision
* must not have one invented for it, and making them optional here would let
* exactly that compile.
*/
export type RestoredExtractedFact = Pick<
ExtractedFactBackupEntry,
| "id"
| "documentId"
| "factType"
| "status"
| "confidence"
| "needsReview"
| "createdAt"
| "updatedAt"
> &
OptionalNullable<
Pick<
ExtractedFactBackupEntry,
| "committedRecordId"
| "committedRecordType"
| "dataEncrypted"
| "provenanceEncrypted"
| "data"
| "provenance"
>
>;
export interface DocumentFilingRestoreInput {
documentConditionLinks: DocumentConditionLinkBackupEntry[];
extractedFacts: RestoredExtractedFact[];
}
/**
* The rows this restore has already written that these two can point at.
*
* Passed in rather than re-queried so this stays a pure function of the
* transaction it was handed, the same arrangement the Coach attachments use.
* `committedRecordIds` is the UNION of the restored lab results, condition
* episodes and medications, because `committedRecordType` chooses between the
* three and a fact naming a row from any of them is equally valid.
*/
export interface DocumentFilingRestoreRefs {
documentIds: ReadonlySet<string>;
episodeIds: ReadonlySet<string>;
committedRecordIds: ReadonlySet<string>;
}
/**
* Re-create the filings and the staged facts.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section. Both tables cascade from `InboundDocument`, so the document wipe
* earlier in the restore has already emptied them — they are counted and
* cleared explicitly anyway, so the cleared numbers are truthful rather than
* "whatever the cascade took" and so this stays correct if the document wipe
* ever moves.
*
* MUST be called AFTER the documents, the condition episodes, the lab results
* and the medications are restored. The first two are foreign keys and would
* abort the transaction; the last two are what `committedRecordId` resolves
* against, and resolving before they exist would null every commitment and
* still report success.
*/
export async function restoreDocumentFilingData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: DocumentFilingRestoreInput,
refs: DocumentFilingRestoreRefs,
skips: RestoreSkipLog,
): Promise<DocumentFilingRestoreCleared> {
const [clearedLinks, clearedFacts] = await Promise.all([
tx.documentConditionLink.deleteMany({ where: { userId: ownerId } }),
tx.extractedFact.deleteMany({ where: { userId: ownerId } }),
]);
const droppedFilings: string[] = [];
const writableLinks = payload.documentConditionLinks.filter((link) => {
if (
refs.documentIds.has(link.documentId) &&
refs.episodeIds.has(link.episodeId)
) {
return true;
}
// Reported under the end that is missing, so an operator reading the list
// can tell "the page is gone" from "the condition is gone".
droppedFilings.push(
refs.documentIds.has(link.documentId) ? link.episodeId : link.documentId,
);
return false;
});
if (writableLinks.length > 0) {
await tx.documentConditionLink.createMany({
data: writableLinks.map((link) => ({
userId: ownerId,
documentId: link.documentId,
episodeId: link.episodeId,
createdAt: new Date(link.createdAt),
})),
});
}
recordUnknownKeys(
skips,
"documentConditionLink",
[...new Set(droppedFilings)],
droppedFilings,
);
const droppedFacts: string[] = [];
const danglingCommitments: string[] = [];
const writableFacts = payload.extractedFacts.filter((fact) => {
if (refs.documentIds.has(fact.documentId)) return true;
droppedFacts.push(fact.documentId);
return false;
});
if (writableFacts.length > 0) {
await tx.extractedFact.createMany({
data: writableFacts.map((fact) => {
// Nulled as a PAIR. A type with no id claims the fact was committed
// somewhere and cannot say where, which sends a reader looking for a
// row that is not there.
let committedRecordId = fact.committedRecordId ?? null;
let committedRecordType = fact.committedRecordType ?? null;
if (
committedRecordId &&
!refs.committedRecordIds.has(committedRecordId)
) {
danglingCommitments.push(committedRecordId);
committedRecordId = null;
committedRecordType = null;
}
return {
id: fact.id,
userId: ownerId,
documentId: fact.documentId,
factType: fact.factType as never,
status: fact.status as never,
confidence: fact.confidence,
needsReview: fact.needsReview,
committedRecordId,
committedRecordType,
dataEncrypted: resolveFactBytes(fact.dataEncrypted, fact.data),
provenanceEncrypted: resolveFactBytes(
fact.provenanceEncrypted,
fact.provenance,
),
createdAt: new Date(fact.createdAt),
updatedAt: new Date(fact.updatedAt),
};
}),
});
}
recordUnknownKeys(
skips,
"extractedFact",
[...new Set(droppedFacts)],
droppedFacts,
);
recordUnknownKeys(
skips,
"factCommitment",
[...new Set(danglingCommitments)],
danglingCommitments,
);
return {
documentConditionLinks: clearedLinks.count,
extractedFacts: clearedFacts.count,
};
}
/**
* A fact column's stored bytes, whichever end of the contract the file came
* from.
*
* A disaster-recovery file carries ciphertext that decodes straight back into
* the column. A portable file carries the decrypted JSON, which has to be
* encrypted on the way in under the TARGET instance's key — that is what makes
* a portable file portable. An absent column encrypts `null` rather than
* throwing, because the column is NOT NULL and a fact with unreadable
* provenance is still a fact.
*/
function resolveFactBytes(
ciphertext: string | undefined,
json: unknown,
): Uint8Array<ArrayBuffer> {
if (ciphertext !== undefined) {
const decoded = Buffer.from(ciphertext, "base64");
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
bytes.set(decoded);
return bytes;
}
return encryptToBytes(JSON.stringify(json ?? null));
}
+21 -1
View File
@@ -72,6 +72,12 @@ import {
type CoachMemoryBackupCounts,
type CoachMemoryBackupSection,
} from "@/lib/export/coach-backup";
import {
buildDocumentFilingBackupSection,
countDocumentFilingBackupSection,
type DocumentFilingBackupCounts,
type DocumentFilingBackupSection,
} from "@/lib/export/document-filing-backup";
import {
buildIntradayProfileBackupSection,
countIntradayProfileBackupSection,
@@ -90,7 +96,8 @@ export interface FullBackupCounts
RemindersBackupCounts,
CoachBackupCounts,
CoachMemoryBackupCounts,
SensitiveBackupCounts {
SensitiveBackupCounts,
DocumentFilingBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -317,6 +324,7 @@ export async function buildFullBackupPayload(
coach,
coachMemory,
sensitive,
documentFiling,
nutrientDays,
] = await Promise.all([
disasterRecovery
@@ -506,6 +514,15 @@ export async function buildFullBackupPayload(
buildSensitiveBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// Which conditions a document was filed against, and what the extraction
// pass read out of it. Both ends live in
// `src/lib/export/document-filing-backup.ts` beside each other, the same
// arrangement as the sections above and for the same reason. The documents
// themselves ride in `records-backup.ts`; these two say what was done with
// them, which a restored vault otherwise loses in silence.
buildDocumentFilingBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// Nutrient day totals were absent from every export path, which
// contradicted the schema's own reason for denormalising the unit column
// ("rows stay self-describing in exports even if the catalog ever drifts").
@@ -542,6 +559,7 @@ export async function buildFullBackupPayload(
consentReceipts: sensitive.consentReceipts,
};
const sensitiveManifest: SensitiveBackupManifest = sensitive.manifest;
const documentFilingSection: DocumentFilingBackupSection = documentFiling;
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
@@ -897,6 +915,7 @@ export async function buildFullBackupPayload(
// `manifest` key, and two of them would silently shadow each other
// depending on spread order.
manifest: { ...recordsSection.manifest, ...sensitiveManifest },
...documentFilingSection,
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -949,6 +968,7 @@ export async function buildFullBackupPayload(
...countCoachBackupSection(coach),
...countCoachMemoryBackupSection(coachMemory),
...countSensitiveBackupSection(sensitive),
...countDocumentFilingBackupSection(documentFiling),
},
};
}
+22 -1
View File
@@ -73,7 +73,25 @@
* reminder still fires. A portable export omits tombstoned plans, which is the
* ordinary way a live reminder ends up naming one the file does not carry.
*
* The tenth, `checkupClosure`, is not about a restore at all, and it borrows
* The tenth and eleventh, `documentConditionLink` and `extractedFact`, name a
* vault row whose parent the restore did not put back — the page a filing was
* made against, the condition it was filed under, or the document a staged
* fact was read out of. All three are real foreign keys, so unlike every kind
* above the alternative to dropping the row is not a dangling pointer but an
* aborted transaction and no restore at all. The builder carries a filing or a
* fact only when both of its ends are carried, so a file this release writes
* never trips either — they exist for the hand-edited or truncated file.
*
* The twelfth, `factCommitment`, is the pointer kind rather than the row kind,
* exactly like `coachReference`: `ExtractedFact.committedRecordId` names the
* lab result, condition episode or medication an approved fact was committed
* to, and it is a bare id column with no relation, so a value pointing at
* nothing costs no error and simply stops meaning anything. The fact itself
* restores; what it loses is a pointer that was already going nowhere, and its
* `committedRecordType` goes with it so the row does not claim a commitment it
* cannot name.
*
* The thirteenth, `checkupClosure`, is not about a restore at all, and it borrows
* this shape deliberately rather than growing a second reporting mechanism
* beside it. The situation is the same one: something a write was asked to do
* could not be done, the record itself survives, and the person is told which
@@ -91,6 +109,9 @@ export type SkippedCatalogue =
| "reminderReference"
| "coachAttachment"
| "coachReference"
| "documentConditionLink"
| "extractedFact"
| "factCommitment"
| "checkupClosure";
/** One key this instance does not know, and the links it cost. */
@@ -58,15 +58,20 @@ const LINK_CALL_RE = new RegExp(
const MODULE_DIR = "lib/links/";
/**
* The two files exempt by the same standing rule the wipe and key-rotation
* writers hold: a backup RESTORE rebuilds an account rather than filing
* something in one, so it preserves ids and creation instants and writes the
* link tables directly. It is the only exemption, and it is frozen here so a
* third arrives as a diff a human reviewed rather than as a quiet spread.
* The files exempt by the same standing rule the wipe and key-rotation writers
* hold: a backup RESTORE rebuilds an account rather than filing something in
* one, so it preserves ids and creation instants and writes the link tables
* directly. The set is frozen here so the next one arrives as a diff a human
* reviewed rather than as a quiet spread.
*/
const RESTORE_EXEMPTIONS = [
"lib/export/visits-backup.ts",
"lib/export/vaccinations-backup.ts",
// The document↔condition filing, carried and restored from the release that
// takes it off the backup debt register. Same standing rule as the two
// above: it re-creates the link with the instant it was originally filed at,
// which the service's filing signature has no way to express.
"lib/export/document-filing-backup.ts",
].sort();
/** Every non-test, non-generated source file under `src/`, relative to `src/`. */
@@ -123,14 +128,15 @@ describe("link-surface guard — the tables have one gateway", () => {
}
});
it("the restore exemption set is exactly the two backup writers", () => {
it("the restore exemption set is exactly the three backup writers", () => {
// Freeze the exemption list so widening it is a reviewed diff, not a quiet
// addition. Both files must exist and must actually write a link table.
// addition. Every file must exist and must actually write a link table.
for (const rel of RESTORE_EXEMPTIONS) {
expect(matchedDelegates(read(rel)).length).toBeGreaterThan(0);
}
expect(RESTORE_EXEMPTIONS).toEqual(
[
"lib/export/document-filing-backup.ts",
"lib/export/vaccinations-backup.ts",
"lib/export/visits-backup.ts",
].sort(),
+61
View File
@@ -34,6 +34,8 @@ import {
DocumentSummaryState,
EncounterKind,
EncounterStatus,
ExtractedFactStatus,
ExtractedFactType,
FamilyRelationship,
FlowLevel,
GlucoseContext,
@@ -1215,6 +1217,49 @@ const coachReminderBackupSchema = z
})
.passthrough();
/** One document filed against one condition episode. */
const documentConditionLinkBackupSchema = z
.object({
documentId: z.string().min(1),
episodeId: z.string().min(1),
createdAt: isoDateTime,
})
.passthrough();
/**
* One fact the extraction pass staged against a document.
*
* `status`, `confidence` and `needsReview` are REQUIRED rather than defaulted,
* unlike most optional-looking fields in this file. Each has a schema default
* that means "nobody has looked at this yet", and the confirm endpoint acts on
* a PENDING fact by committing it into the structured store — so a file that
* does not state the review decision must fail to parse rather than have one
* invented for it and let an already-committed reading be approved twice.
*
* `dataEncrypted` / `data` and their provenance siblings are the two ends of
* the same contract, so all four are optional and exactly one pair arrives: a
* disaster-recovery file carries the ciphertext, a portable file carries the
* decrypted JSON.
*/
const extractedFactBackupSchema = z
.object({
id: z.string().min(1),
documentId: z.string().min(1),
factType: z.enum(ExtractedFactType),
status: z.enum(ExtractedFactStatus),
confidence: z.number(),
needsReview: z.boolean(),
committedRecordId: z.string().nullable().optional(),
committedRecordType: z.string().nullable().optional(),
dataEncrypted: base64BytesSchema.optional(),
provenanceEncrypted: base64BytesSchema.optional(),
data: z.json().optional(),
provenance: z.json().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
})
.passthrough();
/**
* A grouping the account created for its own mood factors. `id` is REQUIRED,
* unlike everywhere else in this file, because a custom tag addresses its
@@ -1414,6 +1459,13 @@ export const backupPayloadSchema = z
familyHistory: z.array(familyHistoryBackupSchema).default([]),
workouts: z.array(workoutBackupSchema).default([]),
documents: z.array(documentBackupSchema).default([]),
// What a document was filed against, and what was read out of it. Defaulted
// for the same reason as the sections below: a file written before the
// vault filing travelled carries no key, and an unsorted vault writes [].
documentConditionLinks: z
.array(documentConditionLinkBackupSchema)
.default([]),
extractedFacts: z.array(extractedFactBackupSchema).default([]),
nutrientDays: z.array(nutrientDaySchema).default([]),
// Durable self-context and user-defined series. Defaulted so files written
// before either rode the wire still parse; an account with neither writes
@@ -1518,6 +1570,10 @@ export interface BackupSummary {
workouts: number;
/** Document records (ciphertext included in canonical DR payloads). */
documents: number;
/** Which documents were filed against which condition. */
documentConditionLinks: number;
/** Facts staged out of a document, with their review decision. */
extractedFacts: number;
/** 1 when the account's durable self-context rides the file, 0 otherwise. */
healthProfile: number;
/** Effective-dated structured health-profile revisions. */
@@ -1587,6 +1643,11 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
familyHistory: payload.familyHistory.length,
workouts: payload.workouts.length,
documents: payload.documents.length,
// Counted from the release that carries them, so the admin's "what did I
// just restore" answer never under-counts a filed vault the way it once
// did for visits.
documentConditionLinks: payload.documentConditionLinks.length,
extractedFacts: payload.extractedFacts.length,
healthProfile: payload.healthProfile ? 1 : 0,
healthProfileFactRevisions: payload.healthProfileFacts.length,
customMetrics: payload.customMetrics.length,
+362 -11
View File
@@ -26,18 +26,22 @@
* The registry below is keyed by `TwoEndedModel`, so a name added to the plan's
* two-ended list without a row seeded and counted here does not compile.
*
* What this still does not prove, with one exception: that a restored row
* carries the right VALUES. It asks whether the rows came back, not whether
* they came back intact — `admin-backups-canonical-roundtrip.test.ts` is where
* field-level fidelity is asserted. A restore that wrote one row per model with
* every column defaulted would satisfy this file and fail that one.
* What the count-back alone does not prove: that a restored row carries the
* right VALUES. It asks whether the rows came back, not whether they came back
* intact — `admin-backups-canonical-roundtrip.test.ts` is where field-level
* fidelity is asserted for the file as a whole. A restore that wrote one row
* per model with every column defaulted would satisfy the counts and fail that
* one.
*
* The exception is the medication side effect. Its note lives in an encrypted
* column beside a legacy plaintext one, so "the row came back" and "the note
* came back" are genuinely different answers here: a restore that dropped the
* ciphertext would still be counted as recovered by everything above. The
* severity, the category and the entry are asserted alongside it, because a
* side effect without them says something happened and not what.
* So each section added since has brought a field-level assertion of its own
* for the column whose loss the count cannot see: the side effect's encrypted
* note beside its legacy plaintext one, the open pause era's null `resumedAt`,
* the Coach's permanent fence flag and its two bare-id references, the stock
* count that must not be recalculated from its ledger, the dose ramp's order,
* the reminder's snooze and skip cursors — and, at the bottom of this file, the
* document filing's PAIRING and the review decision on a staged fact. Each one
* is a thing a restore could get wrong while returning the right number of
* rows, which is the only test worth writing here.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -48,6 +52,12 @@ import type { PrismaClient } from "@/generated/prisma/client";
import { encrypt, encryptBytes } from "@/lib/crypto";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { readNote } from "@/lib/crypto/note-cipher";
import {
decryptFactData,
decryptFactProvenance,
encryptFactData,
encryptFactProvenance,
} from "@/lib/documents/store";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
@@ -97,6 +107,7 @@ const COACH_USER_TURN = "my readings look higher this week, is that real?";
const COACH_ASSISTANT_TURN =
"the last seven mornings average 4 mmHg above the fortnight before";
const DOSE_CHANGE_NOTE = "titration note, encrypted at rest";
const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)";
const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
beforeEach(async () => {
@@ -201,6 +212,9 @@ const COUNT_BACK: Record<
MentalHealthAssessment: (p, userId) =>
p.mentalHealthAssessment.count({ where: { userId } }),
ConsentReceipt: (p, userId) => p.consentReceipt.count({ where: { userId } }),
DocumentConditionLink: (p, userId) =>
p.documentConditionLink.count({ where: { userId } }),
ExtractedFact: (p, userId) => p.extractedFact.count({ where: { userId } }),
};
/**
@@ -508,6 +522,18 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
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"),
},
});
const illnessSymptom = await prisma.illnessSymptom.create({
data: { key: "round_trip_cough", labelKey: "illness.symptom.roundTrip" },
});
@@ -643,6 +669,56 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
},
});
// 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
@@ -1457,6 +1533,95 @@ describe("every model the plan claims two-ended survives a real restore", () =>
},
]);
// The vault came back SORTED, not merely populated.
//
// The count says one filing returned. It cannot say which page was filed
// under which condition, and the account has two conditions — so a restore
// that paired the document with the first episode it found would satisfy
// every count above and hand back a lab report filed under a head cold.
// The pair is resolved through the relations rather than compared against
// the ids the fixture used, because both ends were re-created by the
// restore and it is the pairing that has to survive, not the identifiers.
const filing = await prisma.documentConditionLink.findFirstOrThrow({
where: { userId: OWNER_ID },
include: {
document: { select: { title: true } },
episode: { select: { label: true } },
},
});
expect(
{
document: filing.document.title,
condition: filing.episode.label,
createdAt: filing.createdAt.toISOString(),
},
"the page must come back filed under the condition it was filed under",
).toEqual({
document: "June labs",
condition: "Iron deficiency",
createdAt: "2026-07-02T09:00:00.000Z",
});
// The staged fact, and the decision on it.
//
// This is the assertion the count cannot make. `status`, `needsReview`,
// `committedRecordId` and `committedRecordType` all default to "nobody has
// looked at this yet", so a restore that wrote the row and ignored them
// returns exactly one fact, exactly as the plan promises, and hands the
// account a reviewed and committed reading back in its review queue. The
// confirm endpoint acts only on a PENDING fact and commits it through the
// normal create, so approving it a second time writes a SECOND ferritin
// result. The commitment is checked against the lab row the restore itself
// wrote, because a pointer that survives as a string but no longer names a
// live row is the same loss wearing a value.
const restoredLab = await prisma.labResult.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const stagedFact = await prisma.extractedFact.findFirstOrThrow({
where: { userId: OWNER_ID },
include: { document: { select: { title: true } } },
});
expect(
{
document: stagedFact.document.title,
factType: stagedFact.factType,
status: stagedFact.status,
confidence: stagedFact.confidence,
needsReview: stagedFact.needsReview,
committedRecordId: stagedFact.committedRecordId,
committedRecordType: stagedFact.committedRecordType,
data: decryptFactData(stagedFact.dataEncrypted),
provenance: decryptFactProvenance(stagedFact.provenanceEncrypted),
},
"a reviewed and committed fact must not come back up for review",
).toEqual({
document: "June labs",
factType: "OBSERVATION",
status: "APPROVED",
confidence: 0.94,
needsReview: false,
committedRecordId: restoredLab.id,
committedRecordType: "labResult",
data: {
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 400,
effectiveDate: "2026-06-30",
},
provenance: {
sourceText: EXTRACTED_FACT_SPAN,
anchored: true,
sourceOffset: 412,
page: 2,
confidence: 0.94,
},
});
// The appointment's reminder reference survives too — same remap, other
// referrer.
const restoredEncounter = await prisma.encounter.findFirstOrThrow({
@@ -1582,4 +1747,190 @@ describe("every model the plan claims two-ended survives a real restore", () =>
reminderId: null,
});
});
/**
* The hand-edited file, which is the only way the vault filing can carry a
* reference the restore cannot place.
*
* The builder carries a filing or a staged fact only when both of its ends
* are carried, so no file this release writes reaches the drop path. That is
* exactly why the path is worth a test of its own: an arm nothing exercises
* is an arm nobody notices is wrong, and here being wrong has two very
* different prices. `documentConditionLink.episodeId` is a real foreign key,
* so writing it unchecked does not lose one edge — it violates a constraint
* and rolls the WHOLE account back, which is what the mood categories did
* before they travelled. `ExtractedFact.committedRecordId` is a bare id
* column with no relation, so writing it unchecked costs no error at all and
* simply leaves a fact pointing at a lab result that is not there.
*
* Both references are broken in the same file so the two answers can be seen
* side by side: a dropped row and a nulled pointer, each named in the report,
* and a restore that still answers 200.
*/
it("drops and names a filing and a commitment a truncated file cannot resolve", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const episode = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Iron deficiency",
type: "CHRONIC",
onsetAt: AT("2026-03-01T00:00:00.000Z"),
},
});
const labResult = await prisma.labResult.create({
data: {
userId: OWNER_ID,
analyte: "Ferritin",
value: 91,
unit: "ng/mL",
takenAt: AT("2026-06-30T09:00:00.000Z"),
},
});
const documentBytes = encryptBytes(Buffer.from("truncated-file fixture"));
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",
mimeType: "application/pdf",
byteSize: documentBytes.byteLength,
contentEncrypted,
contentCodec: "binary2",
},
});
await prisma.documentConditionLink.create({
data: {
userId: OWNER_ID,
documentId: document.id,
episodeId: episode.id,
},
});
await prisma.extractedFact.create({
data: {
userId: OWNER_ID,
documentId: document.id,
factType: "OBSERVATION",
status: "APPROVED",
confidence: 0.9,
needsReview: false,
committedRecordId: labResult.id,
committedRecordType: "labResult",
dataEncrypted: encryptFactData({
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2026-06-30",
}),
provenanceEncrypted: encryptFactProvenance({
sourceText: EXTRACTED_FACT_SPAN,
anchored: true,
sourceOffset: 412,
page: 2,
confidence: 0.9,
}),
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "disaster-recovery",
});
// The edit a truncated or hand-repaired file makes: both references now
// name rows the file no longer carries.
const edited = payload as typeof payload & {
documentConditionLinks: Array<{ episodeId: string }>;
extractedFacts: Array<{ committedRecordId: string | null }>;
};
edited.documentConditionLinks[0].episodeId = "condition-not-in-this-file";
edited.extractedFacts[0].committedRecordId = "lab-not-in-this-file";
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
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 }) },
);
const body = await response.json();
expect(
response.status,
"a filing naming a condition the file does not carry must cost that " +
"filing, not the account",
).toBe(200);
const reported = body.data.skipped.catalogueKeys as Array<{
catalogue: string;
key: string;
links: number;
}>;
expect(
reported.filter((entry) =>
["documentConditionLink", "factCommitment"].includes(entry.catalogue),
),
).toEqual([
{
catalogue: "documentConditionLink",
key: "condition-not-in-this-file",
links: 1,
},
{ catalogue: "factCommitment", key: "lab-not-in-this-file", links: 1 },
]);
// The filing is gone because it had nowhere to hang. The fact is NOT: what
// it lost is a pointer that was already going nowhere, and the transcribed
// reading is still the account's.
expect(
await prisma.documentConditionLink.count({ where: { userId: OWNER_ID } }),
).toBe(0);
const survivor = await prisma.extractedFact.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
status: survivor.status,
needsReview: survivor.needsReview,
committedRecordId: survivor.committedRecordId,
committedRecordType: survivor.committedRecordType,
data: decryptFactData(survivor.dataEncrypted),
}).toEqual({
status: "APPROVED",
needsReview: false,
// Nulled as a pair: a type with no id would claim a commitment the row
// cannot name.
committedRecordId: null,
committedRecordType: null,
data: {
label: "Ferritin",
code: null,
codeSystem: null,
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2026-06-30",
},
});
});
});