Files
HealthLog/scripts/backfill-mood-note-column.ts
Marc-André Bombeck 7d42cf7122 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.
2026-10-05 12:40:55 +02:00

110 lines
3.2 KiB
TypeScript

#!/usr/bin/env tsx
/**
* scripts/backfill-mood-note-column.ts
*
* One-shot backfill that lifts existing `tags: ["note:<text>"]`
* entries out of `mood_entries.tags` into the new `mood_entries.note`
* column. Operator runs this once after the v1.4.30 schema migration
* applies.
*
* Strategy:
* 1. SELECT every MoodEntry whose `tags` JSON contains an entry
* that begins with `note:`.
* 2. Decode the JSON array, pull the first `note:<text>` entry as
* the new note value, drop the entry from the array.
* 3. UPDATE the row in a transaction: `note = <decoded text>`,
* `tags = <slimmed JSON or null if empty>`.
*
* Idempotent — a row whose `tags` no longer carries any `note:`
* entries is left alone. Re-running on a clean dataset is a no-op.
*
* Usage:
* pnpm tsx scripts/backfill-mood-note-column.ts # dry-run
* pnpm tsx scripts/backfill-mood-note-column.ts --confirm # commit
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { extractNoteAndSlimTags } from "@/lib/mood/note-backfill";
interface ParsedArgs {
confirm: boolean;
}
function parseArgs(argv: readonly string[]): ParsedArgs {
const out: ParsedArgs = { confirm: false };
for (const arg of argv) {
if (arg === "--confirm") out.confirm = true;
else if (arg === "--help" || arg === "-h") {
console.log(
"Usage: pnpm tsx scripts/backfill-mood-note-column.ts [--confirm]",
);
process.exit(0);
} else {
console.error(`Unknown argument: ${arg}`);
process.exit(2);
}
}
return out;
}
interface RowSnapshot {
id: string;
tags: string | null;
note: string | null;
updatedAt: Date;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const dryRun = !args.confirm;
if (dryRun) {
console.log(
"[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.",
);
} else {
console.log("[backfill-note] COMMIT MODE — updating rows in place.");
}
// Naive scan: filter in-memory rather than via JSON predicates so
// the query stays portable across Postgres minor versions. The
// mood_entries table is small (per-user lifetime budget).
const candidates: RowSnapshot[] = await prisma.moodEntry.findMany({
where: {
tags: { not: null },
note: null,
},
select: { id: true, tags: true, note: true, updatedAt: true },
});
console.log(`[backfill-note] scanned ${candidates.length} candidate rows`);
let updated = 0;
for (const row of candidates) {
if (!row.tags) continue;
const { note, newTags } = extractNoteAndSlimTags(row.tags);
if (note === null) continue;
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, updatedAt: row.updatedAt },
});
}
}
console.log(
`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`,
);
await prisma.$disconnect();
}
main().catch((err: unknown) => {
console.error("[backfill-note] failed:", err);
process.exit(1);
});