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:
@@ -119,6 +119,7 @@ function buildCtx(id, moduleRoot) {
|
||||
const uploads = require('../router/v1/admin/imageUpload')
|
||||
const activity = require('../model/activity/activity.model')
|
||||
const users = require('../model/users/users.model')
|
||||
const teams = require('../model/teams/teamSync.model')
|
||||
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -174,6 +175,31 @@ function buildCtx(id, moduleRoot) {
|
||||
// a place nobody looks. `list` stays core's: reading the log is the admin
|
||||
// panel's job, and it spans every actor.
|
||||
activity: { log: activity.log },
|
||||
// Teams (API 1.6.0, TEAMS.md §2.3). Push, to the pull the provider answers.
|
||||
//
|
||||
// Both are fire-and-forget by contract. `publish` is an OPTIMISATION — it
|
||||
// makes a membership change visible at once — and `reconcile` is a REQUEST,
|
||||
// debounced and never awaited, so a module cannot make its own call site slow
|
||||
// or turn a background failure into its own error. Correctness comes from the
|
||||
// reconciler either way; these only decide how soon.
|
||||
//
|
||||
// There is deliberately no reader here. A module answers questions about
|
||||
// Teams; it does not ask them. Every Team table is core-internal (§10.3), and
|
||||
// a `getTeamRoster` on ctx would be core offering to read back the module's
|
||||
// own answer — which is the module's data, in the module's own store.
|
||||
teams: {
|
||||
publish: (event) => teams.publish(event),
|
||||
reconcile: (opts) => teams.request(opts),
|
||||
// §4's activity feed, which lands with the Team pages in phase 3. Declared
|
||||
// in 1.6.0 alongside the rest of the Team surface; calling it before phase 3
|
||||
// throws rather than silently accepting items into a table that does not
|
||||
// exist yet.
|
||||
activity: {
|
||||
push: () => {
|
||||
throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)')
|
||||
},
|
||||
},
|
||||
},
|
||||
// One function, for one caller: the `admin.users.detail` slot router needs
|
||||
// the user its prefix names. Narrowed like `ctx.posts` — the users model
|
||||
// exports creation, role changes and password handling, none of which is a
|
||||
@@ -249,6 +275,14 @@ function buildApi(record) {
|
||||
once('registerTeamProvider')
|
||||
record.staged.registerTeamProvider(provider)
|
||||
},
|
||||
// Declared in 1.6.0 with the rest of the Team surface; the bot half that
|
||||
// executes a command lands in phase 7 (§7.1). Present and throwing rather
|
||||
// than absent, so a module written against the published version fails at
|
||||
// registration with a sentence naming the phase, instead of at whatever
|
||||
// moment someone first types the command.
|
||||
registerSlashCommands() {
|
||||
throw new Error('api.registerSlashCommands is not available until Discord slash commands land (TEAMS.md §7.1)')
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
|
||||
Reference in New Issue
Block a user