// ── The per-Team activity feed (TEAMS.md Part 4) ─────────────────────────── // // Two writers, one table. A module pushes game items through // `ctx.teams.activity.push` (§4.1); core writes its own membership and rename // items directly (§4.2). Both land in `team_activity` with a `source`, and the // read path treats them identically — which is the point of core writing here at // all, since it means the rendering path is exercised from day one on a // deployment whose module pushes nothing. // // **Three rules shape this file.** // // 1. *Core never composes a summary.* `summary` arrives already rendered and is // stored verbatim (§4.1). Core cannot phrase "gained 15,000 gold" for a game // whose vocabulary it does not know, and a core that templated it would have // re-acquired the game semantics the module system exists to remove. Core's OWN // five kinds are the sole exception, and they are about membership and renames // — platform facts, not game ones. // // 2. *Visibility fails closed.* An item with no stated visibility is `members`, // and the read path resolves what a caller may see from their access rather // than from anything they send. // // 3. *A push never throws at its call site.* `ctx.teams.activity.push` is awaited // by a module inside a game-event handler. A bad item is dropped and logged; // an unknown Team is dropped and logged. The alternative — rejecting the batch // — makes core's storage problem into the module's control flow, and the // contract (MODULE_API.md §2.3) is that ctx pushes are fire-and-forget. const activityDb = require('./teamActivity.db') const teamsDb = require('./teams.db') const access = require('./teamAccess.model') const settings = require('../settings/settings.model') const log = require('../../utils/logger')('teams') // Column widths from schema.sql. Truncating rather than refusing: an over-long // summary is a module being verbose, not a module being wrong, and dropping the // item would lose a real event over a display detail. const MAX_SUMMARY = 255 const MAX_KIND = 64 const MAX_MEMBER_KEY = 191 // CHAR(40) — a sha1 hex is the natural fit and what §4.1's example looks like, // but the column is opaque and any stable string within the width works. const MAX_DEDUPE = 40 const VISIBILITIES = ['public', 'members'] // Retention (§4.2). Both are settings so an operator can tighten a busy shard // without a deploy; the defaults are the doc's. const DEFAULT_RETAIN_DAYS = 90 const DEFAULT_ROW_CAP = 2000 /** * Core's own kinds (§4.2). * * `core.forum.thread` is named in the doc and lands with the forum in phase 4 — * there is nothing to emit it from yet. The four here are all core knows how to * say without asking a game anything. */ const CORE_KINDS = { MEMBER_JOINED: 'core.member.joined', MEMBER_LEFT: 'core.member.left', LEADER_CHANGED: 'core.leader.changed', TEAM_RENAMED: 'core.team.renamed', } const clamp = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null) /** * Normalise one pushed item, or return null to drop it. * * `teamId` is resolved by the caller, not carried on the item: a module names its * own `externalId` and core maps it (§4.1), so a module can never write into * another module's Team by guessing an integer. */ function normalise(item, source, teamId) { if (!item || typeof item !== 'object') return null const kind = clamp(item.kind, MAX_KIND) const summary = clamp(item.summary, MAX_SUMMARY) // Both are load-bearing and neither has a safe default: an item with no kind // cannot be filtered or rendered by a slot, and one with no summary is a blank // row on a public page. if (!kind || !summary) return null // `occurredAt` is the game's clock and the feed's sort key. A missing or // unparseable one becomes now — the item is real even when its timestamp is // not, and dropping it would lose an event over metadata. const occurredAt = Number.isFinite(item.occurredAt) ? Number(item.occurredAt) : Date.now() return { teamId, source, kind, summary, visibility: VISIBILITIES.includes(item.visibility) ? item.visibility : 'members', actorMemberKey: clamp(item.actorMemberKey, MAX_MEMBER_KEY), // Resolved BY THE MODULE, like every other user id crossing this boundary // (§2.3) — core takes the number and never looks it up. actorUserId: Number.isInteger(item.actorUserId) && item.actorUserId > 0 ? item.actorUserId : null, payload: item.payload && typeof item.payload === 'object' ? item.payload : null, occurredAt, dedupeKey: clamp(item.dedupeKey, MAX_DEDUPE), } } /** * `ctx.teams.activity.push` — a module's whole write access to the feed. * * Items name their Team by the module's own `externalId`, and only ACTIVE Teams * owned by THAT module resolve. An archived Team is deliberately not writable: its * feed is a read-only record of what happened before the rename or the disband * (§2.2), and letting a late-arriving event append to it would make a closed * record grow. * * Returns the number of items actually stored. Dropped items are logged with the * reason and never raised — see rule 3 above. */ async function push(source, items) { if (!Array.isArray(items)) { log.warn('teams activity push: not an array', { source }) return 0 } if (!items.length) return 0 // One lookup per distinct externalId, not one per item: a champion spawn // completing pushes a batch for a single Team, and re-resolving it per item // would be a query per row. const teamIds = new Map() let stored = 0 let dropped = 0 for (const item of items) { const externalId = item && typeof item.externalId === 'string' ? item.externalId.trim() : '' if (!externalId) { dropped += 1; continue } if (!teamIds.has(externalId)) { // eslint-disable-next-line no-await-in-loop const row = await teamsDb.findActive(source, externalId) teamIds.set(externalId, row ? row.id : null) } const teamId = teamIds.get(externalId) if (!teamId) { dropped += 1; continue } const normalised = normalise(item, source, teamId) if (!normalised) { dropped += 1; continue } // eslint-disable-next-line no-await-in-loop const inserted = await activityDb.insert(normalised) // A dedupe collision is a SUCCESSFUL no-op, not a drop — it is the mechanism // working. Counted as stored so a module replaying a backfill does not read // its own idempotence as data loss. if (inserted) stored += 1 } if (dropped) { log.warn('teams activity push: dropped items', { source, dropped, offered: items.length }) } return stored } /** * Core's own write path (§4.2), used by the reconciler and the rename rule. * * Separate from `push` because core names a Team by its own primary key — it is * already holding the row — and because core's items are always `public`: a * member joining or a Team being renamed is exactly what a public Team page is * for. Nothing here is game vocabulary. */ async function logCore({ teamId, kind, summary, actorMemberKey = null, actorUserId = null, occurredAt = Date.now(), dedupeKey = null }) { if (!teamId || !kind || !summary) return false return activityDb.insert({ teamId, source: 'core', kind: clamp(kind, MAX_KIND), summary: clamp(summary, MAX_SUMMARY), visibility: 'public', actorMemberKey: clamp(actorMemberKey, MAX_MEMBER_KEY), actorUserId: Number.isInteger(actorUserId) && actorUserId > 0 ? actorUserId : null, payload: null, occurredAt, dedupeKey: clamp(dedupeKey, MAX_DEDUPE), }) } /** * Which visibilities a caller may see (§4.3). * * `members` items go to members and to forum-granted users — the same two * authority paths `forumAccess` already resolves, reused rather than re-derived * so the feed can never disagree with the forum about who is inside a Team. * Anyone else, including every anonymous caller, sees `public` only. */ async function visibilitiesFor(teamId, userId) { if (!userId) return ['public'] const resolved = await access.forumAccess(teamId, userId) return resolved.allowed ? ['public', 'members'] : ['public'] } /** The rendered shape. `payload` rides along for the module's slot (§4.3). */ function publicItem(row) { return { id: Number(row.id), source: row.source, kind: row.kind, summary: row.summary, visibility: row.visibility, occurredAt: row.occurred_at, payload: row.payload ?? null, } } /** * One page of a Team's feed for one viewer. * * A HIDDEN Team's feed is not served publicly, for the same reason its roster is * not (§2.8.3): hidden means absent from every public surface, and a feed that * answered while the page 404s would republish the suppressed name in every * `core.team.renamed` summary. */ async function feedFor(slug, userId, { limit = 50, offset = 0 } = {}) { const row = await teamsDb.findBySlug(slug) if (!row) return null const visibilities = await visibilitiesFor(row.id, userId) // A member of a hidden Team still sees its feed — suppression is a // public-surface rule, and a member is not a member of the public (§2.11). if (row.hidden && visibilities.length === 1) return null const [rows, total] = await Promise.all([ activityDb.page(row.id, visibilities, { limit, offset }), activityDb.count(row.id, visibilities), ]) return { items: rows.map(publicItem), total, limit, offset, // So a client can render "members-only items are hidden" rather than // presenting a filtered feed as the whole one. scope: visibilities.includes('members') ? 'members' : 'public', } } // ── Retention (§4.2) ─────────────────────────────────────────────────────── const RETAIN_KEY = 'team_activity_retain_days' const CAP_KEY = 'team_activity_row_cap' /** * Read both limits, falling back to the defaults on anything unreadable. * * Wrapped in a try like `teamSync.intervalSeconds`, and for the same reason: this * runs on a timer with nobody watching, and a settings table that is briefly * unavailable must yield the default rather than an exception that kills the * nightly job. A misconfigured value fails the same way — a zero or a negative * retention would delete the whole feed, so it is rejected rather than honoured. */ async function retentionConfig() { let rawDays let rawCap try { ;[rawDays, rawCap] = await Promise.all([settings.get(RETAIN_KEY), settings.get(CAP_KEY)]) } catch { return { days: DEFAULT_RETAIN_DAYS, cap: DEFAULT_ROW_CAP } } const days = Number.parseInt(rawDays, 10) const cap = Number.parseInt(rawCap, 10) return { days: Number.isFinite(days) && days > 0 ? days : DEFAULT_RETAIN_DAYS, cap: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_ROW_CAP, } } /** * The nightly prune: an age horizon AND a per-Team row cap. * * Both, because either alone has a hole. Age alone lets one busy guild write a * million rows inside the window; a cap alone keeps a dead Team's feed forever. * Unbounded growth on a per-Team feed fed by a game loop is the obvious failure * here and it is cheaper to bound it now than to discover it at cutover. */ async function prune() { const { days, cap } = await retentionConfig() const byAge = await activityDb.deleteOlderThan(days) let byCap = 0 const over = await activityDb.overCap(cap) for (const row of over) { // eslint-disable-next-line no-await-in-loop byCap += await activityDb.trimToCap(row.team_id, cap) } if (byAge || byCap) log.info('teams activity prune', { byAge, byCap, days, cap }) return { byAge, byCap, days, cap } } module.exports = { push, logCore, feedFor, visibilitiesFor, publicItem, prune, retentionConfig, RETAIN_KEY, CAP_KEY, CORE_KINDS, VISIBILITIES, DEFAULT_RETAIN_DAYS, DEFAULT_ROW_CAP, }