Alerts: rules over live data

A rule the user writes, or you ship, that watches the rows and does something when one crosses a line: a toast, a highlight, a badge, a vetoed edit, a line in a log. This page builds the pieces up over one grid of prices that moves when you tell it to: one rule, the three ways a rule fires, what it does when it does, a guardrail on an edit, the bell and its log, saving the rules, a feed that pushes its own changes, and a rule on a clock. Alerts is the reference behind it; Scheduling the clock.

The engine ships in @svgrid/enterprise; enableAlerts() once registers it. The examples share a watch list and a tick that moves the prices the way a feed would, replacing changed rows immutably so the overlay can tell which ones moved.

<script lang="ts">
  import { SvGrid, type GridColumns } from '@svgrid/grid'
  import type { ConditionalFormat } from '@svgrid/grid/format'
  import { SvGridAlerts, enableAlerts, createAlertEngine, type AlertRule, type ExprColumn } from '@svgrid/enterprise'

  enableAlerts()

  type Quote = { symbol: string; name: string; sector: string; price: number; changePct: number; volume: number }
  const seed: Quote[] = [
    { symbol: 'ATO', name: 'Atomic Foods', sector: 'Consumer', price: 182, changePct: 0, volume: 12400 },
    { symbol: 'NVX', name: 'Novax Labs', sector: 'Health', price: 645, changePct: 0, volume: 8800 },
    { symbol: 'ORB', name: 'Orbital Systems', sector: 'Industrial', price: 712, changePct: 0, volume: 15200 },
    { symbol: 'QLM', name: 'Quantum Loom', sector: 'Tech', price: 388, changePct: 0, volume: 22100 },
    { symbol: 'VRD', name: 'Verdant Energy', sector: 'Energy', price: 96, changePct: 0, volume: 30500 },
    { symbol: 'BYT', name: 'Byte Dynamics', sector: 'Tech', price: 934, changePct: 0, volume: 41200 },
    { symbol: 'CRS', name: 'Crestline Bank', sector: 'Finance', price: 118, changePct: 0, volume: 18700 },
    { symbol: 'PLR', name: 'Polaris Freight', sector: 'Logistics', price: 61, changePct: 0, volume: 27800 },
  ]

  // One step of a feed: a few symbols move, by up to 6% either way.
  let step = 1
  function moved(rows: Quote[]): Quote[] {
    step += 1
    return rows.map((q, i) => {
      if ((i + step) % 3 !== 0) return q
      const pct = ((((i + 1) * step * 7919) % 120) - 60) / 10
      const price = Math.round(q.price * (1 + pct / 100) * 100) / 100
      return { ...q, price, changePct: Math.round(((price - seed[i]!.price) / seed[i]!.price) * 10000) / 100 }
    })
  }

  // The columns the rule editor offers, with the type each one compares as.
  const exprColumns: ExprColumn[] = [
    { id: 'symbol', name: 'Symbol', type: 'text' },
    { id: 'sector', name: 'Sector', type: 'text' },
    { id: 'price', name: 'Price', type: 'number' },
    { id: 'changePct', name: 'Change %', type: 'number' },
    { id: 'volume', name: 'Volume', type: 'number' },
  ]
  const columns: GridColumns<Quote> = [
    { field: 'symbol', header: 'Symbol', width: 90 },
    { field: 'name', header: 'Name', width: 160 },
    { field: 'sector', header: 'Sector', width: 110 },
    { field: 'price', header: 'Price', width: 100, align: 'right', format: { type: 'number', options: { minimumFractionDigits: 2, maximumFractionDigits: 2 } } },
    { field: 'changePct', header: 'Change %', width: 100, align: 'right', format: { type: 'number', options: { minimumFractionDigits: 2, maximumFractionDigits: 2 } } },
    { field: 'volume', header: 'Volume', width: 100, align: 'right', format: { type: 'number' } },
  ]
</script>

One rule

