#!/usr/bin/env bun /** * One-shot migration: .github/srs/srs.json → D1. * * Run AFTER the catalog reconcile has filled `problems` (the import only * overlays SRS-owned fields — stage, next_review, defer_until — and inserts * attempts/topic counters). Re-runnable without duplicates: import-sourced * attempts are wiped and re-inserted, everything else upserts. * * bun apps/api/scripts/import-srs.ts # local D1 (wrangler dev state) * bun apps/api/scripts/import-srs.ts --remote # production D1 * * Prints row counts; verify them against srs.json before deleting anything. */ import { $ } from "bun"; import { join } from "node:path"; const ROOT = join(import.meta.dir, "..", ".."); const API = join(ROOT, "api"); const REMOTE = process.argv.includes("--remote"); interface Attempt { date: string; kind: string; result: string; } interface Problem { issue: number; topic: number; difficulty: string; set: string; solved_on?: string; stage: string; next_review?: string; defer_until?: string; history: Attempt[]; } interface State { problems: Record; topics: Record; drill_pool_used: number[]; } const state: State = JSON.parse( await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(), ); const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`); const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"]; let attempts = 0; for (const [lc, p] of Object.entries(state.problems)) { lines.push( `UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` + `defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`, ); for (const a of p.history) { lines.push( `INSERT INTO attempts (lc_number, date, kind, result, source) ` + `VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`, ); attempts++; } } for (const [topic, t] of Object.entries(state.topics)) { lines.push( `UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`, ); } for (const lc of state.drill_pool_used) { lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`); } const sqlPath = join(API, "migrations", ".import.sql"); await Bun.write(sqlPath, lines.join("\n") + "\n"); const flag = REMOTE ? "--remote" : "--local"; await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API); await $`rm ${sqlPath}`; const counts = await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}` .cwd(API) .json(); const expected = { json_problems: Object.keys(state.problems).length, json_attempts: attempts, json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length, json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length, json_topics: Object.keys(state.topics).length, json_drills_used: state.drill_pool_used.length, }; console.log("expected from srs.json:", JSON.stringify(expected)); console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));