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:
@@ -58,6 +58,16 @@ const legs = new Map()
|
||||
// a collision with a name attached rather than a silently doubled side effect.
|
||||
const postHooks = new Map()
|
||||
|
||||
// { owner, getTeams, getTeamMembers, getTeamLeaders } or null — the Team provider
|
||||
// (API 1.6.0, TEAMS.md §2.3).
|
||||
//
|
||||
// A SINGLE value rather than a Map, unlike every registry above it, and that is
|
||||
// the contract: one provider per deployment. Teams have one authoritative source
|
||||
// by construction — two modules answering "what teams exist" would produce two
|
||||
// disjoint sets under one `teams` table with no rule for merging them, so a
|
||||
// second registration is a collision rather than an addition.
|
||||
let teamProvider = null
|
||||
|
||||
let coreRegistered = false
|
||||
|
||||
// Stream ids that predate the module system and may not carry their owner's
|
||||
@@ -196,6 +206,14 @@ const announceLegIds = () => [...legs.keys()]
|
||||
/** One leg, or null. */
|
||||
const announceLeg = (leg) => legs.get(leg) || null
|
||||
|
||||
// ── Team provider (TEAMS.md §2.3) ──────────────────────────────────────────
|
||||
|
||||
/** The registered provider, or null when no module supplies one. */
|
||||
const registeredTeamProvider = () => teamProvider
|
||||
|
||||
/** Is there a Team provider at all? Read by the reconciler and the read API. */
|
||||
const hasTeamProvider = () => teamProvider !== null
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
@@ -225,6 +243,23 @@ function checkLegShape(entry) {
|
||||
return { leg, label: label || leg, dispatch, classify }
|
||||
}
|
||||
|
||||
// All three methods are REQUIRED, with no optional half. A provider that could
|
||||
// list Teams but not their members would leave core holding Teams it can never
|
||||
// populate, and the reconciler has no sensible behaviour for that — it is not the
|
||||
// same as a call that fails, which is staleness and already handled (§2.4). A
|
||||
// module unable to answer one of the three answers `{ ok: false }` at call time.
|
||||
function checkTeamProviderShape(entry) {
|
||||
const provider = entry || {}
|
||||
const out = {}
|
||||
for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
|
||||
if (typeof provider[name] !== 'function') {
|
||||
throw new Error(`registerTeamProvider: ${name}() is missing or not a function`)
|
||||
}
|
||||
out[name] = provider[name]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
|
||||
* required. A registration with neither is a subscription that can never fire,
|
||||
@@ -265,7 +300,7 @@ function checkExtensionShape(slot, router, specFile) {
|
||||
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] }
|
||||
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [] }
|
||||
return {
|
||||
staged,
|
||||
registerNotificationStreams(entries) {
|
||||
@@ -281,6 +316,9 @@ function stage(owner) {
|
||||
registerPostHook(entry) {
|
||||
staged.postHooks.push(checkPostHookShape(entry))
|
||||
},
|
||||
registerTeamProvider(entry) {
|
||||
staged.teamProviders.push(checkTeamProviderShape(entry))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +331,14 @@ function stage(owner) {
|
||||
* PR 2 learned to protect (mounting inside the scan loop made every collision
|
||||
* look like it was with core).
|
||||
*/
|
||||
function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions, postHooks: newPostHooks = [] }) {
|
||||
function apply({
|
||||
owner,
|
||||
streams: newStreams,
|
||||
legs: newLegs,
|
||||
extensions: newExtensions,
|
||||
postHooks: newPostHooks = [],
|
||||
teamProviders: newTeamProviders = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
for (const s of newStreams) {
|
||||
@@ -332,6 +377,11 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
|
||||
throw new Error(`"${owner}" already registered a post hook`)
|
||||
}
|
||||
|
||||
if (newTeamProviders.length > 1) throw new Error(`"${owner}" registered more than one team provider`)
|
||||
if (newTeamProviders.length && teamProvider) {
|
||||
throw new Error(`a team provider is already registered by "${teamProvider.owner}"`)
|
||||
}
|
||||
|
||||
// ── commit — nothing below can fail ──
|
||||
for (const s of newStreams) {
|
||||
streamOwners.set(s.id, owner)
|
||||
@@ -345,6 +395,7 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
|
||||
entry.router.use(x.router)
|
||||
}
|
||||
for (const h of newPostHooks) postHooks.set(owner, h)
|
||||
for (const p of newTeamProviders) teamProvider = { owner, ...p }
|
||||
}
|
||||
|
||||
// ── Core's own registrations ───────────────────────────────────────────────
|
||||
@@ -410,6 +461,7 @@ function _reset() {
|
||||
streamOwners.clear()
|
||||
legs.clear()
|
||||
postHooks.clear()
|
||||
teamProvider = null
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
@@ -427,6 +479,8 @@ module.exports = {
|
||||
announceLeg,
|
||||
postHookEntries,
|
||||
dispatchPostHook,
|
||||
registeredTeamProvider,
|
||||
hasTeamProvider,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
|
||||
Reference in New Issue
Block a user