<SvGridAlerts> sits beside the grid and watches the same data. It runs the rules on every change and hands back formats, the conditional formats the matching rows should wear, which you spread into the grid's conditionalFormats. A rule is plain data: a predicate, a trigger, a scope, and the actions.

<script lang="ts">
  let rows = $state.raw<Quote[]>(seed)
  let formats = $state<ConditionalFormat<Quote>[]>([])
  const rules: AlertRule[] = [
    {
      id: 'over-700', name: 'Price over 700', enabled: true, severity: 'warning', scope: 'row',
      predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 700 },
      trigger: { type: 'dataChange' },
      actions: [
        { kind: 'toast', message: '{symbol} crossed 700: {value}' },
        { kind: 'highlight', style: { background: '#fef3c7', color: '#92400e' } },
      ],
      createdAt: 0,
    },
  ]
</script>

<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
  <button type="button" onclick={() => (rows = moved(rows))}>Tick the feed</button>
  <SvGridAlerts data={rows} columns={exprColumns} getRowId={(r) => r.symbol} {rules} bind:formats />
</div>
<SvGrid data={rows} {columns} getRowId={(r) => r.symbol} conditionalFormats={formats} containerHeight={320} />

Two rows are amber from the start: ORB and BYT are over 700 in the seed, and a rule seeds silently, matching rows highlighted and no toast, so a page does not open with a burst. Tick the feed until a third price passes 700 (NVX, on the fourth tick): a toast names the symbol, the row turns amber, and the bell counts one. Tick again and the row stays amber while it stays over 700 but the toast does not repeat: dataChange fires on the true edge, when a row newly matches, and re-arms once it stops matching. The two buttons the overlay renders are the bell (the log) and Manage alerts (the rule manager, where the user writes rules of their own with the same shape). The log is one per page, shared by every overlay on it, so on this page the bell also counts the firings of the examples and demos below.

The overlay diffs snapshots rather than listening to an edit event, so a feed, a transaction and a typed edit all count. Evaluation runs on the next animation frame after the grid painted, never inside its render.

Three ways a rule fires

dataChange is the edge. relativeChange fires when a value moves: a delta of at least so much, a percentChange, or crossed a threshold in a direction; it reads the previous snapshot of the row. A rule with scope: 'aggregate' watches a number over the whole dataset, written as a scalarCmp between an aggregate and a literal. validation is the fourth, on an edit, and gets its own section below.

<script lang="ts">
  let rows = $state.raw<Quote[]>(seed)
  let formats = $state<ConditionalFormat<Quote>[]>([])
  const rules: AlertRule[] = [
    {
      id: 'big-move', name: 'Moved more than 4%', enabled: true, severity: 'info', scope: 'cell', columns: ['changePct'],
      predicate: { kind: 'const', value: true },
      trigger: { type: 'relativeChange', expr: { kind: 'percentChange', column: 'price', op: '>=', value: 4, abs: true } },
      actions: [{ kind: 'cellFlash' }],
      createdAt: 0,
    },
    {
      id: 'under-100', name: 'Fell under 100', enabled: true, severity: 'error', scope: 'row',
      predicate: { kind: 'cmp', column: 'price', op: 'lessThan', value: 100 },
      trigger: { type: 'relativeChange', expr: { kind: 'crossed', column: 'price', threshold: 100, direction: 'below' } },
      actions: [{ kind: 'toast', message: '{symbol} fell under 100 ({value})' }, { kind: 'highlight', style: { background: '#fee2e2', color: '#991b1b' } }],
      createdAt: 0,
    },
    {
      id: 'board-average', name: 'Average price over 400', enabled: true, severity: 'success', scope: 'aggregate',
      predicate: { kind: 'scalarCmp', left: { kind: 'agg', fn: 'avg', column: 'price' }, op: '>', right: { kind: 'lit', value: 400 } },
      trigger: { type: 'dataChange' },
      actions: [{ kind: 'toast', message: 'The board averages over 400' }],
      createdAt: 0,
    },
  ]
