feat(teams): the reconciler, its four refusal gates, and ctx.teams (API 1.6.0)
Core's projection of the module's Teams, kept in step (docs/website/TEAMS.md
§2.4), plus the two ctx members a module pushes through.
The four gates are the file, and each is invariant 1 in a different costume --
module unavailability is staleness, never emptiness:
1. getTeams() not ok -> record the failure, touch NOTHING, return.
2. ok but empty, core holds >=1 -> quarantine; apply only if the NEXT
authoritative answer, an interval later,
agrees.
3. getTeamMembers() not ok -> that Team's roster untouched and stale; the
other Teams sync normally.
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 to confirm; getting it wrong empties
every roster on the site.
Events are an optimisation, never the source of truth. Member and leadership
deltas apply at once for a Team core already knows; team.created and
team.disbanded only ask for a run. §2.2 scopes archival to an authoritative full
list, so a repeated or spurious disband event costs a reconcile rather than a
Team -- and a Team invented from a delta would have no name, no roster and no
leaders anyway.
Two columns TEAMS.md did not contemplate, both on `teams`:
- roster_synced_at, because team_sync_state holds one row per MODULE and gate 3
leaves ONE Team behind while the others sync. Without a per-Team stamp that
Team's page would report the module's last success as its own -- exactly the
staleness the gate exists to surface.
- members_empty_since, gate 4's per-Team quarantine. The twin of
team_sync_state.pending_empty_since, which is per module and cannot express it.
One real bug found by its own test. The roster upsert was writing is_leader, so a
refused getTeamLeaders() left every member demoted -- the roster had already
written `leader: false` before the authoritative call was even made. §2.5 is
explicit that path 2 is answered by getTeamLeaders(), so is_leader is now set on
INSERT only (seeding a Team so it is not leaderless while that call fails) and
moved afterwards by setLeaders() alone. Two writers for one column was the whole
defect.
MODULE_API_VERSION 1.6.0 on both halves -- they state one contract and a module
declares one coreApi range. The number covers the whole Team surface per Part 11;
the members arrive by phase. registerTeamProvider, ctx.teams.publish and
ctx.teams.reconcile are live. ctx.teams.activity.push (§4, phase 3) and
api.registerSlashCommands (§7.1, phase 7) are present and THROW with a sentence
naming their phase, rather than being absent or silently accepting data into
tables that do not exist yet.
39 tests here, and the ctx surface guard in moduleLoader.test.js updated -- it
caught the addition, which is what it is for. Server 809 passed, client 192
passed, 0 failed.
Refs docs/website/TEAMS.md §2.2, §2.3, §2.4, Part 11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
50
server/src/model/teams/teamSlug.js
Normal file
50
server/src/model/teams/teamSlug.js
Normal file
@@ -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 }
|
||||
482
server/src/model/teams/teamSync.model.js
Normal file
482
server/src/model/teams/teamSync.model.js
Normal file
@@ -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,
|
||||
}
|
||||
297
server/src/model/teams/teams.db.js
Normal file
297
server/src/model/teams/teams.db.js
Normal file
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user