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:
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user