</script>

<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
  <button type="button" onclick={() => (rows = moved(rows))}>Tick the feed</button>
  <SvGridAlerts data={rows} columns={exprColumns} getRowId={(r) => r.symbol} {rules} bind:formats />
</div>
<SvGrid data={rows} {columns} getRowId={(r) => r.symbol} conditionalFormats={formats} containerHeight={320} />

A relativeChange rule fires on every move that qualifies, which is the point of it and the reason to keep its actions quiet: a flash here, not a toast. A highlight or a badge is painted from the predicate, not from the firing, so a rule that carries one needs a predicate that says which rows to paint: under-100 is highlighted while the price is under 100 and toasts on the crossing. The aggregate rule has no row; its event has no rowId and its toast names nothing.

A predicate is the same expression the advanced filter uses: cmp with a column, an operator and a value; and, or and not over parts; scalarCmp for arithmetic across columns ([amount] / [budget] >= 0.9). The rule manager writes them from a builder, so a user never sees the JSON.

What a rule does

Every firing is recorded in the log whatever its actions, so the bell is always right. The actions are what else happens:

kind Effect
toast A toast in the rule's severity colour, with the message.
highlight Tints the matching row, or the rule's columns, while it matches.
badge Colours the targeted cells and draws its icon before the value.
cellFlash A brief flash on the cell that fired.
preventEdit Marks a validation rule as a veto: validateEdit answers vetoed, and a column with rejectInvalid refuses the write.
log Nothing visible: the log alone.

A message is a template: {value}, {column}, {rule}, {severity}, and any field of the row as {symbol} or {row.symbol}. severity is info, success, warning or error, and picks the toast colour and the log's filter.

A guardrail on an edit

A validation rule is checked on the edit, before it lands, and a preventEdit action marks it as a veto. The overlay is not in that path; the pure engine is. createAlertEngine takes the rules, and validateEdit(row, column, candidate) says whether the value would trip one. The column's validate hook hands that message to the grid, which paints the cell red with it, and rejectInvalid: true turns the flag into a refusal: an edit the rule vetoes is not written, the cell keeps its old value and onCellValueChange does not fire. Without rejectInvalid, validate only flags.

<script lang="ts">
  type Expense = { id: string; item: string; amount: number; budget: number }
  let rows = $state<Expense[]>([
    { id: 'e1', item: 'Cloud hosting', amount: 4200, budget: 5000 },
    { id: 'e2', item: 'Conference travel', amount: 3100, budget: 3000 },
    { id: 'e3', item: 'Design tools', amount: 900, budget: 1200 },
  ])
  const guardrails: AlertRule[] = [
    {
      id: 'no-negative', name: 'No negative amounts', enabled: true, severity: 'error', scope: 'cell', columns: ['amount'],
      predicate: { kind: 'cmp', column: 'amount', op: 'lessThan', value: 0 },
      trigger: { type: 'validation' },
      actions: [{ kind: 'preventEdit', message: 'Amount cannot be negative' }],
      createdAt: 0,
    },
    {
      id: 'over-budget', name: 'Over budget', enabled: true, severity: 'error', scope: 'cell', columns: ['amount'],
      predicate: { kind: 'scalarCmp', left: { kind: 'col', id: 'amount' }, op: '>', right: { kind: 'col', id: 'budget' } },
      trigger: { type: 'validation' },
      actions: [{ kind: 'preventEdit', message: '{item}: {value} is over the {budget} budget' }],
      createdAt: 0,
    },
  ]
  const engine = createAlertEngine<Expense>({ rules: guardrails, getRowId: (r) => r.id })
  // Pure: the grid calls it inside a reactive computation to show validity.
  const validateAmount = (value: unknown, row: Expense): string | null => {
    const result = engine.validateEdit(row, 'amount', Number(value))
    return result.vetoed ? (result.events[0]?.message ?? 'Edit blocked') : null
  }
  const money = { type: 'number' as const, options: { style: 'currency' as const, currency: 'USD', maximumFractionDigits: 0 } }
  const expenseColumns: GridColumns<Expense> = [
    { field: 'item', header: 'Item', width: 180 },
    { field: 'amount', header: 'Amount', width: 130, align: 'right', format: money, editorType: 'number',
      validate: ({ value, row }) => validateAmount(value, row), rejectInvalid: true },
    { field: 'budget', header: 'Budget', width: 130, align: 'right', format: money, editorType: 'number' },
  ]
