TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.
Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.
Three rules shape the model:
- core never composes a summary. It arrives already rendered and is stored
verbatim; core cannot phrase "gained 15,000 gold" for a game whose
vocabulary it does not know.
- visibility fails closed. An item with no stated visibility is `members`.
- a push never throws at its call site. It is called from inside a game-event
handler, and a storage problem of core's must not become the module's
control flow.
Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.
Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.
The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.
Co-Authored-By: Claude <noreply@anthropic.com>
134 lines
4.5 KiB
JavaScript
134 lines
4.5 KiB
JavaScript
// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every
|
|
// decision about what a caller may SEE lives in teamActivity.model.js.
|
|
|
|
const { query } = require('../../utils/db')
|
|
|
|
const ACTIVITY_COLUMNS = `
|
|
id, team_id, source, kind, summary, visibility,
|
|
actor_member_key, actor_user_id, payload, occurred_at, created_at`
|
|
|
|
/**
|
|
* Insert one item, idempotently when it carries a dedupe key.
|
|
*
|
|
* INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a
|
|
* sidecar reconnect backfills a window of events it already delivered, and
|
|
* without this every reconnect would double-post the feed. The same trick
|
|
* `shard_events` uses, for the same reason.
|
|
*
|
|
* The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in
|
|
* a unique index, so items WITHOUT a key never collide with each other — an
|
|
* un-keyed push is always an insert, which is the documented contract (§4.1:
|
|
* `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out).
|
|
*
|
|
* IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The
|
|
* model validates and truncates before calling, so what reaches here can only fail
|
|
* on the dedupe key, and `affectedRows` reports which happened.
|
|
*/
|
|
async function insert(item) {
|
|
const res = await query(
|
|
`INSERT IGNORE INTO team_activity
|
|
(team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
item.teamId,
|
|
item.source,
|
|
item.kind,
|
|
item.summary,
|
|
item.visibility,
|
|
item.actorMemberKey,
|
|
item.actorUserId,
|
|
item.payload === null ? null : JSON.stringify(item.payload),
|
|
new Date(item.occurredAt),
|
|
item.dedupeKey,
|
|
],
|
|
)
|
|
return Number(res.affectedRows) > 0
|
|
}
|
|
|
|
/**
|
|
* One page of a Team's feed, already narrowed to the visibilities the caller may
|
|
* see.
|
|
*
|
|
* `visibilities` is always supplied by the model and never by a request
|
|
* parameter — a caller naming its own visibility filter is the whole bug this
|
|
* table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the
|
|
* game's clock, not `created_at`: a backfill that arrives late still sorts where
|
|
* it happened.
|
|
*/
|
|
async function page(teamId, visibilities, { limit, offset }) {
|
|
const slots = visibilities.map(() => '?').join(', ')
|
|
return query(
|
|
`SELECT ${ACTIVITY_COLUMNS} FROM team_activity
|
|
WHERE team_id = ? AND visibility IN (${slots})
|
|
ORDER BY occurred_at DESC, id DESC
|
|
LIMIT ? OFFSET ?`,
|
|
[teamId, ...visibilities, limit, offset],
|
|
)
|
|
}
|
|
|
|
/** Total matching rows, for the same filter — so a client can page honestly. */
|
|
async function count(teamId, visibilities) {
|
|
const slots = visibilities.map(() => '?').join(', ')
|
|
const rows = await query(
|
|
`SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`,
|
|
[teamId, ...visibilities],
|
|
)
|
|
return Number(rows[0] ? rows[0].n : 0)
|
|
}
|
|
|
|
/** Everything older than the retention horizon, across every Team. */
|
|
async function deleteOlderThan(days) {
|
|
const res = await query(
|
|
'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)',
|
|
[days],
|
|
)
|
|
return Number(res.affectedRows) || 0
|
|
}
|
|
|
|
/**
|
|
* Which Teams currently exceed the per-Team row cap, and by how much.
|
|
*
|
|
* Asked first so the trim only runs for Teams that need it. A feed fed by a game
|
|
* loop is the obvious unbounded-growth failure (§4.2), and on a shard with one
|
|
* busy guild and fifty quiet ones this keeps the nightly job proportional to the
|
|
* problem rather than to the number of Teams.
|
|
*/
|
|
async function overCap(cap) {
|
|
return query(
|
|
`SELECT team_id, COUNT(*) AS n FROM team_activity
|
|
GROUP BY team_id HAVING n > ?`,
|
|
[cap],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Trim one Team back to the newest `cap` rows.
|
|
*
|
|
* Expressed as "delete everything at or below the id of the cap-th newest row"
|
|
* rather than as a correlated subquery on the same table, which MariaDB refuses
|
|
* inside a DELETE (error 1093). The derived table is what makes it legal — the
|
|
* subquery is materialised before the delete runs.
|
|
*/
|
|
async function trimToCap(teamId, cap) {
|
|
const rows = await query(
|
|
`SELECT id FROM team_activity
|
|
WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`,
|
|
[teamId, cap],
|
|
)
|
|
if (!rows[0]) return 0
|
|
const res = await query(
|
|
'DELETE FROM team_activity WHERE team_id = ? AND id <= ?',
|
|
[teamId, rows[0].id],
|
|
)
|
|
return Number(res.affectedRows) || 0
|
|
}
|
|
|
|
module.exports = {
|
|
insert,
|
|
page,
|
|
count,
|
|
deleteOlderThan,
|
|
overCap,
|
|
trimToCap,
|
|
}
|