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>
313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
// ── 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,
|
|
}
|