feat(modules): registerTeamProvider, and a call path that cannot answer "empty"
The registration a module uses to become the authoritative source of Teams
(docs/website/TEAMS.md §2.3), plus the wrapper core calls it through.
registerTeamProvider is the first registration where core CALLS THE MODULE and
waits for an answer. Every existing one is either the module claiming a mount or
core notifying it; the closest precedent is registerAnnounceLeg's dispatch, and
this is modelled on it rather than invented. It also holds a single value rather
than a map, unlike every other registry: Teams have one authoritative source by
construction, and two modules answering "what teams exist" would produce two
disjoint sets under one `teams` table with no rule for merging them. A second
registration is therefore a collision, named against the module that holds it.
teamProvider.js is where invariant 1 -- module unavailability is staleness,
never emptiness -- is actually enforced. It is deliberately generous about what
counts as a failure: a rejected promise, a synchronous throw, a timeout, a
non-object, a bare array, a missing `ok`, or a structurally malformed row all
leave as the same `{ ok: false }` a module would have sent on purpose. There is
no shape a broken provider can produce that arrives at the reconciler looking
like an authoritative empty list -- which is the entire argument for the
envelope, since a bare array has exactly one such shape and it is the one a
module returns while its sidecar is still connecting.
A malformed row fails the whole call rather than being dropped. Salvaging is the
dangerous option: one unreadable member quietly omitted from a roster is
indistinguishable, downstream, from that member having left, and the sync would
mark them departed on the strength of a broken payload. Refusing costs one stale
interval.
The deadline timer is unreffed as well as cleared. Clearing covers the case
where the race settles; it cannot cover a module promise that never settles at
all, where nothing exists to clear until the deadline fires. Caught by the test
file taking 10.2s to run 265ms of assertions -- the same class of bug as the
mariadb pool that used to hold the suite open (test/_setup.js). 292ms now.
28 tests. Full suite 770 passed, 0 failed.
Refs docs/website/TEAMS.md §2.3, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
182
server/src/model/teams/teamProvider.js
Normal file
182
server/src/model/teams/teamProvider.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// ── Calling the Team provider ──────────────────────────────────────────────
|
||||
//
|
||||
// The one place core asks a module a question and waits for the answer
|
||||
// (docs/website/TEAMS.md §2.3). Everything here exists to serve invariant 1:
|
||||
//
|
||||
// **Module unavailability is staleness, never emptiness.**
|
||||
//
|
||||
// No Team subsystem may apply a destructive result derived from a failed,
|
||||
// timed-out or unanswered module call. This file is where "failed" is defined, and
|
||||
// it is deliberately generous about what counts: a rejected promise, a timeout, a
|
||||
// non-object, a missing `ok`, or a structurally malformed row all leave with the
|
||||
// same `{ ok: false }` the module would have sent deliberately.
|
||||
//
|
||||
// **There is no shape a failure can take that core reads as "zero teams".** That
|
||||
// is the whole argument for the envelope, and the reason the provider signature is
|
||||
// not the obvious `getTeams(): Team[]` — a bare array has exactly one such shape,
|
||||
// `[]`, and it is the one a module returns while its sidecar is still connecting.
|
||||
//
|
||||
// Nothing here touches the database. It calls the module and hands back a value
|
||||
// the reconciler can trust the SHAPE of; whether to ACT on it is §2.4's question.
|
||||
|
||||
const registries = require('../../modules/registries')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
// The budget from §2.3. A provider is answering from its own cache or its own
|
||||
// sidecar client, both of which have their own timeouts well inside this; a call
|
||||
// that reaches ten seconds is wedged, not slow.
|
||||
const CALL_TIMEOUT_MS = 10_000
|
||||
|
||||
/** A uniform refusal. `reason` is for the operator, via team_sync_state. */
|
||||
const fail = (reason) => ({ ok: false, reason })
|
||||
|
||||
/**
|
||||
* Await `promise` with a timeout that cannot outlive the call.
|
||||
*
|
||||
* The timer is always cleared — including on the winning path — because an
|
||||
* uncleared 10s timer holds the event loop open, which in a test run means the
|
||||
* process hangs long after the assertions passed. The suite already learned this
|
||||
* one from a mariadb pool (test/_setup.js).
|
||||
*
|
||||
* It is also `unref`ed, which covers the case clearing cannot: when the module's
|
||||
* promise NEVER settles, the race stays pending and there is nothing to clear
|
||||
* until the deadline fires. An unreffed timer still fires normally while the
|
||||
* process is alive — the server's own listener is what keeps it alive — but it no
|
||||
* longer holds a shutdown open for ten seconds waiting on a module that is not
|
||||
* going to answer.
|
||||
*/
|
||||
function withTimeout(promise, ms) {
|
||||
let timer
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(fail(`provider did not answer within ${ms}ms`)), ms)
|
||||
if (typeof timer.unref === 'function') timer.unref()
|
||||
})
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Call one provider method and normalise whatever comes back into an envelope.
|
||||
*
|
||||
* `normalise` is only ever run on an `ok` answer, and may itself return a refusal
|
||||
* — a structurally malformed row is treated as a failed call rather than as data
|
||||
* to salvage. Salvaging is the dangerous option: dropping one unreadable member
|
||||
* from a roster is indistinguishable, downstream, from that member having left,
|
||||
* and the sync would mark them departed. Refusing costs one stale interval.
|
||||
*/
|
||||
async function call(method, normalise, ...args) {
|
||||
const provider = registries.registeredTeamProvider()
|
||||
if (!provider) return fail('no team provider is registered')
|
||||
|
||||
let answer
|
||||
try {
|
||||
answer = await withTimeout(Promise.resolve().then(() => provider[method](...args)), CALL_TIMEOUT_MS)
|
||||
} catch (err) {
|
||||
// A rejected promise is a module that threw, which is exactly as
|
||||
// unauthoritative as one that answered `{ ok: false }`.
|
||||
return fail(`${method}() threw: ${err.message}`)
|
||||
}
|
||||
|
||||
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) {
|
||||
return fail(`${method}() returned ${Array.isArray(answer) ? 'an array' : typeof answer}, not an envelope`)
|
||||
}
|
||||
// `ok` must be present and true. A module that forgot the field is not one
|
||||
// asserting authority, and reading a missing field as truthy would put the
|
||||
// single most consequential decision in this file on a typo.
|
||||
if (answer.ok !== true) return fail(answer.reason || `${method}() answered not-ok`)
|
||||
|
||||
const normalised = normalise(answer)
|
||||
if (normalised.ok === false) {
|
||||
log.warn('team provider answered with a malformed payload', {
|
||||
owner: provider.owner, method, reason: normalised.reason,
|
||||
})
|
||||
}
|
||||
return normalised
|
||||
}
|
||||
|
||||
// `complete` defaults to TRUE when the module omits it, matching §2.3: the
|
||||
// envelope's optional field marks a partial answer, so its absence is the
|
||||
// ordinary authoritative case. A module that cannot enumerate exhaustively says
|
||||
// so explicitly.
|
||||
const isComplete = (answer) => answer.complete !== false
|
||||
|
||||
const str = (v) => (typeof v === 'string' ? v.trim() : '')
|
||||
|
||||
/** `{ ok, complete, teams: [{ externalId, name, abbr, meta }] }` */
|
||||
function normaliseTeams(answer) {
|
||||
if (!Array.isArray(answer.teams)) return fail('getTeams() answered ok with no teams array')
|
||||
const teams = []
|
||||
for (const raw of answer.teams) {
|
||||
const externalId = str(raw && raw.externalId)
|
||||
const name = str(raw && raw.name)
|
||||
// Both are load-bearing and neither has a safe default: externalId is the
|
||||
// identity the whole rename rule (§2.2) turns on, and a Team with no name has
|
||||
// no slug and no page.
|
||||
if (!externalId) return fail('a team in getTeams() has no externalId')
|
||||
if (!name) return fail(`team "${externalId}" has no name`)
|
||||
teams.push({
|
||||
externalId,
|
||||
name,
|
||||
abbr: str(raw.abbr) || null,
|
||||
// Opaque by contract (§10.5) — stored and handed back, never branched on.
|
||||
meta: raw.meta && typeof raw.meta === 'object' ? raw.meta : null,
|
||||
})
|
||||
}
|
||||
return { ok: true, complete: isComplete(answer), teams }
|
||||
}
|
||||
|
||||
/** `{ ok, complete, members: [{ memberKey, displayName, rankLabel, leader, online, userId }] }` */
|
||||
function normaliseMembers(answer) {
|
||||
if (!Array.isArray(answer.members)) return fail('getTeamMembers() answered ok with no members array')
|
||||
const members = []
|
||||
const seen = new Set()
|
||||
for (const raw of answer.members) {
|
||||
const memberKey = str(raw && raw.memberKey)
|
||||
if (!memberKey) return fail('a member has no memberKey')
|
||||
// A duplicate key would upsert twice and inflate no count but confuse every
|
||||
// reader; it also means the module's own identity rule is broken, which is
|
||||
// worth surfacing rather than quietly collapsing.
|
||||
if (seen.has(memberKey)) return fail(`member "${memberKey}" appears twice`)
|
||||
seen.add(memberKey)
|
||||
members.push({
|
||||
memberKey,
|
||||
displayName: str(raw.displayName) || null,
|
||||
rankLabel: str(raw.rankLabel) || null,
|
||||
leader: Boolean(raw.leader),
|
||||
online: Boolean(raw.online),
|
||||
// Resolved BY THE MODULE — it owns the game↔site link table (§2.3). Core
|
||||
// takes the number and never looks it up.
|
||||
userId: Number.isInteger(raw.userId) && raw.userId > 0 ? raw.userId : null,
|
||||
})
|
||||
}
|
||||
return { ok: true, complete: isComplete(answer), members }
|
||||
}
|
||||
|
||||
/** `{ ok, leaders: [memberKey] }` */
|
||||
function normaliseLeaders(answer) {
|
||||
if (!Array.isArray(answer.leaders)) return fail('getTeamLeaders() answered ok with no leaders array')
|
||||
const leaders = []
|
||||
for (const raw of answer.leaders) {
|
||||
const key = str(raw)
|
||||
if (!key) return fail('a leader entry is not a member key')
|
||||
if (!leaders.includes(key)) leaders.push(key)
|
||||
}
|
||||
return { ok: true, leaders }
|
||||
}
|
||||
|
||||
const getTeams = () => call('getTeams', normaliseTeams)
|
||||
const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
|
||||
const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
|
||||
|
||||
/** Which module is authoritative, or null. The reconciler keys sync state on it. */
|
||||
const providerModuleId = () => {
|
||||
const provider = registries.registeredTeamProvider()
|
||||
return provider ? provider.owner : null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTeams,
|
||||
getTeamMembers,
|
||||
getTeamLeaders,
|
||||
providerModuleId,
|
||||
CALL_TIMEOUT_MS,
|
||||
}
|
||||
Reference in New Issue
Block a user