feat(teams): the activity feed, its two writers and its retention

TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.

Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.

Three rules shape the model:

  - core never composes a summary. It arrives already rendered and is stored
    verbatim; core cannot phrase "gained 15,000 gold" for a game whose
    vocabulary it does not know.
  - visibility fails closed. An item with no stated visibility is `members`.
  - a push never throws at its call site. It is called from inside a game-event
    handler, and a storage problem of core's must not become the module's
    control flow.

Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.

Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.

The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:15:18 -05:00
parent 1f175786a7
commit aa332eda82
9 changed files with 1100 additions and 9 deletions

View File

@@ -0,0 +1,64 @@
// ── Team activity retention worker ──────────────────────────────────────────
//
// TEAMS.md §4.2's nightly prune: an age horizon (`team_activity_retain_days`,
// default 90) and a per-Team row cap (`team_activity_row_cap`, default 2000).
// Both live in `settings`, so an operator can tighten a busy shard without a
// deploy.
//
// Same in-process shape as utils/announceWorker and middleware/botScore's
// sweeper — setInterval + unref + stop(), wired into server.js start/shutdown.
// There is no cron in this stack and adding one for a single daily DELETE would
// be a dependency to justify at every future upgrade.
//
// **The first run is delayed rather than immediate.** A prune at boot would put a
// table-wide DELETE in front of the first request on every restart, and a
// deployment that is crash-looping would run it on every loop. Five minutes in is
// past the point where a boot has either succeeded or failed.
const teamActivity = require('../model/teams/teamActivity.model')
const log = require('./logger')('teams')
// Nightly, per §4.2. Not aligned to a wall-clock hour: the work is proportional
// to what arrived rather than to when it is done, and pinning it to 03:00 would
// mean a process that restarts each afternoon never prunes at all.
const INTERVAL_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_MS) || 24 * 60 * 60 * 1000
const FIRST_RUN_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_DELAY_MS) || 5 * 60 * 1000
let timer = null
let firstRun = null
/** One prune. Never throws — it runs on a timer with nobody to catch it. */
async function tick() {
try {
return await teamActivity.prune()
} catch (err) {
log.error('team activity prune failed', { message: err.message })
return null
}
}
function start() {
if (timer || firstRun) return timer
firstRun = setTimeout(() => {
firstRun = null
tick()
timer = setInterval(() => { tick() }, INTERVAL_MS)
if (timer.unref) timer.unref()
}, FIRST_RUN_MS)
if (firstRun.unref) firstRun.unref()
log.info('team activity retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
return timer
}
function stop() {
if (firstRun) {
clearTimeout(firstRun)
firstRun = null
}
if (timer) {
clearInterval(timer)
timer = null
}
}
module.exports = { start, stop, tick, INTERVAL_MS, FIRST_RUN_MS }