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,271 @@
// The per-Team activity feed (docs/website/TEAMS.md Part 4).
//
// The db layer is stubbed and an in-memory table stands in for `team_activity`,
// so these are assertions about the RULES: what a module is allowed to write,
// what a caller is allowed to see, and what the prune takes away. The three worth
// protecting are the ones that are easy to "simplify" into a leak:
//
// 1. a module writes only into its OWN active Teams, named by external id;
// 2. visibility defaults to `members` and is resolved from the session, never
// from a request parameter;
// 3. a hidden Team's feed does not answer a public caller.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const activityDb = require('../src/model/teams/teamActivity.db')
const teamsDb = require('../src/model/teams/teams.db')
const access = require('../src/model/teams/teamAccess.model')
const settings = require('../src/model/settings/settings.model')
const activity = require('../src/model/teams/teamActivity.model')
let store
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
function stub() {
store = {
rows: [],
nextId: 1,
teams: [
{ id: 1, module_id: 'uo', external_id: 'g1', slug: 'the-guild', hidden: 0, status: 'active' },
{ id: 2, module_id: 'uo', external_id: 'g2', slug: 'hidden-guild', hidden: 1, status: 'active' },
],
allowed: new Set(), // userIds with member/grant access, keyed "teamId:userId"
}
patch(teamsDb, 'findActive', async (moduleId, externalId) =>
store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active'))
patch(teamsDb, 'findBySlug', async (slug) => store.teams.find((t) => t.slug === slug))
patch(access, 'forumAccess', async (teamId, userId) => ({
allowed: store.allowed.has(`${teamId}:${userId}`),
viaMembership: store.allowed.has(`${teamId}:${userId}`),
viaGrant: false,
isLeader: false,
}))
patch(activityDb, 'insert', async (item) => {
if (item.dedupeKey && store.rows.some((r) => r.team_id === item.teamId && r.dedupe_key === item.dedupeKey)) {
return false // the unique index doing its job
}
store.rows.push({
id: store.nextId++,
team_id: item.teamId,
source: item.source,
kind: item.kind,
summary: item.summary,
visibility: item.visibility,
actor_member_key: item.actorMemberKey,
actor_user_id: item.actorUserId,
payload: item.payload,
occurred_at: new Date(item.occurredAt),
dedupe_key: item.dedupeKey,
})
return true
})
patch(activityDb, 'page', async (teamId, visibilities, { limit, offset }) =>
store.rows
.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility))
.sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id)
.slice(offset, offset + limit))
patch(activityDb, 'count', async (teamId, visibilities) =>
store.rows.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility)).length)
patch(activityDb, 'deleteOlderThan', async (days) => {
const cutoff = Date.now() - days * 86400_000
const before = store.rows.length
store.rows = store.rows.filter((r) => r.occurred_at.getTime() >= cutoff)
return before - store.rows.length
})
patch(activityDb, 'overCap', async (cap) => {
const byTeam = new Map()
for (const r of store.rows) byTeam.set(r.team_id, (byTeam.get(r.team_id) || 0) + 1)
return [...byTeam].filter(([, n]) => n > cap).map(([team_id, n]) => ({ team_id, n }))
})
patch(activityDb, 'trimToCap', async (teamId, cap) => {
const mine = store.rows
.filter((r) => r.team_id === teamId)
.sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id)
const keep = new Set(mine.slice(0, cap).map((r) => r.id))
const before = store.rows.length
store.rows = store.rows.filter((r) => r.team_id !== teamId || keep.has(r.id))
return before - store.rows.length
})
patch(settings, 'get', async () => null) // defaults
}
const item = (extra = {}) => ({ externalId: 'g1', kind: 'uo.thing', summary: 'A thing happened', ...extra })
beforeEach(stub)
afterEach(restore)
// ── What a module may write ────────────────────────────────────────────────
test('a pushed item lands against the team its external id names', async () => {
const stored = await activity.push('uo', [item()])
assert.equal(stored, 1)
assert.equal(store.rows[0].team_id, 1)
assert.equal(store.rows[0].source, 'uo')
})
test('a module cannot write into another module\'s team', async () => {
// 'other' owns no team with external id g1, so there is nothing to resolve —
// and no integer the module could have sent instead, which is the point of
// naming Teams by external id on this path.
const stored = await activity.push('other', [item()])
assert.equal(stored, 0)
assert.equal(store.rows.length, 0)
})
test('an unknown external id is dropped rather than raised', async () => {
const stored = await activity.push('uo', [item({ externalId: 'nope' })])
assert.equal(stored, 0)
})
test('visibility defaults to members, and an unknown value does not widen it', async () => {
await activity.push('uo', [item(), item({ visibility: 'everyone' }), item({ visibility: 'public' })])
assert.deepEqual(store.rows.map((r) => r.visibility), ['members', 'members', 'public'])
})
test('an item with no kind or no summary is dropped, and the rest of the batch still lands', async () => {
const stored = await activity.push('uo', [item({ kind: '' }), item({ summary: ' ' }), item()])
assert.equal(stored, 1)
assert.equal(store.rows.length, 1)
})
test('an over-long summary is truncated rather than losing the event', async () => {
await activity.push('uo', [item({ summary: 'x'.repeat(400) })])
assert.equal(store.rows[0].summary.length, 255)
})
test('a replayed batch with dedupe keys stores each item once', async () => {
const batch = [item({ dedupeKey: 'champ:77' }), item({ dedupeKey: 'champ:78' })]
await activity.push('uo', batch)
await activity.push('uo', batch) // the sidecar reconnect backfill
assert.equal(store.rows.length, 2)
})
test('items without a dedupe key are never collapsed into each other', async () => {
await activity.push('uo', [item(), item()])
assert.equal(store.rows.length, 2)
})
test('a push is never rejected for being malformed at the top level', async () => {
assert.equal(await activity.push('uo', null), 0)
assert.equal(await activity.push('uo', []), 0)
})
// ── What a caller may see ──────────────────────────────────────────────────
test('an anonymous caller gets public items only, and is told the scope', async () => {
await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })])
const feed = await activity.feedFor('the-guild', null)
assert.equal(feed.items.length, 1)
assert.equal(feed.items[0].visibility, 'public')
assert.equal(feed.scope, 'public')
// `total` is the caller's total, not the table's — otherwise paging lies.
assert.equal(feed.total, 1)
})
test('a member sees both, via the same resolver the forum uses', async () => {
await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })])
store.allowed.add('1:7')
const feed = await activity.feedFor('the-guild', 7)
assert.equal(feed.items.length, 2)
assert.equal(feed.scope, 'members')
})
test('an authenticated non-member is exactly an anonymous caller here', async () => {
await activity.push('uo', [item({ visibility: 'members' })])
const feed = await activity.feedFor('the-guild', 99)
assert.equal(feed.items.length, 0)
assert.equal(feed.scope, 'public')
})
test('a hidden team\'s feed does not answer the public, but does answer its members', async () => {
await activity.push('uo', [item({ externalId: 'g2', visibility: 'public' })])
assert.equal(await activity.feedFor('hidden-guild', null), null)
store.allowed.add('2:7')
const feed = await activity.feedFor('hidden-guild', 7)
assert.equal(feed.items.length, 1)
})
test('an unknown slug is not found rather than empty', async () => {
assert.equal(await activity.feedFor('no-such-team', null), null)
})
test('the rendered item carries the payload and never the actor identifiers', async () => {
await activity.push('uo', [item({
visibility: 'public', payload: { serial: '0x77' }, actorMemberKey: '0x40012ab3', actorUserId: 7,
})])
const feed = await activity.feedFor('the-guild', null)
assert.deepEqual(feed.items[0].payload, { serial: '0x77' })
assert.equal('actorMemberKey' in feed.items[0], false)
assert.equal('actorUserId' in feed.items[0], false)
})
// ── Core's own items ───────────────────────────────────────────────────────
test('core writes as source=core and public', async () => {
await activity.logCore({ teamId: 1, kind: activity.CORE_KINDS.MEMBER_JOINED, summary: 'Aldric joined' })
assert.equal(store.rows[0].source, 'core')
assert.equal(store.rows[0].visibility, 'public')
})
test('core refuses an item with nothing to say', async () => {
assert.equal(await activity.logCore({ teamId: 1, kind: 'core.x' }), false)
assert.equal(store.rows.length, 0)
})
// ── Retention ──────────────────────────────────────────────────────────────
test('the prune drops rows past the age horizon', async () => {
const old = Date.now() - 100 * 86400_000
await activity.push('uo', [item({ occurredAt: old }), item()])
const res = await activity.prune()
assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS)
assert.equal(res.byAge, 1)
assert.equal(store.rows.length, 1)
})
test('the prune trims a team back to the row cap, newest kept', async () => {
patch(settings, 'get', async (key) => (key === activity.CAP_KEY ? '3' : null))
const base = Date.now()
for (let i = 0; i < 6; i++) {
// eslint-disable-next-line no-await-in-loop
await activity.push('uo', [item({ summary: `event ${i}`, occurredAt: base + i * 1000 })])
}
const res = await activity.prune()
assert.equal(res.byCap, 3)
assert.deepEqual(store.rows.map((r) => r.summary), ['event 3', 'event 4', 'event 5'])
})
test('a zero or negative retention setting is rejected rather than emptying the feed', async () => {
patch(settings, 'get', async (key) => (key === activity.RETAIN_KEY ? '0' : null))
await activity.push('uo', [item()])
const res = await activity.prune()
assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS)
assert.equal(store.rows.length, 1)
})
test('an unreadable settings table leaves the defaults standing', async () => {
patch(settings, 'get', async () => { throw new Error('pool down') })
const res = await activity.retentionConfig()
assert.deepEqual(res, { days: activity.DEFAULT_RETAIN_DAYS, cap: activity.DEFAULT_ROW_CAP })
})

