Merge pull request #814 from MBombeck/feat/backup-coach-memory

Carry the Coach's memory through backup and restore
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 00:59:10 +02:00
commit f4e65c827a
11 files changed
+735 -9

No files matched your search

@@ -297,6 +297,9 @@ function sourceClient() {
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
@@ -50,7 +50,10 @@ 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 {
restoreCoachData,
restoreCoachMemoryData,
} from "@/lib/export/coach-backup";
import { restoreRemindersData } from "@/lib/export/reminders-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
@@ -102,6 +105,9 @@ interface RestoreResponse {
coachConversations: number;
coachMessages: number;
coachConversationDocuments: number;
coachFacts: number;
coachPlans: number;
coachReminders: number;
};
}
@@ -1433,6 +1439,19 @@ const handler = apiHandler(
skips,
);
// What the Coach keeps between threads. AFTER the transcript,
// because all three carry a `sourceConversationId` that is resolved
// against the threads the branch above has just written — resolving
// earlier would cut every fact loose from the conversation it came
// out of and still report success.
const coachMemoryCleared = await restoreCoachMemoryData(
tx,
ownerId,
payload,
new Set(payload.coachConversations.map((c) => c.id)),
skips,
);
const cleared = {
measurements: measurements.count,
medications: meds.count,
@@ -1470,6 +1489,9 @@ const handler = apiHandler(
coachConversations: coachCleared.coachConversations,
coachMessages: coachCleared.coachMessages,
coachConversationDocuments: coachCleared.coachConversationDocuments,
coachFacts: coachMemoryCleared.coachFacts,
coachPlans: coachMemoryCleared.coachPlans,
coachReminders: coachMemoryCleared.coachReminders,
};
return { cleared, skipped: summarizeRestoreSkips(skips) };
},
@@ -43,6 +43,9 @@ vi.mock("@/lib/db", () => ({
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -131,6 +134,9 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.coachConversation.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -41,6 +41,9 @@ vi.mock("@/lib/db", () => ({
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
@@ -309,6 +309,9 @@ function makePrisma() {
// future `include` that forgets the relation fails here loudly instead of
// exporting an account whose Coach never spoke.
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { 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.
+12 -6
View File
@@ -331,6 +331,18 @@ export const TWO_ENDED_MODELS = [
"CoachConversation",
"CoachMessage",
"CoachConversationDocument",
// And what the Coach keeps between threads. The transcript alone restores an
// account with every word of every conversation and a Coach that has to be
// told its own history again.
//
// Both of the references here — `sourceConversationId` on all three,
// `relatedPlanId` on the reminder — are bare id columns with no foreign key,
// so a value pointing at nothing costs no error and just stops meaning
// anything. The restore resolves them against what it wrote and reports what
// it could not, rather than writing a pointer it knows is dead.
"CoachFact",
"CoachPlan",
"CoachReminder",
] as const;
/** One model claimed to travel both ways. */
@@ -399,12 +411,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.",
CoachFact:
"Durable facts the Coach was told to remember. A restore makes the account re-tell its history.",
CoachPlan:
"An agreed plan and its progress. Restoring the conversation without the plan keeps the talk and loses the commitment.",
CoachReminder:
"Reminders the Coach agreed to send. A restored account silently stops sending them.",
NotificationPreference:
"Per-event channel and quiet-hours choices. A restore reverts to defaults, so an account that had deliberately silenced a category starts being notified again.",
ReminderPhaseConfig:
+474
View File
@@ -438,3 +438,477 @@ function decodeBase64(encoded: string): Uint8Array<ArrayBuffer> {
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;
}
+16 -1
View File
@@ -57,9 +57,13 @@ import {
} from "@/lib/export/reminders-backup";
import {
buildCoachBackupSection,
buildCoachMemoryBackupSection,
countCoachBackupSection,
countCoachMemoryBackupSection,
type CoachBackupCounts,
type CoachBackupSection,
type CoachMemoryBackupCounts,
type CoachMemoryBackupSection,
} from "@/lib/export/coach-backup";
import {
buildIntradayProfileBackupSection,
@@ -77,7 +81,8 @@ export interface FullBackupCounts
VisitsBackupCounts,
VaccinationsBackupCounts,
RemindersBackupCounts,
CoachBackupCounts {
CoachBackupCounts,
CoachMemoryBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -295,6 +300,7 @@ export async function buildFullBackupPayload(
vaccinations,
reminders,
coach,
coachMemory,
nutrientDays,
] = await Promise.all([
disasterRecovery
@@ -449,6 +455,12 @@ export async function buildFullBackupPayload(
buildCoachBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// What the Coach keeps BETWEEN threads. Runs beside the transcript rather
// than inside it because the two answer different questions, and because a
// restore has to write them in that order.
buildCoachMemoryBackupSection(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").
@@ -479,6 +491,7 @@ export async function buildFullBackupPayload(
const vaccinationsSection: VaccinationsBackupSection = vaccinations;
const remindersSection: RemindersBackupSection = reminders;
const coachSection: CoachBackupSection = coach;
const coachMemorySection: CoachMemoryBackupSection = coachMemory;
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
@@ -758,6 +771,7 @@ export async function buildFullBackupPayload(
...vaccinationsSection,
...remindersSection,
...coachSection,
...coachMemorySection,
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -797,6 +811,7 @@ export async function buildFullBackupPayload(
...countVaccinationsBackupSection(vaccinations),
...countRemindersBackupSection(reminders),
...countCoachBackupSection(coach),
...countCoachMemoryBackupSection(coachMemory),
},
};
}
+11 -1
View File
@@ -64,7 +64,16 @@
* 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
* The ninth, `coachReference`, names a conversation or a plan that a Coach
* fact, plan or reminder pointed at and the restore did not put back. It is
* unlike every kind above in one way worth stating: neither `sourceConversationId`
* nor `relatedPlanId` is a foreign key, so a dangling value costs no error and
* simply stops meaning anything. What is dropped here is therefore the POINTER
* and not the row — the fact, the plan and the reminder all restore, and the
* 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
* 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
@@ -81,6 +90,7 @@ export type SkippedCatalogue =
| "vaccinationReference"
| "reminderReference"
| "coachAttachment"
| "coachReference"
| "checkupClosure";
/** One key this instance does not know, and the links it cost. */
+71
View File
@@ -1093,6 +1093,67 @@ const coachConversationBackupSchema = z
})
.passthrough();
/**
* The Coach's memory. `deletedAt` is optional rather than nullable-required:
* a portable file omits tombstoned rows entirely and never states the field,
* while a disaster-recovery file always does.
*/
const coachFactBackupSchema = z
.object({
id: z.string().min(1),
factEncrypted: base64BytesSchema.optional(),
fact: z.string().optional(),
category: z.string().min(1),
confidence: z.number().int().min(0).max(100).optional(),
sourceConversationId: z.string().nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
const coachPlanBackupSchema = z
.object({
id: z.string().min(1),
metric: z.string().min(1),
ifCueEncrypted: base64BytesSchema.optional(),
ifCue: z.string().optional(),
thenActionEncrypted: base64BytesSchema.optional(),
thenAction: z.string().optional(),
targetEncrypted: base64BytesSchema.nullable().optional(),
target: z.string().nullable().optional(),
outcomeEncrypted: base64BytesSchema.nullable().optional(),
outcome: z.string().nullable().optional(),
status: z.string().optional(),
reviewDate: isoDateTime.nullable().optional(),
sourceConversationId: z.string().nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
const coachReminderBackupSchema = z
.object({
id: z.string().min(1),
noteEncrypted: base64BytesSchema.optional(),
note: z.string().optional(),
metric: z.string().nullable().optional(),
relatedPlanId: z.string().nullable().optional(),
triggerKind: z.string().optional(),
dueAt: isoDateTime.nullable().optional(),
contextCue: z.string().nullable().optional(),
status: z.string().optional(),
source: z.string().min(1),
sourceConversationId: z.string().nullable().optional(),
lastSurfacedAt: isoDateTime.nullable().optional(),
surfaceCount: z.number().int().min(0).optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
const allergyBackupSchema = z
.object({
id: z.string().min(1),
@@ -1260,6 +1321,9 @@ export const backupPayloadSchema = z
// written before the reminders travelled carries no key, and an account
// with none writes [].
coachConversations: z.array(coachConversationBackupSchema).default([]),
coachFacts: z.array(coachFactBackupSchema).default([]),
coachPlans: z.array(coachPlanBackupSchema).default([]),
coachReminders: z.array(coachReminderBackupSchema).default([]),
measurementReminders: z.array(measurementReminderBackupSchema).default([]),
measurementReminderEvents: z
.array(measurementReminderEventBackupSchema)
@@ -1354,6 +1418,10 @@ export interface BackupSummary {
coachConversations: number;
/** Coach turns across every thread. */
coachMessages: number;
/** Durable facts, agreed plans and things to bring back up. */
coachFacts: number;
coachPlans: number;
coachReminders: number;
}
export function summarizeBackup(payload: BackupPayload): BackupSummary {
@@ -1415,6 +1483,9 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
(total, conversation) => total + conversation.messages.length,
0,
),
coachFacts: payload.coachFacts.length,
coachPlans: payload.coachPlans.length,
coachReminders: payload.coachReminders.length,
};
}
+113
View File
@@ -82,6 +82,10 @@ vi.mock("@/lib/cache/invalidate", () => ({
const OWNER_ID = "round-trip-owner";
const AT = (iso: string) => new Date(iso);
const COACH_FACT = "does not tolerate ACE inhibitors";
const COACH_PLAN_CUE = "if the evening reading is over 140";
const COACH_PLAN_ACTION = "then walk twenty minutes before dinner tomorrow";
const COACH_REMINDER_NOTE = "ask how the evening walks are going";
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 =
@@ -175,6 +179,9 @@ const COUNT_BACK: Record<
p.coachMessage.count({ where: { conversation: { userId } } }),
CoachConversationDocument: (p, userId) =>
p.coachConversationDocument.count({ where: { conversation: { userId } } }),
CoachFact: (p, userId) => p.coachFact.count({ where: { userId } }),
CoachPlan: (p, userId) => p.coachPlan.count({ where: { userId } }),
CoachReminder: (p, userId) => p.coachReminder.count({ where: { userId } }),
};
/**
@@ -543,6 +550,48 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
},
});
// What the Coach keeps between threads, seeded so both references are
// exercised: the fact and the plan point back at the thread they came out
// of, and the reminder points at the plan.
const healthThread = await prisma.coachConversation.findFirstOrThrow({
where: { userId: OWNER_ID, documentScoped: false },
});
await prisma.coachFact.create({
data: {
userId: OWNER_ID,
factEncrypted: encryptToBytes(COACH_FACT),
category: "constraint",
confidence: 80,
sourceConversationId: healthThread.id,
},
});
const plan = await prisma.coachPlan.create({
data: {
userId: OWNER_ID,
metric: "BLOOD_PRESSURE",
ifCueEncrypted: encryptToBytes(COACH_PLAN_CUE),
thenActionEncrypted: encryptToBytes(COACH_PLAN_ACTION),
targetEncrypted: encryptToBytes("under 130 by October"),
status: "active",
reviewDate: AT("2026-10-01T00:00:00.000Z"),
sourceConversationId: healthThread.id,
},
});
await prisma.coachReminder.create({
data: {
userId: OWNER_ID,
noteEncrypted: encryptToBytes(COACH_REMINDER_NOTE),
metric: "BLOOD_PRESSURE",
relatedPlanId: plan.id,
triggerKind: "date",
dueAt: AT("2026-09-01T07:00:00.000Z"),
status: "active",
source: "extractor",
sourceConversationId: healthThread.id,
surfaceCount: 2,
},
});
// One visit, the practice it was at, and one link of each of the three
// kinds. The links reach the document, the lab result and the condition
// episode seeded above rather than fresh rows, because a link to something
@@ -901,6 +950,70 @@ describe("every model the plan claims two-ended survives a real restore", () =>
},
]);
// The Coach's memory, and both of its references still pointing at
// something. Neither is a foreign key, so a restore that wrote them back
// as null would break nothing, satisfy every count, and leave the account
// with a Coach that remembers a fact and no longer knows where it came
// from.
const restoredThread = await prisma.coachConversation.findFirstOrThrow({
where: { userId: OWNER_ID, documentScoped: false },
});
const restoredPlan = await prisma.coachPlan.findFirstOrThrow({
where: { userId: OWNER_ID },
});
const fact = await prisma.coachFact.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect({
fact: decryptFromBytes(fact.factEncrypted),
category: fact.category,
confidence: fact.confidence,
source: fact.sourceConversationId,
deletedAt: fact.deletedAt,
}).toEqual({
fact: COACH_FACT,
category: "constraint",
confidence: 80,
source: restoredThread.id,
deletedAt: null,
});
expect({
metric: restoredPlan.metric,
ifCue: decryptFromBytes(restoredPlan.ifCueEncrypted),
thenAction: decryptFromBytes(restoredPlan.thenActionEncrypted),
target: restoredPlan.targetEncrypted
? decryptFromBytes(restoredPlan.targetEncrypted)
: null,
status: restoredPlan.status,
source: restoredPlan.sourceConversationId,
}).toEqual({
metric: "BLOOD_PRESSURE",
ifCue: COACH_PLAN_CUE,
thenAction: COACH_PLAN_ACTION,
target: "under 130 by October",
status: "active",
source: restoredThread.id,
});
const reminder = await prisma.coachReminder.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect(
{
note: decryptFromBytes(reminder.noteEncrypted),
relatedPlanId: reminder.relatedPlanId,
source: reminder.sourceConversationId,
surfaceCount: reminder.surfaceCount,
status: reminder.status,
},
"the reminder still knows which plan it belongs to",
).toEqual({
note: COACH_REMINDER_NOTE,
relatedPlanId: restoredPlan.id,
source: restoredThread.id,
surfaceCount: 2,
status: "active",
});
// 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.