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:
2026-08-17 14:53:52 -05:00
parent 8b63ffc725
commit 92631347f9
10 changed files with 1650 additions and 3 deletions

View File

@@ -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 teams 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)
})