mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(coach): keep updatedAt through storage-only rewrites
The title encryption backfill moved each Coach conversation's title into ciphertext with a plain Prisma update, which stamps updatedAt. The Coach panel orders and groups conversations by that column, so threads untouched for months showed up under Today. The memory summary refresh did the same in the background. Storage-only rewrites now carry the row's own updatedAt and match on it, so a turn that lands meanwhile wins and the row is retried on the next pass: conversation titles, practitioner contacts, measurement and mood notes (where updatedAt is the sync cursor), inventory notes, and the mood-note script. The summary refresh writes through raw SQL. A guard freezes both rules: every backfill write on a model with @updatedAt names the column or is listed with the reason the stamp is wanted, and every conversation write outside the person's own actions keeps it.
This commit is contained in:
1 parent
3381026acb
commit
7d42cf7122
13 files changed
+504
-77
No files matched your search
@@ -52,6 +52,7 @@ interface RowSnapshot {
|
||||
id: string;
|
||||
tags: string | null;
|
||||
note: string | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -74,7 +75,7 @@ async function main(): Promise<void> {
|
||||
tags: { not: null },
|
||||
note: null,
|
||||
},
|
||||
select: { id: true, tags: true, note: true },
|
||||
select: { id: true, tags: true, note: true, updatedAt: true },
|
||||
});
|
||||
|
||||
console.log(`[backfill-note] scanned ${candidates.length} candidate rows`);
|
||||
@@ -87,9 +88,11 @@ async function main(): Promise<void> {
|
||||
|
||||
updated += 1;
|
||||
if (!dryRun) {
|
||||
// The entry did not change for its reader; it keeps its `updatedAt`
|
||||
// (the sync cursor) rather than taking the script's run time.
|
||||
await prisma.moodEntry.update({
|
||||
where: { id: row.id },
|
||||
data: { note, tags: newTags },
|
||||
data: { note, tags: newTags, updatedAt: row.updatedAt },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* A write that is not activity keeps the row's `updatedAt`.
|
||||
*
|
||||
* Prisma's `@updatedAt` stamps "now" on every `update` / `updateMany` /
|
||||
* `upsert` that does not name the column. That is right for an edit and
|
||||
* wrong for a one-shot rewrite of how a value is stored: the title
|
||||
* encryption backfill bumped every Coach conversation it touched, and the
|
||||
* Coach panel, which groups and orders conversations by `updatedAt`, then
|
||||
* showed months-old threads under "Today". The same column is the sync
|
||||
* cursor for measurements and mood entries, so a storage-only rewrite there
|
||||
* made every client pull rows that had not changed.
|
||||
*
|
||||
* Two freezes:
|
||||
*
|
||||
* 1. Every Prisma write in a backfill (`lib/jobs/*-backfill.ts`,
|
||||
* `scripts/backfill-*.ts`) on a model with an `@updatedAt` column names
|
||||
* `updatedAt` in the call (carrying the row's own value forward), or is
|
||||
* listed below with the reason the stamp is wanted.
|
||||
* 2. Every `coachConversation` write anywhere names `updatedAt`, or is
|
||||
* listed as something the person did in the conversation. Background
|
||||
* bookkeeping (the memory summary) writes through raw SQL, which Prisma
|
||||
* does not stamp.
|
||||
*
|
||||
* A tripwire, not a proof: it cannot tell whether a listed reason is still
|
||||
* true, only that the set did not change without someone editing this file.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
/** Models (as Prisma client accessors) that carry an `@updatedAt` column. */
|
||||
function modelsWithUpdatedAt(): Set<string> {
|
||||
const schema = readFileSync(join(ROOT, "prisma/schema.prisma"), "utf8");
|
||||
const out = new Set<string>();
|
||||
for (const m of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
|
||||
if (/@updatedAt\b/.test(m[2])) {
|
||||
out.add(m[1][0].toLowerCase() + m[1].slice(1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const WRITE_RE = /\.\s*([a-zA-Z]+)\s*\.\s*(update|updateMany|upsert)\s*\(/g;
|
||||
|
||||
/** The argument text of the call whose `(` sits at `open`. */
|
||||
function callArgs(source: string, open: number): string {
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i++) {
|
||||
const c = source[i];
|
||||
if (c === "(") depth++;
|
||||
else if (c === ")") {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return source.slice(open);
|
||||
}
|
||||
|
||||
interface Write {
|
||||
file: string;
|
||||
model: string;
|
||||
namesUpdatedAt: boolean;
|
||||
}
|
||||
|
||||
function writesIn(file: string, source: string): Write[] {
|
||||
const out: Write[] = [];
|
||||
for (const m of source.matchAll(WRITE_RE)) {
|
||||
const open = (m.index ?? 0) + m[0].length - 1;
|
||||
out.push({
|
||||
file,
|
||||
model: m[1],
|
||||
namesUpdatedAt: /\bupdatedAt\s*:/.test(callArgs(source, open)),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function backfillFiles(): string[] {
|
||||
const jobs = readdirSync(join(SRC, "lib/jobs"))
|
||||
.filter((f) => /-backfill\.ts$/.test(f))
|
||||
.map((f) => `src/lib/jobs/${f}`);
|
||||
const scripts = readdirSync(join(ROOT, "scripts"))
|
||||
.filter((f) => /^backfill-.*\.ts$/.test(f))
|
||||
.map((f) => `scripts/${f}`);
|
||||
return [...jobs, ...scripts].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill writes that stamp `updatedAt` on purpose, keyed `file > model`.
|
||||
*/
|
||||
const BACKFILL_STAMPS: Record<string, string> = {
|
||||
"src/lib/jobs/fitbit-backfill.ts > fitbitConnection":
|
||||
"the job's own progress on the connection row; not a listed record",
|
||||
"src/lib/jobs/google-health-backfill.ts > googleHealthConnection":
|
||||
"the job's own progress on the connection row; not a listed record",
|
||||
"src/lib/jobs/whoop-backfill.ts > whoopConnection":
|
||||
"the job's own progress on the connection row; not a listed record",
|
||||
"src/lib/jobs/sleep-timeline-backfill.ts > whoopConnection":
|
||||
"marks the connection's one-shot as done; not a listed record",
|
||||
"src/lib/jobs/sleep-timeline-backfill.ts > withingsConnection":
|
||||
"marks the connection's one-shot as done; not a listed record",
|
||||
"src/lib/jobs/strava-backfill.ts > user":
|
||||
"stamps the account's backfill-completed marker",
|
||||
"src/lib/jobs/document-content-index-backfill.ts > documentContentIndex":
|
||||
"the index row is rebuilt content; its updatedAt is the rebuild time",
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts > biomarker":
|
||||
"shared catalogue row created or refreshed by the backfill",
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts > labResult":
|
||||
"bulk link to a catalogue id; no lab surface orders by updatedAt and a bulk write cannot carry each row's own value",
|
||||
"src/lib/jobs/free-text-encryption-backfill.ts > measurementReminder":
|
||||
"bulk clear of a retired readable copy; reminders are listed by schedule, not by updatedAt",
|
||||
};
|
||||
|
||||
/**
|
||||
* `coachConversation` writes outside backfills that stamp `updatedAt`
|
||||
* because the person did something in the conversation, keyed by file.
|
||||
*/
|
||||
const COACH_ACTIVITY: Record<string, string> = {
|
||||
"src/lib/ai/coach/persistence.ts":
|
||||
"a turn sets updatedAt to now explicitly; a rename and a document attach are the person acting on the thread",
|
||||
};
|
||||
|
||||
describe("backfills keep updatedAt", () => {
|
||||
const stamped = modelsWithUpdatedAt();
|
||||
const files = backfillFiles();
|
||||
const writes = files.flatMap((f) =>
|
||||
writesIn(f, readFileSync(join(ROOT, f), "utf8")),
|
||||
);
|
||||
|
||||
it("reads the schema and the backfills at all", () => {
|
||||
// An empty match set would agree with any allowlist.
|
||||
expect(stamped.size).toBeGreaterThanOrEqual(40);
|
||||
expect(stamped.has("coachConversation")).toBe(true);
|
||||
expect(files.length).toBeGreaterThanOrEqual(10);
|
||||
expect(writes.length).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it("carries updatedAt through every storage-only rewrite", () => {
|
||||
const offenders = writes
|
||||
.filter((w) => stamped.has(w.model) && !w.namesUpdatedAt)
|
||||
.map((w) => `${w.file} > ${w.model}`)
|
||||
.filter((key) => !(key in BACKFILL_STAMPS));
|
||||
expect(
|
||||
[...new Set(offenders)],
|
||||
"a backfill write bumps updatedAt: carry the row's own value (`updatedAt: fresh.updatedAt`), or list it with the reason the stamp is wanted",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("has no stale entries", () => {
|
||||
const seen = new Set(
|
||||
writes
|
||||
.filter((w) => stamped.has(w.model) && !w.namesUpdatedAt)
|
||||
.map((w) => `${w.file} > ${w.model}`),
|
||||
);
|
||||
expect(Object.keys(BACKFILL_STAMPS).filter((k) => !seen.has(k))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("coach conversations move only on activity", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.map((p) => `src/${p}`);
|
||||
const writes = [
|
||||
...files.flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))),
|
||||
...backfillFiles()
|
||||
.filter((f) => f.startsWith("scripts/"))
|
||||
.flatMap((f) => writesIn(f, readFileSync(join(ROOT, f), "utf8"))),
|
||||
].filter((w) => w.model === "coachConversation");
|
||||
|
||||
it("finds the conversation writes at all", () => {
|
||||
expect(writes.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("names updatedAt in every write that is not the person's own action", () => {
|
||||
const offenders = writes
|
||||
.filter((w) => !w.namesUpdatedAt && !(w.file in COACH_ACTIVITY))
|
||||
.map((w) => w.file);
|
||||
expect(
|
||||
[...new Set(offenders)],
|
||||
"a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL)",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,8 @@ function makePrisma(conversation: unknown) {
|
||||
update,
|
||||
findFirst,
|
||||
client: {
|
||||
coachConversation: { findFirst, update },
|
||||
coachConversation: { findFirst },
|
||||
$executeRaw: update,
|
||||
} as never,
|
||||
};
|
||||
}
|
||||
@@ -161,7 +162,7 @@ describe("refreshConversationSummary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("(e) success path encrypts and persists summaryEncrypted + turn count + updatedAt", async () => {
|
||||
it("(e) success path persists the summary and turn count, and leaves updatedAt alone", async () => {
|
||||
const now = new Date("2026-06-04T08:00:00.000Z");
|
||||
const { client, update } = makePrisma(
|
||||
makeConversation(30, { summaryTurnCount: 0 }),
|
||||
@@ -177,12 +178,19 @@ describe("refreshConversationSummary", () => {
|
||||
expect(result.status).toBe("generated");
|
||||
expect(encryptToBytes).toHaveBeenCalledWith(okCompletion.content);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
const updateArg = update.mock.calls[0]![0];
|
||||
expect(updateArg.where).toEqual({ id: "conv-1" });
|
||||
expect(updateArg.data.summaryEncrypted).toBeDefined();
|
||||
expect(updateArg.data.summaryUpdatedAt).toBe(now);
|
||||
const [strings, ...values] = update.mock.calls[0]! as [
|
||||
TemplateStringsArray,
|
||||
...unknown[],
|
||||
];
|
||||
const sql = strings.join("?");
|
||||
expect(sql).toContain('UPDATE "coach_conversations"');
|
||||
expect(sql).toContain('"summary_encrypted"');
|
||||
// A memory rewrite is not activity: the panel orders by updated_at.
|
||||
expect(sql).not.toMatch(/"updated_at"/);
|
||||
expect(values[1]).toBe(now);
|
||||
// foldHighWater = 30 - 18 = 12.
|
||||
expect(updateArg.data.summaryTurnCount).toBe(12);
|
||||
expect(values[2]).toBe(12);
|
||||
expect(values[3]).toBe("conv-1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ type RunCompletion = typeof runStatusCompletion;
|
||||
export interface RefreshConversationSummaryOptions {
|
||||
now?: Date;
|
||||
runCompletion?: RunCompletion;
|
||||
prisma?: Pick<PrismaClient, "coachConversation">;
|
||||
prisma?: Pick<PrismaClient, "coachConversation" | "$executeRaw">;
|
||||
locale?: "de" | "en";
|
||||
}
|
||||
|
||||
@@ -227,14 +227,16 @@ export async function refreshConversationSummary(
|
||||
|
||||
// 6. Encrypt + persist. Field-by-field data object (no spread).
|
||||
const summaryEncrypted = encryptToBytes(text);
|
||||
await prisma.coachConversation.update({
|
||||
where: { id: conversationId },
|
||||
data: {
|
||||
summaryEncrypted,
|
||||
summaryUpdatedAt: now,
|
||||
summaryTurnCount: foldHighWater,
|
||||
},
|
||||
});
|
||||
// Raw SQL on purpose: a Prisma `update` stamps `updatedAt`, and the Coach
|
||||
// panel orders and groups conversations by it. A memory rewrite is
|
||||
// bookkeeping, not activity, so it must not move the thread to "Today".
|
||||
// Every value is a bound parameter.
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "coach_conversations"
|
||||
SET "summary_encrypted" = ${summaryEncrypted},
|
||||
"summary_updated_at" = ${now},
|
||||
"summary_turn_count" = ${foldHighWater}
|
||||
WHERE "id" = ${conversationId}`;
|
||||
|
||||
// 7. Counts + ids only — never the summary text.
|
||||
annotate({
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ConversationRow {
|
||||
userId: string;
|
||||
title: string | null;
|
||||
titleEncrypted: Uint8Array | null;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
interface EntryRow {
|
||||
id: string;
|
||||
@@ -26,6 +27,7 @@ interface PractitionerRow {
|
||||
location: string | null;
|
||||
phoneEncrypted: Uint8Array | null;
|
||||
locationEncrypted: Uint8Array | null;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
interface RouteRow {
|
||||
id: string;
|
||||
@@ -67,22 +69,49 @@ vi.mock("@/lib/db", () => {
|
||||
},
|
||||
findUnique: async (args: { where: { id: string } }) =>
|
||||
rows().find((x) => x.id === args.where.id) ?? null,
|
||||
update: async (args: { where: { id: string }; data: Partial<R> }) => {
|
||||
const r = rows().find((x) => x.id === args.where.id)!;
|
||||
// Prisma's DbNull sentinel stores SQL NULL.
|
||||
const data = Object.fromEntries(
|
||||
Object.entries(args.data).map(([k, v]) => [
|
||||
k,
|
||||
v !== null && typeof v === "object" && !(v instanceof Uint8Array)
|
||||
? null
|
||||
: v,
|
||||
]),
|
||||
);
|
||||
Object.assign(r, data);
|
||||
return r;
|
||||
update: async (args: { where: { id: string }; data: Partial<R> }) =>
|
||||
write(rows, args.where.id, args.data),
|
||||
// The storage-only rewrites match on the row's own `updatedAt` and
|
||||
// carry it forward; a row whose stamp moved meanwhile is not written.
|
||||
updateMany: async (args: {
|
||||
where: { id: string; updatedAt?: Date };
|
||||
data: Partial<R>;
|
||||
}) => {
|
||||
const r = rows().find((x) => x.id === args.where.id) as
|
||||
(R & { updatedAt?: Date }) | undefined;
|
||||
if (!r) return { count: 0 };
|
||||
if (
|
||||
args.where.updatedAt !== undefined &&
|
||||
r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()
|
||||
) {
|
||||
return { count: 0 };
|
||||
}
|
||||
write(rows, args.where.id, args.data);
|
||||
return { count: 1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
function write<R extends { id: string }>(
|
||||
rows: () => R[],
|
||||
id: string,
|
||||
patch: Partial<R>,
|
||||
): R {
|
||||
const r = rows().find((x) => x.id === id)!;
|
||||
// Prisma's DbNull sentinel stores SQL NULL; a Date is a real value.
|
||||
const data = Object.fromEntries(
|
||||
Object.entries(patch).map(([k, v]) => [
|
||||
k,
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
!(v instanceof Uint8Array) &&
|
||||
!(v instanceof Date)
|
||||
? null
|
||||
: v,
|
||||
]),
|
||||
);
|
||||
Object.assign(r, data);
|
||||
return r;
|
||||
}
|
||||
const delegates = {
|
||||
coachConversation: delegate(() => store.conversations, ["title"]),
|
||||
customMetricEntry: delegate(() => store.entries, ["note"]),
|
||||
@@ -126,6 +155,7 @@ beforeEach(() => {
|
||||
userId: "u1",
|
||||
title: "Why is my pressure up after the new tablets?",
|
||||
titleEncrypted: null,
|
||||
updatedAt: new Date("2026-03-02T09:15:00.000Z"),
|
||||
},
|
||||
// Already migrated: left alone.
|
||||
{
|
||||
@@ -218,6 +248,9 @@ describe("runFreeTextEncryptionBackfillForUser", () => {
|
||||
expect(readNote(c1.titleEncrypted, null)).toBe(
|
||||
"Why is my pressure up after the new tablets?",
|
||||
);
|
||||
// Sealing a title is not activity: the Coach panel orders and groups by
|
||||
// updatedAt, so the conversation keeps the stamp it had.
|
||||
expect(c1.updatedAt).toEqual(new Date("2026-03-02T09:15:00.000Z"));
|
||||
|
||||
const e1 = store.entries.find((r) => r.id === "e1")!;
|
||||
expect(e1.note).toBeNull();
|
||||
|
||||
@@ -20,6 +20,7 @@ interface InventoryRow {
|
||||
userId: string;
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
@@ -99,16 +100,31 @@ vi.mock("@/lib/db", () => {
|
||||
.map((r) => ({ id: r.id })),
|
||||
findUnique: async (args: { where: { id: string } }) => {
|
||||
const r = store.inventory.find((x) => x.id === args.where.id);
|
||||
return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null;
|
||||
return r
|
||||
? {
|
||||
notes: r.notes,
|
||||
notesEncrypted: r.notesEncrypted,
|
||||
updatedAt: r.updatedAt,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
update: async (args: {
|
||||
where: { id: string };
|
||||
data: { notes: string | null; notesEncrypted: Uint8Array | null };
|
||||
updateMany: async (args: {
|
||||
where: { id: string; updatedAt?: Date };
|
||||
data: {
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
}) => {
|
||||
const r = store.inventory.find((x) => x.id === args.where.id)!;
|
||||
const r = store.inventory.find((x) => x.id === args.where.id);
|
||||
if (!r || r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()) {
|
||||
return { count: 0 };
|
||||
}
|
||||
r.notes = args.data.notes;
|
||||
r.notesEncrypted = args.data.notesEncrypted;
|
||||
return r;
|
||||
// Prisma stamps now unless the write names the column.
|
||||
r.updatedAt = args.data.updatedAt ?? new Date();
|
||||
return { count: 1 };
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -148,7 +164,13 @@ beforeEach(() => {
|
||||
},
|
||||
];
|
||||
store.inventory = [
|
||||
{ id: "i1", userId: "u1", notes: "opened pen #2", notesEncrypted: null },
|
||||
{
|
||||
id: "i1",
|
||||
userId: "u1",
|
||||
notes: "opened pen #2",
|
||||
notesEncrypted: null,
|
||||
updatedAt: new Date("2026-01-12T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "i2",
|
||||
userId: "u1",
|
||||
@@ -181,6 +203,8 @@ describe("runMedNotesEncryptionBackfillForUser", () => {
|
||||
const i1 = store.inventory.find((r) => r.id === "i1")!;
|
||||
expect(i1.notes).toBeNull();
|
||||
expect(readNote(i1.notesEncrypted, null)).toBe("opened pen #2");
|
||||
// A storage-only rewrite keeps the row's own updatedAt.
|
||||
expect(i1.updatedAt).toEqual(new Date("2026-01-12T10:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("leaves note-less rows untouched and never writes a both-null content row", async () => {
|
||||
|
||||
@@ -7,12 +7,14 @@ interface MeasRow {
|
||||
userId: string;
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
interface MoodRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
note: string | null;
|
||||
noteEncrypted: Uint8Array | null;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
@@ -44,16 +46,31 @@ vi.mock("@/lib/db", () => {
|
||||
.map((r) => ({ id: r.id })),
|
||||
findUnique: async (args: { where: { id: string } }) => {
|
||||
const r = store.measurements.find((x) => x.id === args.where.id);
|
||||
return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null;
|
||||
return r
|
||||
? {
|
||||
notes: r.notes,
|
||||
notesEncrypted: r.notesEncrypted,
|
||||
updatedAt: r.updatedAt,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
update: async (args: {
|
||||
where: { id: string };
|
||||
data: { notes: string | null; notesEncrypted: Uint8Array | null };
|
||||
updateMany: async (args: {
|
||||
where: { id: string; updatedAt?: Date };
|
||||
data: {
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
}) => {
|
||||
const r = store.measurements.find((x) => x.id === args.where.id)!;
|
||||
const r = store.measurements.find((x) => x.id === args.where.id);
|
||||
if (!r || r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()) {
|
||||
return { count: 0 };
|
||||
}
|
||||
r.notes = args.data.notes;
|
||||
r.notesEncrypted = args.data.notesEncrypted;
|
||||
return r;
|
||||
// Prisma stamps now unless the write names the column.
|
||||
r.updatedAt = args.data.updatedAt ?? new Date();
|
||||
return { count: 1 };
|
||||
},
|
||||
},
|
||||
moodEntry: {
|
||||
@@ -69,16 +86,30 @@ vi.mock("@/lib/db", () => {
|
||||
.map((r) => ({ id: r.id })),
|
||||
findUnique: async (args: { where: { id: string } }) => {
|
||||
const r = store.mood.find((x) => x.id === args.where.id);
|
||||
return r ? { note: r.note, noteEncrypted: r.noteEncrypted } : null;
|
||||
return r
|
||||
? {
|
||||
note: r.note,
|
||||
noteEncrypted: r.noteEncrypted,
|
||||
updatedAt: r.updatedAt,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
update: async (args: {
|
||||
where: { id: string };
|
||||
data: { note: string | null; noteEncrypted: Uint8Array | null };
|
||||
updateMany: async (args: {
|
||||
where: { id: string; updatedAt?: Date };
|
||||
data: {
|
||||
note: string | null;
|
||||
noteEncrypted: Uint8Array | null;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
}) => {
|
||||
const r = store.mood.find((x) => x.id === args.where.id)!;
|
||||
const r = store.mood.find((x) => x.id === args.where.id);
|
||||
if (!r || r.updatedAt?.getTime() !== args.where.updatedAt?.getTime()) {
|
||||
return { count: 0 };
|
||||
}
|
||||
r.note = args.data.note;
|
||||
r.noteEncrypted = args.data.noteEncrypted;
|
||||
return r;
|
||||
r.updatedAt = args.data.updatedAt ?? new Date();
|
||||
return { count: 1 };
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -99,7 +130,13 @@ beforeEach(() => {
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", KEY);
|
||||
store.measurements = [
|
||||
{ id: "m1", userId: "u1", notes: "felt dizzy", notesEncrypted: null },
|
||||
{
|
||||
id: "m1",
|
||||
userId: "u1",
|
||||
notes: "felt dizzy",
|
||||
notesEncrypted: null,
|
||||
updatedAt: new Date("2026-02-01T07:00:00.000Z"),
|
||||
},
|
||||
{ id: "m2", userId: "u1", notes: null, notesEncrypted: null }, // no note
|
||||
{
|
||||
id: "m3",
|
||||
@@ -109,7 +146,13 @@ beforeEach(() => {
|
||||
},
|
||||
];
|
||||
store.mood = [
|
||||
{ id: "x1", userId: "u1", note: "rough night", noteEncrypted: null },
|
||||
{
|
||||
id: "x1",
|
||||
userId: "u1",
|
||||
note: "rough night",
|
||||
noteEncrypted: null,
|
||||
updatedAt: new Date("2026-02-03T22:00:00.000Z"),
|
||||
},
|
||||
{ id: "x2", userId: "u2", note: "other user", noteEncrypted: null },
|
||||
];
|
||||
fail.encryptThrows = false;
|
||||
@@ -135,6 +178,10 @@ describe("runNoteEncryptionBackfillForUser", () => {
|
||||
const x1 = store.mood.find((r) => r.id === "x1")!;
|
||||
expect(x1.note).toBeNull();
|
||||
expect(readNote(x1.noteEncrypted, null)).toBe("rough night");
|
||||
|
||||
// updatedAt is the sync cursor: a storage-only rewrite keeps it.
|
||||
expect(m1.updatedAt).toEqual(new Date("2026-02-01T07:00:00.000Z"));
|
||||
expect(x1.updatedAt).toEqual(new Date("2026-02-03T22:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("leaves note-less rows untouched and never writes a both-null content row", async () => {
|
||||
|
||||
@@ -183,15 +183,23 @@ async function migrateConversationTitle(id: string): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const fresh = await tx.coachConversation.findUnique({
|
||||
where: { id },
|
||||
select: { title: true },
|
||||
select: { title: true, updatedAt: true },
|
||||
});
|
||||
if (!fresh || fresh.title === null) return false;
|
||||
// FAIL-CLOSED: a key error throws here and rolls the tx back.
|
||||
await tx.coachConversation.update({
|
||||
where: { id },
|
||||
data: { titleEncrypted: encryptToBytes(fresh.title), title: null },
|
||||
// `updatedAt` is carried, not stamped: the Coach panel orders and groups
|
||||
// by it, and moving a title into ciphertext is not activity. Matching on
|
||||
// it too means a turn that lands meanwhile wins; the row is retried on
|
||||
// the next pass.
|
||||
const { count } = await tx.coachConversation.updateMany({
|
||||
where: { id, updatedAt: fresh.updatedAt },
|
||||
data: {
|
||||
titleEncrypted: encryptToBytes(fresh.title),
|
||||
title: null,
|
||||
updatedAt: fresh.updatedAt,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
return count === 1;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,14 +232,16 @@ async function migratePractitionerContact(id: string): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const fresh = await tx.practitioner.findUnique({
|
||||
where: { id },
|
||||
select: { phone: true, location: true },
|
||||
select: { phone: true, location: true, updatedAt: true },
|
||||
});
|
||||
if (!fresh || (fresh.phone === null && fresh.location === null)) {
|
||||
return false;
|
||||
}
|
||||
await tx.practitioner.update({
|
||||
where: { id },
|
||||
// Storage-only rewrite: the row keeps its own `updatedAt`.
|
||||
const { count } = await tx.practitioner.updateMany({
|
||||
where: { id, updatedAt: fresh.updatedAt },
|
||||
data: {
|
||||
updatedAt: fresh.updatedAt,
|
||||
...(fresh.phone !== null
|
||||
? { phoneEncrypted: encryptNote(fresh.phone), phone: null }
|
||||
: {}),
|
||||
@@ -240,7 +250,7 @@ async function migratePractitionerContact(id: string): Promise<boolean> {
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
return count === 1;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -120,15 +120,20 @@ async function migrateInventoryItemRow(id: string): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const fresh = await tx.medicationInventoryItem.findUnique({
|
||||
where: { id },
|
||||
select: { notes: true, notesEncrypted: true },
|
||||
select: { notes: true, notesEncrypted: true, updatedAt: true },
|
||||
});
|
||||
if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false;
|
||||
const encrypted = encryptNote(fresh.notes);
|
||||
await tx.medicationInventoryItem.update({
|
||||
where: { id },
|
||||
data: { notesEncrypted: encrypted, notes: null },
|
||||
// Storage-only rewrite: the row keeps its own `updatedAt`.
|
||||
const { count } = await tx.medicationInventoryItem.updateMany({
|
||||
where: { id, updatedAt: fresh.updatedAt },
|
||||
data: {
|
||||
notesEncrypted: encrypted,
|
||||
notes: null,
|
||||
updatedAt: fresh.updatedAt,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
return count === 1;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -68,18 +68,24 @@ async function migrateMeasurementRow(id: string): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const fresh = await tx.measurement.findUnique({
|
||||
where: { id },
|
||||
select: { notes: true, notesEncrypted: true },
|
||||
select: { notes: true, notesEncrypted: true, updatedAt: true },
|
||||
});
|
||||
// Already migrated (ciphertext present) or nothing to migrate.
|
||||
if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false;
|
||||
// FAIL-CLOSED: a key error throws here and rolls the tx back, leaving the
|
||||
// plaintext row untouched.
|
||||
const encrypted = encryptNote(fresh.notes);
|
||||
await tx.measurement.update({
|
||||
where: { id },
|
||||
data: { notesEncrypted: encrypted, notes: null },
|
||||
// `updatedAt` is the sync cursor; a storage-only rewrite carries it so no
|
||||
// client re-pulls a reading that did not change.
|
||||
const { count } = await tx.measurement.updateMany({
|
||||
where: { id, updatedAt: fresh.updatedAt },
|
||||
data: {
|
||||
notesEncrypted: encrypted,
|
||||
notes: null,
|
||||
updatedAt: fresh.updatedAt,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
return count === 1;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,15 +97,19 @@ async function migrateMoodRow(id: string): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const fresh = await tx.moodEntry.findUnique({
|
||||
where: { id },
|
||||
select: { note: true, noteEncrypted: true },
|
||||
select: { note: true, noteEncrypted: true, updatedAt: true },
|
||||
});
|
||||
if (!fresh || fresh.noteEncrypted || fresh.note === null) return false;
|
||||
const encrypted = encryptNote(fresh.note);
|
||||
await tx.moodEntry.update({
|
||||
where: { id },
|
||||
data: { noteEncrypted: encrypted, note: null },
|
||||
const { count } = await tx.moodEntry.updateMany({
|
||||
where: { id, updatedAt: fresh.updatedAt },
|
||||
data: {
|
||||
noteEncrypted: encrypted,
|
||||
note: null,
|
||||
updatedAt: fresh.updatedAt,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
return count === 1;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The Coach memory summary is bookkeeping, not activity (real Postgres).
|
||||
*
|
||||
* The Coach panel orders and groups conversations by `updatedAt`. The summary
|
||||
* refresh rewrites a conversation row in the background, and as a Prisma
|
||||
* `update` it stamped `updatedAt` with the run time, so a thread nobody had
|
||||
* touched could surface under "Today". The write now goes through raw SQL;
|
||||
* this pins that the summary lands (the ciphertext binds as `bytea`) and that
|
||||
* the stamp stays where the last turn left it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { refreshConversationSummary } from "@/lib/ai/coach/conversation-summary";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER = "user-coach-summary";
|
||||
const LAST_TURN = new Date("2026-04-10T19:30:00.000Z");
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: "coach-summary",
|
||||
email: "coach-summary@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("coach memory summary", () => {
|
||||
it("writes the summary and leaves updatedAt alone", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const conversation = await prisma.coachConversation.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
titleEncrypted: encryptToBytes("Training plan"),
|
||||
},
|
||||
});
|
||||
await prisma.coachMessage.createMany({
|
||||
data: Array.from({ length: 30 }, (_, i) => ({
|
||||
conversationId: conversation.id,
|
||||
role: i % 2 === 0 ? "user" : "assistant",
|
||||
encryptedContent: encryptToBytes(`turn ${i}`),
|
||||
createdAt: new Date(LAST_TURN.getTime() - (30 - i) * 60_000),
|
||||
})),
|
||||
});
|
||||
// Created rows stamp now; set the stamp a last turn would have left.
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "coach_conversations" SET "updated_at" = ${LAST_TURN}
|
||||
WHERE "id" = ${conversation.id}`;
|
||||
|
||||
const now = new Date("2026-10-05T06:00:00.000Z");
|
||||
const result = await refreshConversationSummary(conversation.id, USER, {
|
||||
now,
|
||||
runCompletion: async () => ({
|
||||
kind: "ok",
|
||||
content: "Training for a 10k, prefers morning runs.",
|
||||
providerType: "admin-openai",
|
||||
model: "test",
|
||||
tokensUsed: 10,
|
||||
}),
|
||||
});
|
||||
expect(result.status).toBe("generated");
|
||||
|
||||
const row = await prisma.coachConversation.findUniqueOrThrow({
|
||||
where: { id: conversation.id },
|
||||
});
|
||||
expect(decryptFromBytes(row.summaryEncrypted!)).toBe(
|
||||
"Training for a 10k, prefers morning runs.",
|
||||
);
|
||||
expect(row.summaryUpdatedAt).toEqual(now);
|
||||
expect(row.summaryTurnCount).toBe(12);
|
||||
expect(row.updatedAt).toEqual(LAST_TURN);
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,15 @@ describe("free-text encryption backfill (real Postgres)", () => {
|
||||
await Promise.all([seedUser("ft-a"), seedUser("ft-b"), seedUser("ft-c")]);
|
||||
|
||||
// ft-a: a legacy readable title and a sealed one.
|
||||
// Last active months ago: the backfill must not make it look recent.
|
||||
const lastActive = new Date("2026-03-02T09:15:00.000Z");
|
||||
await prisma.coachConversation.create({
|
||||
data: { id: "conv-legacy", userId: "ft-a", title: "Legacy title" },
|
||||
data: {
|
||||
id: "conv-legacy",
|
||||
userId: "ft-a",
|
||||
title: "Legacy title",
|
||||
updatedAt: lastActive,
|
||||
},
|
||||
});
|
||||
await prisma.coachConversation.create({
|
||||
data: {
|
||||
@@ -150,6 +157,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
|
||||
{ id: "conv-legacy", readable: null, text: "Legacy title" },
|
||||
{ id: "conv-sealed", readable: null, text: "Sealed title" },
|
||||
]);
|
||||
// The Coach panel orders and groups by updatedAt; sealing a title is not
|
||||
// activity, so the stamp is the one the conversation already had.
|
||||
expect(conversations[0].updatedAt).toEqual(lastActive);
|
||||
const entries = await prisma.customMetricEntry.findMany({
|
||||
where: { userId: "ft-b" },
|
||||
orderBy: { id: "asc" },
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("note-encryption backfill (real Postgres)", () => {
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
notes: "felt dizzy after",
|
||||
updatedAt: new Date("2026-05-16T08:05:00.000Z"),
|
||||
},
|
||||
});
|
||||
const noNote = await prisma.measurement.create({
|
||||
@@ -62,6 +63,7 @@ describe("note-encryption backfill (real Postgres)", () => {
|
||||
score: 3,
|
||||
moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
note: "rough night",
|
||||
updatedAt: new Date("2026-05-16T08:06:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,6 +87,11 @@ describe("note-encryption backfill (real Postgres)", () => {
|
||||
const x = await prisma.moodEntry.findUnique({ where: { id: mood.id } });
|
||||
expect(x?.note).toBeNull();
|
||||
expect(readNote(x?.noteEncrypted ?? null, null)).toBe("rough night");
|
||||
|
||||
// updatedAt is the sync cursor: moving a note into ciphertext changes
|
||||
// nothing a client reads, so neither row takes a new stamp.
|
||||
expect(m?.updatedAt).toEqual(new Date("2026-05-16T08:05:00.000Z"));
|
||||
expect(x?.updatedAt).toEqual(new Date("2026-05-16T08:06:00.000Z"));
|
||||
});
|
||||
|
||||
it("is idempotent: a second run migrates zero rows and preserves the data", async () => {
|
||||
|
||||
Reference in new issue
Block a user