From aa332eda8262b7fc10ff42ff98f27747fc720209 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:15:18 -0500 Subject: [PATCH] feat(teams): the activity feed, its two writers and its retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/db/schema.sql | 40 +++ server/src/model/teams/teamActivity.db.js | 133 ++++++++ server/src/model/teams/teamActivity.model.js | 312 +++++++++++++++++++ server/src/model/teams/teamSync.model.js | 88 +++++- server/src/modules/loader.js | 21 +- server/src/server.js | 7 + server/src/utils/teamActivityPrune.js | 64 ++++ server/test/teamActivity.test.js | 271 ++++++++++++++++ server/test/teamSync.test.js | 173 ++++++++++ 9 files changed, 1100 insertions(+), 9 deletions(-) create mode 100644 server/src/model/teams/teamActivity.db.js create mode 100644 server/src/model/teams/teamActivity.model.js create mode 100644 server/src/utils/teamActivityPrune.js create mode 100644 server/test/teamActivity.test.js diff --git a/server/db/schema.sql b/server/db/schema.sql index 2808561..2fd7ec4 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1051,6 +1051,46 @@ CREATE TABLE IF NOT EXISTS team_moderation_requests ( INDEX idx_tmr_queue (status, requested_at) ) 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=. 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 ., 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 -- 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. diff --git a/server/src/model/teams/teamActivity.db.js b/server/src/model/teams/teamActivity.db.js new file mode 100644 index 0000000..efd6f48 --- /dev/null +++ b/server/src/model/teams/teamActivity.db.js @@ -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, +} diff --git a/server/src/model/teams/teamActivity.model.js b/server/src/model/teams/teamActivity.model.js new file mode 100644 index 0000000..f6d0e84 --- /dev/null +++ b/server/src/model/teams/teamActivity.model.js @@ -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, +} diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js index 1847470..067f41f 100644 --- a/server/src/model/teams/teamSync.model.js +++ b/server/src/model/teams/teamSync.model.js @@ -32,6 +32,7 @@ const teamsDb = require('./teams.db') const teamProvider = require('./teamProvider') const moderation = require('./teamModeration.model') +const activity = require('./teamActivity.model') const { slugify, uniqueSlug } = require('./teamSlug') const settings = require('../settings/settings.model') const log = require('../../utils/logger')('teams') @@ -130,9 +131,66 @@ async function applyRename(moduleId, existing, team) { log.info('team renamed; previous row archived', { 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 } +/** 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. * @@ -152,7 +210,13 @@ async function syncRoster(team) { 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. 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 // partial", so additions and updates apply and nothing is taken away. + let left = [] if (answer.complete) { 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 // cannot answer it leaves the synced value alone rather than demoting everyone. const leaders = await teamProvider.getTeamLeaders(team.external_id) + let promoted = [] + let demoted = [] 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) } else { log.warn('leadership left untouched; provider could not answer', { @@ -203,6 +285,8 @@ async function syncRoster(team) { } 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) return true } diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index c1d4370..9d42d1e 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -120,6 +120,7 @@ function buildCtx(id, moduleRoot) { const activity = require('../model/activity/activity.model') const users = require('../model/users/users.model') const teams = require('../model/teams/teamSync.model') + const teamActivity = require('../model/teams/teamActivity.model') const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit') /* eslint-enable global-require */ @@ -190,14 +191,20 @@ function buildCtx(id, moduleRoot) { teams: { publish: (event) => teams.publish(event), reconcile: (opts) => teams.request(opts), - // §4's activity feed, which lands with the Team pages in phase 3. Declared - // in 1.6.0 alongside the rest of the Team surface; calling it before phase 3 - // throws rather than silently accepting items into a table that does not - // exist yet. + // §4's activity feed (phase 3). `source` is bound to the CALLING module and + // is never taken from the item — a module writes its own items, under its + // own name, and items name their Team by the module's own `externalId`, so + // 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: { - push: () => { - throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)') - }, + push: (items) => teamActivity.push(id, items).then( + (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 diff --git a/server/src/server.js b/server/src/server.js index 9de900a..4474214 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -9,6 +9,7 @@ const http = require('http') // now because none of it reaches the loader's scan. const botScore = require('./middleware/botScore') const announceWorker = require('./utils/announceWorker') +const teamActivityPrune = require('./utils/teamActivityPrune') const { ensureSchema, close } = require('./utils/db') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') 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. 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) } @@ -167,6 +173,7 @@ function setupShutdown(server, internalServer) { await moduleLifecycle.shutdown() botScore.stopSweeper() // stop the bot-store cleanup interval announceWorker.stop() // stop the news-announcement dispatcher poller + teamActivityPrune.stop() // stop the Team activity retention timer server.close(() => log.info('http server closed')) if (internalServer) internalServer.close(() => log.info('internal http server closed')) try { diff --git a/server/src/utils/teamActivityPrune.js b/server/src/utils/teamActivityPrune.js new file mode 100644 index 0000000..6f2e71e --- /dev/null +++ b/server/src/utils/teamActivityPrune.js @@ -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 } diff --git a/server/test/teamActivity.test.js b/server/test/teamActivity.test.js new file mode 100644 index 0000000..58fb32b --- /dev/null +++ b/server/test/teamActivity.test.js @@ -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 }) +}) diff --git a/server/test/teamSync.test.js b/server/test/teamSync.test.js index 614f986..26ea2aa 100644 --- a/server/test/teamSync.test.js +++ b/server/test/teamSync.test.js @@ -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') +})