// ── 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 } } /** * `{ ok, members: [memberKey] }` — WHICH rows the module permits this viewer. * * Deliberately a set of keys rather than a set of rows. Core already holds the * rows and knows their public shape; asking the module for rows back would let a * module widen what is published — re-adding a `userId` or a `memberKey` that * §3.2 says is never published — and core's field guarantee would then rest on * every module's good behaviour rather than on core. So the module answers the * question it actually owns (who may be seen at this rung) and core keeps the * question it owns (what a member row looks like in public). */ function normaliseVisibleKeys(answer) { if (!Array.isArray(answer.members)) return fail('projectRoster() answered ok with no members array') const keys = [] for (const raw of answer.members) { const key = str(raw) if (!key) return fail('a projectRoster() entry is not a member key') if (!keys.includes(key)) keys.push(key) } return { ok: true, members: keys } } const getTeams = () => call('getTeams', normaliseTeams) const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId) const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId) /** * Ask the module which roster rows this viewer may see (§3.3). * * The per-audience projection is the module's because the visibility framework * and its rung configuration are module-owned (§10.5) — core does not know what a * rung is. Core supplies the roster and a description of the viewer; the module * returns the member keys it permits. * * **"No audience model" and "could not answer" are different, and the caller must * be able to tell them apart** — so the refusal carries `projects`. * * `projects: false` — no provider is registered, or the registered one does not * implement `projectRoster`. There is no rung system to consult and nothing * is being withheld; the roster is served at core's public shape. This is why * the member is OPTIONAL: bare core, and a module with no audience model of * its own, both render exactly the page core writes. * * `projects: true` — the module HAS an audience model and core could not reach * it (refused, threw, timed out, answered malformed). Here the caller must * fail CLOSED, because "leave it alone" would mean publishing the very rows * the rungs exist to withhold. This is the one place in the Team subsystem * where unavailability is not staleness: everywhere else a refused call * leaves data alone, and doing that to a *visibility* question is a leak. */ async function projectRoster(externalId, members, viewer) { const provider = registries.registeredTeamProvider() if (!provider) return { ...fail('no team provider is registered'), projects: false } if (typeof provider.projectRoster !== 'function') { return { ...fail('provider does not project rosters'), projects: false } } const answer = await call('projectRoster', normaliseVisibleKeys, externalId, members, viewer) return answer.ok ? answer : { ...answer, projects: true } } /** 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, projectRoster, providerModuleId, CALL_TIMEOUT_MS, }