Files
HealthLog/src/lib/export/coach-backup.ts
T
Marc-André Bombeck 1fbd3b628b Carry the Coach's memory through backup and restore
The transcript came back in the previous change; what the Coach keeps
BETWEEN threads did not. A restored account had every word of every
conversation and a Coach that has to be told its own history again — the
register said as much for all three: "a restore makes the account
re-tell its history", "keeps the talk and loses the commitment", "a
restored account silently stops sending them".

Tombstones ride only in a disaster-recovery file. All three soft-delete,
and a deleted fact is hidden from injection while kept for audit — a
distinction only the owning instance can act on. A portable file
carrying them would hand a person rows their own app refuses to show
them, and a restore that resurrected one would put a fact back into the
Coach's mouth that the person had deliberately taken out of it.

Neither reference here is a foreign key. sourceConversationId, on all
three, and relatedPlanId on the reminder are bare id columns, so nothing
in the database refuses a value pointing at nothing. That makes them
more dangerous than a real key rather than less: a dangling reference
costs no error, it just quietly stops meaning anything while the row
goes on looking complete. The restore resolves both against what it
actually wrote, nulls what it cannot resolve, and reports the count.

Nulling rather than dropping is the point. A live reminder must not be
lost because the plan it mentions was tombstoned out of a portable file.
The reminder is still the person's and it still fires; what it loses is
a pointer that was already going nowhere.

Order follows from that: threads, then plans, then reminders. Running
the memory before the transcript would cut every fact loose from the
conversation it came out of and still report success.

Verified by breaking it: making the conversation lookup always return
null fails the round trip on the fact's own source field.
2026-08-22 00:41:26 +02:00

915 lines
31 KiB
TypeScript