</script>

<SvGrid data={rows} columns={expenseColumns} getRowId={(r) => r.id} editable containerHeight={220} />

Double-click an Amount and type -5, or 9000 on the first line: the cell turns red with the rule's message as you type, and on Enter it keeps its old value. Raise the Budget first and the same amount goes through, because the second rule compares two columns of the row.

Open the live example: Validation guardrails (Alerts)

The bell and the log

The bell's badge is the unacknowledged count; clicking it opens SvAlertsPanel, a drawer of fired alerts newest first, filterable by severity, with Acknowledge, Clear and Go to row. onJump gets the event so the app can scroll to and select the row. controls={false} hides the two buttons, and bind:panelOpen and bind:managerOpen drive the drawers from chrome of your own:

<script lang="ts">
  let rows = $state.raw<Quote[]>(seed)
  let formats = $state<ConditionalFormat<Quote>[]>([])
  let panelOpen = $state(false)
  let managerOpen = $state(false)
  let lastJump = $state('')
  const rules: AlertRule[] = [{
    id: 'over-700', name: 'Price over 700', enabled: true, severity: 'warning', scope: 'row',
    predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 700 },
    trigger: { type: 'dataChange' },
    actions: [{ kind: 'log' }, { kind: 'highlight', style: { background: '#fef3c7' } }],
    createdAt: 0,
  }]
</script>

<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
  <button type="button" onclick={() => (rows = moved(rows))}>Tick the feed</button>
  <button type="button" onclick={() => (panelOpen = true)}>Open the log</button>
  <button type="button" onclick={() => (managerOpen = true)}>Edit the rules</button>
  <span style="font-size: 12px">{lastJump}</span>
  <SvGridAlerts data={rows} columns={exprColumns} getRowId={(r) => r.symbol} {rules} controls={false}
    bind:formats bind:panelOpen bind:managerOpen onJump={(e) => (lastJump = `jump to ${e.rowId}`)} />
</div>
<SvGrid data={rows} {columns} getRowId={(r) => r.symbol} conditionalFormats={formats} containerHeight={320} />

The rule here logs and highlights but never toasts, which is the right setting for a rule that fires often on a busy screen: the bell keeps count, the drawer has the history, nothing interrupts.

Saving and sharing the rules

Pass storageKey and the rules persist in localStorage under it; the rules prop then seeds storage only when it is empty, so a user's edits win over the shipped set on the next visit. The manager's Export and Import buttons round-trip the whole set as JSON, which is how one person hands a set of alerts to a team. Under it is the same pluggable store as saved views:

import { createAlertRules, localStorageAlertRules } from '@svgrid/enterprise'

const store = createAlertRules(localStorageAlertRules('app:alerts'))
store.save(rule)
const json = store.export() // share it
store.import(json)          // load it

memoryAlertRules() is the same store without persistence, for a test or a rule set the server owns.

A feed that pushes its own changes

By default the overlay diffs data when it changes and evaluates the rows whose reference moved, which is fine for most grids. A feed that already knows which rows changed can skip the scan: watch={false}, take the handle from onReady, and call pushChanged with the changed rows alone, so a pass costs those rows whatever the total. Many rows crossing a line in a second means many toasts; toastCooldownMs keeps it to one per rule per interval while every event still reaches the log.

