// One-time claims shared by every running copy of the app (Marty, 2026-09-25). // // Why: a Coolify deploy starts the new container and only stops the old one once the new one // is healthy, so for half a minute two copies of this app watch the chain. On 25 September // both saw Marty's $5 purchase: two confirmation emails, and the Five Dollar Friday bonus // granted twice (+100 and +100, 19 seconds apart). Every dedupe until now lived in process // memory or a JSON file on the volume, neither of which a second process shares in time. // // This is the shared version: a row in MySQL that only one INSERT can win. `claim(key)` is // true for exactly one caller across all instances, false for everyone else. Gate anything // that must happen once per event, and never gate the in-memory feed (each instance keeps // its own). // // Without a database (local QA, the JSON store) it degrades to a per-process Set, which is // exactly the old behaviour: one instance, one memory. 'use strict'; let db = null; const local = new Set(); function init(deps) { db = deps && deps.db; } async function ensureTable() { if (!db || !db.enabled()) return; await db.q('CREATE TABLE IF NOT EXISTS event_marks (k VARCHAR(160) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB'); } // true = this caller owns the key; false = someone (possibly another instance) already does. async function claim(key) { key = String(key).slice(0, 160); if (!db || !db.enabled()) { if (local.has(key)) return false; local.add(key); return true; } try { const r = await db.q('INSERT IGNORE INTO event_marks (k, ts) VALUES (?, ?)', [key, Date.now()]); return !!(r && r.affectedRows === 1); } catch (e) { // The table missing, or the DB unreachable, must not silently double-send: log it and // fall back to the per-process memory, which at least keeps ONE instance honest. console.error('marks: claim fell back to memory for ' + key + ': ' + e.message); if (local.has(key)) return false; local.add(key); return true; } } // Keep the table from growing forever: marks older than 60 days are of no use, every event // they guard has long since been handled. async function prune() { if (!db || !db.enabled()) return; try { await db.q('DELETE FROM event_marks WHERE ts < ?', [Date.now() - 60 * 86400000]); } catch (e) {} } module.exports = { init, ensureTable, claim, prune };