Walked against real MariaDB, the real app, and a fake standing in for Discord
that mounts the bot's real internal routes — everything up to the Discord API
call was production code. 47 assertions, and it found two things every unit
test in the phase had passed over.
1. **Every query failed: two result columns named `team_id`.** `desiredTeams`
and `holdersWithoutClaim` both select `t.id AS team_id`, and the shared
column list added `i.team_id` beside it. The `mariadb` driver refuses a
result set with a repeated field name outright, so the pass died at its
first query with "Error in results, duplicate field name `team_id`" — on the
one code path every unit test stubs.
It was also the wrong column: `desiredTeams` LEFT JOINs, so `i.team_id` is
NULL for exactly the Teams that have no channel yet, which is the create
case. The two queries that do not join `teams` now ask for it by name.
The regression test checks the INTERPOLATED sql captured from a fake
`query`, not the source text — in the source the shared list is still a
`${COLUMNS}` placeholder, and a first attempt that read the file passed
happily with the bug reintroduced.
2. **"Sync now" said "Nothing was done" while it was doing it.** Saving the
settings with voice switched on asks for a pass. An operator who then
presses Sync now — the obvious next thing — hit `running` and got back
`ran: false, reason: "a pass is already running"`, which the panel renders
as nothing having happened, while the pass they triggered was busy creating
their channels. A pass in flight is now JOINED and its real outcome
returned, the same choice `teamSync.reconcileNow` makes for the same reason.
Tests: 1162 server (+2), 53 bot, 284 client.
Co-Authored-By: Claude <noreply@anthropic.com>
214 lines
8.6 KiB
JavaScript
214 lines
8.6 KiB
JavaScript
// SQL for per-Team external resources — today, the Discord voice channel
|
|
// (TEAMS.md §7.3, phase 9).
|
|
//
|
|
// Two queries carry the phase. `desiredTeams` is what SHOULD have a channel and
|
|
// `holdersWithoutClaim` is what HAS one and should not; the reconciler is the
|
|
// difference between them, and keeping both as single queries is what stops a
|
|
// pass from being one round trip per Team before it has made a single Discord
|
|
// call.
|
|
//
|
|
// **`discordSubjectsFor` is the whole identity chain in one statement** (§2.6):
|
|
// team_members → users → user_identities. A member with no site account has no
|
|
// row to join, and a member with a site account but no Discord identity drops out
|
|
// at the second join — which is exactly right, because a role can only be granted
|
|
// to somebody Discord knows about. Nothing else in the phase is allowed to
|
|
// shortcut this with `teams.linked_count`, which counts hop 1 and is always the
|
|
// larger number.
|
|
|
|
const { query } = require('../../utils/db')
|
|
|
|
// The provider id a Discord identity is stored under. Matches `auth_providers.id`
|
|
// and the built-in provider in `auth/providers/discord.provider.js`; a constant
|
|
// rather than a literal because it appears in two queries and a typo in either
|
|
// would silently return an empty grant set — a Team whose channel nobody can
|
|
// enter, with no error anywhere.
|
|
const DISCORD_PROVIDER = 'discord'
|
|
|
|
// Deliberately WITHOUT `i.team_id`, and this is not tidiness.
|
|
//
|
|
// The two queries below join `teams` and already select `t.id AS team_id`, so
|
|
// including the integration row's copy produces two result columns with the same
|
|
// name — which the `mariadb` driver refuses outright: "Error in results, duplicate
|
|
// field name `team_id`". Every caller sees the whole pass fail, and no unit test
|
|
// can see it, because they stub this layer.
|
|
//
|
|
// It would also be the WRONG column even if the driver allowed it: `desiredTeams`
|
|
// LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel
|
|
// yet — the create case, where knowing the Team's id matters most. The two queries
|
|
// that do not join `teams` ask for it explicitly.
|
|
const COLUMNS = `
|
|
i.id, i.platform, i.resource, i.external_ref, i.role_ref,
|
|
i.state, i.remove_after, i.last_error, i.synced_at, i.updated_at`
|
|
|
|
/**
|
|
* Every Team that qualifies for a resource, with its integration row if it has
|
|
* one.
|
|
*
|
|
* The three conditions are §7.3's provisioning gate and §2.8's publication rule
|
|
* together:
|
|
*
|
|
* - `status = 'active'` — an archived Team is a record, not a place to talk.
|
|
* - `hidden = 0` — the channel is NAMED after the Team, and a Discord channel
|
|
* name is a game-sourced string published outside the site. A hidden Team's
|
|
* name is suppressed on every public surface; a voice channel would be the
|
|
* one place it still appeared.
|
|
* - `member_count >= ?` — the operator's threshold, counting ALL active members
|
|
* regardless of what they have linked (org lead, 2026-08-18). §7.3 wrote
|
|
* `voice_min_linked_members`; the number an operator is actually judging is
|
|
* "is this Team real", and link state answers a different question.
|
|
*
|
|
* LEFT JOIN rather than two queries: the reconciler needs "should have, and does
|
|
* it" as one answer, and a Team with no row yet is the create case.
|
|
*/
|
|
async function desiredTeams({ platform, resource, minMembers }) {
|
|
return query(
|
|
`SELECT t.id AS team_id, t.name, t.display_name_override, t.slug, t.abbr,
|
|
t.member_count, t.linked_count, ${COLUMNS}
|
|
FROM teams t
|
|
LEFT JOIN team_integrations i
|
|
ON i.team_id = t.id AND i.platform = ? AND i.resource = ?
|
|
WHERE t.status = 'active' AND t.hidden = 0 AND t.member_count >= ?
|
|
ORDER BY t.id`,
|
|
[platform, resource, Number(minMembers)],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Rows that hold a resource for a Team that no longer qualifies.
|
|
*
|
|
* The mirror of `desiredTeams`, and deliberately not its negation in JavaScript:
|
|
* a Team can stop qualifying by being archived, by being hidden, by losing
|
|
* members, or by having its row deleted out from under core, and enumerating
|
|
* those in a filter would mean re-deriving the gate in a second place that could
|
|
* disagree with the first.
|
|
*
|
|
* Rows already in 'pending_removal' are included — the grace window is decided by
|
|
* the caller, which needs to see them to know whether one has expired.
|
|
*/
|
|
async function holdersWithoutClaim({ platform, resource, minMembers }) {
|
|
return query(
|
|
`SELECT t.id AS team_id, t.name, t.display_name_override, t.status, t.hidden,
|
|
t.member_count, ${COLUMNS}
|
|
FROM team_integrations i
|
|
JOIN teams t ON t.id = i.team_id
|
|
WHERE i.platform = ? AND i.resource = ?
|
|
AND (i.external_ref IS NOT NULL OR i.role_ref IS NOT NULL)
|
|
AND (t.status <> 'active' OR t.hidden = 1 OR t.member_count < ?)
|
|
ORDER BY t.id`,
|
|
[platform, resource, Number(minMembers)],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The Discord user ids of a Team's members — hop 3 of §2.6, and the only set a
|
|
* role can be granted to.
|
|
*
|
|
* DISTINCT because a user could in principle hold two rows for the same provider
|
|
* across a provider rename; the unique key prevents it for one (provider,
|
|
* subject) pair, not for one user with two subjects. Two role-adds for the same
|
|
* person is harmless and one duplicate in a diff is a phantom removal next pass,
|
|
* which is not.
|
|
*/
|
|
async function discordSubjectsFor(teamId) {
|
|
const rows = await query(
|
|
`SELECT DISTINCT ui.subject
|
|
FROM team_members m
|
|
JOIN user_identities ui ON ui.user_id = m.user_id AND ui.provider = ?
|
|
WHERE m.team_id = ? AND m.status = 'active' AND m.user_id IS NOT NULL
|
|
ORDER BY ui.subject`,
|
|
[DISCORD_PROVIDER, Number(teamId)],
|
|
)
|
|
return rows.map((row) => String(row.subject))
|
|
}
|
|
|
|
/** Every row for a platform, with the Team's name — the admin panel's listing. */
|
|
async function listForPlatform(platform, resource) {
|
|
return query(
|
|
`SELECT i.team_id, ${COLUMNS}, t.name AS team_name, t.slug AS team_slug,
|
|
t.display_name_override, t.status AS team_status, t.hidden AS team_hidden,
|
|
t.member_count, t.linked_count
|
|
FROM team_integrations i
|
|
JOIN teams t ON t.id = i.team_id
|
|
WHERE i.platform = ? AND i.resource = ?
|
|
ORDER BY t.name`,
|
|
[platform, resource],
|
|
)
|
|
}
|
|
|
|
async function getForTeam(teamId, platform, resource) {
|
|
const rows = await query(
|
|
`SELECT i.team_id, ${COLUMNS} FROM team_integrations i
|
|
WHERE i.team_id = ? AND i.platform = ? AND i.resource = ? LIMIT 1`,
|
|
[Number(teamId), platform, resource],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
/**
|
|
* Write what the reconciler believes after a pass.
|
|
*
|
|
* A full upsert of the mutable columns rather than a patch, because every caller
|
|
* has just decided all of them together: a pass that created a channel knows the
|
|
* state, the refs, the error (none) and the stamp, and letting it write three of
|
|
* the four would leave the fourth describing a previous pass.
|
|
*
|
|
* `remove_after` is written explicitly on every call, `NULL` included — a Team
|
|
* that climbs back above the threshold inside its window has to have the window
|
|
* cleared, and an upsert that skipped NULLs would leave it armed.
|
|
*/
|
|
async function upsert({ teamId, platform, resource, externalRef, roleRef, state, removeAfter, lastError, syncedAt }) {
|
|
await query(
|
|
`INSERT INTO team_integrations
|
|
(team_id, platform, resource, external_ref, role_ref, state, remove_after, last_error, synced_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
external_ref = VALUES(external_ref),
|
|
role_ref = VALUES(role_ref),
|
|
state = VALUES(state),
|
|
remove_after = VALUES(remove_after),
|
|
last_error = VALUES(last_error),
|
|
synced_at = VALUES(synced_at)`,
|
|
[
|
|
Number(teamId),
|
|
platform,
|
|
resource,
|
|
externalRef || null,
|
|
roleRef || null,
|
|
state,
|
|
removeAfter || null,
|
|
lastError ? String(lastError).slice(0, 500) : null,
|
|
syncedAt || null,
|
|
],
|
|
)
|
|
return getForTeam(teamId, platform, resource)
|
|
}
|
|
|
|
async function remove(teamId, platform, resource) {
|
|
const res = await query(
|
|
'DELETE FROM team_integrations WHERE team_id = ? AND platform = ? AND resource = ?',
|
|
[Number(teamId), platform, resource],
|
|
)
|
|
return Number(res && res.affectedRows) || 0
|
|
}
|
|
|
|
/** How many rows currently hold a role — the input to the 250-role ceiling. */
|
|
async function roleCount(platform) {
|
|
const rows = await query(
|
|
'SELECT COUNT(*) AS n FROM team_integrations WHERE platform = ? AND role_ref IS NOT NULL',
|
|
[platform],
|
|
)
|
|
return Number(rows[0] && rows[0].n) || 0
|
|
}
|
|
|
|
module.exports = {
|
|
DISCORD_PROVIDER,
|
|
desiredTeams,
|
|
holdersWithoutClaim,
|
|
discordSubjectsFor,
|
|
listForPlatform,
|
|
getForTeam,
|
|
upsert,
|
|
remove,
|
|
roleCount,
|
|
}
|