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:
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