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:
@@ -1051,6 +1051,46 @@ CREATE TABLE IF NOT EXISTS team_moderation_requests (
|
||||
INDEX idx_tmr_queue (status, requested_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table:
|
||||
-- core writes its own membership and rename items with source='core', and a module
|
||||
-- pushes game items through ctx.teams.activity.push with source=<moduleId>. That
|
||||
-- core writes 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.
|
||||
--
|
||||
-- `summary` is ALREADY-RENDERED text and core never composes one (§4.1). Core
|
||||
-- cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know,
|
||||
-- and a core that templated it would have re-acquired exactly the game semantics
|
||||
-- the module system exists to remove. `kind` and `payload` are likewise opaque:
|
||||
-- core stores and filters them, and only the module's `team.overview` slot renders
|
||||
-- anything richer than the text.
|
||||
CREATE TABLE IF NOT EXISTS team_activity (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
source VARCHAR(32) NOT NULL, -- 'core' or a module id
|
||||
kind VARCHAR(64) NOT NULL, -- namespaced <source>.<name>, opaque to core
|
||||
summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one
|
||||
-- Defaults to 'members' — fail closed. The module CHOOSES visibility per item;
|
||||
-- core ENFORCES it on the read path. Same shape as a module owning the
|
||||
-- public-safety filter for its push streams (MODULE_API.md §2.4).
|
||||
visibility ENUM('public','members') NOT NULL DEFAULT 'members',
|
||||
actor_member_key VARCHAR(191) NULL,
|
||||
actor_user_id INT NULL,
|
||||
payload JSON NULL, -- opaque; rendered only by the module's slot
|
||||
occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived
|
||||
-- Optional idempotence key. INSERT IGNORE against this unique index is the same
|
||||
-- trick shard_events already uses, and it is what makes a sidecar reconnect
|
||||
-- backfill safe: replaying a window of events re-posts nothing.
|
||||
dedupe_key CHAR(40) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
-- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the
|
||||
-- Team's history of what happened, only the attribution.
|
||||
CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key),
|
||||
INDEX idx_team_activity_feed (team_id, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
Reference in New Issue
Block a user