View File

@@ -12,6 +12,7 @@ const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const teamsDb = require('../src/model/teams/teams.db')
const moderation = require('../src/model/teams/teamModeration.model')
const activity = require('../src/model/teams/teamActivity.model')
const settings = require('../src/model/settings/settings.model')
const teamSync = require('../src/model/teams/teamSync.model')
@@ -113,6 +114,16 @@ function stubDb() {
patch(teamsDb, 'memberKeys', async (teamId) =>
[...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key))
// The sync reads the full rows, not just the keys: the activity feed needs each
// changing member's display name and PRIOR is_leader, both of which the upsert
// is about to overwrite. Stubbing this is not optional — an unstubbed seam here
// reaches the real pool, and the symptom is the suite hanging on dead-pool
// retries rather than failing (see test/_setup.js).
patch(teamsDb, 'membersByTeam', async (teamId, { includeDeparted = false } = {}) =>
[...membersOf(teamId).values()]
.filter((m) => includeDeparted || m.status === 'active')
.map((m) => ({ ...m })))
patch(teamsDb, 'upsertMember', async (m) => {
const existing = membersOf(m.teamId).get(m.memberKey)
membersOf(m.teamId).set(m.memberKey, {
@@ -182,6 +193,16 @@ function stubDb() {
return { hidden: false }
})
patch(moderation, 'rescreen', async () => 0)
// The activity feed is its own unit (teamActivity.test.js); here it is captured
// so the reconciler's side of §4.2 can be asserted without a database. Stubbing
// the MODEL rather than the db layer keeps these tests about which items the
// sync decides to emit, which is the reconciler's half of the contract.
store.activity = []
patch(activity, 'logCore', async (item) => {
store.activity.push(item)
return true
})
}
// A provider whose answers the test controls. Defaults are authoritative and
@@ -802,3 +823,155 @@ test('start() is inert with no provider registered', async () => {
await teamSync.start()
assert.equal(store.teams.length, 0)
})
// ── Core's own activity items (§4.2) ───────────────────────────────────────
//
// The reconciler's half of the feed: which items it DECIDES to emit. The feed's
// own rules — visibility, dedupe, retention — live in teamActivity.test.js.
const kinds = () => store.activity.map((a) => a.kind)
const summaries = () => store.activity.map((a) => a.summary)
const withMembers = (members, leaders = []) => ({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
getTeamLeaders: async () => ({ ok: true, leaders }),
})
// Re-provide between runs: the tests above establish that a provider registers
// once, so a second answer means a fresh registration.
async function resync(overrides, reason) {
registries._reset()
provide(overrides)
return teamSync.reconcileNow(reason)
}
test('the FIRST roster emits nothing — an import is not 155 people joining', async () => {
provide(withMembers([member('0x1'), member('0x2')]))
await teamSync.reconcileNow('setup')
assert.equal(activeMembers(1).length, 2, 'the members did land')
assert.deepEqual(store.activity, [], 'and none of them was announced')
})
test('a member arriving after the first roster is announced', async () => {
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })]), 'test')
assert.deepEqual(kinds(), ['core.member.joined'])
assert.deepEqual(summaries(), ['Brenna joined'])
assert.equal(store.activity[0].actorMemberKey, '0x2')
})
test('a member who leaves is announced by the name core last knew them by', async () => {
provide(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })]))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1')]), 'test')
// The module no longer mentions them at all, so the display name can only come
// from the row core is about to depart — which is why the sync reads the ROWS
// before the upsert rather than just the keys.
assert.deepEqual(kinds(), ['core.member.left'])
assert.deepEqual(summaries(), ['Brenna left'])
})
test('an INCOMPLETE answer announces no departures, because it removed none', async () => {
provide(withMembers([member('0x1'), member('0x2')]))
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x1')] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
}, 'partial')
assert.equal(activeMembers(1).length, 2, 'nobody was removed')
assert.deepEqual(store.activity, [], 'so nobody is announced as leaving')
})
test('promotion and demotion are announced; unchanged leadership is not', async () => {
provide(withMembers([member('0x1'), member('0x2')], ['0x1']))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2')], ['0x2']), 'test')
assert.deepEqual(kinds(), ['core.leader.changed', 'core.leader.changed'])
assert.deepEqual(summaries().sort(), ['0x1 stepped down as a leader', '0x2 became a leader'])
})
test('a member who joins already a leader is announced once, as joining', async () => {
provide(withMembers([member('0x1')], ['0x1']))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2']), 'test')
assert.deepEqual(kinds(), ['core.member.joined'], 'never a non-leader here to be promoted from')
})
test('a departing leader is announced as leaving, not as stepping down', async () => {
provide(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2']))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1')], ['0x1']), 'test')
assert.deepEqual(kinds(), ['core.member.left'], 'one event, not two')
})
test('a refused leadership answer announces nothing — it demoted nobody', async () => {
provide(withMembers([member('0x1')], ['0x1']))
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
getTeamLeaders: async () => ({ ok: false, reason: 'unavailable' }),
}, 'test')
assert.deepEqual(store.activity, [])
assert.equal(activeMembers(1)[0].is_leader, 1, 'and left the stored value alone')
})
test('a rename is announced on the successor, naming the old name', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
})
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
}, 'rename')
const renames = store.activity.filter((a) => a.kind === 'core.team.renamed')
assert.equal(renames.length, 1)
assert.equal(renames[0].summary, 'Renamed from The Silver Hand')
// The SUCCESSOR row, not the archived one: the archived row is a read-only
// record of what came before, and the reader is looking at the live page.
const successor = store.teams.find((t) => t.status === 'active')
assert.equal(renames[0].teamId, successor.id)
})
test('a member with no display name is announced without leaking the member key', async () => {
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2', { displayName: null })]), 'test')
assert.deepEqual(summaries(), ['A member joined'])
})
test('a feed write that fails never fails the sync', async () => {
patch(activity, 'logCore', async () => { throw new Error('table gone') })
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
const result = await resync(withMembers([member('0x1'), member('0x2')]), 'test')
assert.equal(result.ok, true)
assert.equal(activeMembers(1).length, 2, 'the roster is the source of truth and it applied')
})