diff --git a/client/src/modules/version.js b/client/src/modules/version.js index 73934de..c0c833a 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,13 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half +// changed yet: the two client additions the version covers are the `team.overview` +// and `team.member.row` slots, and a slot can only be declared by the page that +// hosts it, which lands with the Team pages in phase 3. This file bumps anyway, +// for the reason at the top — the two halves state ONE version, and a module +// declares one `coreApi` range against both. +// // 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' | // 'wide') that renders the `shell-… page-body` wrapper core's own pages write by // hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit @@ -38,4 +45,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.5.0' +export const MODULE_API_VERSION = '1.6.0' diff --git a/server/db/schema.sql b/server/db/schema.sql index f12fc68..2808561 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -885,6 +885,16 @@ CREATE TABLE IF NOT EXISTS teams ( -- runs on every sync, and this is what stops it re-hiding a Team a human has -- already allowed — without it the override would be undone every 15 minutes. name_reviewed_at DATETIME NULL, + -- PER-TEAM freshness, which team_sync_state cannot express: it holds one row per + -- MODULE, and §2.4 gate 3 leaves one Team's roster untouched while the others + -- sync normally. Without a per-Team stamp that Team's page would claim the + -- module's last success as its own, which is precisely the staleness the rule + -- exists to surface. Bumped only when a roster is actually applied. + roster_synced_at DATETIME NULL, + -- §2.4 gate 4's per-Team quarantine, the twin of team_sync_state.pending_empty_ + -- since: an authoritative-but-empty ROSTER for a Team that currently has members + -- is remembered here and applied only if the next answer agrees. + members_empty_since DATETIME NULL, -- Staff may change what is DISPLAYED without touching identity (§2.8.3). display_name_override VARCHAR(160) NULL, -- The successor row written at archive time when this Team was renamed, so the diff --git a/server/src/model/teams/teamSlug.js b/server/src/model/teams/teamSlug.js new file mode 100644 index 0000000..da1eb47 --- /dev/null +++ b/server/src/model/teams/teamSlug.js @@ -0,0 +1,50 @@ +// Deriving a Team's URL slug from a game-written name (TEAMS.md §2.1). +// +// A slug is derived ONCE, at create, and then frozen for the life of the row — +// like `name`, and for the same reason: the Team page URL has to stay stable, and +// a rename is an archive plus a create rather than an edit. + +const MAX_SLUG = 180 // the column is 191; leaves room for a -NN suffix + +/** + * Reduce a name to a URL-safe stem. + * + * Diacritics are folded rather than stripped so "Ünderdark" becomes "underdark" + * and not "nderdark". A name made entirely of characters that do not survive — + * which a guild name genuinely can be, since the game accepts far more than a URL + * does — leaves an empty stem, and the caller substitutes a stable fallback + * rather than minting a Team with no address. + */ +function slugify(name) { + return String(name || '') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, MAX_SLUG) + .replace(/-+$/g, '') +} + +/** + * A slug not already taken, given the ones that are. + * + * `taken` must include ARCHIVED teams' slugs, not only active ones. The unique + * key constrains active rows alone, so the database would allow a new Team to + * take a retired Team's slug — and §2.2 promises the retired one stays readable + * at that address, which is what a bookmark or an old Discord link resolves to. + */ +function uniqueSlug(name, taken, { fallback = 'team' } = {}) { + const base = slugify(name) || fallback + const used = new Set(taken) + if (!used.has(base)) return base + // Bounded rather than unbounded: a suffix search that cannot terminate is worse + // than a slug with an id in it, and 999 same-named teams is already absurd. + for (let n = 2; n <= 999; n++) { + const candidate = `${base}-${n}` + if (!used.has(candidate)) return candidate + } + return `${base}-${Date.now().toString(36)}` +} + +module.exports = { slugify, uniqueSlug, MAX_SLUG } diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js new file mode 100644 index 0000000..4d5f764 --- /dev/null +++ b/server/src/model/teams/teamSync.model.js @@ -0,0 +1,482 @@ +// ── The reconciler ───────────────────────────────────────────────────────── +// +// Core's projection of the module's Teams, kept in step (TEAMS.md §2.4). This is +// the only thing that writes `team_members`, and one of only two things that +// write `teams.status`. +// +// **The four places it refuses to act** are the point of the file, and they are +// all one rule stated four ways: *a result derived from an answer core does not +// trust is never applied.* Anything less specific tends to collapse, under +// maintenance, into "a failed call means no teams" — which is invariant 1's +// failure mode and would empty every roster on the site the first time a sidecar +// restarted. +// +// 1. `getTeams()` not ok → write sync state, touch NOTHING, return. +// 2. ok but empty, core holds ≥1 → quarantine; apply only if the NEXT +// authoritative answer agrees. +// 3. `getTeamMembers()` not ok → that Team's roster untouched and stale; +// the other Teams carry on. +// 4. ok but zero members, had some → the same two-strikes quarantine, per Team. +// +// Gates 2 and 4 exist because an authoritative-looking empty answer during a cold +// start is the one failure indistinguishable from a real wipe. "Every Team on the +// shard disbanded at once" costs one interval of delay to confirm; getting it +// wrong costs every roster on the site. +// +// Events (§2.3) are an OPTIMISATION, never the source of truth. They make the +// common case immediate; reconciliation is what makes it correct. Nothing +// destructive at Team level is ever driven by one — §2.2 scopes archival to an +// authoritative full list, so a `team.disbanded` event schedules a run rather +// than archiving, and a spurious event costs a reconcile instead of a Team. + +const teamsDb = require('./teams.db') +const teamProvider = require('./teamProvider') +const { slugify, uniqueSlug } = require('./teamSlug') +const settings = require('../settings/settings.model') +const log = require('../../utils/logger')('teams') + +// At most one run per 30s (§2.4), so a sidecar flapping cannot become a +// reconciliation storm — every flap publishes events, and every event asks for a +// run. +const DEBOUNCE_MS = 30_000 +const DEFAULT_INTERVAL_S = 900 +const MIN_INTERVAL_S = 60 +const INTERVAL_KEY = 'teams_reconcile_interval_s' + +// The six kinds a module may publish (§2.3). Six rather than the four a +// membership-shaped reading suggests, because leadership is its own authority +// path and a leadership change must be expressible without pretending someone +// joined or left. +const EVENT_KINDS = new Set([ + 'team.created', 'team.disbanded', + 'team.member.added', 'team.member.removed', + 'team.leader.added', 'team.leader.removed', +]) + +// Kinds that can only be answered by a full list. `team.created` cannot be +// applied from a delta — a Team built from one has no name, no roster and no +// leaders — and `team.disbanded` must not be, per §2.2. +const RECONCILE_ONLY = new Set(['team.created', 'team.disbanded']) + +// ── Scheduling state (in-process; one provider per deployment) ───────────── + +let running = false +let rerunReason = null +let lastRunAt = 0 +let debounceTimer = null +let pollTimer = null +let started = false + +/** Resolve the poll interval, floored so a bad setting cannot become a hot loop. */ +async function intervalSeconds() { + let raw + try { + raw = await settings.get(INTERVAL_KEY) + } catch { + return DEFAULT_INTERVAL_S + } + const n = Number.parseInt(raw, 10) + if (!Number.isFinite(n) || n < MIN_INTERVAL_S) return DEFAULT_INTERVAL_S + return n +} + +/** + * Backoff, capped at the poll interval (§2.4). + * + * The cap is what keeps this a backoff rather than an outage: a module down for a + * day would otherwise reach a delay measured in weeks and stay stale long after + * it recovered. + */ +function backoffSeconds(consecutiveFailures, intervalS) { + if (!consecutiveFailures) return intervalS + return Math.min(intervalS, 2 ** Math.min(consecutiveFailures, 16) * 15) +} + +// ── Applying one Team ────────────────────────────────────────────────────── + +/** + * Create the row for a Team core has not seen, deriving its slug. + * + * Screening the name against the reserved list happens here in a later commit; + * the row is created either way, because core cannot refuse a name — the guild + * already exists in the game and core is a mirror of it, not an authority over it. + */ +async function createTeam(moduleId, team) { + const taken = await teamsDb.slugsLike(slugify(team.name) || 'team') + const slug = uniqueSlug(team.name, taken) + const id = await teamsDb.insertTeam({ moduleId, slug, ...team }) + log.info('team created', { moduleId, externalId: team.externalId, name: team.name, slug }) + return id +} + +/** + * The §2.2 rename rule: same id and a different name is an archive plus a create. + * + * The old row keeps its forum, its activity and its grants, all read-only, and + * points at its successor so the old slug can explain itself instead of 404ing. + * Core never decides whether this is "really" the same team — that judgement is + * the module's, expressed in whether it reuses the external id (§10.5). + */ +async function applyRename(moduleId, existing, team) { + const successorId = await createTeam(moduleId, team) + await teamsDb.archiveTeam(existing.id, 'renamed', successorId) + log.info('team renamed; previous row archived', { + externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId, + }) + return successorId +} + +/** + * Sync one Team's roster and leadership. Gates 3 and 4 live here. + * + * Returns whether the roster was applied, so the caller can tell "synced" from + * "left alone", which is the difference between fresh and stale on that Team's + * page. + */ +async function syncRoster(team) { + const answer = await teamProvider.getTeamMembers(team.external_id) + + // Gate 3. One Team's unanswerable roster is not the other Teams' problem, and + // it is certainly not an empty roster. + if (!answer.ok) { + log.warn('roster left untouched; provider could not answer', { + externalId: team.external_id, reason: answer.reason, + }) + return false + } + + const known = await teamsDb.memberKeys(team.id) + + // Gate 4, the per-Team twin of gate 2. + if (answer.complete && answer.members.length === 0 && known.length > 0) { + if (!team.members_empty_since) { + await teamsDb.setMembersEmptySince(team.id, new Date()) + log.warn('empty roster quarantined; awaiting a second answer', { + externalId: team.external_id, had: known.length, + }) + return false + } + log.warn('empty roster confirmed by a second answer; departing every member', { + externalId: team.external_id, had: known.length, + }) + } else if (team.members_empty_since) { + // Any non-empty answer clears the quarantine. + await teamsDb.setMembersEmptySince(team.id, null) + } + + for (const member of answer.members) { + // eslint-disable-next-line no-await-in-loop + await teamsDb.upsertMember({ + teamId: team.id, + memberKey: member.memberKey, + displayName: member.displayName, + userId: member.userId, + isLeader: member.leader, + rankLabel: member.rankLabel, + online: member.online, + }) + } + + // Removals only from a COMPLETE answer. `complete: false` means "valid but + // partial", so additions and updates apply and nothing is taken away. + if (answer.complete) { + const seen = new Set(answer.members.map((m) => m.memberKey)) + await teamsDb.markDeparted(team.id, known.filter((key) => !seen.has(key))) + } + + // 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) + if (leaders.ok) { + await teamsDb.setLeaders(team.id, leaders.leaders) + } else { + log.warn('leadership left untouched; provider could not answer', { + externalId: team.external_id, reason: leaders.reason, + }) + } + + await teamsDb.recount(team.id) + await teamsDb.markRosterSynced(team.id) + return true +} + +// ── The run ──────────────────────────────────────────────────────────────── + +/** + * One full reconciliation. Callers use `request()`; this is the body it guards. + * + * Never throws: a reconcile is a background job, and a rejection here would + * surface as an unhandled rejection in the poll timer rather than as anything an + * operator could act on. The failure is recorded where it can be read — in + * `team_sync_state`, which Admin → Teams shows verbatim. + */ +async function runOnce(reason) { + const moduleId = teamProvider.providerModuleId() + if (!moduleId) return { ok: false, reason: 'no team provider is registered' } + + await teamsDb.recordAttempt(moduleId) + const answer = await teamProvider.getTeams() + + // Gate 1. + if (!answer.ok) { + await teamsDb.recordFailure(moduleId, answer.reason) + log.warn('reconcile refused; provider could not answer', { reason: answer.reason, trigger: reason }) + return { ok: false, reason: answer.reason } + } + + const existing = await teamsDb.activeByModule(moduleId) + + // Gate 2. Only a COMPLETE answer can mean "there are no teams" — a partial one + // removes nothing by definition. + if (answer.complete && answer.teams.length === 0 && existing.length > 0) { + const state = await teamsDb.syncState(moduleId) + if (!state || !state.pending_empty_since) { + await teamsDb.setPendingEmpty(moduleId, new Date()) + await teamsDb.recordSuccess(moduleId) + log.warn('empty team list quarantined; awaiting a second answer', { held: existing.length }) + return { ok: true, quarantined: true, applied: 0 } + } + const intervalS = await intervalSeconds() + const waited = (Date.now() - new Date(state.pending_empty_since).getTime()) / 1000 + if (waited < intervalS) { + await teamsDb.recordSuccess(moduleId) + log.warn('empty team list still quarantined', { waitedSeconds: Math.round(waited), intervalS }) + return { ok: true, quarantined: true, applied: 0 } + } + log.warn('empty team list confirmed; archiving every active team', { count: existing.length }) + } else if (answer.teams.length) { + // Any non-empty answer clears the quarantine. + await teamsDb.setPendingEmpty(moduleId, null) + } + + const byExternalId = new Map(existing.map((t) => [t.external_id, t])) + const seen = new Set() + let created = 0 + let renamed = 0 + let rosters = 0 + + for (const team of answer.teams) { + seen.add(team.externalId) + const current = byExternalId.get(team.externalId) + let id + if (!current) { + id = await createTeam(moduleId, team) + created += 1 + } else if (current.name !== team.name) { + id = await applyRename(moduleId, current, team) + renamed += 1 + } else { + id = current.id + await teamsDb.updateTeam(id, { abbr: team.abbr, meta: team.meta }) + } + + // Re-read rather than reusing `current`: a create or a rename has just made a + // row this loop has never seen, and syncRoster reads the quarantine stamp off + // it. Passing a stale object would drop the second strike of gate 4. + const row = await teamsDb.findById(id) + if (row && await syncRoster(row)) rosters += 1 + } + + // Archive what the module no longer lists — the §2.2 disband path, and the only + // one. Guarded by `complete` for the same reason removals are. + let archived = 0 + if (answer.complete) { + for (const team of existing) { + if (seen.has(team.external_id)) continue + await teamsDb.archiveTeam(team.id, 'disbanded') + archived += 1 + log.info('team archived; absent from an authoritative list', { + externalId: team.external_id, name: team.name, + }) + } + } + + await teamsDb.recordSuccess(moduleId) + log.info('reconcile complete', { trigger: reason, created, renamed, archived, rosters, total: answer.teams.length }) + return { ok: true, created, renamed, archived, rosters } +} + +// ── The public entry points ──────────────────────────────────────────────── + +/** + * Run now, awaited, with the per-module lock held. Admin → Resync uses this, + * because an operator pressing a button is owed the outcome rather than a + * promise that something will happen soon. + * + * A run already in progress is JOINED rather than queued: the caller wants "the + * projection is now current", and a run that started a moment ago delivers that. + */ +async function reconcileNow(reason = 'manual') { + if (running) { + rerunReason = reason + return { ok: true, joined: true } + } + running = true + try { + const result = await runOnce(reason) + lastRunAt = Date.now() + return result + } catch (err) { + log.error('reconcile threw', { message: err.message, trigger: reason }) + return { ok: false, reason: err.message } + } finally { + running = false + const queued = rerunReason + rerunReason = null + // Something asked while this run was in flight, so it saw state this run may + // have been too early to include. Ask again, through the debounce. + if (queued) request({ reason: queued }) + } +} + +/** + * Ask for a reconciliation. Returns immediately and never rejects — this is what + * `ctx.teams.reconcile()` is (§2.3), and a module must not be able to make its + * own call site slow or its own errors someone else's. + */ +function request({ reason = 'module' } = {}) { + if (debounceTimer) return + const since = Date.now() - lastRunAt + if (running) { + rerunReason = reason + return + } + if (since >= DEBOUNCE_MS) { + reconcileNow(reason).catch(() => {}) + return + } + debounceTimer = setTimeout(() => { + debounceTimer = null + reconcileNow(reason).catch(() => {}) + }, DEBOUNCE_MS - since) + // Unreffed for the same reason the provider's deadline is: a pending debounce + // must not hold a shutdown open waiting to do background work. + if (typeof debounceTimer.unref === 'function') debounceTimer.unref() +} + +/** + * Apply a module-published event (§2.3). + * + * Deltas are applied only for a Team core already knows, and only for the four + * kinds a delta can express. Everything else — an unknown Team, a create, a + * disband — asks for a reconciliation instead, because a Team invented from a + * delta has no name, no roster and no leaders, and an archive driven by one is + * destruction on the strength of a message that may simply have been repeated. + */ +async function publish(event) { + const { kind, externalId } = event || {} + if (!EVENT_KINDS.has(kind)) throw new Error(`teams.publish: unknown event kind "${kind}"`) + const id = typeof externalId === 'string' ? externalId.trim() : '' + if (!id) throw new Error(`teams.publish: ${kind} has no externalId`) + + const moduleId = teamProvider.providerModuleId() + if (!moduleId) return + + if (RECONCILE_ONLY.has(kind)) { + request({ reason: kind }) + return + } + + const team = await teamsDb.findActive(moduleId, id) + if (!team) { + request({ reason: `${kind} for an unknown team` }) + return + } + + const memberKey = typeof event.memberKey === 'string' ? event.memberKey.trim() : '' + if (!memberKey) throw new Error(`teams.publish: ${kind} has no memberKey`) + + switch (kind) { + case 'team.member.added': + await teamsDb.upsertMember({ + teamId: team.id, + memberKey, + displayName: typeof event.displayName === 'string' ? event.displayName.trim() : null, + userId: Number.isInteger(event.userId) && event.userId > 0 ? event.userId : null, + isLeader: Boolean(event.leader), + rankLabel: typeof event.rankLabel === 'string' ? event.rankLabel.trim() : null, + online: Boolean(event.online), + }) + break + case 'team.member.removed': + await teamsDb.markDeparted(team.id, [memberKey]) + break + case 'team.leader.added': + case 'team.leader.removed': + // A no-op when the member is unknown: the row is created by the roster, not + // by a leadership delta, and inventing one here would put a member on the + // roster whose only evidence is that someone promoted them. + await teamsDb.setMemberLeader(team.id, memberKey, kind === 'team.leader.added') + break + default: + break + } + + await teamsDb.recount(team.id) + // A delta is a hint that something changed, not a claim to have applied all of + // it, so every one still asks for the run that makes it correct. + request({ reason: kind }) +} + +// ── The poll ─────────────────────────────────────────────────────────────── + +async function scheduleNextPoll() { + const intervalS = await intervalSeconds() + const moduleId = teamProvider.providerModuleId() + let delayS = intervalS + if (moduleId) { + const state = await teamsDb.syncState(moduleId).catch(() => null) + if (state) delayS = backoffSeconds(state.consecutive_failures, intervalS) + } + pollTimer = setTimeout(() => { + reconcileNow('poll').catch(() => {}).then(() => { if (started) scheduleNextPoll().catch(() => {}) }) + }, delayS * 1000) + if (typeof pollTimer.unref === 'function') pollTimer.unref() +} + +/** + * Start the boot reconcile and the poll. Called from the module lifecycle, after + * every module has started — the website may have been down across a whole guild + * war, so the first thing it does on the way up is ask. + */ +async function start() { + if (started) return + started = true + if (!teamProvider.providerModuleId()) { + log.info('no team provider registered; the reconciler stays idle') + return + } + await reconcileNow('boot') + await scheduleNextPoll() +} + +function stop() { + started = false + if (pollTimer) clearTimeout(pollTimer) + if (debounceTimer) clearTimeout(debounceTimer) + pollTimer = null + debounceTimer = null +} + +// Test-only: the scheduler is module-level state, so a test that triggers a run +// has to be able to put it back. +function _reset() { + stop() + running = false + rerunReason = null + lastRunAt = 0 +} + +module.exports = { + reconcileNow, + request, + publish, + start, + stop, + intervalSeconds, + backoffSeconds, + EVENT_KINDS, + DEBOUNCE_MS, + DEFAULT_INTERVAL_S, + _reset, +} diff --git a/server/src/model/teams/teams.db.js b/server/src/model/teams/teams.db.js new file mode 100644 index 0000000..f7f3c27 --- /dev/null +++ b/server/src/model/teams/teams.db.js @@ -0,0 +1,297 @@ +// SQL for the Team tables. Raw parameterised mariadb, no ORM, per the layered +// backend convention (router → controller → model → db). +// +// This file holds statements only. Every decision about WHETHER to write — the +// four refusal gates, the quarantine, the rename rule — lives in the models above +// it, because a gate expressed as a WHERE clause is a gate nobody can find. + +const { query } = require('../../utils/db') + +// ── teams ────────────────────────────────────────────────────────────────── + +const TEAM_COLUMNS = ` + id, module_id, external_id, name, abbr, slug, status, meta, + member_count, linked_count, online_count, + hidden, hidden_reason, hidden_term, name_reviewed_at, display_name_override, + roster_synced_at, members_empty_since, + succeeded_by, created_at, archived_at, archived_reason` + +/** Every ACTIVE team for a module — the set the reconciler diffs against. */ +async function activeByModule(moduleId) { + return query( + `SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND status = 'active' ORDER BY id`, + [moduleId], + ) +} + +/** The ACTIVE row for an external id, or undefined. At most one, by uq_teams_active. */ +async function findActive(moduleId, externalId) { + const rows = await query( + `SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND external_id = ? AND status = 'active'`, + [moduleId, externalId], + ) + return rows[0] +} + +async function findById(id) { + const rows = await query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE id = ?`, [id]) + return rows[0] +} + +/** By slug, ACTIVE or ARCHIVED — an archived Team stays reachable at its old slug (§2.2). */ +async function findBySlug(slug) { + const rows = await query( + `SELECT ${TEAM_COLUMNS} FROM teams WHERE slug = ? ORDER BY (status = 'active') DESC, id DESC LIMIT 1`, + [slug], + ) + return rows[0] +} + +/** + * Slugs already taken, ACTIVE OR ARCHIVED. + * + * The unique key only constrains active rows, and this deliberately checks more + * than the key does: §2.2 promises an archived Team stays readable at its old + * slug, and handing that slug to a new Team would silently break every bookmark + * and Discord link pointing at the old one. + */ +async function slugsLike(base) { + const rows = await query('SELECT slug FROM teams WHERE slug = ? OR slug LIKE ?', [base, `${base}-%`]) + return rows.map((r) => r.slug) +} + +async function insertTeam({ moduleId, externalId, name, abbr, slug, meta, hidden, hiddenReason, hiddenTerm }) { + const res = await query( + `INSERT INTO teams (module_id, external_id, name, abbr, slug, meta, hidden, hidden_reason, hidden_term) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [moduleId, externalId, name, abbr, slug, meta == null ? null : JSON.stringify(meta), + hidden ? 1 : 0, hiddenReason || null, hiddenTerm || null], + ) + return res.insertId +} + +/** Update the mutable fields. `name` and `slug` are absent by design — §2.2 freezes both. */ +async function updateTeam(id, { abbr, meta }) { + await query('UPDATE teams SET abbr = ?, meta = ? WHERE id = ?', + [abbr, meta == null ? null : JSON.stringify(meta), id]) +} + +async function archiveTeam(id, reason, succeededBy = null) { + await query( + `UPDATE teams SET status = 'archived', archived_at = NOW(), archived_reason = ?, succeeded_by = ? + WHERE id = ? AND status = 'active'`, + [reason, succeededBy, id], + ) +} + +/** + * Recompute the three denormalised counts from the projection. + * + * Derived in one statement rather than incremented as rows change, so a missed + * delta can never leave a count drifting from the table it summarises — the count + * is only ever as wrong as the projection is. + */ +async function recount(teamId) { + await query( + `UPDATE teams t SET + member_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active'), + linked_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.user_id IS NOT NULL), + online_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.online = 1) + WHERE t.id = ?`, + [teamId], + ) +} + +// ── team_members ─────────────────────────────────────────────────────────── + +const MEMBER_COLUMNS = ` + team_id, member_key, display_name, user_id, is_leader, rank_label, online, status, + first_seen_at, last_seen_at, departed_at` + +async function membersByTeam(teamId, { includeDeparted = false } = {}) { + return query( + `SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ?` + + (includeDeparted ? '' : " AND status = 'active'") + + ' ORDER BY is_leader DESC, display_name, member_key', + [teamId], + ) +} + +async function memberKeys(teamId) { + const rows = await query("SELECT member_key FROM team_members WHERE team_id = ? AND status = 'active'", [teamId]) + return rows.map((r) => r.member_key) +} + +async function findMember(teamId, memberKey) { + const rows = await query(`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND member_key = ?`, + [teamId, memberKey]) + return rows[0] +} + +/** The caller's ACTIVE membership of a team, or undefined. Path 1 of §2.5, and only path 1. */ +async function activeByUser(teamId, userId) { + const rows = await query( + `SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'`, + [teamId, userId], + ) + return rows[0] +} + +/** Every ACTIVE membership a user holds, with the team joined on. */ +async function activeTeamsForUser(userId) { + return query( + `SELECT ${TEAM_COLUMNS.split(',').map((c) => `t.${c.trim()}`).join(', ')}, + m.member_key, m.is_leader, m.rank_label, m.display_name AS member_display_name + FROM team_members m JOIN teams t ON t.id = m.team_id + WHERE m.user_id = ? AND m.status = 'active' AND t.status = 'active' + ORDER BY t.name`, + [userId], + ) +} + +/** + * Insert or refresh one member row. + * + * `first_seen_at` is never overwritten, so a member who leaves and rejoins keeps + * the date they first appeared; `status` returns to active on the same statement, + * which is what makes a rejoin a revived row rather than a second one. + * + * **`is_leader` is set on INSERT only, and deliberately not on update.** Path 2 of + * §2.5 is answered by `getTeamLeaders()`, not by the roster — two writers for one + * column is how a refused leadership answer turns into a silent demotion, because + * the roster would already have written `leader: false` before the authoritative + * call was even made. Seeding it on insert means a Team whose leadership call is + * failing is not leaderless from the start; after that, only setLeaders() moves it. + */ +async function upsertMember({ teamId, memberKey, displayName, userId, isLeader, rankLabel, online }) { + await query( + `INSERT INTO team_members (team_id, member_key, display_name, user_id, is_leader, rank_label, online) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), + user_id = VALUES(user_id), + rank_label = VALUES(rank_label), + online = VALUES(online), + status = 'active', + departed_at = NULL, + last_seen_at = NOW()`, + [teamId, memberKey, displayName, userId, isLeader ? 1 : 0, rankLabel, online ? 1 : 0], + ) +} + +/** Soft-depart the named members. Rows are kept so history and rejoins survive. */ +async function markDeparted(teamId, memberKeys_) { + if (!memberKeys_.length) return + const holes = memberKeys_.map(() => '?').join(', ') + await query( + `UPDATE team_members SET status = 'departed', departed_at = NOW(), online = 0 + WHERE team_id = ? AND status = 'active' AND member_key IN (${holes})`, + [teamId, ...memberKeys_], + ) +} + +/** Set is_leader for a whole team in one pass — the sync's path-2 write. */ +async function setLeaders(teamId, leaderKeys) { + if (leaderKeys.length) { + const holes = leaderKeys.map(() => '?').join(', ') + await query( + `UPDATE team_members SET is_leader = (member_key IN (${holes})) WHERE team_id = ?`, + [...leaderKeys, teamId], + ) + } else { + await query('UPDATE team_members SET is_leader = 0 WHERE team_id = ?', [teamId]) + } +} + +async function setMemberLeader(teamId, memberKey, isLeader) { + await query('UPDATE team_members SET is_leader = ? WHERE team_id = ? AND member_key = ?', + [isLeader ? 1 : 0, teamId, memberKey]) +} + +// ── team_sync_state ──────────────────────────────────────────────────────── + +async function syncState(moduleId) { + const rows = await query( + `SELECT module_id, last_attempt_at, last_success_at, consecutive_failures, last_error, pending_empty_since + FROM team_sync_state WHERE module_id = ?`, + [moduleId], + ) + return rows[0] +} + +async function recordAttempt(moduleId) { + await query( + `INSERT INTO team_sync_state (module_id, last_attempt_at) VALUES (?, NOW()) + ON DUPLICATE KEY UPDATE last_attempt_at = NOW()`, + [moduleId], + ) +} + +async function recordFailure(moduleId, error) { + await query( + `INSERT INTO team_sync_state (module_id, last_attempt_at, consecutive_failures, last_error) + VALUES (?, NOW(), 1, ?) + ON DUPLICATE KEY UPDATE + last_attempt_at = NOW(), + consecutive_failures = consecutive_failures + 1, + last_error = VALUES(last_error)`, + [moduleId, String(error || '').slice(0, 500)], + ) +} + +async function recordSuccess(moduleId) { + await query( + `INSERT INTO team_sync_state (module_id, last_attempt_at, last_success_at, consecutive_failures, last_error) + VALUES (?, NOW(), NOW(), 0, NULL) + ON DUPLICATE KEY UPDATE + last_attempt_at = NOW(), last_success_at = NOW(), consecutive_failures = 0, last_error = NULL`, + [moduleId], + ) +} + +/** Bumped only when a roster was actually APPLIED — never on a refused call. */ +async function markRosterSynced(teamId) { + await query('UPDATE teams SET roster_synced_at = NOW() WHERE id = ?', [teamId]) +} + +/** §2.4 gate 4's per-Team quarantine. `since = null` clears it. */ +async function setMembersEmptySince(teamId, since) { + await query('UPDATE teams SET members_empty_since = ? WHERE id = ?', [since, teamId]) +} + +/** The §2.4 gate-2 quarantine. `since = null` clears it. */ +async function setPendingEmpty(moduleId, since) { + await query( + `INSERT INTO team_sync_state (module_id, pending_empty_since) VALUES (?, ?) + ON DUPLICATE KEY UPDATE pending_empty_since = VALUES(pending_empty_since)`, + [moduleId, since], + ) +} + +module.exports = { + activeByModule, + findActive, + findById, + findBySlug, + slugsLike, + insertTeam, + updateTeam, + archiveTeam, + recount, + markRosterSynced, + setMembersEmptySince, + membersByTeam, + memberKeys, + findMember, + activeByUser, + activeTeamsForUser, + upsertMember, + markDeparted, + setLeaders, + setMemberLeader, + syncState, + recordAttempt, + recordFailure, + recordSuccess, + setPendingEmpty, +} diff --git a/server/src/modules/lifecycle.js b/server/src/modules/lifecycle.js index 637df88..3e23a3d 100644 --- a/server/src/modules/lifecycle.js +++ b/server/src/modules/lifecycle.js @@ -170,6 +170,15 @@ async function boot({ modules, model } = {}) { }) } } + + // The Team reconciler's boot trigger (TEAMS.md §2.4), last — after every module + // has started, because the provider is registered by a module and a module that + // warms a cache in onBoot must be allowed to finish before it is asked anything. + // + // `safe` for the same reason every step above uses it: an unreachable provider + // is a stale projection, never a site that will not start. + // eslint-disable-next-line global-require + await safe('starting the team reconciler', () => require('../model/teams/teamSync.model').start()) } /** Reject if `fn`'s promise has not settled within `ms`. */ diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 51faad2..c1d4370 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -119,6 +119,7 @@ function buildCtx(id, moduleRoot) { const uploads = require('../router/v1/admin/imageUpload') const activity = require('../model/activity/activity.model') const users = require('../model/users/users.model') + const teams = require('../model/teams/teamSync.model') const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit') /* eslint-enable global-require */ @@ -174,6 +175,31 @@ function buildCtx(id, moduleRoot) { // a place nobody looks. `list` stays core's: reading the log is the admin // panel's job, and it spans every actor. activity: { log: activity.log }, + // Teams (API 1.6.0, TEAMS.md §2.3). Push, to the pull the provider answers. + // + // Both are fire-and-forget by contract. `publish` is an OPTIMISATION — it + // makes a membership change visible at once — and `reconcile` is a REQUEST, + // debounced and never awaited, so a module cannot make its own call site slow + // or turn a background failure into its own error. Correctness comes from the + // reconciler either way; these only decide how soon. + // + // There is deliberately no reader here. A module answers questions about + // Teams; it does not ask them. Every Team table is core-internal (§10.3), and + // a `getTeamRoster` on ctx would be core offering to read back the module's + // own answer — which is the module's data, in the module's own store. + 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. + activity: { + push: () => { + throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)') + }, + }, + }, // One function, for one caller: the `admin.users.detail` slot router needs // the user its prefix names. Narrowed like `ctx.posts` — the users model // exports creation, role changes and password handling, none of which is a @@ -249,6 +275,14 @@ function buildApi(record) { once('registerTeamProvider') record.staged.registerTeamProvider(provider) }, + // Declared in 1.6.0 with the rest of the Team surface; the bot half that + // executes a command lands in phase 7 (§7.1). Present and throwing rather + // than absent, so a module written against the published version fails at + // registration with a sentence naming the phase, instead of at whatever + // moment someone first types the command. + registerSlashCommands() { + throw new Error('api.registerSlashCommands is not available until Discord slash commands land (TEAMS.md §7.1)') + }, // The two lifecycle hooks (§2.5). Registered here, dispatched from // lifecycle.js — this file runs with no database and the hooks run with one. // Both are optional: a module with no warm-up and nothing to close simply diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 6ce1362..b7ef857 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,21 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Additions only, so +// minor: `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`, +// `ctx.teams.publish(event)`, `ctx.teams.reconcile({ reason })`, +// `ctx.teams.activity.push(items)`, `api.registerSlashCommands([...])`, and the +// client slots `team.overview` / `team.member.row`. module-uo's `coreApi: +// "^1.3.0"` still resolves. +// +// **The number covers the whole surface; the members arrive by phase.** The three +// this phase implements are live. `activity.push` lands with the Team activity +// feed (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1, +// phase 7) — until then each is present and THROWS rather than being absent or, +// worse, silently accepting data into a table that does not exist. MODULE_API.md +// names the phase against each member, so a module author reads what is callable +// today rather than discovering it at runtime. +// // 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that // renders the page body wrapper core's own pages write by hand (MODULE_API.md // §3.4). Minor, not major: §3.4 makes *changing* a kit component's props a major @@ -44,6 +59,6 @@ // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.5.0' +const MODULE_API_VERSION = '1.6.0' module.exports = { MODULE_API_VERSION } diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index c0ebe3a..cef7339 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -507,9 +507,13 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { // extraction needed it and none could be vendored: an admin action a module // performs belongs in core's one audit log, the extension slot needs the user // its prefix names, and §2.7 forbids a module reading core's APP_BASE_URL. + // API 1.6.0 added `teams` — push, to the pull the team provider answers + // (TEAMS.md §2.3). Read-only by omission: a module answers questions about + // Teams and never asks them, so there is no getter here to add later by + // accident. assert.deepEqual(probe.keys, [ 'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths', - 'posts', 'push', 'secretBox', 'settings', 'site', 'uploads', 'users', 'validator', + 'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator', ]) // is core's limiter FACTORY, not a limiter: a module states its own // window and cap and takes the plumbing, so there is one express-rate-limit in diff --git a/server/test/teamSync.test.js b/server/test/teamSync.test.js new file mode 100644 index 0000000..b2cdfe4 --- /dev/null +++ b/server/test/teamSync.test.js @@ -0,0 +1,739 @@ +// The reconciler and its four refusal gates (docs/website/TEAMS.md §2.4). +// +// The db layer is stubbed and an in-memory projection stands in for the tables, +// so these are assertions about the ALGORITHM: which answers are applied, which +// are refused, and what is left untouched when one is refused. The gates are the +// reason the file exists — every one of them is invariant 1 in a different +// costume, and each is easy to "simplify" away by someone who has not seen what +// an empty answer during a cold start does to a site full of rosters. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const teamsDb = require('../src/model/teams/teams.db') +const settings = require('../src/model/settings/settings.model') +const teamSync = require('../src/model/teams/teamSync.model') + +// ── An in-memory stand-in for the four tables ────────────────────────────── + +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 freshStore() { + return { + teams: [], // { id, module_id, external_id, name, abbr, slug, status, ... } + members: new Map(), // teamId -> Map(memberKey -> row) + sync: new Map(), // moduleId -> row + nextId: 1, + } +} + +function membersOf(teamId) { + if (!store.members.has(teamId)) store.members.set(teamId, new Map()) + return store.members.get(teamId) +} + +function stubDb() { + patch(teamsDb, 'activeByModule', async (moduleId) => + store.teams.filter((t) => t.module_id === moduleId && t.status === 'active')) + + patch(teamsDb, 'findActive', async (moduleId, externalId) => + store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active')) + + patch(teamsDb, 'findById', async (id) => store.teams.find((t) => t.id === id)) + + patch(teamsDb, 'slugsLike', async (base) => + store.teams.filter((t) => t.slug === base || t.slug.startsWith(`${base}-`)).map((t) => t.slug)) + + patch(teamsDb, 'insertTeam', async (row) => { + const id = store.nextId++ + store.teams.push({ + id, + module_id: row.moduleId, + external_id: row.externalId, + name: row.name, + abbr: row.abbr ?? null, + slug: row.slug, + meta: row.meta ?? null, + status: 'active', + hidden: row.hidden ? 1 : 0, + hidden_reason: row.hiddenReason || null, + hidden_term: row.hiddenTerm || null, + members_empty_since: null, + roster_synced_at: null, + succeeded_by: null, + member_count: 0, + linked_count: 0, + online_count: 0, + }) + return id + }) + + patch(teamsDb, 'updateTeam', async (id, { abbr, meta }) => { + const t = store.teams.find((x) => x.id === id) + if (t) Object.assign(t, { abbr, meta }) + }) + + patch(teamsDb, 'archiveTeam', async (id, reason, succeededBy = null) => { + const t = store.teams.find((x) => x.id === id && x.status === 'active') + if (t) Object.assign(t, { status: 'archived', archived_reason: reason, succeeded_by: succeededBy }) + }) + + patch(teamsDb, 'recount', async (teamId) => { + const t = store.teams.find((x) => x.id === teamId) + if (!t) return + const rows = [...membersOf(teamId).values()].filter((m) => m.status === 'active') + t.member_count = rows.length + t.linked_count = rows.filter((m) => m.user_id != null).length + t.online_count = rows.filter((m) => m.online).length + }) + + patch(teamsDb, 'markRosterSynced', async (teamId) => { + const t = store.teams.find((x) => x.id === teamId) + if (t) t.roster_synced_at = new Date() + }) + + patch(teamsDb, 'setMembersEmptySince', async (teamId, since) => { + const t = store.teams.find((x) => x.id === teamId) + if (t) t.members_empty_since = since + }) + + patch(teamsDb, 'memberKeys', async (teamId) => + [...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key)) + + patch(teamsDb, 'upsertMember', async (m) => { + const existing = membersOf(m.teamId).get(m.memberKey) + membersOf(m.teamId).set(m.memberKey, { + team_id: m.teamId, + member_key: m.memberKey, + display_name: m.displayName ?? null, + user_id: m.userId ?? null, + // Insert-only, mirroring the ON DUPLICATE KEY UPDATE clause that omits it: + // leadership is getTeamLeaders()'s answer, not the roster's. + is_leader: existing ? existing.is_leader : (m.isLeader ? 1 : 0), + rank_label: m.rankLabel ?? null, + online: m.online ? 1 : 0, + status: 'active', + first_seen_at: existing ? existing.first_seen_at : 'first', + }) + }) + + patch(teamsDb, 'markDeparted', async (teamId, keys) => { + for (const key of keys) { + const row = membersOf(teamId).get(key) + if (row && row.status === 'active') Object.assign(row, { status: 'departed', online: 0 }) + } + }) + + patch(teamsDb, 'setLeaders', async (teamId, leaderKeys) => { + for (const row of membersOf(teamId).values()) row.is_leader = leaderKeys.includes(row.member_key) ? 1 : 0 + }) + + patch(teamsDb, 'setMemberLeader', async (teamId, key, isLeader) => { + const row = membersOf(teamId).get(key) + if (row) row.is_leader = isLeader ? 1 : 0 + }) + + patch(teamsDb, 'syncState', async (moduleId) => store.sync.get(moduleId)) + patch(teamsDb, 'recordAttempt', async (moduleId) => { + const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 } + s.last_attempt_at = new Date() + store.sync.set(moduleId, s) + }) + patch(teamsDb, 'recordFailure', async (moduleId, error) => { + const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 } + s.consecutive_failures += 1 + s.last_error = error + store.sync.set(moduleId, s) + }) + patch(teamsDb, 'recordSuccess', async (moduleId) => { + const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 } + s.consecutive_failures = 0 + s.last_error = null + s.last_success_at = new Date() + store.sync.set(moduleId, s) + }) + patch(teamsDb, 'setPendingEmpty', async (moduleId, since) => { + const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 } + s.pending_empty_since = since + store.sync.set(moduleId, s) + }) +} + +// A provider whose answers the test controls. Defaults are authoritative and +// well-formed, so each test only states the part it is about. +function provide(overrides = {}) { + const provider = { + getTeams: async () => ({ ok: true, teams: [] }), + getTeamMembers: async () => ({ ok: true, members: [] }), + getTeamLeaders: async () => ({ ok: true, leaders: [] }), + ...overrides, + } + const api = registries.stage('uo') + api.registerTeamProvider(provider) + registries.apply(api.staged) + return provider +} + +const team = (externalId, name, extra = {}) => ({ externalId, name, abbr: null, meta: null, ...extra }) +const member = (memberKey, extra = {}) => ({ + memberKey, displayName: memberKey, rankLabel: null, leader: false, online: false, userId: null, ...extra, +}) + +const activeTeams = () => store.teams.filter((t) => t.status === 'active') +const activeMembers = (teamId) => [...membersOf(teamId).values()].filter((m) => m.status === 'active') + +beforeEach(() => { + store = freshStore() + registries._reset() + teamSync._reset() + stubDb() + // The settings read is the only other database touch on this path. + patch(settings, 'get', async () => null) +}) + +afterEach(() => { + teamSync._reset() + registries._reset() + restore() +}) + +// ── Gate 1: a failed getTeams() touches nothing ──────────────────────────── + +test('gate 1 — a provider that cannot answer leaves every row untouched', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }) }) + await teamSync.reconcileNow('setup') + // Compared as JSON on both sides: the rows carry Date objects, and a snapshot + // taken through JSON would otherwise "differ" from the live rows purely by + // having stringified them. + const before = JSON.stringify(store.teams) + assert.equal(store.teams.length, 1) + + registries._reset() + provide({ getTeams: async () => ({ ok: false, reason: 'sidecar unreachable' }) }) + const result = await teamSync.reconcileNow('test') + + assert.equal(result.ok, false) + assert.equal(JSON.stringify(store.teams), before, 'not a single row may change') + assert.equal(store.sync.get('uo').consecutive_failures, 1) + assert.equal(store.sync.get('uo').last_error, 'sidecar unreachable') +}) + +test('gate 1 — a hung or throwing provider is the same refusal, not an empty list', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => { throw new Error('EPIPE') } }) + await teamSync.reconcileNow('test') + assert.equal(activeTeams().length, 1, 'a thrown error must never read as "no teams"') +}) + +test('failures accumulate and a success clears them', async () => { + provide({ getTeams: async () => ({ ok: false, reason: 'down' }) }) + await teamSync.reconcileNow('a') + await teamSync.reconcileNow('b') + assert.equal(store.sync.get('uo').consecutive_failures, 2) + + registries._reset() + provide() + await teamSync.reconcileNow('c') + assert.equal(store.sync.get('uo').consecutive_failures, 0) + assert.equal(store.sync.get('uo').last_error, null) +}) + +// ── Gate 2: an authoritative empty list is quarantined ───────────────────── + +test('gate 2 — the first empty answer archives nothing', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) }) + await teamSync.reconcileNow('setup') + assert.equal(activeTeams().length, 2) + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + const result = await teamSync.reconcileNow('test') + + assert.equal(result.quarantined, true) + assert.equal(activeTeams().length, 2, 'a cold start must not empty the site') + assert.ok(store.sync.get('uo').pending_empty_since, 'the answer is remembered') +}) + +test('gate 2 — a second empty answer, an interval later, is applied', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + await teamSync.reconcileNow('first empty') + // Age the quarantine past one full interval. + store.sync.get('uo').pending_empty_since = new Date(Date.now() - (teamSync.DEFAULT_INTERVAL_S + 1) * 1000) + + const result = await teamSync.reconcileNow('second empty') + assert.equal(result.archived, 1, 'every team on the shard really did disband') + assert.equal(activeTeams().length, 0) + assert.equal(store.teams[0].archived_reason, 'disbanded') +}) + +test('gate 2 — a second empty answer TOO SOON is still quarantined', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + await teamSync.reconcileNow('first') + const result = await teamSync.reconcileNow('second, immediately') + + assert.equal(result.quarantined, true, 'two answers a second apart are one cold start, not two') + assert.equal(activeTeams().length, 1) +}) + +test('gate 2 — any non-empty answer clears the quarantine', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + await teamSync.reconcileNow('empty') + assert.ok(store.sync.get('uo').pending_empty_since) + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('recovered') + assert.equal(store.sync.get('uo').pending_empty_since, null) +}) + +test('gate 2 — an empty list with nothing held is not a quarantine, just nothing to do', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + const result = await teamSync.reconcileNow('test') + assert.equal(result.ok, true) + assert.notEqual(result.quarantined, true) +}) + +test('an incomplete answer never removes, so an empty partial list is harmless', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, complete: false, teams: [] }) }) + const result = await teamSync.reconcileNow('partial') + assert.equal(result.archived, 0) + assert.equal(activeTeams().length, 1) + assert.ok(!store.sync.get('uo').pending_empty_since, 'no quarantine needed — nothing was at risk') +}) + +// ── Gate 3: one Team's unanswerable roster ───────────────────────────────── + +test('gate 3 — a refused roster leaves that team alone and the others sync', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }), + getTeamMembers: async (id) => (id === 'g1' + ? { ok: true, members: [member('0x1'), member('0x2')] } + : { ok: true, members: [member('0x9')] }), + }) + await teamSync.reconcileNow('setup') + assert.equal(activeMembers(1).length, 2) + assert.equal(activeMembers(2).length, 1) + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }), + getTeamMembers: async (id) => (id === 'g1' + ? { ok: false, reason: 'roster unavailable' } + : { ok: true, members: [member('0x9'), member('0xA')] }), + }) + const result = await teamSync.reconcileNow('test') + + assert.equal(activeMembers(1).length, 2, "g1's roster is untouched, not emptied") + assert.equal(activeMembers(2).length, 2, "g2 syncs normally — one team's problem is its own") + assert.equal(result.rosters, 1, 'only one roster was applied') +}) + +test('gate 3 — a refused roster does not bump that team’s freshness stamp', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), + }) + await teamSync.reconcileNow('setup') + const syncedAt = store.teams[0].roster_synced_at + assert.ok(syncedAt) + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: false, reason: 'nope' }), + }) + await teamSync.reconcileNow('test') + assert.equal(store.teams[0].roster_synced_at, syncedAt, 'stale must show as stale, not as just-synced') +}) + +test('leadership is a separate answer — a refused one does not demote anybody', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }), + getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }), + }) + await teamSync.reconcileNow('setup') + assert.equal(membersOf(1).get('0x1').is_leader, 1) + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }), + getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }), + }) + await teamSync.reconcileNow('test') + assert.equal(membersOf(1).get('0x1').is_leader, 1, 'an unanswerable question is not the answer "nobody"') +}) + +test('the roster seeds is_leader on a new row but never overwrites it afterwards', async () => { + // Two writers for one column is how a refused leadership answer becomes a + // silent demotion: the roster would write `leader: false` before the + // authoritative call was even made. Seeding on insert keeps a Team from being + // leaderless while getTeamLeaders() is failing. + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: true })] }), + getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }), + }) + await teamSync.reconcileNow('first sync, leadership unanswerable') + assert.equal(membersOf(1).get('0x1').is_leader, 1, 'seeded from the roster rather than left blank') + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: false })] }), + getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }), + }) + await teamSync.reconcileNow('roster disagrees with the authority') + assert.equal(membersOf(1).get('0x1').is_leader, 1, 'getTeamLeaders() is path 2, and the roster is not') +}) + +// ── Gate 4: an authoritative empty roster ────────────────────────────────── + +test('gate 4 — the first empty roster departs nobody', async () => { + let members = [member('0x1'), member('0x2')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + assert.equal(activeMembers(1).length, 2) + + members = [] + await teamSync.reconcileNow('empty roster') + assert.equal(activeMembers(1).length, 2, 'a cold cache must not empty a roster') + assert.ok(store.teams[0].members_empty_since) +}) + +test('gate 4 — a second empty roster is applied', async () => { + let members = [member('0x1'), member('0x2')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + + members = [] + await teamSync.reconcileNow('first empty') + await teamSync.reconcileNow('second empty') + assert.equal(activeMembers(1).length, 0, 'the guild really was emptied') + assert.equal(store.teams[0].member_count, 0) +}) + +test('gate 4 — a non-empty roster clears the quarantine', async () => { + let members = [member('0x1')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + + members = [] + await teamSync.reconcileNow('empty') + assert.ok(store.teams[0].members_empty_since) + + members = [member('0x1')] + await teamSync.reconcileNow('recovered') + assert.equal(store.teams[0].members_empty_since, null) +}) + +test('gate 4 — a team that never had members takes an empty roster at once', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + const result = await teamSync.reconcileNow('test') + assert.equal(result.rosters, 1, 'nothing is at risk, so nothing is quarantined') + assert.equal(store.teams[0].members_empty_since, null) +}) + +// ── Ordinary syncing ─────────────────────────────────────────────────────── + +test('a new team is created with a slug, and its roster and counts follow', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand', { abbr: 'TSH' })] }), + getTeamMembers: async () => ({ + ok: true, + members: [member('0x1', { userId: 7, online: true }), member('0x2')], + }), + getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }), + }) + const result = await teamSync.reconcileNow('test') + + assert.equal(result.created, 1) + const row = store.teams[0] + assert.equal(row.slug, 'the-silver-hand') + assert.equal(row.member_count, 2) + assert.equal(row.linked_count, 1) + assert.equal(row.online_count, 1) + assert.equal(membersOf(1).get('0x1').is_leader, 1) +}) + +test('a member who disappears from a complete roster is departed, not deleted', async () => { + let members = [member('0x1'), member('0x2')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + + members = [member('0x1')] + await teamSync.reconcileNow('test') + assert.equal(membersOf(1).get('0x2').status, 'departed', 'the row survives so history and rejoins do') + assert.equal(activeMembers(1).length, 1) +}) + +test('a rejoining member revives their row and keeps their first_seen_at', async () => { + let members = [member('0x1'), member('0x2')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + members = [member('0x1')] + await teamSync.reconcileNow('leaves') + members = [member('0x1'), member('0x2')] + await teamSync.reconcileNow('returns') + + assert.equal(membersOf(1).get('0x2').status, 'active') + assert.equal(membersOf(1).get('0x2').first_seen_at, 'first', 'a rejoin is a revived row, not a second one') +}) + +test('an incomplete roster adds and updates but removes nothing', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }), + }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x3')] }), + }) + await teamSync.reconcileNow('partial') + assert.equal(activeMembers(1).length, 3, 'a partial answer is not a claim about who is absent') +}) + +test('a team absent from a complete list is archived as disbanded', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('test') + + assert.equal(activeTeams().length, 1) + const archived = store.teams.find((t) => t.external_id === 'g2') + assert.equal(archived.status, 'archived') + assert.equal(archived.archived_reason, 'disbanded') +}) + +// ── The rename rule (§2.2) ───────────────────────────────────────────────── + +test('a renamed team is archived and succeeded, never edited in place', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), + }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), + }) + const result = await teamSync.reconcileNow('rename') + + assert.equal(result.renamed, 1) + const [old_, next] = store.teams + assert.equal(old_.name, 'The Silver Hand', 'the name is immutable for the life of the row') + assert.equal(old_.status, 'archived') + assert.equal(old_.archived_reason, 'renamed') + assert.equal(old_.succeeded_by, next.id, 'the old slug can explain itself instead of 404ing') + assert.equal(next.name, 'The Golden Hand') + assert.equal(next.slug, 'the-golden-hand') + assert.equal(next.status, 'active') +}) + +test('a rename back to a previous name does not reuse the retired slug', async () => { + const names = ['Alpha', 'Beta', 'Alpha'] + let i = 0 + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', names[i])] }) }) + await teamSync.reconcileNow('a') + i = 1 + await teamSync.reconcileNow('b') + i = 2 + await teamSync.reconcileNow('c') + + const slugs = store.teams.map((t) => t.slug) + assert.deepEqual(slugs, ['alpha', 'beta', 'alpha-2']) + assert.equal(new Set(slugs).size, 3, 'an archived team stays readable at its own address') +}) + +test('two teams with the same name get distinct slugs', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Guard'), team('g2', 'Guard')] }) }) + await teamSync.reconcileNow('test') + assert.deepEqual(store.teams.map((t) => t.slug), ['guard', 'guard-2']) +}) + +test('a name with nothing URL-safe in it still gets an address', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', '★☆★')] }) }) + await teamSync.reconcileNow('test') + assert.equal(store.teams[0].slug, 'team') + assert.equal(store.teams[0].name, '★☆★', 'the identity keeps what the player typed') +}) + +// ── Events (§2.3) ────────────────────────────────────────────────────────── + +test('an unknown event kind is rejected', async () => { + provide() + await assert.rejects(() => teamSync.publish({ kind: 'team.exploded', externalId: 'g1' }), /unknown event kind/) +}) + +test('an event with no externalId is rejected', async () => { + provide() + await assert.rejects(() => teamSync.publish({ kind: 'team.member.added' }), /no externalId/) +}) + +test('team.disbanded never archives — it asks for a reconciliation', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + await teamSync.publish({ kind: 'team.disbanded', externalId: 'g1' }) + assert.equal(activeTeams().length, 1, 'destruction is never driven by a delta that may be a repeat') +}) + +test('team.created does not invent a team', async () => { + provide() + await teamSync.publish({ kind: 'team.created', externalId: 'brand-new' }) + assert.equal(store.teams.length, 0, 'a team built from a delta has no name, roster or leaders') +}) + +test('a member delta applies at once for a known team and updates the counts', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + await teamSync.publish({ + kind: 'team.member.added', externalId: 'g1', memberKey: '0x5', displayName: 'Ada', userId: 3, + }) + assert.equal(activeMembers(1).length, 1) + assert.equal(membersOf(1).get('0x5').display_name, 'Ada') + assert.equal(store.teams[0].member_count, 1) + assert.equal(store.teams[0].linked_count, 1) + + await teamSync.publish({ kind: 'team.member.removed', externalId: 'g1', memberKey: '0x5' }) + assert.equal(activeMembers(1).length, 0) + assert.equal(store.teams[0].member_count, 0) +}) + +test('a leadership delta writes is_leader and nothing else', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), + }) + await teamSync.reconcileNow('setup') + + await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0x1' }) + assert.equal(membersOf(1).get('0x1').is_leader, 1) + assert.equal(activeMembers(1).length, 1, 'promotion is not a join') + + await teamSync.publish({ kind: 'team.leader.removed', externalId: 'g1', memberKey: '0x1' }) + assert.equal(membersOf(1).get('0x1').is_leader, 0) + assert.equal(activeMembers(1).length, 1, 'demotion is not a departure') +}) + +test('a leadership delta for an unknown member creates nobody', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0xdead' }) + assert.equal(activeMembers(1).length, 0, 'a promotion is not evidence of membership') +}) + +test('an event for an unknown team asks for a reconciliation instead of guessing', async () => { + provide() + await teamSync.publish({ kind: 'team.member.added', externalId: 'nope', memberKey: '0x1' }) + assert.equal(store.teams.length, 0) +}) + +test('publish is a no-op when no provider is registered', async () => { + await teamSync.publish({ kind: 'team.member.added', externalId: 'g1', memberKey: '0x1' }) + assert.equal(store.teams.length, 0) +}) + +// ── Scheduling ───────────────────────────────────────────────────────────── + +test('a run already in flight is joined rather than run twice', async () => { + let calls = 0 + let release + const gate = new Promise((resolve) => { release = resolve }) + provide({ + getTeams: async () => { + calls += 1 + await gate + return { ok: true, teams: [] } + }, + }) + + const first = teamSync.reconcileNow('first') + const second = await teamSync.reconcileNow('second') + assert.equal(second.joined, true) + release() + await first + assert.equal(calls, 1, 'the lock is what stops two runs writing the same rows') +}) + +test('the poll interval falls back and is floored against a bad setting', async () => { + patch(settings, 'get', async () => 'not a number') + assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S) + + patch(settings, 'get', async () => '5') + assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S, 'a hot loop is not a valid interval') + + patch(settings, 'get', async () => '120') + assert.equal(await teamSync.intervalSeconds(), 120) + + patch(settings, 'get', async () => { throw new Error('db down') }) + assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S) +}) + +test('backoff grows with failures and is capped at the poll interval', async () => { + assert.equal(teamSync.backoffSeconds(0, 900), 900, 'no failures means the ordinary poll') + assert.equal(teamSync.backoffSeconds(1, 900), 30) + assert.equal(teamSync.backoffSeconds(2, 900), 60) + assert.ok(teamSync.backoffSeconds(4, 900) < 900) + assert.equal(teamSync.backoffSeconds(50, 900), 900, 'a module down for a day must recover promptly, not in weeks') +}) + +test('start() is inert with no provider registered', async () => { + await teamSync.start() + assert.equal(store.teams.length, 0) +})