mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge pull request #813 from MBombeck/feat/backup-coach-transcript
Carry the Coach transcript through backup and restore
This commit is contained in:
11 files changed
+730
-8
No files matched your search
@@ -296,6 +296,7 @@ function sourceClient() {
|
||||
// same reason as the visit tables above.
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -50,6 +50,7 @@ import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup
|
||||
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
|
||||
import { restoreVisitsData } from "@/lib/export/visits-backup";
|
||||
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
|
||||
import { restoreCoachData } from "@/lib/export/coach-backup";
|
||||
import { restoreRemindersData } from "@/lib/export/reminders-backup";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
@@ -98,6 +99,9 @@ interface RestoreResponse {
|
||||
vaccinationLinks: number;
|
||||
measurementReminders: number;
|
||||
measurementReminderEvents: number;
|
||||
coachConversations: number;
|
||||
coachMessages: number;
|
||||
coachConversationDocuments: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1413,6 +1417,22 @@ const handler = apiHandler(
|
||||
skips,
|
||||
);
|
||||
|
||||
// The Coach's threads, turns and document links. AFTER the
|
||||
// documents on purpose: an attachment carries a `documentId` that
|
||||
// is a foreign key against them, so running earlier would make
|
||||
// every attachment unresolvable and drop the provenance that makes
|
||||
// an old answer checkable. The id set is threaded in rather than
|
||||
// re-queried so the function stays a pure reader of this
|
||||
// transaction. Both ends of this section live in
|
||||
// `src/lib/export/coach-backup.ts`.
|
||||
const coachCleared = await restoreCoachData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
new Set(payload.documents.map((document) => document.id)),
|
||||
skips,
|
||||
);
|
||||
|
||||
const cleared = {
|
||||
measurements: measurements.count,
|
||||
medications: meds.count,
|
||||
@@ -1447,6 +1467,9 @@ const handler = apiHandler(
|
||||
measurementReminders: remindersCleared.measurementReminders,
|
||||
measurementReminderEvents:
|
||||
remindersCleared.measurementReminderEvents,
|
||||
coachConversations: coachCleared.coachConversations,
|
||||
coachMessages: coachCleared.coachMessages,
|
||||
coachConversationDocuments: coachCleared.coachConversationDocuments,
|
||||
};
|
||||
return { cleared, skipped: summarizeRestoreSkips(skips) };
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// same reason as the visit tables above.
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
@@ -129,6 +130,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.measurementReminderEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.coachConversation.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// same reason as the visit tables above.
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -305,6 +305,10 @@ function makePrisma() {
|
||||
// same reason as the visit tables above.
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The Coach transcript. Deliberately NOT `?? []` at the call site, so a
|
||||
// future `include` that forgets the relation fails here loudly instead of
|
||||
// exporting an account whose Coach never spoke.
|
||||
coachConversation: { 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.
|
||||
|
||||
@@ -157,6 +157,11 @@ export const BACKED_UP_MODELS = [
|
||||
"VaccinationDocumentLink",
|
||||
|
||||
// ── Coach ─────────────────────────────────────────────────────────────────
|
||||
// The transcript — conversation, message, attachment — travels; see
|
||||
// `src/lib/export/coach-backup.ts`. What is still owed is the Coach's
|
||||
// MEMORY: the facts, plans and reminders it carries between threads. Each of
|
||||
// those addresses a conversation by `sourceConversationId`, so they follow
|
||||
// the transcript rather than lead it.
|
||||
"CoachConversation",
|
||||
"CoachMessage",
|
||||
"CoachConversationDocument",
|
||||
@@ -211,6 +216,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
|
||||
// The Vorsorge reminders and their completion ledger, both ends beside each
|
||||
// other like the visits and vaccinations above.
|
||||
"src/lib/export/reminders-backup.ts",
|
||||
// The Coach transcript, same arrangement. `attachments` is a relation name
|
||||
// this file shares with nothing else, but each model still reads through its
|
||||
// own delegate here for the reason the visits comment gives.
|
||||
"src/lib/export/coach-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
@@ -222,6 +231,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
|
||||
"src/lib/export/visits-backup.ts",
|
||||
"src/lib/export/vaccinations-backup.ts",
|
||||
"src/lib/export/reminders-backup.ts",
|
||||
"src/lib/export/coach-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
@@ -306,6 +316,21 @@ export const TWO_ENDED_MODELS = [
|
||||
// dropping to NULL in the same release — the rows they name come back now.
|
||||
"MeasurementReminder",
|
||||
"MeasurementReminderEvent",
|
||||
// The Coach's transcript. The register entry for the messages called them
|
||||
// "the largest volume of free text an account owns after its documents",
|
||||
// and until now a restored account got a Coach that had never spoken to it.
|
||||
// The three travel together because two of them are meaningless alone: a
|
||||
// turn without its thread has nowhere to hang, and an attachment names a
|
||||
// pairing rather than a thing.
|
||||
//
|
||||
// `documentScoped` is the field that makes this more than history. It is a
|
||||
// permanent fence — no code path lowers it — so a restore that let it
|
||||
// default to false would hand the tool loop back to precisely the
|
||||
// conversations the fence exists to keep away from it, invisibly. It is
|
||||
// carried and asserted, not defaulted.
|
||||
"CoachConversation",
|
||||
"CoachMessage",
|
||||
"CoachConversationDocument",
|
||||
] as const;
|
||||
|
||||
/** One model claimed to travel both ways. */
|
||||
@@ -374,12 +399,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
|
||||
"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.",
|
||||
CoachConversation:
|
||||
"The thread structure of past coaching conversations — which messages belong together and what each exchange was about. Without it the messages below, if they were ever carried, would restore as one undifferentiated pile.",
|
||||
CoachMessage:
|
||||
"The messages themselves, encrypted at rest. The largest volume of free text an account owns after its documents.",
|
||||
CoachConversationDocument:
|
||||
"Which documents a conversation was grounded in — the provenance that makes an old answer checkable.",
|
||||
CoachFact:
|
||||
"Durable facts the Coach was told to remember. A restore makes the account re-tell its history.",
|
||||
CoachPlan:
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* The Coach's conversations, with both backup ends in one file.
|
||||
*
|
||||
* Same arrangement as `reminders-backup.ts`, `visits-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.
|
||||
*
|
||||
* Three models ride: the conversation, its messages, and the join naming which
|
||||
* vault documents a conversation was grounded in. All three sat on the
|
||||
* coverage-pending register, where the entry for the messages read "the largest
|
||||
* volume of free text an account owns after its documents". A restore without
|
||||
* this section hands back an account whose Coach has never spoken to it.
|
||||
*
|
||||
* ## Three things this file is careful about
|
||||
*
|
||||
* **The fence flag is the important field.** `documentScoped` is a PERMANENT
|
||||
* marker: it goes true when a conversation is created through the fenced
|
||||
* endpoint or gets its first document, and no code path anywhere sets it back
|
||||
* to false — not detaching every document, not deleting the document. A
|
||||
* conversation whose history may contain document-derived text must never
|
||||
* regain the tool loop. So it is carried verbatim and restored verbatim. A
|
||||
* restore that let it default to false would hand the tool loop back to exactly
|
||||
* the conversations the fence exists to keep away from it, and nothing in the
|
||||
* interface would look wrong afterwards. It is the one field here where losing
|
||||
* the value is a security regression rather than lost history.
|
||||
*
|
||||
* **Ids are carried in both modes**, as the reminders are, and for the same
|
||||
* kind of reason: a message addresses its conversation, an attachment addresses
|
||||
* both a conversation and a document, and the Coach's facts, plans and
|
||||
* reminders carry a `sourceConversationId` that has to land somewhere real.
|
||||
*
|
||||
* **The prose follows the contract every other encrypted column here uses.** A
|
||||
* disaster-recovery payload carries the stored ciphertext verbatim as base64,
|
||||
* because the same instance's key reads it back unchanged. A portable export
|
||||
* decrypts it, 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. There is no third mode where a person's own words are
|
||||
* withheld from their own export.
|
||||
*
|
||||
* ## The one reference that needs care
|
||||
*
|
||||
* `CoachConversationDocument.documentId` addresses a vault document, which is
|
||||
* restored by the route BEFORE this section runs. A DR payload carries every
|
||||
* document, so the reference always resolves. A portable payload never reaches
|
||||
* a restore at all while the account has documents — the route refuses it
|
||||
* ahead of the wipe, because document ciphertext is not in the file — so an
|
||||
* attachment cannot survive into a restore whose document is missing. That
|
||||
* leaves the hand-edited or truncated file, and there the row is dropped and
|
||||
* REPORTED rather than invented, the same answer the ledger gives for a
|
||||
* reminder it cannot find.
|
||||
*/
|
||||
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 CoachBackupOptions {
|
||||
purpose?: "portable-export" | "disaster-recovery";
|
||||
}
|
||||
|
||||
/**
|
||||
* One turn. `role` is the app-side pair "user" | "assistant", stored as a
|
||||
* plain string rather than an enum, so it travels as written.
|
||||
*/
|
||||
export interface CoachMessageBackupEntry {
|
||||
id: string;
|
||||
role: string;
|
||||
/** Ciphertext as base64. Present on a disaster-recovery payload only. */
|
||||
contentEncrypted?: string;
|
||||
/** Plaintext. Present on a portable payload only. */
|
||||
content?: string;
|
||||
metricSourceJson: string | null;
|
||||
providerType: string | null;
|
||||
promptVersion: string | null;
|
||||
tokensUsed: number | null;
|
||||
model: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Which vault document a conversation was grounded in. */
|
||||
export interface CoachConversationDocumentBackupEntry {
|
||||
documentId: string;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface CoachConversationBackupEntry {
|
||||
/** Always carried: messages, attachments and the Coach's own facts, plans
|
||||
* and reminders all address a conversation by it. */
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* The permanent fence marker. Carried and restored verbatim; see the file
|
||||
* header for why letting this default is a security regression and not a
|
||||
* cosmetic one.
|
||||
*/
|
||||
documentScoped: boolean;
|
||||
/** Rolling summary of elided older turns. Same codec as a message. */
|
||||
summaryEncrypted?: string | null;
|
||||
summary?: string | null;
|
||||
summaryUpdatedAt: string | null;
|
||||
summaryTurnCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: CoachMessageBackupEntry[];
|
||||
attachments: CoachConversationDocumentBackupEntry[];
|
||||
}
|
||||
|
||||
export interface CoachBackupSection {
|
||||
coachConversations: CoachConversationBackupEntry[];
|
||||
}
|
||||
|
||||
export interface CoachBackupCounts {
|
||||
coachConversations: number;
|
||||
coachMessages: number;
|
||||
coachConversationDocuments: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every restorable `CoachConversation` scalar plus its two child relations.
|
||||
*
|
||||
* Named rather than inlined so a structural test can read the field list, and
|
||||
* so a column added to the model shows up as a diff here rather than as a
|
||||
* silent omission from the file.
|
||||
*/
|
||||
const COACH_CONVERSATION_BACKUP_SELECT = {
|
||||
id: true,
|
||||
title: true,
|
||||
documentScoped: true,
|
||||
summaryEncrypted: true,
|
||||
summaryUpdatedAt: true,
|
||||
summaryTurnCount: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
messages: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
encryptedContent: true,
|
||||
metricSourceJson: true,
|
||||
providerType: true,
|
||||
promptVersion: true,
|
||||
tokensUsed: true,
|
||||
model: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
orderBy: { addedAt: "asc" },
|
||||
select: { documentId: true, addedAt: true },
|
||||
},
|
||||
} satisfies Prisma.CoachConversationSelect;
|
||||
|
||||
/**
|
||||
* Decrypt for a reader, or say plainly that this one turn 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 transcript is still the
|
||||
* person's, and a thrown error would cost them all of it. The placeholder is
|
||||
* deliberately a sentence rather than an empty string, because an empty string
|
||||
* reads as "they said nothing here" and that is not what happened.
|
||||
*/
|
||||
function decryptTurnSoft(bytes: Uint8Array | null): string | null {
|
||||
if (!bytes) return null;
|
||||
try {
|
||||
return decryptFromBytes(bytes);
|
||||
} catch {
|
||||
return "[unreadable: encrypted with a key this instance no longer holds]";
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildCoachBackupSection(
|
||||
prisma: Pick<PrismaClient, "coachConversation">,
|
||||
userId: string,
|
||||
options: CoachBackupOptions = {},
|
||||
): Promise<CoachBackupSection> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
|
||||
const rows = await prisma.coachConversation.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: COACH_CONVERSATION_BACKUP_SELECT,
|
||||
});
|
||||
|
||||
return {
|
||||
coachConversations: rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
documentScoped: row.documentScoped,
|
||||
...(disasterRecovery
|
||||
? {
|
||||
summaryEncrypted: row.summaryEncrypted
|
||||
? Buffer.from(row.summaryEncrypted).toString("base64")
|
||||
: null,
|
||||
}
|
||||
: { summary: decryptTurnSoft(row.summaryEncrypted) }),
|
||||
summaryUpdatedAt: row.summaryUpdatedAt?.toISOString() ?? null,
|
||||
summaryTurnCount: row.summaryTurnCount,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
messages: row.messages.map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
...(disasterRecovery
|
||||
? {
|
||||
contentEncrypted: Buffer.from(message.encryptedContent).toString(
|
||||
"base64",
|
||||
),
|
||||
}
|
||||
: { content: decryptTurnSoft(message.encryptedContent) ?? "" }),
|
||||
metricSourceJson: message.metricSourceJson,
|
||||
providerType: message.providerType,
|
||||
promptVersion: message.promptVersion,
|
||||
tokensUsed: message.tokensUsed,
|
||||
model: message.model,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
})),
|
||||
attachments: row.attachments.map((attachment) => ({
|
||||
documentId: attachment.documentId,
|
||||
addedAt: attachment.addedAt.toISOString(),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Row counts for the audit trail, mirroring the other section counters. */
|
||||
export function countCoachBackupSection(
|
||||
section: CoachBackupSection,
|
||||
): CoachBackupCounts {
|
||||
return {
|
||||
coachConversations: section.coachConversations.length,
|
||||
coachMessages: section.coachConversations.reduce(
|
||||
(total, conversation) => total + conversation.messages.length,
|
||||
0,
|
||||
),
|
||||
coachConversationDocuments: section.coachConversations.reduce(
|
||||
(total, conversation) => total + conversation.attachments.length,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Counts the Coach restore wiped, for the audit trail. */
|
||||
export interface CoachRestoreCleared {
|
||||
coachConversations: number;
|
||||
coachMessages: number;
|
||||
coachConversationDocuments: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of a parsed backup this restore consumes.
|
||||
*
|
||||
* Required rather than optional: the payload schema defaults the key, so every
|
||||
* caller already satisfies this, and one that stops satisfying it fails to
|
||||
* compile instead of quietly restoring nothing.
|
||||
*/
|
||||
export interface CoachRestoreInput {
|
||||
coachConversations: RestoredCoachConversation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, so the restore reads that shape rather than the builder's. The
|
||||
* one field kept strictly required is `documentScoped`: a file that does not
|
||||
* state the fence must not be silently re-fenced to the permissive value, and
|
||||
* making it optional here would let exactly that compile.
|
||||
*/
|
||||
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
|
||||
|
||||
export type RestoredCoachMessage = Pick<
|
||||
CoachMessageBackupEntry,
|
||||
"id" | "role" | "createdAt"
|
||||
> &
|
||||
OptionalNullable<
|
||||
Pick<
|
||||
CoachMessageBackupEntry,
|
||||
| "contentEncrypted"
|
||||
| "content"
|
||||
| "metricSourceJson"
|
||||
| "providerType"
|
||||
| "promptVersion"
|
||||
| "tokensUsed"
|
||||
| "model"
|
||||
>
|
||||
>;
|
||||
|
||||
export type RestoredCoachConversation = Pick<
|
||||
CoachConversationBackupEntry,
|
||||
"id" | "title" | "documentScoped" | "createdAt" | "updatedAt"
|
||||
> &
|
||||
OptionalNullable<
|
||||
Pick<
|
||||
CoachConversationBackupEntry,
|
||||
"summaryEncrypted" | "summary" | "summaryUpdatedAt" | "summaryTurnCount"
|
||||
>
|
||||
> & {
|
||||
messages: RestoredCoachMessage[];
|
||||
attachments: CoachConversationDocumentBackupEntry[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-create the account's Coach conversations, turns and document links.
|
||||
*
|
||||
* Delete-then-recreate inside the caller's transaction, matching every other
|
||||
* section. Messages and attachments cascade from the conversation, so deleting
|
||||
* the conversations is enough to empty them — they are counted first anyway, so
|
||||
* the cleared numbers are truthful rather than "whatever the cascade took".
|
||||
*
|
||||
* MUST be called AFTER the vault documents are restored: an attachment carries
|
||||
* a `documentId` that is a foreign key against them, and running earlier would
|
||||
* make every attachment unresolvable.
|
||||
*
|
||||
* `documentIds` is the set of documents the restore actually put back, passed
|
||||
* in rather than re-queried so this stays a pure function of the transaction it
|
||||
* was handed. An attachment naming anything outside it is dropped and reported.
|
||||
*/
|
||||
export async function restoreCoachData(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
payload: CoachRestoreInput,
|
||||
documentIds: ReadonlySet<string>,
|
||||
skips: RestoreSkipLog,
|
||||
): Promise<CoachRestoreCleared> {
|
||||
const [clearedMessages, clearedAttachments] = await Promise.all([
|
||||
tx.coachMessage.count({ where: { conversation: { userId: ownerId } } }),
|
||||
tx.coachConversationDocument.count({
|
||||
where: { conversation: { userId: ownerId } },
|
||||
}),
|
||||
]);
|
||||
const clearedConversations = await tx.coachConversation.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
for (const conversation of payload.coachConversations) {
|
||||
await tx.coachConversation.create({
|
||||
data: {
|
||||
id: conversation.id,
|
||||
userId: ownerId,
|
||||
title: conversation.title,
|
||||
// Verbatim. See the file header: this is the one field whose loss is a
|
||||
// security regression rather than a gap in the history.
|
||||
documentScoped: conversation.documentScoped,
|
||||
summaryEncrypted: resolveSummaryBytes(conversation),
|
||||
summaryUpdatedAt: conversation.summaryUpdatedAt
|
||||
? new Date(conversation.summaryUpdatedAt)
|
||||
: null,
|
||||
summaryTurnCount: conversation.summaryTurnCount ?? 0,
|
||||
createdAt: new Date(conversation.createdAt),
|
||||
updatedAt: new Date(conversation.updatedAt),
|
||||
messages: {
|
||||
create: conversation.messages.map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
encryptedContent: resolveTurnBytes(message),
|
||||
metricSourceJson: message.metricSourceJson ?? null,
|
||||
providerType: message.providerType ?? null,
|
||||
promptVersion: message.promptVersion ?? null,
|
||||
tokensUsed: message.tokensUsed ?? null,
|
||||
model: message.model ?? null,
|
||||
createdAt: new Date(message.createdAt),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The attachments go in a second pass so a dropped document reference costs
|
||||
// one link rather than the conversation that held it.
|
||||
const droppedDocumentRefs: string[] = [];
|
||||
for (const conversation of payload.coachConversations) {
|
||||
const writable = conversation.attachments.filter((attachment) => {
|
||||
if (documentIds.has(attachment.documentId)) return true;
|
||||
droppedDocumentRefs.push(attachment.documentId);
|
||||
return false;
|
||||
});
|
||||
if (writable.length === 0) continue;
|
||||
await tx.coachConversationDocument.createMany({
|
||||
data: writable.map((attachment) => ({
|
||||
conversationId: conversation.id,
|
||||
documentId: attachment.documentId,
|
||||
addedAt: new Date(attachment.addedAt),
|
||||
})),
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"coachAttachment",
|
||||
[...new Set(droppedDocumentRefs)],
|
||||
droppedDocumentRefs,
|
||||
);
|
||||
|
||||
return {
|
||||
coachConversations: clearedConversations.count,
|
||||
coachMessages: clearedMessages,
|
||||
coachConversationDocuments: clearedAttachments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn'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 plaintext, which has to be encrypted on
|
||||
* the way in — the target instance's key, not the source's, which is the whole
|
||||
* point of a portable file being portable.
|
||||
*/
|
||||
function resolveTurnBytes(
|
||||
message: RestoredCoachMessage,
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
if (message.contentEncrypted !== undefined) {
|
||||
return decodeBase64(message.contentEncrypted);
|
||||
}
|
||||
return encryptToBytes(message.content ?? "");
|
||||
}
|
||||
|
||||
function resolveSummaryBytes(
|
||||
conversation: RestoredCoachConversation,
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (conversation.summaryEncrypted !== undefined) {
|
||||
return conversation.summaryEncrypted === null
|
||||
? null
|
||||
: decodeBase64(conversation.summaryEncrypted);
|
||||
}
|
||||
// A portable file with no summary and one with an empty summary are the same
|
||||
// thing to a reader, and neither should write a zero-byte ciphertext.
|
||||
return conversation.summary ? encryptToBytes(conversation.summary) : null;
|
||||
}
|
||||
|
||||
function decodeBase64(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
@@ -55,6 +55,12 @@ import {
|
||||
type RemindersBackupCounts,
|
||||
type RemindersBackupSection,
|
||||
} from "@/lib/export/reminders-backup";
|
||||
import {
|
||||
buildCoachBackupSection,
|
||||
countCoachBackupSection,
|
||||
type CoachBackupCounts,
|
||||
type CoachBackupSection,
|
||||
} from "@/lib/export/coach-backup";
|
||||
import {
|
||||
buildIntradayProfileBackupSection,
|
||||
countIntradayProfileBackupSection,
|
||||
@@ -70,7 +76,8 @@ export interface FullBackupCounts
|
||||
HealthScoreBackupCounts,
|
||||
VisitsBackupCounts,
|
||||
VaccinationsBackupCounts,
|
||||
RemindersBackupCounts {
|
||||
RemindersBackupCounts,
|
||||
CoachBackupCounts {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
@@ -287,6 +294,7 @@ export async function buildFullBackupPayload(
|
||||
visits,
|
||||
vaccinations,
|
||||
reminders,
|
||||
coach,
|
||||
nutrientDays,
|
||||
] = await Promise.all([
|
||||
disasterRecovery
|
||||
@@ -435,6 +443,12 @@ export async function buildFullBackupPayload(
|
||||
buildRemindersBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// The Coach's conversations, turns and document links. Both ends live in
|
||||
// `src/lib/export/coach-backup.ts` beside each other, the same arrangement
|
||||
// as the three sections above and for the same reason.
|
||||
buildCoachBackupSection(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").
|
||||
@@ -464,6 +478,7 @@ export async function buildFullBackupPayload(
|
||||
const visitsSection: VisitsBackupSection = visits;
|
||||
const vaccinationsSection: VaccinationsBackupSection = vaccinations;
|
||||
const remindersSection: RemindersBackupSection = reminders;
|
||||
const coachSection: CoachBackupSection = coach;
|
||||
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
@@ -742,6 +757,7 @@ export async function buildFullBackupPayload(
|
||||
...visitsSection,
|
||||
...vaccinationsSection,
|
||||
...remindersSection,
|
||||
...coachSection,
|
||||
nutrientDays: nutrientDays.map((n) => ({
|
||||
day: n.day,
|
||||
nutrient: n.nutrient,
|
||||
@@ -780,6 +796,7 @@ export async function buildFullBackupPayload(
|
||||
...countVisitsBackupSection(visits),
|
||||
...countVaccinationsBackupSection(vaccinations),
|
||||
...countRemindersBackupSection(reminders),
|
||||
...countCoachBackupSection(coach),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -55,7 +55,16 @@
|
||||
* it exists for the hand-edited or truncated file, where inventing the
|
||||
* reminder and silently dropping the row are equally wrong.
|
||||
*
|
||||
* The eighth, `checkupClosure`, is not about a restore at all, and it borrows
|
||||
* The eighth, `coachAttachment`, is the same kind again, for the join naming
|
||||
* which vault documents a Coach conversation was grounded in. The document is
|
||||
* restored before the conversations are, so a file this release writes always
|
||||
* resolves: a disaster-recovery payload carries every document, and a portable
|
||||
* payload never reaches a restore while the account has any, because the route
|
||||
* refuses it ahead of the wipe. It exists for the hand-edited or truncated
|
||||
* file, where inventing the document and silently dropping the provenance are
|
||||
* equally wrong.
|
||||
*
|
||||
* The ninth, `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
|
||||
@@ -71,6 +80,7 @@ export type SkippedCatalogue =
|
||||
| "visitReference"
|
||||
| "vaccinationReference"
|
||||
| "reminderReference"
|
||||
| "coachAttachment"
|
||||
| "checkupClosure";
|
||||
|
||||
/** One key this instance does not know, and the links it cost. */
|
||||
|
||||
@@ -1042,6 +1042,57 @@ const measurementReminderEventBackupSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* One Coach turn.
|
||||
*
|
||||
* `contentEncrypted` and `content` are the two ends of the same contract, so
|
||||
* both are optional here and exactly one arrives: a disaster-recovery file
|
||||
* carries the ciphertext, a portable file carries the prose. Requiring either
|
||||
* would refuse half the valid files; requiring both would refuse all of them.
|
||||
* The restore picks whichever it was handed.
|
||||
*/
|
||||
const coachMessageBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
role: z.string().min(1),
|
||||
contentEncrypted: base64BytesSchema.optional(),
|
||||
content: z.string().optional(),
|
||||
metricSourceJson: z.string().nullable().optional(),
|
||||
providerType: z.string().nullable().optional(),
|
||||
promptVersion: z.string().nullable().optional(),
|
||||
tokensUsed: z.number().int().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const coachConversationDocumentBackupSchema = z
|
||||
.object({
|
||||
documentId: z.string().min(1),
|
||||
addedAt: isoDateTime,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const coachConversationBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string(),
|
||||
// NOT optional with a default. The fence is permanent and a file that does
|
||||
// not state it is a file that cannot be trusted to re-fence the
|
||||
// conversation, so the absence has to be visible rather than defaulted to
|
||||
// the permissive value.
|
||||
documentScoped: z.boolean(),
|
||||
summaryEncrypted: base64BytesSchema.nullable().optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
summaryUpdatedAt: isoDateTime.nullable().optional(),
|
||||
summaryTurnCount: z.number().int().min(0).optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
messages: z.array(coachMessageBackupSchema).default([]),
|
||||
attachments: z.array(coachConversationDocumentBackupSchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const allergyBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -1208,6 +1259,7 @@ export const backupPayloadSchema = z
|
||||
// iOS #68). Defaulted for the same reason as the sections above: a file
|
||||
// written before the reminders travelled carries no key, and an account
|
||||
// with none writes [].
|
||||
coachConversations: z.array(coachConversationBackupSchema).default([]),
|
||||
measurementReminders: z.array(measurementReminderBackupSchema).default([]),
|
||||
measurementReminderEvents: z
|
||||
.array(measurementReminderEventBackupSchema)
|
||||
@@ -1298,6 +1350,10 @@ export interface BackupSummary {
|
||||
measurementReminders: number;
|
||||
/** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */
|
||||
measurementReminderEvents: number;
|
||||
/** Coach conversation threads. */
|
||||
coachConversations: number;
|
||||
/** Coach turns across every thread. */
|
||||
coachMessages: number;
|
||||
}
|
||||
|
||||
export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
@@ -1354,6 +1410,11 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
// file with reminders the way it once did for visits.
|
||||
measurementReminders: payload.measurementReminders.length,
|
||||
measurementReminderEvents: payload.measurementReminderEvents.length,
|
||||
coachConversations: payload.coachConversations.length,
|
||||
coachMessages: payload.coachConversations.reduce(
|
||||
(total, conversation) => total + conversation.messages.length,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
|
||||
const OWNER_ID = "round-trip-owner";
|
||||
const AT = (iso: string) => new Date(iso);
|
||||
const COACH_SUMMARY = "earlier turns: weight trend and evening walks";
|
||||
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 SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
|
||||
|
||||
@@ -163,6 +167,14 @@ const COUNT_BACK: Record<
|
||||
p.measurementReminder.count({ where: { userId } }),
|
||||
MeasurementReminderEvent: (p, userId) =>
|
||||
p.measurementReminderEvent.count({ where: { userId } }),
|
||||
CoachConversation: (p, userId) =>
|
||||
p.coachConversation.count({ where: { userId } }),
|
||||
// Neither the turn nor the attachment carries its own `userId` — both are
|
||||
// reached through the thread that holds them.
|
||||
CoachMessage: (p, userId) =>
|
||||
p.coachMessage.count({ where: { conversation: { userId } } }),
|
||||
CoachConversationDocument: (p, userId) =>
|
||||
p.coachConversationDocument.count({ where: { conversation: { userId } } }),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -475,6 +487,62 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// 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),
|
||||
providerType: "anthropic",
|
||||
model: "claude-opus-5",
|
||||
tokensUsed: 812,
|
||||
createdAt: AT("2026-07-20T09:59:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.coachConversation.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
title: "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") },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -757,6 +825,82 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
{ pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null },
|
||||
]);
|
||||
|
||||
// The Coach came back able to speak, and the fence held.
|
||||
//
|
||||
// `documentScoped` is asserted per thread rather than in aggregate: a
|
||||
// restore that wrote `false` everywhere would still return two threads,
|
||||
// two counts and every turn, and the only visible difference would be a
|
||||
// fenced conversation quietly holding a tool loop again.
|
||||
// Resolved from the database rather than remembered from the fixture: the
|
||||
// attachment has to point at the document the RESTORE put back, not at an
|
||||
// id that happens to match one the seeding used.
|
||||
const vaultDocument = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
const threads = await prisma.coachConversation.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
attachments: true,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
threads.map((thread) => ({
|
||||
title: thread.title,
|
||||
documentScoped: thread.documentScoped,
|
||||
summary: thread.summaryEncrypted
|
||||
? decryptFromBytes(thread.summaryEncrypted)
|
||||
: null,
|
||||
summaryTurnCount: thread.summaryTurnCount,
|
||||
turns: thread.messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: decryptFromBytes(message.encryptedContent),
|
||||
model: message.model,
|
||||
tokensUsed: message.tokensUsed,
|
||||
})),
|
||||
attachments: thread.attachments.map((a) => a.documentId),
|
||||
})),
|
||||
"the transcript, the fence and the provenance all come back",
|
||||
).toEqual([
|
||||
{
|
||||
title: "How is my blood pressure trending?",
|
||||
documentScoped: false,
|
||||
summary: COACH_SUMMARY,
|
||||
summaryTurnCount: 4,
|
||||
turns: [
|
||||
{
|
||||
role: "user",
|
||||
content: COACH_USER_TURN,
|
||||
model: null,
|
||||
tokensUsed: null,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: COACH_ASSISTANT_TURN,
|
||||
model: "claude-opus-5",
|
||||
tokensUsed: 812,
|
||||
},
|
||||
],
|
||||
attachments: [],
|
||||
},
|
||||
{
|
||||
title: "About my June labs",
|
||||
documentScoped: true,
|
||||
summary: null,
|
||||
summaryTurnCount: 0,
|
||||
turns: [
|
||||
{
|
||||
role: "user",
|
||||
content: "What does the ferritin mean?",
|
||||
model: null,
|
||||
tokensUsed: null,
|
||||
},
|
||||
],
|
||||
attachments: [vaultDocument.id],
|
||||
},
|
||||
]);
|
||||
|
||||
// The ramp, in order, with the note back in its column. Counting says two
|
||||
// rows returned; only this says they came back as the same ramp, and that
|
||||
// the encrypted note did not land in the plaintext column on the way.
|
||||
|
||||
Reference in new issue
Block a user