Skip to content

feat: tracked activity feed (resumable subscriptions demo)#4

Merged
iorlas merged 21 commits intomainfrom
feat/tracked-activity-feed
Apr 22, 2026
Merged

feat: tracked activity feed (resumable subscriptions demo)#4
iorlas merged 21 commits intomainfrom
feat/tracked-activity-feed

Conversation

@iorlas
Copy link
Copy Markdown
Member

@iorlas iorlas commented Apr 22, 2026

Summary

  • Adds a new activity-feed domain: Prisma ActivityEvent model, tRPC query + tracked() subscription, web hook, sidebar panel on the todo-list detail page.
  • Demonstrates resumable subscriptions using the domain table as the replay buffer (no Redis Stream) — reconnecting clients replay missed events from the DB, not via full refetch.
  • Wires activity emission into 6 existing todo-list mutations (create/complete/uncomplete/delete todo + accept-invite + remove-collaborator).
  • New convention: when to use tracked() (durable ordered domain events) vs not (invalidate-style notifications, ephemeral state).
  • New convention: E2E locator hierarchy — role + landmark-scoped locators over bare page.locator("li"). Full migration of existing step defs queued in TODO.md.

Test plan

  • Unit: 122/122 (make test-unit)
  • BDD: 49/49 across two consecutive runs (make test) — 46 pre-existing + 3 new @activity-feed scenarios
  • Lint: green (make lint, turbo-cached)
  • Manual: live event streaming verified via BDD (Alice + Bob in two browser contexts)
  • Manual: resume-on-reconnect verified via BDD (@resume scenario — sever ws via CDP, Bob emits 3 events, reconnect, Alice sees all 3 in order with no todo.list refetch)

Notable design decisions

  • Storage as buffer: the activity_event table IS the replay buffer. On reconnect, streamActivityEvents reads WHERE id > lastEventId (cap 500 events / 24h). Over cap → resync sentinel → client falls back to full fetch.
  • Transport unchanged: live fan-out still uses Redis pub/sub via the existing RedisChannel. Transport and replay are orthogonal.
  • Authz cascade: revoked members' subscriptions self-close on member-removed events matching their own id.
  • Plan: docs/superpowers/plans/2026-04-22-tracked-activity-feed.md.

iorlas and others added 21 commits April 22, 2026 23:48
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cursor-paginated query ordered by id DESC with over-fetch-by-one to
detect next page. Scopes strictly by todoListId and caps limit at
ACTIVITY_LIST_PAGE_SIZE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Core tracked-subscription primitive for resumable activity feeds.
Subscribes to the channel before DB work (so live events during
gap-fill are buffered, not lost), replays rows with id > lastEventId
up to ACTIVITY_REPLAY_GAP_MAX (yielding a resync sentinel when over
cap or older than ACTIVITY_REPLAY_MAX_AGE_MS), then drains the live
buffer with per-id dedup against the replayed tail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every user-visible todo-list mutation now inserts an ActivityEvent row
inside the same $transaction and publishes to the activity channel
afterward. Covers createTodo, completeTodo (both branches), deleteTodo,
acceptInvite (member-added), and removeCollaborator (member-removed).

Helpers live in a new activity-publishers.ts sibling (mirroring the
user-inbox-publishers pattern). Existing realtime events are unchanged
— activity events are additive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expose the activity-feed service over tRPC:
- `activity.list` paginated query gated by canReadList.
- `activity.onListEvents` subscription yielding tracked(id, envelope)
  so tRPC threads Last-Event-Id back to the service on reconnect.
- Register under alpha-sorted key `activity` in appRouter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the activity-feed frontend primitives for T9:
- `formatActivityEvent(event, actorName)` — pure, exhaustive-switch
  formatter for all ActivityEventPayload kinds.
- `useActivityFeed(trpc, todoListId)` — combines the paginated
  `trpc.activity.list` query with the `trpc.activity.onListEvents`
  subscription. Tracked `event` envelopes append to a live buffer
  merged in front of the initial page (dedup by id); `resync`
  envelopes clear the buffer and invalidate the list query for a
  full refetch. Reconnect resumption relies on tRPC threading
  `lastEventId` from tracked envelopes; the hook passes only
  `{ todoListId }` as input.

Adds `@project/api/domains/activity-feed/events` subpath export.
Knip-ignores the hook file until T10 wires it into the panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends ActivityEventRecord with an `actor: { id, name }` field so clients
can render human-readable lines like "Alice added buy milk" without a
separate user lookup. listActivityEvents and recordActivityEvent now
include the actor relation via Prisma `include`; gap-fill in
streamActivityEvents does the same so replayed events have the actor too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the scrollable sidebar panel for the activity feed. Pure display —
the hook `useActivityFeed` owns the query + live-subscription wiring;
the panel formats each event via `formatActivityEvent` and exposes the
`data-testid="activity-feed"` selector the E2E suite asserts against.

