aboutsummaryrefslogtreecommitdiffstats
path: root/scripts/audit-missing-history.ts
blob: 30c2c8fcafd8931fd33e73ee43c06de8be90271e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#!/usr/bin/env bun
// Audit how widespread missing payment history is and classify WHY.
//
// For each user with a non-empty :payments list, check each referenced
// payment:<id> across db and arc, and bucket the user:
//   - clean:        all records resolve (db or arc)
//   - recoverable:  some/all records missing from db but present in arc
//   - lost:         some records missing from BOTH db and arc (true loss)
// Also records per-payment counts so we can size a restore.
//
// Read-only. Samples by default; pass --full to scan every user (slower).
// Usage: bun scripts/audit-missing-history.ts [--full] [--sample N] [--examples N]

import { createClient } from "redis";

const full = process.argv.includes("--full");
const sampleN = Number(process.argv[process.argv.indexOf("--sample") + 1]) || 4000;
const exN = Number(process.argv[process.argv.indexOf("--examples") + 1]) || 15;

const db = createClient({ url: "redis://127.0.0.1:6379", socket: { reconnectStrategy: () => false } });
const arc = createClient({ url: "redis://127.0.0.1:6380", socket: { reconnectStrategy: () => false } });
await db.connect();
await arc.connect();

// Resolve uid -> username lazily (only for examples) to keep memory low.
async function nameOf(uid: string) {
  const u = await db.get(`user:${uid}`);
  if (u && u[0] === "{") { try { return JSON.parse(u).username || uid.slice(0, 8); } catch {} }
  return uid.slice(0, 8);
}

let usersScanned = 0;
let usersWithPayments = 0;
const bucket = { clean: 0, recoverable: 0, lost: 0 };
let totalRefs = 0, inDb = 0, inArcOnly = 0, missingBoth = 0;
const examples: { recoverable: string[]; lost: string[] } = { recoverable: [], lost: [] };

// Helper: does payment:<id> exist in db / arc? (batched EXISTS)
async function classifyUser(uid: string): Promise<"clean" | "recoverable" | "lost" | null> {
  const pids = await db.lRange(`${uid}:payments`, 0, -1);
  if (!pids.length) return null;
  usersWithPayments++;

  let anyArcOnly = false, anyMissing = false;
  for (const pid of pids) {
    totalRefs++;
    const key = `payment:${pid}`;
    const dbHas = await db.exists(key);
    if (dbHas) { inDb++; continue; }
    const arcHas = await arc.exists(key);
    if (arcHas) { inArcOnly++; anyArcOnly = true; }
    else { missingBoth++; anyMissing = true; }
  }
  if (anyMissing) return "lost";
  if (anyArcOnly) return "recoverable";
  return "clean";
}

const t0 = Date.now();
let processed = 0;
for await (const key of db.scanIterator({ MATCH: "user:*", COUNT: 1000 })) {
  // Only the canonical record key user:<uuid> (skip user:<username> pointers).
  const id = (key as string).slice("user:".length);
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-/.test(id)) continue;
  usersScanned++;
  if (!full && usersScanned > sampleN) break;

  const cls = await classifyUser(id);
  if (!cls) continue;
  bucket[cls]++;
  if (cls === "recoverable" && examples.recoverable.length < exN) examples.recoverable.push(id);
  if (cls === "lost" && examples.lost.length < exN) examples.lost.push(id);
  processed++;
}
const secs = ((Date.now() - t0) / 1000).toFixed(1);

const totalClassified = bucket.clean + bucket.recoverable + bucket.lost;
const pct = (n: number) => totalClassified ? ((n / totalClassified) * 100).toFixed(1) + "%" : "0%";

console.log("═".repeat(66));
console.log(`  MISSING-HISTORY AUDIT — ${full ? "FULL scan" : `sample of ${sampleN} users`}`);
console.log(`  scanned ${usersScanned} user records, ${usersWithPayments} with payments, in ${secs}s`);
console.log("═".repeat(66));
console.log(`\n  Users with payment history: ${totalClassified}`);
console.log(`    clean (all records present) ...... ${bucket.clean}  (${pct(bucket.clean)})`);
console.log(`    recoverable (in arc, not db) ..... ${bucket.recoverable}  (${pct(bucket.recoverable)})  <- restorable`);
console.log(`    lost (missing from db AND arc) ... ${bucket.lost}  (${pct(bucket.lost)})  <- true loss`);
console.log(`\n  Payment records referenced: ${totalRefs}`);
console.log(`    in db ............ ${inDb}  (${((inDb/totalRefs)*100||0).toFixed(1)}%)`);
console.log(`    in arc only ...... ${inArcOnly}  (${((inArcOnly/totalRefs)*100||0).toFixed(1)}%)  <- restorable`);
console.log(`    missing both ..... ${missingBoth}  (${((missingBoth/totalRefs)*100||0).toFixed(1)}%)  <- gone`);

if (examples.recoverable.length) {
  console.log(`\n  Example RECOVERABLE users:`);
  for (const uid of examples.recoverable) console.log(`    ${await nameOf(uid)}  (${uid})`);
}
if (examples.lost.length) {
  console.log(`\n  Example LOST-record users:`);
  for (const uid of examples.lost) console.log(`    ${await nameOf(uid)}  (${uid})`);
}
if (!full) {
  const est = Math.round((bucket.recoverable / Math.min(usersScanned, sampleN)) * usersScanned);
  console.log(`\n  (sample only — run --full for exact totals)`);
}
console.log("═".repeat(66));
process.exit(0);