/**
* 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;
}
/* ────────────────────────────────────────────────────────────────────────────
* The Coach's MEMORY: the facts, plans and reminders it carries BETWEEN
* threads.
*
* The transcript above is what was said. This is what the Coach was asked to
* keep. Without it a restored account has every word of every conversation and
* a Coach that has to be told its own history again — which the register named
* exactly: "a restore makes the account re-tell its history", "keeps the talk
* and loses the commitment", "a restored account silently stops sending them".
*
* ## Tombstones ride, but only in a disaster-recovery file
*
* All three soft-delete. A deleted fact is hidden from injection and kept for
* audit, which is a distinction only the owning instance can act on, so a
* disaster-recovery payload carries the tombstone and a portable export omits
* it — the same split the Vorsorge reminders use. A portable file that carried
* them would hand a person rows their own app refuses to show them, and a
* restore that resurrected one would put a fact back into the Coach's mouth
* that the person had deliberately taken out of it.
*
* ## Two references, neither of them a foreign key
*
* `sourceConversationId` and `relatedPlanId` are bare id columns — the schema
* declares no relation for either, so nothing in the database refuses a value
* that points at nothing. That makes them more dangerous than a real key, not
* less: a dangling reference costs no error, it just quietly stops meaning
* anything, and the row goes on looking complete.
*
* So the restore resolves both against what it actually wrote and NULLS what it
* cannot resolve, then reports the count. Nulling rather than dropping is the
* point: a live reminder must not be lost because the plan it mentions was
* tombstoned out of a portable file. The reminder is still the person's, and it
* still fires. What it loses is a pointer that was already going nowhere.
* ──────────────────────────────────────────────────────────────────────────── */
/** One durable fact the Coach was told to remember. */
export interface CoachFactBackupEntry {
id: string;
/** Ciphertext as base64. Present on a disaster-recovery payload only. */
factEncrypted?: string;
/** Plaintext. Present on a portable payload only. */
fact?: string;
category: string;
confidence: number;
sourceConversationId: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/** One implementation intention: "if <cue>, then <action>". */
export interface CoachPlanBackupEntry {
id: string;
metric: string;
ifCueEncrypted?: string;
ifCue?: string;
thenActionEncrypted?: string;
thenAction?: string;
targetEncrypted?: string | null;
target?: string | null;
outcomeEncrypted?: string | null;
outcome?: string | null;
status: string;
reviewDate: string | null;
sourceConversationId: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/** One thing to bring back up, in the person's own framing. */
export interface CoachReminderBackupEntry {
id: string;
noteEncrypted?: string;
note?: string;
metric: string | null;
relatedPlanId: string | null;
triggerKind: string;
dueAt: string | null;
contextCue: string | null;
status: string;
source: string;
sourceConversationId: string | null;
lastSurfacedAt: string | null;
surfaceCount: number;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
export interface CoachMemoryBackupSection {
coachFacts: CoachFactBackupEntry[];
coachPlans: CoachPlanBackupEntry[];
coachReminders: CoachReminderBackupEntry[];
}
export interface CoachMemoryBackupCounts {
coachFacts: number;
coachPlans: number;
coachReminders: number;
}
export async function buildCoachMemoryBackupSection(
prisma: Pick<PrismaClient, "coachFact" | "coachPlan" | "coachReminder">,
userId: string,
options: CoachBackupOptions = {},
): Promise<CoachMemoryBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const scope = disasterRecovery ? { userId } : { userId, deletedAt: null };
const [factRows, planRows, reminderRows] = await Promise.all([
prisma.coachFact.findMany({
where: scope,
orderBy: { createdAt: "asc" },
select: COACH_FACT_BACKUP_SELECT,
}),
prisma.coachPlan.findMany({
where: scope,
orderBy: { createdAt: "asc" },
select: COACH_PLAN_BACKUP_SELECT,
}),
prisma.coachReminder.findMany({
where: scope,
orderBy: { createdAt: "asc" },
select: COACH_REMINDER_BACKUP_SELECT,
}),
]);
const asBase64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64");
const asBase64OrNull = (bytes: Uint8Array | null) =>
bytes ? asBase64(bytes) : null;
return {
coachFacts: factRows.map((row) => ({
id: row.id,
...(disasterRecovery
? {
factEncrypted: asBase64(row.factEncrypted),
deletedAt: row.deletedAt?.toISOString() ?? null,
}
: { fact: decryptTurnSoft(row.factEncrypted) ?? "" }),
category: row.category,
confidence: row.confidence,
sourceConversationId: row.sourceConversationId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
coachPlans: planRows.map((row) => ({
id: row.id,
metric: row.metric,
...(disasterRecovery
? {
ifCueEncrypted: asBase64(row.ifCueEncrypted),
thenActionEncrypted: asBase64(row.thenActionEncrypted),
targetEncrypted: asBase64OrNull(row.targetEncrypted),
outcomeEncrypted: asBase64OrNull(row.outcomeEncrypted),
deletedAt: row.deletedAt?.toISOString() ?? null,
}
: {
ifCue: decryptTurnSoft(row.ifCueEncrypted) ?? "",
thenAction: decryptTurnSoft(row.thenActionEncrypted) ?? "",
target: decryptTurnSoft(row.targetEncrypted),
outcome: decryptTurnSoft(row.outcomeEncrypted),
}),
status: row.status,
reviewDate: row.reviewDate?.toISOString() ?? null,
sourceConversationId: row.sourceConversationId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
coachReminders: reminderRows.map((row) => ({
id: row.id,
...(disasterRecovery
? {
noteEncrypted: asBase64(row.noteEncrypted),
deletedAt: row.deletedAt?.toISOString() ?? null,
}
: { note: decryptTurnSoft(row.noteEncrypted) ?? "" }),
metric: row.metric,
relatedPlanId: row.relatedPlanId,
triggerKind: row.triggerKind,
dueAt: row.dueAt?.toISOString() ?? null,
contextCue: row.contextCue,
status: row.status,
source: row.source,
sourceConversationId: row.sourceConversationId,
lastSurfacedAt: row.lastSurfacedAt?.toISOString() ?? null,
surfaceCount: row.surfaceCount,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
};
}
const COACH_FACT_BACKUP_SELECT = {
id: true,
factEncrypted: true,
category: true,
confidence: true,
sourceConversationId: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} satisfies Prisma.CoachFactSelect;
const COACH_PLAN_BACKUP_SELECT = {
id: true,
metric: true,
ifCueEncrypted: true,
thenActionEncrypted: true,
targetEncrypted: true,
outcomeEncrypted: true,
status: true,
reviewDate: true,
sourceConversationId: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} satisfies Prisma.CoachPlanSelect;
const COACH_REMINDER_BACKUP_SELECT = {
id: true,
noteEncrypted: true,
metric: true,
relatedPlanId: true,
triggerKind: true,
dueAt: true,
contextCue: true,
status: true,
source: true,
sourceConversationId: true,
lastSurfacedAt: true,
surfaceCount: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} satisfies Prisma.CoachReminderSelect;
/** Row counts for the audit trail, mirroring the other section counters. */
export function countCoachMemoryBackupSection(
section: CoachMemoryBackupSection,
): CoachMemoryBackupCounts {
return {
coachFacts: section.coachFacts.length,
coachPlans: section.coachPlans.length,
coachReminders: section.coachReminders.length,
};
}
/** Counts the Coach-memory restore wiped, for the audit trail. */
export interface CoachMemoryRestoreCleared {
coachFacts: number;
coachPlans: number;
coachReminders: number;
}
type OptionalMemory<T> = { [K in keyof T]?: T[K] | undefined };
export type RestoredCoachFact = Pick<
CoachFactBackupEntry,
"id" | "category" | "createdAt" | "updatedAt"
> &
OptionalMemory<
Pick<
CoachFactBackupEntry,
| "factEncrypted"
| "fact"
| "confidence"
| "sourceConversationId"
| "deletedAt"
>
>;
export type RestoredCoachPlan = Pick<
CoachPlanBackupEntry,
"id" | "metric" | "createdAt" | "updatedAt"
> &
OptionalMemory<
Pick<
CoachPlanBackupEntry,
| "ifCueEncrypted"
| "ifCue"
| "thenActionEncrypted"
| "thenAction"
| "targetEncrypted"
| "target"
| "outcomeEncrypted"
| "outcome"
| "status"
| "reviewDate"
| "sourceConversationId"
| "deletedAt"
>
>;
export type RestoredCoachReminder = Pick<
CoachReminderBackupEntry,
"id" | "source" | "createdAt" | "updatedAt"
> &
OptionalMemory<
Pick<
CoachReminderBackupEntry,
| "noteEncrypted"
| "note"
| "metric"
| "relatedPlanId"
| "triggerKind"
| "dueAt"
| "contextCue"
| "status"
| "sourceConversationId"
| "lastSurfacedAt"
| "surfaceCount"
| "deletedAt"
>
>;
export interface CoachMemoryRestoreInput {
coachFacts: RestoredCoachFact[];
coachPlans: RestoredCoachPlan[];
coachReminders: RestoredCoachReminder[];
}
/**
* Re-create the Coach's memory.
*
* MUST be called AFTER `restoreCoachData`: every one of the three carries a
* `sourceConversationId`, and a reference resolved before the threads exist
* would read as unresolvable and be nulled — a restore that reported success
* while quietly cutting every fact loose from the conversation it came out of.
*
* Plans go in before reminders for the same reason one step down: a reminder's
* `relatedPlanId` is resolved against the plans this function has just written.
*/
export async function restoreCoachMemoryData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: CoachMemoryRestoreInput,
conversationIds: ReadonlySet<string>,
skips: RestoreSkipLog,
): Promise<CoachMemoryRestoreCleared> {
const [clearedFacts, clearedPlans, clearedReminders] = await Promise.all([
tx.coachFact.deleteMany({ where: { userId: ownerId } }),
tx.coachPlan.deleteMany({ where: { userId: ownerId } }),
tx.coachReminder.deleteMany({ where: { userId: ownerId } }),
]);
// Both references are nulled rather than dropped when they do not resolve,
// and every miss is reported. See the section header: these are bare id
// columns, so a dangling value costs no error and simply stops meaning
// anything.
const danglingRefs: string[] = [];
const resolveConversation = (id: string | null | undefined) => {
if (!id) return null;
if (conversationIds.has(id)) return id;
danglingRefs.push(id);
return null;
};
if (payload.coachFacts.length > 0) {
await tx.coachFact.createMany({
data: payload.coachFacts.map((fact) => ({
id: fact.id,
userId: ownerId,
factEncrypted: resolveMemoryBytes(fact.factEncrypted, fact.fact),
category: fact.category,
confidence: fact.confidence ?? 50,
sourceConversationId: resolveConversation(fact.sourceConversationId),
createdAt: new Date(fact.createdAt),
updatedAt: new Date(fact.updatedAt),
deletedAt: fact.deletedAt ? new Date(fact.deletedAt) : null,
})),
});
}
if (payload.coachPlans.length > 0) {
await tx.coachPlan.createMany({
data: payload.coachPlans.map((plan) => ({
id: plan.id,
userId: ownerId,
metric: plan.metric,
ifCueEncrypted: resolveMemoryBytes(plan.ifCueEncrypted, plan.ifCue),
thenActionEncrypted: resolveMemoryBytes(
plan.thenActionEncrypted,
plan.thenAction,
),
targetEncrypted: resolveOptionalMemoryBytes(
plan.targetEncrypted,
plan.target,
),
outcomeEncrypted: resolveOptionalMemoryBytes(
plan.outcomeEncrypted,
plan.outcome,
),
status: plan.status ?? "proposed",
reviewDate: plan.reviewDate ? new Date(plan.reviewDate) : null,
sourceConversationId: resolveConversation(plan.sourceConversationId),
createdAt: new Date(plan.createdAt),
updatedAt: new Date(plan.updatedAt),
deletedAt: plan.deletedAt ? new Date(plan.deletedAt) : null,
})),
});
}
const restoredPlanIds = new Set(payload.coachPlans.map((plan) => plan.id));
if (payload.coachReminders.length > 0) {
await tx.coachReminder.createMany({
data: payload.coachReminders.map((reminder) => {
let relatedPlanId = reminder.relatedPlanId ?? null;
if (relatedPlanId && !restoredPlanIds.has(relatedPlanId)) {
danglingRefs.push(relatedPlanId);
relatedPlanId = null;
}
return {
id: reminder.id,
userId: ownerId,
noteEncrypted: resolveMemoryBytes(
reminder.noteEncrypted,
reminder.note,
),
metric: reminder.metric ?? null,
relatedPlanId,
triggerKind: reminder.triggerKind ?? "date",
dueAt: reminder.dueAt ? new Date(reminder.dueAt) : null,
contextCue: reminder.contextCue ?? null,
status: reminder.status ?? "active",
source: reminder.source,
sourceConversationId: resolveConversation(
reminder.sourceConversationId,
),
lastSurfacedAt: reminder.lastSurfacedAt
? new Date(reminder.lastSurfacedAt)
: null,
surfaceCount: reminder.surfaceCount ?? 0,
createdAt: new Date(reminder.createdAt),
updatedAt: new Date(reminder.updatedAt),
deletedAt: reminder.deletedAt ? new Date(reminder.deletedAt) : null,
};
}),
});
}
recordUnknownKeys(
skips,
"coachReference",
[...new Set(danglingRefs)],
danglingRefs,
);
return {
coachFacts: clearedFacts.count,
coachPlans: clearedPlans.count,
coachReminders: clearedReminders.count,
};
}
function resolveMemoryBytes(
ciphertext: string | undefined,
plaintext: string | undefined,
): Uint8Array<ArrayBuffer> {
if (ciphertext !== undefined) return decodeBase64(ciphertext);
return encryptToBytes(plaintext ?? "");
}
function resolveOptionalMemoryBytes(
ciphertext: string | null | undefined,
plaintext: string | null | undefined,
): Uint8Array<ArrayBuffer> | null {
if (ciphertext !== undefined) {
return ciphertext === null ? null : decodeBase64(ciphertext);
}
return plaintext ? encryptToBytes(plaintext) : null;
}