// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every // decision about what a caller may SEE lives in teamActivity.model.js. const { query } = require('../../utils/db') const ACTIVITY_COLUMNS = ` id, team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, created_at` /** * Insert one item, idempotently when it carries a dedupe key. * * INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a * sidecar reconnect backfills a window of events it already delivered, and * without this every reconnect would double-post the feed. The same trick * `shard_events` uses, for the same reason. * * The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in * a unique index, so items WITHOUT a key never collide with each other — an * un-keyed push is always an insert, which is the documented contract (§4.1: * `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out). * * IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The * model validates and truncates before calling, so what reaches here can only fail * on the dedupe key, and `affectedRows` reports which happened. */ async function insert(item) { const res = await query( `INSERT IGNORE INTO team_activity (team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ item.teamId, item.source, item.kind, item.summary, item.visibility, item.actorMemberKey, item.actorUserId, item.payload === null ? null : JSON.stringify(item.payload), new Date(item.occurredAt), item.dedupeKey, ], ) return Number(res.affectedRows) > 0 } /** * One page of a Team's feed, already narrowed to the visibilities the caller may * see. * * `visibilities` is always supplied by the model and never by a request * parameter — a caller naming its own visibility filter is the whole bug this * table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the * game's clock, not `created_at`: a backfill that arrives late still sorts where * it happened. */ async function page(teamId, visibilities, { limit, offset }) { const slots = visibilities.map(() => '?').join(', ') return query( `SELECT ${ACTIVITY_COLUMNS} FROM team_activity WHERE team_id = ? AND visibility IN (${slots}) ORDER BY occurred_at DESC, id DESC LIMIT ? OFFSET ?`, [teamId, ...visibilities, limit, offset], ) } /** Total matching rows, for the same filter — so a client can page honestly. */ async function count(teamId, visibilities) { const slots = visibilities.map(() => '?').join(', ') const rows = await query( `SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`, [teamId, ...visibilities], ) return Number(rows[0] ? rows[0].n : 0) } /** Everything older than the retention horizon, across every Team. */ async function deleteOlderThan(days) { const res = await query( 'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)', [days], ) return Number(res.affectedRows) || 0 } /** * Which Teams currently exceed the per-Team row cap, and by how much. * * Asked first so the trim only runs for Teams that need it. A feed fed by a game * loop is the obvious unbounded-growth failure (§4.2), and on a shard with one * busy guild and fifty quiet ones this keeps the nightly job proportional to the * problem rather than to the number of Teams. */ async function overCap(cap) { return query( `SELECT team_id, COUNT(*) AS n FROM team_activity GROUP BY team_id HAVING n > ?`, [cap], ) } /** * Trim one Team back to the newest `cap` rows. * * Expressed as "delete everything at or below the id of the cap-th newest row" * rather than as a correlated subquery on the same table, which MariaDB refuses * inside a DELETE (error 1093). The derived table is what makes it legal — the * subquery is materialised before the delete runs. */ async function trimToCap(teamId, cap) { const rows = await query( `SELECT id FROM team_activity WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`, [teamId, cap], ) if (!rows[0]) return 0 const res = await query( 'DELETE FROM team_activity WHERE team_id = ? AND id <= ?', [teamId, rows[0].id], ) return Number(res.affectedRows) || 0 } module.exports = { insert, page, count, deleteOlderThan, overCap, trimToCap, }