Storybook: four stories (`Loading`, `Empty`, `Single`, `Many`) seed the
paginated list query via the global `parameters.trpc.queries` decorator.
Subscription ops route through a no-op link so `useSubscription` doesn't
throw without a WebSocket in the preview environment.

Also adds the previously-deferred sibling test for `useActivityFeed` —
covers the initial-page render, prepend-on-event, dedup, and resync-
invalidation paths — and removes the T9-era knip ignore now that the
hook is wired into the panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prefetch activity events in the route loader (with the same FORBIDDEN/NOT_FOUND swallow pattern as the existing todoList/todo fetches) and render the panel alongside the main todo-list content. Stacks vertically on mobile, side-by-side on md+.
Covers all three scenarios in e2e/features/activity-feed/activity-feed.feature:
live events, reconnect replay with tracked(), and revoked-member isolation.

Step defs register Alice/Bob in separate Playwright browser contexts, short-
circuit invite/accept via tRPC HTTP (same pattern as collaborators.ts), and
tap each page's request log for the "no refetch during reconnect" assertion.
WebSocket severance is driven by context.setOffline() — it tears down the
active WS and blocks outgoing fetches, matching the real network-drop path
the tracked() subscription was built for.

Note: these scenarios currently fail because mounting ActivityFeedPanel on
$listId (T11) triggers a client-side "Cannot read properties of undefined
(reading 'id')" crash that breaks every existing scenario on that route too
(12 pre-existing + 3 new, 15 total). Root cause is in app code (T9/T10/T11
integration), not in these step defs — see DONE_WITH_CONCERNS handover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Collapses the multi-paragraph file-header comment into a 6-line summary
  so the file clears the 500-line-per-.ts cap enforced by make lint / fix.
- Annotates each bare page.getBy*(...) call with a
  // placement-agnostic: <reason> comment so the scoped-landmarks check
  (which the pre-commit hook enforces, unlike make fix) passes without
  adding this new file to the grandfather allowlist.

No behavior change — the locators and scenarios are identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The activity subscription yields `tracked(id, envelope)` on the server,
which tRPC's ws/sse link delivers to `onData` as `{id, data: envelope}`
(so the client can thread `Last-Event-Id` on reconnect). The hook was
treating that wrapper as the envelope itself: `envelope.kind` was
undefined, the resync branch was skipped, and `envelope.event` —
undefined — was pushed into `liveEvents`. The next `useMemo` iteration
read `.id` off that undefined and crashed the detail page, bubbling to
the root 500 and breaking every pre-existing `$listId` scenario as
well as the three new @activity-feed ones.

Detect the wrapped shape by looking for a nested `data.kind` that
matches one of our discriminants and unwrap it; otherwise treat `data`
as the envelope. Resync sentinels (yielded without `tracked`) arrive
unwrapped and still match.
The activity-feed panel renders <li> entries containing the same todo
titles verbatim ("alice added buy milk"), which collided with bare
`page.locator("li", { hasText: ... })` lookups and broke ~12 scenarios
across todo-list and collaborator-realtime steps. Scope every todo-row
lookup to `[data-testid="todo-row"]` (set by SortableTodoItem and
CompletedTodoItem). The "I should not see" negative assertion scopes
to `<main>` so a legitimate historical activity entry ("alice deleted
Old task") doesn't spuriously satisfy the assertion.

Add a `todoRow` helper in collaborators.ts to keep the file under the
500-line cap while consolidating the scope pattern.
The @resume BDD scenario timed out on `page.waitForEvent("websocket")`
because Playwright's `BrowserContext.setOffline` only intercepts fetches
— it leaves open sockets alone. Without a close frame the tRPC wsLink
stays "connected," never runs its reconnect path, and no new WebSocket
is constructed for Playwright to observe. Fix: drop the active socket
via CDP's `Network.emulateNetworkConditions({offline: true})` so the
wsLink sees a real close and enters retry-backoff; bringing the network
back lets the next retry succeed and events replay.

While verifying, the "Revoked member stops receiving activity" scenario
started failing in-suite: the activity-feed subscription kept running
after the viewer was removed, so Bob still saw subsequent entries. The
todo-list subscription has an authz-cascade close (see
todo-list/events.ts `subscribeToListEvents`); mirror that in
`streamActivityEvents` by closing when a `member-removed` event names
the viewer.

Root cause clarification: in tRPC v11.16 `ProcedureResolverOptions`
does NOT expose `lastEventId` — the SSE/WS runtime merges it into the
parsed `input` object (verified in
`packages/ws-*.cjs:145` and `resolveResponse-*.cjs:122`). The existing
`lastEventId?: string` on the Zod input schema is the correct pattern
for this version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ocator hierarchy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@iorlas iorlas merged commit 9f26116 into main Apr 22, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant