Event patterns

The tflo-cep crate composes typed signals into multi-event domain signals — “user added to cart but did not purchase within 5 minutes,” “page viewed then scrolled deep within 30 seconds,” “three identical clicks within 1 second.” The API is closure-based, mirrors tflo-cel / tflo-rhai / tflo-rego, and is WASM-clean by construction.

The Rust crate, the WebAssembly bindings (tflo-cep-wasm), and the TypeScript SDK (@tflo/browser-events) all drive the same engine::Runtime — one state machine, three idiomatic surfaces. Examples below run end-to-end through the WASM bridge in the browser.

The six builder methods

Patterns are linear sequences finalized by an emit closure:

Method Purpose
.timestamp(\|e\| e.ts)Event-time extractor (required for .within to work).
.when(predicate)Initial match — opens a partial match for every event the predicate accepts.
.then(predicate)Positive sequential step — advances a partial match.
.not_then(predicate)Negative terminal step — succeeds when no matching event arrives before the bound.
.within(Duration)Time bound on the previous step. Required after .not_then.
.emit(\|m\| ...)Finalize. The closure receives a Match<E> and returns the user's chosen output type.

Pattern 1 — abandoned cart

Shape: when(A) → not_then(B) within T. The pattern emits when an add_to_cart event isn't followed by a purchase within 5 minutes. End-of-stream resolves any pending negatives, so closing the tab still ships the signal.

Rust

use std::time::Duration;
use tflo_cep::prelude::*;

#[derive(Clone)]
struct Event { ts: i64, action: &'static str, cart_id: String }

let abandoned_cart = Pattern::<Event>::new("abandoned_cart")
    .timestamp(|e| e.ts)
    .when(|e| e.action == "add_to_cart")
    .not_then(|e| e.action == "purchase")
    .within(Duration::from_secs(5 * 60))
    .emit(|m| format!("abandoned cart {}", m.first().cart_id))
    .expect("pattern is well-formed");

let signals: Vec<String> = events.into_iter()
    .match_pattern(abandoned_cart)
    .collect();

TypeScript (browser, WASM)

import { Pattern, type EventRecord } from "@tflo/browser-events";

const abandonedCart = new Pattern<EventRecord>("abandoned_cart")
  .timestamp((e) => e.ts)
  .when((e) => e.kind === "add_to_cart")
  .notThen((e) => e.kind === "purchase")
  .within(5 * 60 * 1000)
  .emit((m) => ({
    name: "abandoned_cart",
    payload: { cartId: m.first().fields.cartId },
  }));

tflo.addPattern(abandonedCart);

Pattern 2 — engaged with product

Shape: when(A) → then(B) within T. Positive sequential: a product view followed by a deep scroll within 30 seconds is engagement. The deep-scroll capture's timestamp gives you dwell-time pre-computed.

Rust

// "Viewed a product page, then scrolled deep into it within 30s."
let engaged = Pattern::<Event>::new("engaged_with_product")
    .timestamp(|e| e.ts)
    .when(|e| e.kind == "product_view")
    .then(|e| e.kind == "deep_scroll")
    .within(Duration::from_secs(30))
    .emit(|m| (m.first().ts, m.last().ts))
    .expect("pattern is well-formed");

TypeScript

const engaged = new Pattern<EventRecord>("engaged_with_product")
  .timestamp((e) => e.ts)
  .when((e) => e.kind === "product_view")
  .then((e) => e.kind === "deep_scroll")
  .within(30 * 1000)
  .emit((m) => ({
    name: "engaged_with_product",
    payload: {
      viewedAt: m.first().ts,
      scrolledAt: m.last().ts,
      durationMs: m.last().ts - m.first().ts,
    },
  }));

Pattern 3 — rage click

Shape: when → then within → then within — three identical clicks within one second each. The .repeated(n..=m, ...) quantifier sugar lands in v0.2; for v0.1 you chain explicit .then().within() pairs, which is exactly what the macro would compile to.

Rust

// "Three identical clicks within 1 second" — chained then-within.
let rage = Pattern::<Event>::new("rage_click")
    .timestamp(|e| e.ts)
    .when(|e| e.kind == "click" && e.target_id == "buy_button")
    .then(|e| e.kind == "click" && e.target_id == "buy_button")
    .within(Duration::from_millis(1000))
    .then(|e| e.kind == "click" && e.target_id == "buy_button")
    .within(Duration::from_millis(1000))
    .emit(|m| format!("rage_click on {}", m.first().target_id))
    .expect("pattern is well-formed");

TypeScript

const rage = new Pattern<EventRecord>("rage_click")
  .timestamp((e) => e.ts)
  .when((e) => e.kind === "click" && e.fields.targetId === "buy_button")
  .then((e) => e.kind === "click" && e.fields.targetId === "buy_button")
  .within(1_000)
  .then((e) => e.kind === "click" && e.fields.targetId === "buy_button")
  .within(1_000)
  .emit((m) => ({
    name: "rage_click",
    payload: { targetId: m.first().fields.targetId },
  }));

The Match<E> interface

The emit closure receives a Match<E> — the captured events in capture order. Both languages expose the same accessors:

Rust

.emit(|m: &Match<Event>| {
    // first() and last() — always-present captures
    let opened   = m.first();   // the .when capture
    let closed   = m.last();    // the last positive capture

    // at(name) — look up by step name
    let checkout = m.at("checkout").unwrap();

    // named() — iterate (name, event) pairs in capture order
    for (step_name, ev) in m.named() {
        eprintln!("step {step_name}: ts={}", ev.ts);
    }

    Signal::new("checkout_complete", closed.clone())
})

TypeScript

.emit((m) => {
  // first() and last() — always-present captures
  const opened   = m.first();
  const closed   = m.last();

  // at(name) — look up by step name
  const checkout = m.at("checkout");

  // all() — every captured event in order
  for (const ev of m.all()) {
    console.debug("captured", ev.kind, "ts=" + ev.ts);
  }

  return {
    name: "checkout_complete",
    payload: { sessionId: closed.fields.sessionId },
  };
})

Bounded by construction

Partial matches are bounded by event rate within the within window and hard-capped at MAX_IN_FLIGHT = 1024 per pattern — when the cap is reached, the oldest partial match is dropped. This is the design rule that keeps tflo-cep honest about edge / WASM memory budgets.

Negative matches (not_then) resolve in two ways: when their deadline elapses without the predicate firing (success), or when the predicate fires before the deadline (failure — the partial match drops). End-of-stream is treated as a deadline that never closed, so streams that terminate before a bound still ship their negative matches.

One engine, three surfaces

The matching state machine lives in tflo-cep::engine::Runtime, generic over three callback traits:

tflo-cep ships Arc-based impls so patterns are Send + Sync; tflo-cep-wasm ships Rc-based impls backed by js_sys::Function so JS callbacks plug straight in. No duplicate state machine. Feature additions land once.

Next: See the browser analytics page for the end-to-end workflow — capture → patterns → sinks — or browse the crate source.