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>
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
// 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 })
|
|
})
|