[FEATURE] Add GET /api/events global event feed with filters#648
[FEATURE] Add GET /api/events global event feed with filters#648Chigybillionz wants to merge 2 commits into
Conversation
|
@Chigybillionz is attempting to deploy a commit to the ritik4ever's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Warning Review limit reached
Next review available in: 51 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughAdds filter support (eventType, streamId, actor, from/to date range) to the global ChangesGlobal event feed with filters
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Endpoint as GET /api/events
participant Schema as listEventsQuerySchema
participant Service as eventHistory Service
participant DB as stream_events table
Client->>Endpoint: request with eventType/streamId/actor/from/to/page/limit
Endpoint->>Schema: validate and normalize query
Schema-->>Endpoint: filters + pagination
Endpoint->>Service: countEvents(filters)
Service->>DB: COUNT(*) WHERE filters
DB-->>Service: total
Endpoint->>Service: queryEvents(filters, limit, offset, cursor)
Service->>DB: SELECT WHERE filters ORDER BY timestamp DESC, id DESC
DB-->>Service: rows
Service-->>Endpoint: data
Endpoint-->>Client: { data, total }
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@Chigybillionz Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
backend/src/validation/schemas.ts (1)
150-168: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUses deprecated
ctx.addIssue(); Zod v4 recommendsctx.issues.push().Per the Zod v4 changelog,
ctx.addIssue()/ctx.addIssues()are deprecated in favor of pushing directly ontoctx.issues. There's also a reported behavioral quirk wherectx.addIssue()in transforms doesn't short-circuit the same wayctx.issues.push()does. Since the project is on zod 4.3.6, prefer the non-deprecated API here.♻️ Suggested change
const ms = Date.parse(value); if (Number.isNaN(ms)) { - ctx.addIssue({ - code: "custom", - message: - "must be a UNIX timestamp in seconds or an ISO-8601 date (e.g. 2026-07-01).", - }); + ctx.issues.push({ + code: "custom", + input: value, + message: + "must be a UNIX timestamp in seconds or an ISO-8601 date (e.g. 2026-07-01).", + }); return z.NEVER; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/src/validation/schemas.ts` around lines 150 - 168, The transform in eventDateBoundSchema still uses the deprecated ctx.addIssue() API. Update the invalid-date branch to report the validation error by pushing onto ctx.issues instead, keeping the same custom message and returning z.NEVER so the transform stops correctly under Zod v4.3.6.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/src/services/eventHistory.ts`:
- Around line 112-177: `buildEventFilterClause`, `queryEvents`, and
`countEvents` currently use positional `?` placeholders with a parallel `params`
array, which violates the backend binding convention and makes parameter
ordering fragile. Refactor the SQL construction in `buildEventFilterClause` to
build named bindings (for example, based on `eventType`, `streamId`, `actor`,
`from`, `to`, and `cursor`) and update `queryEvents`/`countEvents` to pass those
named parameters into the `better-sqlite3` prepared statements instead of
relying on array order.
---
Nitpick comments:
In `@backend/src/validation/schemas.ts`:
- Around line 150-168: The transform in eventDateBoundSchema still uses the
deprecated ctx.addIssue() API. Update the invalid-date branch to report the
validation error by pushing onto ctx.issues instead, keeping the same custom
message and returning z.NEVER so the transform stops correctly under Zod v4.3.6.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 42808f3b-5637-49d3-b5fa-89851bee3198
📒 Files selected for processing (5)
backend/src/index.test.tsbackend/src/index.tsbackend/src/services/db.tsbackend/src/services/eventHistory.tsbackend/src/validation/schemas.ts
| function buildEventFilterClause( | ||
| filters: EventFilters, | ||
| cursor?: number, | ||
| streamId?: string, | ||
| since?: number, | ||
| ): StreamEvent[] { | ||
| const db = getDb(); | ||
| ): { clause: string; params: any[] } { | ||
| const conditions: string[] = []; | ||
| const params: any[] = []; | ||
|
|
||
| if (eventType) { | ||
| if (filters.eventType !== undefined) { | ||
| conditions.push("event_type = ?"); | ||
| params.push(eventType); | ||
| params.push(filters.eventType); | ||
| } | ||
|
|
||
| if (filters.streamId !== undefined) { | ||
| conditions.push("stream_id = ?"); | ||
| params.push(filters.streamId); | ||
| } | ||
| if (filters.actor !== undefined) { | ||
| conditions.push("actor = ?"); | ||
| params.push(filters.actor); | ||
| } | ||
| if (filters.from !== undefined) { | ||
| conditions.push("timestamp >= ?"); | ||
| params.push(filters.from); | ||
| } | ||
| if (filters.to !== undefined) { | ||
| conditions.push("timestamp <= ?"); | ||
| params.push(filters.to); | ||
| } | ||
| // Keyset guard for stable pagination when new events arrive mid-scroll. | ||
| if (cursor !== undefined) { | ||
| conditions.push("id < ?"); | ||
| params.push(cursor); | ||
| } | ||
|
|
||
| if (streamId) { | ||
| conditions.push("stream_id = ?"); | ||
| params.push(streamId); | ||
| } | ||
|
|
||
| if (since !== undefined) { | ||
| conditions.push("timestamp > ?"); | ||
| params.push(since); | ||
| } | ||
|
|
||
| let query = "SELECT * FROM stream_events"; | ||
| if (conditions.length > 0) { | ||
| query += " WHERE " + conditions.join(" AND "); | ||
| } | ||
| query += " ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"; | ||
| params.push(limit, offset); | ||
| const clause = conditions.length ? ` WHERE ${conditions.join(" AND ")}` : ""; | ||
| return { clause, params }; | ||
| } | ||
|
|
||
| const rows = db.prepare(query).all(...params) as EventRow[]; | ||
| /** | ||
| * Return events across all streams matching `filters`, newest first | ||
| * (timestamp DESC, id DESC). Used by the global event feed. | ||
| */ | ||
| export function queryEvents( | ||
| filters: EventFilters, | ||
| limit: number, | ||
| offset: number, | ||
| cursor?: number, | ||
| ): StreamEvent[] { | ||
| const db = getDb(); | ||
| const { clause, params } = buildEventFilterClause(filters, cursor); | ||
| const rows = db | ||
| .prepare( | ||
| `SELECT * FROM stream_events${clause} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`, | ||
| ) | ||
| .all(...params, limit, offset) as EventRow[]; | ||
| return rows.map(rowToEvent); | ||
| } | ||
|
|
||
| export function countAllEvents( | ||
| eventType?: StreamEventType, | ||
| streamId?: string, | ||
| since?: number, | ||
| ): number { | ||
| /** Count events matching `filters` (used for pagination totals). */ | ||
| export function countEvents(filters: EventFilters): number { | ||
| const db = getDb(); | ||
| const conditions: string[] = []; | ||
| const params: any[] = []; | ||
|
|
||
| if (eventType) { | ||
| conditions.push("event_type = ?"); | ||
| params.push(eventType); | ||
| } | ||
|
|
||
| if (streamId) { | ||
| conditions.push("stream_id = ?"); | ||
| params.push(streamId); | ||
| } | ||
| const { clause, params } = buildEventFilterClause(filters); | ||
| const row = db | ||
| .prepare(`SELECT COUNT(*) as count FROM stream_events${clause}`) | ||
| .get(...params) as { count: number }; | ||
| return row.count; | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Uses ? positional placeholders instead of @name binding, per backend coding guidelines.
buildEventFilterClause, queryEvents, and countEvents all build SQL with ? placeholders and positional params arrays. This is functionally safe from injection (still parameterized), but it violates the stated repo convention and makes it easy to introduce a param-ordering bug later (e.g. reordering a condition without reordering params.push(...)). As per coding guidelines, "use @name parameter binding syntax for better-sqlite3 prepared statements instead of ? placeholders."
♻️ Suggested refactor to named parameters
function buildEventFilterClause(
filters: EventFilters,
cursor?: number,
-): { clause: string; params: any[] } {
+): { clause: string; params: Record<string, unknown> } {
const conditions: string[] = [];
- const params: any[] = [];
+ const params: Record<string, unknown> = {};
if (filters.eventType !== undefined) {
- conditions.push("event_type = ?");
- params.push(filters.eventType);
+ conditions.push("event_type = `@eventType`");
+ params.eventType = filters.eventType;
}
if (filters.streamId !== undefined) {
- conditions.push("stream_id = ?");
- params.push(filters.streamId);
+ conditions.push("stream_id = `@streamId`");
+ params.streamId = filters.streamId;
}
if (filters.actor !== undefined) {
- conditions.push("actor = ?");
- params.push(filters.actor);
+ conditions.push("actor = `@actor`");
+ params.actor = filters.actor;
}
if (filters.from !== undefined) {
- conditions.push("timestamp >= ?");
- params.push(filters.from);
+ conditions.push("timestamp >= `@from`");
+ params.from = filters.from;
}
if (filters.to !== undefined) {
- conditions.push("timestamp <= ?");
- params.push(filters.to);
+ conditions.push("timestamp <= `@to`");
+ params.to = filters.to;
}
if (cursor !== undefined) {
- conditions.push("id < ?");
- params.push(cursor);
+ conditions.push("id < `@cursor`");
+ params.cursor = cursor;
}
const clause = conditions.length ? ` WHERE ${conditions.join(" AND ")}` : "";
return { clause, params };
}
export function queryEvents(
filters: EventFilters,
limit: number,
offset: number,
cursor?: number,
): StreamEvent[] {
const db = getDb();
const { clause, params } = buildEventFilterClause(filters, cursor);
const rows = db
.prepare(
- `SELECT * FROM stream_events${clause} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`,
+ `SELECT * FROM stream_events${clause} ORDER BY timestamp DESC, id DESC LIMIT `@limit` OFFSET `@offset``,
)
- .all(...params, limit, offset) as EventRow[];
+ .all({ ...params, limit, offset }) as EventRow[];
return rows.map(rowToEvent);
}
export function countEvents(filters: EventFilters): number {
const db = getDb();
const { clause, params } = buildEventFilterClause(filters);
const row = db
.prepare(`SELECT COUNT(*) as count FROM stream_events${clause}`)
- .get(...params) as { count: number };
+ .get(params) as { count: number };
return row.count;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function buildEventFilterClause( | |
| filters: EventFilters, | |
| cursor?: number, | |
| streamId?: string, | |
| since?: number, | |
| ): StreamEvent[] { | |
| const db = getDb(); | |
| ): { clause: string; params: any[] } { | |
| const conditions: string[] = []; | |
| const params: any[] = []; | |
| if (eventType) { | |
| if (filters.eventType !== undefined) { | |
| conditions.push("event_type = ?"); | |
| params.push(eventType); | |
| params.push(filters.eventType); | |
| } | |
| if (filters.streamId !== undefined) { | |
| conditions.push("stream_id = ?"); | |
| params.push(filters.streamId); | |
| } | |
| if (filters.actor !== undefined) { | |
| conditions.push("actor = ?"); | |
| params.push(filters.actor); | |
| } | |
| if (filters.from !== undefined) { | |
| conditions.push("timestamp >= ?"); | |
| params.push(filters.from); | |
| } | |
| if (filters.to !== undefined) { | |
| conditions.push("timestamp <= ?"); | |
| params.push(filters.to); | |
| } | |
| // Keyset guard for stable pagination when new events arrive mid-scroll. | |
| if (cursor !== undefined) { | |
| conditions.push("id < ?"); | |
| params.push(cursor); | |
| } | |
| if (streamId) { | |
| conditions.push("stream_id = ?"); | |
| params.push(streamId); | |
| } | |
| if (since !== undefined) { | |
| conditions.push("timestamp > ?"); | |
| params.push(since); | |
| } | |
| let query = "SELECT * FROM stream_events"; | |
| if (conditions.length > 0) { | |
| query += " WHERE " + conditions.join(" AND "); | |
| } | |
| query += " ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"; | |
| params.push(limit, offset); | |
| const clause = conditions.length ? ` WHERE ${conditions.join(" AND ")}` : ""; | |
| return { clause, params }; | |
| } | |
| const rows = db.prepare(query).all(...params) as EventRow[]; | |
| /** | |
| * Return events across all streams matching `filters`, newest first | |
| * (timestamp DESC, id DESC). Used by the global event feed. | |
| */ | |
| export function queryEvents( | |
| filters: EventFilters, | |
| limit: number, | |
| offset: number, | |
| cursor?: number, | |
| ): StreamEvent[] { | |
| const db = getDb(); | |
| const { clause, params } = buildEventFilterClause(filters, cursor); | |
| const rows = db | |
| .prepare( | |
| `SELECT * FROM stream_events${clause} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`, | |
| ) | |
| .all(...params, limit, offset) as EventRow[]; | |
| return rows.map(rowToEvent); | |
| } | |
| export function countAllEvents( | |
| eventType?: StreamEventType, | |
| streamId?: string, | |
| since?: number, | |
| ): number { | |
| /** Count events matching `filters` (used for pagination totals). */ | |
| export function countEvents(filters: EventFilters): number { | |
| const db = getDb(); | |
| const conditions: string[] = []; | |
| const params: any[] = []; | |
| if (eventType) { | |
| conditions.push("event_type = ?"); | |
| params.push(eventType); | |
| } | |
| if (streamId) { | |
| conditions.push("stream_id = ?"); | |
| params.push(streamId); | |
| } | |
| const { clause, params } = buildEventFilterClause(filters); | |
| const row = db | |
| .prepare(`SELECT COUNT(*) as count FROM stream_events${clause}`) | |
| .get(...params) as { count: number }; | |
| return row.count; | |
| } | |
| function buildEventFilterClause( | |
| filters: EventFilters, | |
| cursor?: number, | |
| ): { clause: string; params: Record<string, unknown> } { | |
| const conditions: string[] = []; | |
| const params: Record<string, unknown> = {}; | |
| if (filters.eventType !== undefined) { | |
| conditions.push("event_type = `@eventType`"); | |
| params.eventType = filters.eventType; | |
| } | |
| if (filters.streamId !== undefined) { | |
| conditions.push("stream_id = `@streamId`"); | |
| params.streamId = filters.streamId; | |
| } | |
| if (filters.actor !== undefined) { | |
| conditions.push("actor = `@actor`"); | |
| params.actor = filters.actor; | |
| } | |
| if (filters.from !== undefined) { | |
| conditions.push("timestamp >= `@from`"); | |
| params.from = filters.from; | |
| } | |
| if (filters.to !== undefined) { | |
| conditions.push("timestamp <= `@to`"); | |
| params.to = filters.to; | |
| } | |
| // Keyset guard for stable pagination when new events arrive mid-scroll. | |
| if (cursor !== undefined) { | |
| conditions.push("id < `@cursor`"); | |
| params.cursor = cursor; | |
| } | |
| const clause = conditions.length ? ` WHERE ${conditions.join(" AND ")}` : ""; | |
| return { clause, params }; | |
| } | |
| /** | |
| * Return events across all streams matching `filters`, newest first | |
| * (timestamp DESC, id DESC). Used by the global event feed. | |
| */ | |
| export function queryEvents( | |
| filters: EventFilters, | |
| limit: number, | |
| offset: number, | |
| cursor?: number, | |
| ): StreamEvent[] { | |
| const db = getDb(); | |
| const { clause, params } = buildEventFilterClause(filters, cursor); | |
| const rows = db | |
| .prepare( | |
| `SELECT * FROM stream_events${clause} ORDER BY timestamp DESC, id DESC LIMIT `@limit` OFFSET `@offset``, | |
| ) | |
| .all({ ...params, limit, offset }) as EventRow[]; | |
| return rows.map(rowToEvent); | |
| } | |
| /** Count events matching `filters` (used for pagination totals). */ | |
| export function countEvents(filters: EventFilters): number { | |
| const db = getDb(); | |
| const { clause, params } = buildEventFilterClause(filters); | |
| const row = db | |
| .prepare(`SELECT COUNT(*) as count FROM stream_events${clause}`) | |
| .get(params) as { count: number }; | |
| return row.count; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/src/services/eventHistory.ts` around lines 112 - 177,
`buildEventFilterClause`, `queryEvents`, and `countEvents` currently use
positional `?` placeholders with a parallel `params` array, which violates the
backend binding convention and makes parameter ordering fragile. Refactor the
SQL construction in `buildEventFilterClause` to build named bindings (for
example, based on `eventType`, `streamId`, `actor`, `from`, `to`, and `cursor`)
and update `queryEvents`/`countEvents` to pass those named parameters into the
`better-sqlite3` prepared statements instead of relying on array order.
Source: Coding guidelines
|
you can check it out! |
Close #604
Summary
Adds full filtering to the global event feed at
GET /api/events. The endpointreturns stream events across all streams, newest first, with pagination and a
complete set of filters:
eventType,streamId,actor, and afrom/todate range.
Root Cause
The
/api/eventsendpoint already existed but only supported a singleeventTypefilter. The underlying query functions (getGlobalEvents,countAllEvents) hardcoded that one filter with duplicated, branch-per-filterSQL, leaving no clean path to add
streamId,actor, or a date range.Solution
Replaced the single-purpose query API with a composable filter-object builder:
buildEventFilterClause()produces the sharedWHEREclause andbound params used by both
queryEvents()(data) andcountEvents()(total),so the pagination total can never drift from the rows returned.
from/toaccept either UNIX seconds or ISO-8601 dates, normalized to UNIXseconds;
from > tois rejected with 400.Key Changes
services/eventHistory.ts— newEventFilterstype,buildEventFilterClause(),queryEvents(),countEvents(); legacy fns delegate.validation/schemas.ts—listEventsQuerySchemaextended withstreamId,actor,from,to; neweventDateBoundSchema;from ≤ torefinement.index.ts—/api/eventshandler builds anEventFiltersobject.services/db.ts— addedidx_stream_events_event_typeandidx_stream_events_actorindexes.index.test.ts— updated mocks and added coverage for every filter, datenormalization,
from>tovalidation, and the empty-result contract.Acceptance Criteria
timestamp DESC.eventType,streamId,actor,from,to(all combinable).pageandlimit.{data: [], total: 0}with HTTP 200 (never 404).Trade-offs / Considerations
actorandstreamIdfilter on exact equality (matches how the data isstored); no partial/prefix matching.
from/toare inclusive bounds. ISO dates without a time component resolveto midnight UTC.
getGlobalEvents/countAllEventsas delegates to avoid breakingany other consumers.
Testing Steps
cd backend && npx vitest run src/index.test.ts— all event-feed tests pass.npm run dev:backend):GET /api/events→ all events, newest first.GET /api/events?eventType=claimed&actor=G...&from=2026-01-01&to=2026-07-01→ correctly filtered subset.
GET /api/events?streamId=1&page=2&limit=10→ paginated slice.GET /api/events?from=2026-12-31&to=2026-01-01→ 400 (fromafterto).GET /api/events?eventType=pausedwith no matches →{data: [], total: 0}, 200.Please kindly checkout this task and please if there is any correction or adjustment regarding my implementaion please do let me, and i would love to here your review from my branch, thank you
Summary by CodeRabbit
New Features
Bug Fixes
Performance