<script lang="ts">
  let rows = $state.raw<Quote[]>(seed)
  let formats = $state<ConditionalFormat<Quote>[]>([])
  let alerts: { pushChanged: (rows: readonly Quote[]) => void } | null = null
  let running = $state(false)
  let timer: ReturnType<typeof setInterval> | undefined
  const rules: AlertRule[] = [{
    id: 'over-700', name: 'Price over 700', enabled: true, severity: 'warning', scope: 'row',
    predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 700 },
    trigger: { type: 'dataChange' },
    actions: [{ kind: 'toast', message: '{symbol} over 700' }, { kind: 'highlight', style: { background: '#fef3c7' } }],
    createdAt: 0,
  }]
  function tick() {
    const next = moved(rows)
    const changed = next.filter((q, i) => q !== rows[i])
    rows = next
    alerts?.pushChanged(changed)
  }
  function toggle() {
    running = !running
    clearInterval(timer)
    if (running) timer = setInterval(tick, 700)
  }
  $effect(() => () => clearInterval(timer))
</script>

<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
  <button type="button" onclick={toggle}>{running ? 'Stop the feed' : 'Start the feed'}</button>
  <SvGridAlerts data={rows} columns={exprColumns} getRowId={(r) => r.symbol} {rules} watch={false} onReady={(h) => (alerts = h)} toastCooldownMs={5000} bind:formats />
</div>
<SvGrid data={rows} {columns} getRowId={(r) => r.symbol} conditionalFormats={formats} containerHeight={320} />

The handle also has reseed(allRows), to re-arm the edges quietly after a full reload of the data, and flush(), to run a pending pass now. On a big feed prefer dataChange rules to relativeChange ones, and keep the actions to a toast or a log: a highlight on thousands of rows is a conditional-format change per pass.

A rule on a clock

trigger: { type: 'scheduled', schedule } is re-checked on a cron expression or once at a time rather than on a change: the overlay runs the enterprise scheduler for it, and on each fire the rule lists every row its predicate matches at that moment, whether or not anything moved, which is what a Friday review wants. The schedule is the scheduler's own shape, so the same expression drives a report:

const rule: AlertRule = {
  id: 'friday-review', name: 'Positions over 700 at Friday close', enabled: true, severity: 'info', scope: 'row',
  predicate: { kind: 'cmp', column: 'price', op: 'greaterThan', value: 700 },
  trigger: { type: 'scheduled', schedule: { id: 'friday', name: 'Friday close', cron: '30 17 * * 5' } },
  actions: [{ kind: 'toast', message: '{symbol} is still over 700' }],
  createdAt: Date.now(),
}

The scheduler ticks twice a minute and fires a schedule at most once per due minute, while the tab is open. Scheduling has the cron reference, the time-zone rule, what happens to a run missed while the tab was closed, and a scheduled export next to a scheduled alert.

Open the live example: Alert Rules engine (Alerts)

Open the live example: KPI & aggregate alerts (Alerts)

See also

Live examples

  • Validation guardrails - A validation-trigger alert is evaluated on edit and can veto the change. A budget sheet is editable, and two guardrail rules block bad edits: a negative amount, or an amount over that line's budget. The pure engine is wired into the grid's per-column validate hook - the blessed integration point for prevent-edit. Toggle a guardrail off to allow the edit through.
  • Alert Rules engine - A live trading desk where end users define alert rules at runtime - no code - that watch the data and react: raise a toast, tint the row, flash the cell, or log it. A visual condition builder (or free-text expression) reuses the grid's own filter operators; rules persist to localStorage and export as shareable JSON. The bell opens the fired-alert log. Engine: @svgrid/enterprise.
  • KPI & aggregate alerts - Alerts that watch a whole-table total, not just a row. An aggregate-scope rule fires once when SUM(revenue) crosses the company target; a row rule flags any region trailing its own target. Close a few deals and watch the aggregate alert fire the moment the total clears the line. Aggregate rules use the expression language's SUM / AVG / COUNT reducers.

Related articles