feat(teams): the activity feed, its two writers and its retention
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>
This commit is contained in:
133
server/src/model/teams/teamActivity.db.js
Normal file
133
server/src/model/teams/teamActivity.db.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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,
|
||||
}
|
||||
Reference in New Issue
Block a user