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

@@ -1051,6 +1051,46 @@ CREATE TABLE IF NOT EXISTS team_moderation_requests (
INDEX idx_tmr_queue (status, requested_at) INDEX idx_tmr_queue (status, requested_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table:
-- core writes its own membership and rename items with source='core', and a module
-- pushes game items through ctx.teams.activity.push with source=<moduleId>. That
-- core writes 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.
--
-- `summary` is ALREADY-RENDERED text and core never composes one (§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 exactly the game semantics
-- the module system exists to remove. `kind` and `payload` are likewise opaque:
-- core stores and filters them, and only the module's `team.overview` slot renders
-- anything richer than the text.
CREATE TABLE IF NOT EXISTS team_activity (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
source VARCHAR(32) NOT NULL, -- 'core' or a module id
kind VARCHAR(64) NOT NULL, -- namespaced <source>.<name>, opaque to core
summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one
-- Defaults to 'members' — fail closed. The module CHOOSES visibility per item;
-- core ENFORCES it on the read path. Same shape as a module owning the
-- public-safety filter for its push streams (MODULE_API.md §2.4).
visibility ENUM('public','members') NOT NULL DEFAULT 'members',
actor_member_key VARCHAR(191) NULL,
actor_user_id INT NULL,
payload JSON NULL, -- opaque; rendered only by the module's slot
occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived
-- Optional idempotence key. INSERT IGNORE against this unique index is the same
-- trick shard_events already uses, and it is what makes a sidecar reconnect
-- backfill safe: replaying a window of events re-posts nothing.
dedupe_key CHAR(40) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the
-- Team's history of what happened, only the attribution.
CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key),
INDEX idx_team_activity_feed (team_id, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses -- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here. -- these columns from the CREATE TABLE above; existing installs get them here.

View File

@@ -0,0 +1,133 @@
// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every
// decision about what a caller may SEE lives in teamActivity.model.js.
const { query } = require('../../utils/db')
const ACTIVITY_COLUMNS = `
id, team_id, source, kind, summary, visibility,
actor_member_key, actor_user_id, payload, occurred_at, created_at`
/**
* Insert one item, idempotently when it carries a dedupe key.
*
* INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a
* sidecar reconnect backfills a window of events it already delivered, and
* without this every reconnect would double-post the feed. The same trick
* `shard_events` uses, for the same reason.
*
* The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in
* a unique index, so items WITHOUT a key never collide with each other — an
* un-keyed push is always an insert, which is the documented contract (§4.1:
* `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out).
*
* IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The
* model validates and truncates before calling, so what reaches here can only fail
* on the dedupe key, and `affectedRows` reports which happened.
*/
async function insert(item) {
const res = await query(
`INSERT IGNORE INTO team_activity
(team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
item.teamId,
item.source,
item.kind,
item.summary,
item.visibility,
item.actorMemberKey,
item.actorUserId,
item.payload === null ? null : JSON.stringify(item.payload),
new Date(item.occurredAt),
item.dedupeKey,
],
)
return Number(res.affectedRows) > 0
}
/**
* One page of a Team's feed, already narrowed to the visibilities the caller may
* see.
*
* `visibilities` is always supplied by the model and never by a request
* parameter — a caller naming its own visibility filter is the whole bug this
* table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the
* game's clock, not `created_at`: a backfill that arrives late still sorts where
* it happened.
*/
async function page(teamId, visibilities, { limit, offset }) {
const slots = visibilities.map(() => '?').join(', ')
return query(
`SELECT ${ACTIVITY_COLUMNS} FROM team_activity
WHERE team_id = ? AND visibility IN (${slots})
ORDER BY occurred_at DESC, id DESC
LIMIT ? OFFSET ?`,
[teamId, ...visibilities, limit, offset],
)
}
/** Total matching rows, for the same filter — so a client can page honestly. */
async function count(teamId, visibilities) {
const slots = visibilities.map(() => '?').join(', ')
const rows = await query(
`SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`,
[teamId, ...visibilities],
)
return Number(rows[0] ? rows[0].n : 0)
}
/** Everything older than the retention horizon, across every Team. */
async function deleteOlderThan(days) {
const res = await query(
'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)',
[days],
)
return Number(res.affectedRows) || 0
}
/**
* Which Teams currently exceed the per-Team row cap, and by how much.
*
* Asked first so the trim only runs for Teams that need it. A feed fed by a game
* loop is the obvious unbounded-growth failure (§4.2), and on a shard with one
* busy guild and fifty quiet ones this keeps the nightly job proportional to the
* problem rather than to the number of Teams.
*/
async function overCap(cap) {
return query(
`SELECT team_id, COUNT(*) AS n FROM team_activity
GROUP BY team_id HAVING n > ?`,
[cap],
)
}
/**
* Trim one Team back to the newest `cap` rows.
*
* Expressed as "delete everything at or below the id of the cap-th newest row"
* rather than as a correlated subquery on the same table, which MariaDB refuses
* inside a DELETE (error 1093). The derived table is what makes it legal — the
* subquery is materialised before the delete runs.
*/
async function trimToCap(teamId, cap) {
const rows = await query(
`SELECT id FROM team_activity
WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`,
[teamId, cap],
)
if (!rows[0]) return 0
const res = await query(
'DELETE FROM team_activity WHERE team_id = ? AND id <= ?',
[teamId, rows[0].id],
)
return Number(res.affectedRows) || 0
}
module.exports = {
insert,
page,
count,
deleteOlderThan,
overCap,
trimToCap,
}

View File

@@ -0,0 +1,312 @@
// ── 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,
}

View File

@@ -32,6 +32,7 @@
const teamsDb = require('./teams.db') const teamsDb = require('./teams.db')
const teamProvider = require('./teamProvider') const teamProvider = require('./teamProvider')
const moderation = require('./teamModeration.model') const moderation = require('./teamModeration.model')
const activity = require('./teamActivity.model')
const { slugify, uniqueSlug } = require('./teamSlug') const { slugify, uniqueSlug } = require('./teamSlug')
const settings = require('../settings/settings.model') const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams') const log = require('../../utils/logger')('teams')
@@ -130,9 +131,66 @@ async function applyRename(moduleId, existing, team) {
log.info('team renamed; previous row archived', { log.info('team renamed; previous row archived', {
externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId, externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId,
}) })
// §4.2's `core.team.renamed`, written to the SUCCESSOR rather than to the row
// that was renamed: the archived row is a read-only record of what happened
// before the rename (§2.2), and the person who wants to know a Team used to be
// called something else is looking at the live page.
//
// The old name is core's own, not game-sourced text a module handed us this
// run — it is the `name` column core has been serving all along — so §2.9's
// approval gate does not apply. It can still be a name staff suppressed, which
// is why a hidden Team's feed is not served publicly (teamActivity.feedFor).
await activity.logCore({
teamId: successorId,
kind: activity.CORE_KINDS.TEAM_RENAMED,
summary: `Renamed from ${existing.display_name_override || existing.name}`,
dedupeKey: `renamed:${existing.id}`,
}).catch((err) => log.warn('rename activity not recorded', { message: err.message }))
return successorId return successorId
} }
/** Never a game-internal member key on a public page: that identifier is not published (§3.2). */
const memberLabel = (row) => (row && row.display_name) || 'A member'
/**
* Core's own membership items for one roster run (§4.2).
*
* **Suppressed on a Team's FIRST roster.** Importing a 155-member guild is one
* Team arriving, not 155 people joining, and emitting a join per member would
* bury every real event under the import and blow through the row cap on day one.
* `roster_synced_at IS NULL` is exactly "core has never held a roster for this
* Team", so the same condition covers a newly created Team and a newly installed
* module adopting an existing one.
*
* Never throws: the feed is a rendering of the sync, and a feed write failing
* must not abort the sync that is the actual source of truth.
*/
async function logRosterActivity(team, { joined, left, promoted, demoted }) {
if (!team.roster_synced_at) return
const items = [
...joined.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_JOINED, row, verb: 'joined' })),
...left.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_LEFT, row, verb: 'left' })),
...promoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'became a leader' })),
...demoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'stepped down as a leader' })),
]
for (const { kind, row, verb } of items) {
try {
// eslint-disable-next-line no-await-in-loop
await activity.logCore({
teamId: team.id,
kind,
summary: `${memberLabel(row)} ${verb}`,
actorMemberKey: row.member_key,
actorUserId: row.user_id ?? null,
})
} catch (err) {
log.warn('roster activity not recorded', { teamId: team.id, kind, message: err.message })
}
}
}
/** /**
* Sync one Team's roster and leadership. Gates 3 and 4 live here. * Sync one Team's roster and leadership. Gates 3 and 4 live here.
* *
@@ -152,7 +210,13 @@ async function syncRoster(team) {
return false return false
} }
const known = await teamsDb.memberKeys(team.id) // The full rows rather than just the keys: the activity feed needs the display
// name and the prior `is_leader` of everyone who is about to change, and both
// are gone once the upsert below has run. One read either way — this replaces
// the `memberKeys` call rather than adding to it.
const knownRows = await teamsDb.membersByTeam(team.id)
const knownByKey = new Map(knownRows.map((row) => [row.member_key, row]))
const known = knownRows.map((row) => row.member_key)
// Gate 4, the per-Team twin of gate 2. // Gate 4, the per-Team twin of gate 2.
if (answer.complete && answer.members.length === 0 && known.length > 0) { if (answer.complete && answer.members.length === 0 && known.length > 0) {
@@ -184,17 +248,35 @@ async function syncRoster(team) {
}) })
} }
// Anyone the module reports that core was not already holding. Read from the
// module's shape, since a joiner has no row yet.
const joined = answer.members
.filter((m) => !knownByKey.has(m.memberKey))
.map((m) => ({ member_key: m.memberKey, display_name: m.displayName, user_id: m.userId }))
// Removals only from a COMPLETE answer. `complete: false` means "valid but // Removals only from a COMPLETE answer. `complete: false` means "valid but
// partial", so additions and updates apply and nothing is taken away. // partial", so additions and updates apply and nothing is taken away.
let left = []
if (answer.complete) { if (answer.complete) {
const seen = new Set(answer.members.map((m) => m.memberKey)) const seen = new Set(answer.members.map((m) => m.memberKey))
await teamsDb.markDeparted(team.id, known.filter((key) => !seen.has(key))) const departedKeys = known.filter((key) => !seen.has(key))
left = departedKeys.map((key) => knownByKey.get(key))
await teamsDb.markDeparted(team.id, departedKeys)
} }
// Leadership is a separate question with a separate answer, and a provider that // Leadership is a separate question with a separate answer, and a provider that
// cannot answer it leaves the synced value alone rather than demoting everyone. // cannot answer it leaves the synced value alone rather than demoting everyone.
const leaders = await teamProvider.getTeamLeaders(team.external_id) const leaders = await teamProvider.getTeamLeaders(team.external_id)
let promoted = []
let demoted = []
if (leaders.ok) { if (leaders.ok) {
// Diffed against the PRIOR rows, before setLeaders overwrites them. A member
// who joined this run as a leader is reported as joining, not as being
// promoted — they were never anything else here.
const nowLeader = new Set(leaders.leaders)
const departed = new Set(left.map((row) => row && row.member_key))
promoted = knownRows.filter((row) => nowLeader.has(row.member_key) && !row.is_leader)
demoted = knownRows.filter((row) => !nowLeader.has(row.member_key) && row.is_leader && !departed.has(row.member_key))
await teamsDb.setLeaders(team.id, leaders.leaders) await teamsDb.setLeaders(team.id, leaders.leaders)
} else { } else {
log.warn('leadership left untouched; provider could not answer', { log.warn('leadership left untouched; provider could not answer', {
@@ -203,6 +285,8 @@ async function syncRoster(team) {
} }
await teamsDb.recount(team.id) await teamsDb.recount(team.id)
// Read before `markRosterSynced` moves the stamp this decision turns on.
await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted })
await teamsDb.markRosterSynced(team.id) await teamsDb.markRosterSynced(team.id)
return true return true
} }

View File

@@ -120,6 +120,7 @@ function buildCtx(id, moduleRoot) {
const activity = require('../model/activity/activity.model') const activity = require('../model/activity/activity.model')
const users = require('../model/users/users.model') const users = require('../model/users/users.model')
const teams = require('../model/teams/teamSync.model') const teams = require('../model/teams/teamSync.model')
const teamActivity = require('../model/teams/teamActivity.model')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit') const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* eslint-enable global-require */ /* eslint-enable global-require */
@@ -190,14 +191,20 @@ function buildCtx(id, moduleRoot) {
teams: { teams: {
publish: (event) => teams.publish(event), publish: (event) => teams.publish(event),
reconcile: (opts) => teams.request(opts), reconcile: (opts) => teams.request(opts),
// §4's activity feed, which lands with the Team pages in phase 3. Declared // §4's activity feed (phase 3). `source` is bound to the CALLING module and
// in 1.6.0 alongside the rest of the Team surface; calling it before phase 3 // is never taken from the item — a module writes its own items, under its
// throws rather than silently accepting items into a table that does not // own name, and items name their Team by the module's own `externalId`, so
// exist yet. // there is no id a module could send that reaches another module's Team.
//
// Like `publish` and `reconcile` above, a failure here never reaches the
// module: this is called from inside a game-event handler, and a storage
// problem of core's must not become the module's control flow. A rejected
// write is logged and the promise still resolves.
activity: { activity: {
push: () => { push: (items) => teamActivity.push(id, items).then(
throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)') (stored) => { void stored },
}, (err) => { log.error('ctx.teams.activity.push failed', { module: id, message: err.message }) },
),
}, },
}, },
// One function, for one caller: the `admin.users.detail` slot router needs // One function, for one caller: the `admin.users.detail` slot router needs

View File

@@ -9,6 +9,7 @@ const http = require('http')
// now because none of it reaches the loader's scan. // now because none of it reaches the loader's scan.
const botScore = require('./middleware/botScore') const botScore = require('./middleware/botScore')
const announceWorker = require('./utils/announceWorker') const announceWorker = require('./utils/announceWorker')
const teamActivityPrune = require('./utils/teamActivityPrune')
const { ensureSchema, close } = require('./utils/db') const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model') const settings = require('./model/settings/settings.model')
@@ -150,6 +151,11 @@ async function start() {
// retry per leg. No-op until a news post is actually published. // retry per leg. No-op until a news post is actually published.
announceWorker.start() announceWorker.start()
// Bound the per-Team activity feed (TEAMS.md §4.2). A feed fed by a game loop
// is the obvious unbounded-growth failure, so retention starts with the feed
// rather than after someone notices. No-op on a deployment with no Teams.
teamActivityPrune.start()
setupShutdown(server, internalServer) setupShutdown(server, internalServer)
} }
@@ -167,6 +173,7 @@ function setupShutdown(server, internalServer) {
await moduleLifecycle.shutdown() await moduleLifecycle.shutdown()
botScore.stopSweeper() // stop the bot-store cleanup interval botScore.stopSweeper() // stop the bot-store cleanup interval
announceWorker.stop() // stop the news-announcement dispatcher poller announceWorker.stop() // stop the news-announcement dispatcher poller
teamActivityPrune.stop() // stop the Team activity retention timer
server.close(() => log.info('http server closed')) server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed')) if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try { try {

View File

@@ -0,0 +1,64 @@
// ── Team activity retention worker ──────────────────────────────────────────
//
// TEAMS.md §4.2's nightly prune: an age horizon (`team_activity_retain_days`,
// default 90) and a per-Team row cap (`team_activity_row_cap`, default 2000).
// Both live in `settings`, so an operator can tighten a busy shard without a
// deploy.
//
// Same in-process shape as utils/announceWorker and middleware/botScore's
// sweeper — setInterval + unref + stop(), wired into server.js start/shutdown.
// There is no cron in this stack and adding one for a single daily DELETE would
// be a dependency to justify at every future upgrade.
//
// **The first run is delayed rather than immediate.** A prune at boot would put a
// table-wide DELETE in front of the first request on every restart, and a
// deployment that is crash-looping would run it on every loop. Five minutes in is
// past the point where a boot has either succeeded or failed.
const teamActivity = require('../model/teams/teamActivity.model')
const log = require('./logger')('teams')
// Nightly, per §4.2. Not aligned to a wall-clock hour: the work is proportional
// to what arrived rather than to when it is done, and pinning it to 03:00 would
// mean a process that restarts each afternoon never prunes at all.
const INTERVAL_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_MS) || 24 * 60 * 60 * 1000
const FIRST_RUN_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_DELAY_MS) || 5 * 60 * 1000
let timer = null
let firstRun = null
/** One prune. Never throws — it runs on a timer with nobody to catch it. */
async function tick() {
try {
return await teamActivity.prune()
} catch (err) {
log.error('team activity prune failed', { message: err.message })
return null
}
}
function start() {
if (timer || firstRun) return timer
firstRun = setTimeout(() => {
firstRun = null
tick()
timer = setInterval(() => { tick() }, INTERVAL_MS)
if (timer.unref) timer.unref()
}, FIRST_RUN_MS)
if (firstRun.unref) firstRun.unref()
log.info('team activity retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
return timer
}
function stop() {
if (firstRun) {
clearTimeout(firstRun)
firstRun = null
}
if (timer) {
clearInterval(timer)
timer = null
}
}
module.exports = { start, stop, tick, INTERVAL_MS, FIRST_RUN_MS }

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 registries = require('../src/modules/registries')
const teamsDb = require('../src/model/teams/teams.db') const teamsDb = require('../src/model/teams/teams.db')
const moderation = require('../src/model/teams/teamModeration.model') 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 settings = require('../src/model/settings/settings.model')
const teamSync = require('../src/model/teams/teamSync.model') const teamSync = require('../src/model/teams/teamSync.model')
@@ -113,6 +114,16 @@ function stubDb() {
patch(teamsDb, 'memberKeys', async (teamId) => patch(teamsDb, 'memberKeys', async (teamId) =>
[...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key)) [...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) => { patch(teamsDb, 'upsertMember', async (m) => {
const existing = membersOf(m.teamId).get(m.memberKey) const existing = membersOf(m.teamId).get(m.memberKey)
membersOf(m.teamId).set(m.memberKey, { membersOf(m.teamId).set(m.memberKey, {
@@ -182,6 +193,16 @@ function stubDb() {
return { hidden: false } return { hidden: false }
}) })
patch(moderation, 'rescreen', async () => 0) 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 // 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() await teamSync.start()
assert.equal(store.teams.length, 0) 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')
})