Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

619 linesCodeBlameRaw
1//! The events service: the bus every state change in g1t is published on,
2//! and its durable log.
3//!
4//! Publishing puts events on a queue and returns. The queue consumer writes
5//! them to the log and passes each batch on to each subscriber's own
6//! queue, so a slow or failing subscriber holds up nobody else. Each
7//! subscriber is sent only the types it acts on
8//! (`g1t_contracts::subscribers`), in batches the queues take (fanout.rs).
9//!
10//! It keeps two more things beside the log: the audit log (audit.rs) and
11//! each person's inbox (inbox.rs), written as events arrive, with who
12//! follows what (subscriptions.rs).
13//!
14//! Other services reach it over `POST /rpc/<method>`; see
15//! `g1t_contracts::events`, `audit` and `inbox` for the methods and their
16//! arguments. `activity_digest` (digest.rs) counts a span of the log for
17//! Home. Security alerts that are not urgent wait for each person's
18//! security digest (security_digest.rs), sent by a cron of its own.
19//!
20//! Repository pages open in someone's browser refresh themselves: each
21//! batch's events that such a page shows are passed to the notify service
22//! (live.rs), which tells those watching the repository.
23
24mod audit;
25mod digest;
26mod fanout;
27mod inbox;
28mod live;
29#[cfg(test)]
30mod schema;
31mod security_digest;
32mod subscriptions;
33mod work_items;
34
35use g1t_contracts::events::{Event, ListArgs, MAX_LIST_REPOS, PublishArgs};
36use g1t_contracts::new_id;
37use g1t_contracts::time::rfc3339;
38use g1t_kit::{args, js, now_ms, reply, rpc_method};
39use serde::Deserialize;
40use std::collections::{HashMap, HashSet};
41use worker::js_sys::{Array, Object};
42use worker::wasm_bindgen::{JsCast, JsValue};
43use worker::{Context, D1Database, Env, MessageBatch, Request, Response, Result, event};
44
45const DEFAULT_PAGE: u32 = 50;
46const MAX_PAGE: u32 = 200;
47/// Statuses and check runs reported on commits: delivered to webhooks, and
48/// left out of a timeline unless asked for by type.
49const REPORTING: [&str; 7] = [
50 "status.created",
51 "check_run.created",
52 "check_run.completed",
53 "check_run.rerequested",
54 "check_run.requested_action",
55 "check_suite.completed",
56 "check_suite.rerequested",
57];
58/// Every binding whose name starts with this is a queue that receives
59/// events: one per subscribing service, sent the types it routes.
60const SUBSCRIBER_PREFIX: &str = "SUBSCRIBER_";
61/// How long a record of which queues a batch reached is kept for its retries.
62const FANOUT_KEEP_MS: u64 = 24 * 60 * 60 * 1000;
63
64/// An event as the log's table holds it.
65#[derive(Deserialize)]
66pub(crate) struct EventRow {
67 id: String,
68 #[serde(rename = "type")]
69 kind: String,
70 source: String,
71 time: String,
72 repo_id: Option<String>,
73 actor: Option<String>,
74 /// JSON.
75 data: String,
76}
77
78impl From<EventRow> for Event {
79 fn from(row: EventRow) -> Self {
80 Event {
81 id: row.id,
82 kind: row.kind,
83 source: row.source,
84 time: row.time,
85 repo_id: row.repo_id,
86 actor: row.actor,
87 data: serde_json::from_str(&row.data).unwrap_or_default(),
88 }
89 }
90}
91
92fn optional(value: &Option<String>) -> JsValue {
93 value.as_deref().map_or(JsValue::NULL, JsValue::from)
94}
95
96#[derive(Deserialize)]
97struct FanoutRow {
98 event_id: String,
99 /// JSON array of binding names.
100 bindings: String,
101}
102
103/// Sends `events` to a queue binding, one message each, in as many
104/// `sendBatch` calls as the queue's limits need (fanout.rs). Events are
105/// expected to have been fit to a message.
106async fn send(queue: &JsValue, events: &[&Event]) -> Result<()> {
107 for chunk in fanout::chunks(events) {
108 let messages = Array::new();
109 for event in chunk {
110 let message = Object::new();
111 js::set(&message, "body", &js::to_js(event)?);
112 messages.push(&message);
113 }
114 js::call(queue, "sendBatch", &[messages.into()]).await?;
115 }
116 Ok(())
117}
118
119struct Events {
120 db: D1Database,
121 env: Env,
122}
123
124impl Events {
125 /// Assigns each event its id and time and puts it on the bus.
126 async fn publish(&self, a: PublishArgs) -> Result<()> {
127 if a.events.is_empty() {
128 return Ok(());
129 }
130 let now = now_ms();
131 let events: Vec<Event> = a
132 .events
133 .into_iter()
134 .map(|event| {
135 let mut event = Event {
136 id: new_id("evt", now),
137 kind: event.kind,
138 source: event.source,
139 time: rfc3339(now),
140 repo_id: event.repo_id,
141 actor: event.actor,
142 data: event.data,
143 };
144 // Too large for one message: its long text is shortened.
145 fanout::fit(&mut event);
146 event
147 })
148 .collect();
149 let events: Vec<&Event> = events.iter().collect();
150 send(&js::binding(&self.env, "BUS")?, &events).await
151 }
152
153 /// Newest first, or with `after` oldest first in the order the log
154 /// wrote them. Callers must have checked that the viewer may see the
155 /// repository asked about.
156 async fn list(&self, a: ListArgs) -> Result<Vec<Event>> {
157 let (filter, binds) = list_filter(&a);
158 let mut values: Vec<JsValue> = binds
159 .into_iter()
160 .map(|bind| match bind {
161 Bind::Text(text) => JsValue::from(text),
162 Bind::Number(number) => JsValue::from(number),
163 })
164 .collect();
165 values.push(a.limit.unwrap_or(DEFAULT_PAGE).min(MAX_PAGE).into());
166 let order = if a.after.is_some() { "seq ASC" } else { "id DESC" };
167 let rows = self
168 .db
169 .prepare(format!(
170 "SELECT * FROM events {filter} ORDER BY {order} LIMIT ?"
171 ))
172 .bind(&values)?
173 .all()
174 .await?
175 .results::<EventRow>()?;
176 Ok(rows.into_iter().map(Event::from).collect())
177 }
178
179 /// Writes a batch from the bus to the log, notes who now follows what
180 /// (inbox.rs), hands each subscriber the events it routes, then tells
181 /// the people it concerns.
182 ///
183 /// Everything before the hand-off is safe to repeat. A retried batch
184 /// skips the queues that already have it: when a hand-off fails part
185 /// way, which queues each event reached is written down first.
186 async fn deliver(&self, events: &[Event]) -> Result<()> {
187 let mut statements = Vec::with_capacity(events.len() + 1);
188 for event in events {
189 statements.push(
190 self.db
191 .prepare(
192 // Redelivered batches must not duplicate log rows.
193 // `seq` is the order of writing, which `after` follows
194 // (0010_events_seq.sql): the log has one writer.
195 "INSERT OR IGNORE INTO events (id, type, source, time, repo_id, actor, data, seq)
196 VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM events))",
197 )
198 .bind(&[
199 event.id.as_str().into(),
200 event.kind.as_str().into(),
201 event.source.as_str().into(),
202 event.time.as_str().into(),
203 optional(&event.repo_id),
204 optional(&event.actor),
205 serde_json::to_string(&event.data)?.into(),
206 ])?,
207 );
208 }
209 let ids: Vec<&str> = events.iter().map(|event| event.id.as_str()).collect();
210 // Read in the same round trip: nearly always nothing.
211 statements.push(
212 self.db
213 .prepare("SELECT event_id, bindings FROM fanout_sent WHERE event_id IN (SELECT value FROM json_each(?))")
214 .bind(&[serde_json::to_string(&ids)?.into()])?,
215 );
216 let results = self.db.batch(statements).await?;
217 let mut sent: HashMap<String, HashSet<String>> = HashMap::new();
218 if let Some(rows) = results.last() {
219 for row in rows.results::<FanoutRow>()? {
220 let bindings: Vec<String> = serde_json::from_str(&row.bindings).unwrap_or_default();
221 sent.insert(row.event_id, bindings.into_iter().collect());
222 }
223 }
224 audit::follow_renames(&self.db, events).await?;
225 // Before the hand-off, so its failing does not send the batch to
226 // every queue again.
227 inbox::follow(&self.db, events).await?;
228
229 let bindings: &JsValue = self.env.as_ref();
230 let mut reached: Vec<String> = Vec::new();
231 let mut failed = None;
232 for name in Object::keys(bindings.unchecked_ref::<Object>()).iter() {
233 let Some(name) = name
234 .as_string()
235 .filter(|name| name.starts_with(SUBSCRIBER_PREFIX))
236 else {
237 continue;
238 };
239 let routed: Vec<&Event> = events
240 .iter()
241 .filter(|event| g1t_contracts::subscribers::routed(&name, &event.kind))
242 .filter(|event| !sent.get(&event.id).is_some_and(|reached| reached.contains(&name)))
243 .collect();
244 if routed.is_empty() {
245 continue;
246 }
247 match send(&js::binding(&self.env, &name)?, &routed).await {
248 Ok(()) => reached.push(name),
249 Err(error) => {
250 worker::console_error!("events: passing {} events to {name} failed: {error}", routed.len());
251 failed = Some(error);
252 }
253 }
254 }
255 if let Some(error) = failed {
256 self.note_reached(events, &sent, &reached).await?;
257 return Err(error);
258 }
259 let (work, repos, identity) = (
260 self.env.service("WORK")?,
261 self.env.service("REPOS")?,
262 self.env.service("IDENTITY")?,
263 );
264 // Optional: without it the inbox still fills, nobody is told live.
265 let notify = self.env.service("NOTIFY").ok();
266 // Pages open on the repositories: told first, as nobody waits longer.
267 if let Some(notify) = notify.as_ref() {
268 live::tell(notify, events).await;
269 }
270 let sources = inbox::Sources {
271 work: &work,
272 repos: &repos,
273 identity: &identity,
274 notify: notify.as_ref(),
275 };
276 inbox::deliver(&self.db, &sources, events).await;
277 Ok(())
278 }
279
280 /// Writes down which queues each event of a batch has reached, with
281 /// those it had reached before, for the batch's retry.
282 async fn note_reached(
283 &self,
284 events: &[Event],
285 sent: &HashMap<String, HashSet<String>>,
286 reached: &[String],
287 ) -> Result<()> {
288 let now = rfc3339(now_ms());
289 let mut statements = Vec::with_capacity(events.len());
290 for event in events {
291 let mut bindings: Vec<&str> = reached.iter().map(String::as_str).collect();
292 if let Some(before) = sent.get(&event.id) {
293 bindings.extend(before.iter().map(String::as_str));
294 }
295 bindings.sort_unstable();
296 bindings.dedup();
297 statements.push(
298 self.db
299 .prepare(
300 "INSERT INTO fanout_sent (event_id, bindings, created_at) VALUES (?, ?, ?)
301 ON CONFLICT (event_id) DO UPDATE SET bindings = excluded.bindings",
302 )
303 .bind(&[event.id.as_str().into(), serde_json::to_string(&bindings)?.into(), now.as_str().into()])?,
304 );
305 }
306 self.db.batch(statements).await?;
307 Ok(())
308 }
309}
310
311#[event(fetch)]
312async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
313 let Some(method) = rpc_method(&request) else {
314 return Response::error("Not found", 404);
315 };
316 let body: serde_json::Value = request.json().await?;
317 let events = Events {
318 db: env.d1("DB")?,
319 env,
320 };
321 match method.as_str() {
322 "publish" => reply(&events.publish(args(body)?).await?),
323 "list" => reply(&events.list(args(body)?).await?),
324 // Home's digest of a span: counted, by who did it (digest.rs).
325 "activity_digest" => reply(&digest::activity_digest(&events.db, args(body)?).await?),
326 "audit_record" => reply(&audit::record(&events.db, args(body)?).await?),
327 "audit_list" => reply(&audit::list(&events.db, args(body)?).await?),
328 "inbox_list" => {
329 let repos = events.env.service("REPOS")?;
330 reply(&inbox::list(&events.db, &repos, args(body)?).await?)
331 }
332 "inbox_counts" => reply(&inbox::counts(&events.db, args(body)?).await?),
333 "inbox_mark" => {
334 let marked: g1t_contracts::inbox::MarkInboxArgs = args(body)?;
335 let username = marked.username.clone();
336 let changed = inbox::mark(&events.db, marked).await?;
337 // Notify: the new count in every open tab, whoever marked (the site, the API, MCP).
338 if changed > 0
339 && let Ok(notify) = events.env.service("NOTIFY")
340 {
341 inbox::tell_inbox_count(&events.db, &notify, &username).await;
342 }
343 reply(&changed)
344 }
345 "inbox_thread" => {
346 let (repos, work) = (events.env.service("REPOS")?, events.env.service("WORK")?);
347 reply(&inbox::thread(&events.db, &repos, &work, args(body)?).await?)
348 }
349 "inbox_subscription" => {
350 let work = events.env.service("WORK")?;
351 reply(&subscriptions::subscription(&events.db, &work, args(body)?).await?)
352 }
353 "inbox_subscribe" => {
354 let work = events.env.service("WORK")?;
355 reply(&subscriptions::subscribe(&events.db, &work, args(body)?).await?)
356 }
357 "inbox_watching" => reply(&subscriptions::watching(&events.db, args(body)?).await?),
358 "inbox_watch" => reply(&subscriptions::watch(&events.db, args(body)?).await?),
359 "inbox_watchers" => reply(&subscriptions::watchers_count(&events.db, args(body)?).await?),
360 "inbox_watched" => reply(&subscriptions::watched(&events.db, args(body)?).await?),
361 "inbox_settings" => reply(&subscriptions::settings(&events.db, args(body)?).await?),
362 "inbox_update_settings" => reply(&subscriptions::update_settings(&events.db, args(body)?).await?),
363 _ => Response::error("Unknown method", 404),
364 }
365}
366
367/// Events from the bus. A batch that fails is retried whole, which is safe
368/// because the log ignores an event it has seen, and queues that already
369/// have it are skipped (`deliver`). After its retries it goes to the
370/// dead-letter queue (wrangler.jsonc).
371#[event(queue)]
372async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> {
373 let events = Events {
374 db: env.d1("DB")?,
375 env,
376 };
377 let delivered: Vec<Event> = batch
378 .messages()?
379 .into_iter()
380 .map(|message| message.into_body())
381 .collect();
382 events.deliver(&delivered).await?;
383 batch.ack_all();
384 Ok(())
385}
386
387/// Once a day, old audit entries are removed: first everything older than
388/// any workspace keeps (`AUDIT_MAX_DAYS`, 400 days), then each workspace's
389/// entries older than its own plan keeps, as billing says (7 days free, 90
390/// on the plan, or what staff set). Workspaces with nothing older than the
391/// shortest (`AUDIT_MIN_DAYS`, 7) are left alone.
392#[event(scheduled)]
393async fn scheduled(event: worker::ScheduledEvent, env: Env, _ctx: worker::ScheduleContext) {
394 // The morning cron sends the security digests that are due, and nothing else.
395 if event.cron() == security_digest::DIGEST_CRON {
396 let (Ok(db), Ok(identity)) = (env.d1("DB"), env.service("IDENTITY")) else { return };
397 let notify = env.service("NOTIFY").ok();
398 match security_digest::send_due(&db, &identity, notify.as_ref(), now_ms()).await {
399 Ok(sent) if sent > 0 => worker::console_log!("sent {sent} security digests"),
400 Ok(_) => {}
401 Err(error) => worker::console_error!("security digests not sent: {error}"),
402 }
403 return;
404 }
405 let days = |name: &str, default: u32| {
406 env.var(name)
407 .ok()
408 .and_then(|v| v.to_string().trim().parse::<u32>().ok())
409 .filter(|days| *days > 0)
410 .unwrap_or(default)
411 };
412 let max_days = days("AUDIT_MAX_DAYS", audit::DEFAULT_MAX_DAYS);
413 let min_days = days("AUDIT_MIN_DAYS", audit::DEFAULT_MIN_DAYS).min(max_days);
414 let Ok(db) = env.d1("DB") else { return };
415 let now = now_ms();
416 let cutoff = rfc3339(now.saturating_sub(FANOUT_KEEP_MS));
417 let purged = match db.prepare("DELETE FROM fanout_sent WHERE created_at < ?").bind(&[cutoff.into()]) {
418 Ok(statement) => statement.run().await.map(|_| ()),
419 Err(error) => Err(error),
420 };
421 if let Err(error) = purged {
422 worker::console_error!("could not remove old fan-out records: {error}");
423 }
424 match inbox::purge(&db, now).await {
425 Ok(removed) if removed > 0 => worker::console_log!("removed {removed} old inbox items"),
426 Ok(_) => {}
427 Err(error) => worker::console_error!("could not remove old inbox items: {error}"),
428 }
429 match audit::purge(&db, &audit::keep_from(now, max_days), 20).await {
430 Ok(removed) if removed > 0 => {
431 worker::console_log!("removed {removed} audit entries older than {max_days} days")
432 }
433 Ok(_) => {}
434 Err(error) => worker::console_error!("could not remove old audit entries: {error}"),
435 }
436 // Without billing nobody's plan is known, so nothing younger than the
437 // ceiling is removed.
438 let billing = match env.service("BILLING") {
439 Ok(billing) => billing,
440 Err(error) => {
441 worker::console_error!(
442 "audit entries kept past their plan's days: no billing: {error}"
443 );
444 return;
445 }
446 };
447 match audit::purge_by_plan(&db, &billing, now, min_days, max_days).await {
448 Ok(removed) if removed > 0 => {
449 worker::console_log!("removed {removed} audit entries older than their plan keeps")
450 }
451 Ok(_) => {}
452 Err(error) => worker::console_error!("audit entries kept past their plan's days: {error}"),
453 }
454}
455
456/// The condition of an `after` cursor, binding its id twice.
457const AFTER: &str = concat!(
458 "seq > COALESCE(",
459 "(SELECT seq FROM events WHERE id = ?), ",
460 "(SELECT seq FROM events WHERE id > ? AND seq IS NOT NULL ORDER BY id LIMIT 1) - 1, ",
461 "(SELECT MAX(seq) FROM events), 0)"
462);
463
464/// A value bound to a `?` in [`list_filter`]'s clause.
465#[derive(Debug, PartialEq)]
466enum Bind {
467 Text(String),
468 Number(f64),
469}
470
471/// The `WHERE` clause `list` reads with, and what it binds, in order.
472fn list_filter(a: &ListArgs) -> (String, Vec<Bind>) {
473 let mut conditions = Vec::new();
474 let mut values = Vec::new();
475 if let Some(repo_id) = &a.repo_id {
476 conditions.push("repo_id = ?".to_owned());
477 values.push(Bind::Text(repo_id.clone()));
478 }
479 if !a.repo_ids.is_empty() {
480 // A workspace's activity across its repositories: one list, bound
481 // as JSON so the statement stays small however many there are.
482 let ids: Vec<&String> = a.repo_ids.iter().take(MAX_LIST_REPOS).collect();
483 conditions.push("repo_id IN (SELECT value FROM json_each(?))".to_owned());
484 values.push(Bind::Text(serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_owned())));
485 }
486 if !a.types.is_empty() {
487 let marks = vec!["?"; a.types.len()].join(", ");
488 conditions.push(format!("type IN ({marks})"));
489 values.extend(a.types.iter().map(|kind| Bind::Text(kind.clone())));
490 } else {
491 // What CI and integrations report on commits goes to webhooks,
492 // and is read from each commit's checks; a timeline asked for
493 // everything would be little else on a busy repository.
494 let marks = vec!["?"; REPORTING.len()].join(", ");
495 conditions.push(format!("type NOT IN ({marks})"));
496 values.extend(REPORTING.iter().map(|kind| Bind::Text((*kind).to_owned())));
497 }
498 if let Some(actor) = &a.actor {
499 conditions.push("actor = ?".to_owned());
500 values.push(Bind::Text(actor.clone()));
501 }
502 if !a.numbers.is_empty() {
503 // A work item's or pull request's own events name it as `number`; a
504 // comment, review or link on it names it as `item`.
505 let marks = vec!["?"; a.numbers.len()].join(", ");
506 conditions.push(format!(
507 "(json_extract(data, '$.number') IN ({marks}) OR json_extract(data, '$.item') IN ({marks}))"
508 ));
509 for _ in 0..2 {
510 values.extend(a.numbers.iter().map(|number| Bind::Number(f64::from(*number))));
511 }
512 }
513 if let Some(since) = &a.since {
514 conditions.push("time >= ?".to_owned());
515 values.push(Bind::Text(since.clone()));
516 }
517 if let Some(before) = &a.before {
518 conditions.push("id < ?".to_owned());
519 values.push(Bind::Text(before.clone()));
520 }
521 if let Some(after) = &a.after {
522 // What the log wrote after the cursor's event. A cursor the log
523 // does not have (yet: an id from a webhook can arrive before its
524 // row is written) starts at the first event with a later id, and
525 // one past them all at the end of the log.
526 conditions.push(AFTER.to_owned());
527 values.push(Bind::Text(after.clone()));
528 values.push(Bind::Text(after.clone()));
529 }
530 let filter = if conditions.is_empty() {
531 String::new()
532 } else {
533 format!("WHERE {}", conditions.join(" AND "))
534 };
535 (filter, values)
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn a_plain_list_leaves_out_what_is_reported_on_commits() {
544 let (filter, binds) = list_filter(&ListArgs { repo_id: Some("rep_1".into()), ..ListArgs::default() });
545 assert!(filter.starts_with("WHERE repo_id = ? AND type NOT IN ("));
546 assert_eq!(binds[0], Bind::Text("rep_1".into()));
547 assert_eq!(binds.len(), 1 + REPORTING.len());
548 }
549
550 #[test]
551 fn a_workspaces_repositories_are_read_as_one_list() {
552 let many: Vec<String> = (0..150).map(|n| format!("rep_{n}")).collect();
553 let (filter, binds) = list_filter(&ListArgs {
554 repo_ids: many,
555 types: vec!["pull.merged".into(), "release.published".into()],
556 since: Some("2026-10-12T00:00:00Z".into()),
557 ..ListArgs::default()
558 });
559 assert_eq!(filter, "WHERE repo_id IN (SELECT value FROM json_each(?)) AND type IN (?, ?) AND time >= ?");
560 let Bind::Text(ids) = &binds[0] else { panic!("the ids are one JSON text") };
561 let ids: Vec<String> = serde_json::from_str(ids).unwrap();
562 assert_eq!(ids.len(), MAX_LIST_REPOS);
563 assert_eq!(ids[0], "rep_0");
564 }
565
566 #[test]
567 fn numbers_match_an_item_or_what_is_said_on_it_since_a_time() {
568 let (filter, binds) = list_filter(&ListArgs {
569 repo_id: Some("rep_1".into()),
570 types: vec!["work_item.opened".into()],
571 numbers: vec![4, 9],
572 since: Some("2026-10-01T00:00:00Z".into()),
573 actor: Some("usr_g1t_agent".into()),
574 ..ListArgs::default()
575 });
576 assert_eq!(
577 filter,
578 "WHERE repo_id = ? AND type IN (?) AND actor = ? AND \
579 (json_extract(data, '$.number') IN (?, ?) OR json_extract(data, '$.item') IN (?, ?)) AND time >= ?"
580 );
581 assert_eq!(
582 binds,
583 vec![
584 Bind::Text("rep_1".into()),
585 Bind::Text("work_item.opened".into()),
586 Bind::Text("usr_g1t_agent".into()),
587 Bind::Number(4.0),
588 Bind::Number(9.0),
589 Bind::Number(4.0),
590 Bind::Number(9.0),
591 Bind::Text("2026-10-01T00:00:00Z".into()),
592 ]
593 );
594 }
595
596 #[test]
597 fn an_after_cursor_reads_what_the_log_wrote_later_with_every_other_filter() {
598 let (filter, binds) = list_filter(&ListArgs {
599 repo_id: Some("rep_1".into()),
600 types: vec!["work.claimed".into(), "integration.failed".into()],
601 numbers: vec![7],
602 since: Some("2026-10-12T00:00:00Z".into()),
603 after: Some("evt_9".into()),
604 ..ListArgs::default()
605 });
606 assert_eq!(
607 filter,
608 format!(
609 "WHERE repo_id = ? AND type IN (?, ?) AND \
610 (json_extract(data, '$.number') IN (?) OR json_extract(data, '$.item') IN (?)) AND time >= ? AND {AFTER}"
611 )
612 );
613 // The cursor's id is bound once to find its place, and once for a
614 // cursor the log does not have.
615 assert_eq!(&binds[binds.len() - 2..], &[Bind::Text("evt_9".into()), Bind::Text("evt_9".into())]);
616 assert_eq!(binds.len(), 8);
617 assert_eq!(AFTER.matches('?').count(), 2);
618 }
619}