mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #814 from MBombeck/feat/backup-coach-memory
Carry the Coach's memory through backup and restore
This commit is contained in:
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user