aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-18 07:43:48 -0700
committerAdam Soltys <[email protected]>2026-06-18 07:43:48 -0700
commit64691693e7ff40aae59b05a234f88025757959e3 (patch)
tree842bf72560b118f2d059be2814b113b35b6f3da3
parent0a1450ada9c20499fa86a619ee36176f717d5414 (diff)
downloadcoinos-server-staging.tar.gz
coinos-server-staging.zip
invoices: don't re-list an invoice id on update (dedupe :invoices)staging
Port of the master fix (2d2f9856) + cleanup script to staging. generate() unconditionally lPush'd the invoice id to `${aid}:invoices`, but it's also called from update() (tip/webhook changes, repeated bolt12 offer creation) re-using the existing id — so each update re-appended the same id and the user's :invoices accumulated duplicate entries, rendering the same offer/invoice multiple times. Guard the push with LPOS so it only adds the id the first time. Also add scripts/dedup-invoices.ts to scrub already-accumulated dupes (dry-run by default, --apply to write, idempotent). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
-rw-r--r--lib/invoices.ts8
-rw-r--r--scripts/dedup-invoices.ts86
2 files changed, 93 insertions, 1 deletions
diff --git a/lib/invoices.ts b/lib/invoices.ts
index c5194ce3..27e25c48 100644
--- a/lib/invoices.ts
+++ b/lib/invoices.ts
@@ -202,7 +202,13 @@ export const generate = async ({ invoice, user }) => {
await s(`invoice:${hash}`, id);
await s(`invoice:${id}`, invoice);
- await db.lPush(`${aid}:invoices`, id);
+ // generate() is also called on UPDATE (re-using an existing invoice id, e.g.
+ // a tip/webhook change or a repeated bolt12 offer create), which must NOT
+ // re-append the id to the list. Only add it the first time — otherwise the
+ // user's :invoices accumulates duplicate entries and the same offer/invoice
+ // renders multiple times (see kaliyi's OCEAN offer, listed 2-6x).
+ if ((await db.lPos(`${aid}:invoices`, id)) === null)
+ await db.lPush(`${aid}:invoices`, id);
if (request_id) {
const request = await g(`request:${request_id}`);
diff --git a/scripts/dedup-invoices.ts b/scripts/dedup-invoices.ts
new file mode 100644
index 00000000..9232566b
--- /dev/null
+++ b/scripts/dedup-invoices.ts
@@ -0,0 +1,86 @@
+#!/usr/bin/env bun
+// One-time cleanup: remove duplicate entries from every `<id>:invoices` list.
+//
+// Background: `generate()` used to `lPush` the invoice id onto the owner's
+// `:invoices` list every time it ran — including when regenerating an existing
+// invoice (adding a tip, webhook, etc.). That pushed the same id multiple times,
+// so a user's `/invoices` response contained duplicate records. The UI's keyed
+// `{#each}` then threw `each_key_duplicate` and rendered a blank page. The source
+// is fixed in lib/invoices.ts (only push when the id is brand new); this script
+// cleans the dupes that already accumulated.
+//
+// Dedup preserves the ORIGINAL position of each id. The list is newest-first
+// (lPush adds to the head), and re-pushes added newer copies at the head, so the
+// deepest (tail-most) occurrence is the original creation push — we keep that one.
+//
+// Read-only by default (dry run). Pass --apply to actually rewrite lists.
+// Usage: bun scripts/dedup-invoices.ts [--apply]
+
+import { createClient } from "redis";
+
+const apply = process.argv.includes("--apply");
+
+const db = createClient({
+ url: "redis://127.0.0.1:6379",
+ socket: { reconnectStrategy: () => false },
+});
+await db.connect();
+
+// Keep the LAST occurrence of each id (original creation order), preserving
+// the relative order of the kept entries.
+function dedupeKeepLast(list: string[]): string[] {
+ const seen = new Set<string>();
+ const out: string[] = [];
+ for (let i = list.length - 1; i >= 0; i--) {
+ const id = list[i];
+ if (seen.has(id)) continue;
+ seen.add(id);
+ out.push(id);
+ }
+ out.reverse();
+ return out;
+}
+
+let lists = 0;
+let listsWithDupes = 0;
+let entriesBefore = 0;
+let entriesRemoved = 0;
+const worst: { key: string; before: number; after: number }[] = [];
+
+for await (const k of db.scanIterator({ MATCH: "*:invoices", COUNT: 2000 })) {
+ const keys = Array.isArray(k) ? k : [k];
+ for (const key of keys) {
+ lists++;
+ const list = await db.lRange(key, 0, -1);
+ entriesBefore += list.length;
+ const deduped = dedupeKeepLast(list);
+ const removed = list.length - deduped.length;
+ if (removed === 0) continue;
+
+ listsWithDupes++;
+ entriesRemoved += removed;
+ worst.push({ key, before: list.length, after: deduped.length });
+
+ if (apply) {
+ // Rewrite atomically: DEL then RPUSH the deduped list (RPUSH keeps head->tail order).
+ const tx = db.multi();
+ tx.del(key);
+ tx.rPush(key, deduped);
+ await tx.exec();
+ }
+ }
+}
+
+worst.sort((a, b) => b.before - b.after - (a.before - a.after));
+
+console.log(`\n${apply ? "APPLIED" : "DRY RUN (use --apply to write)"}`);
+console.log(`lists scanned: ${lists}`);
+console.log(`lists with dupes: ${listsWithDupes}`);
+console.log(`entries before: ${entriesBefore}`);
+console.log(`duplicate entries: ${entriesRemoved}`);
+console.log(`\ntop 10 most-duplicated lists:`);
+for (const w of worst.slice(0, 10)) {
+ console.log(` ${w.key} ${w.before} -> ${w.after} (-${w.before - w.after})`);
+}
+
+await db.quit();