Praxsuite

Table events on the Event Bus

Vincent Depassier · September 29, 2026

A topic can name tables as its sources. From then on, every committed write to one of those tables is announced on that topic — a grid redraws itself, a dashboard ticks, an automation runs, and nobody wrote a polling loop.

The declaration lives on the topic, not on the table. A table has no business knowing a transport exists, and keeping it on the bus side puts every binding in the one screen that owns the channel.

Where the announcement lands

Each change is published twice, to two addresses:

{topic}:{tableId}     one table
{topic}:*             every table the topic binds

Join the first when you care about one table and want to hear nothing else. Join the second when you want all of them — without it you would have to know every table id up front and hold one bus per table, which also burns your 20-bus allowance.

The event

The event name is the operation:

Event

Raised by

row.created

a row inserted, one or many

row.updated

a cell, a row, or a bulk update

row.deleted

a row deleted, one, many, or by filter

row.reordered

a row moved to a new position

And the payload:

{
  "workspace": "…",
  "table": "…",
  "op": "row.created",
  "rowIds": ["…", "…"],
  "count": 2,
  "at": "2026-09-29T14:03:11.284Z"
}

Three things worth knowing about it:

There are no column values, on purpose. The bus has no idea what the person receiving the event is allowed to see, and a fan-out that leaked a column past its access rules would be a far worse problem than one extra read. Take the ids and fetch what you are entitled to.

`rowIds` can be empty. A delete by filter and a bulk import never materialise the ids they touched, so they announce with an empty list and a count of zero. Read that as "this table changed, refetch" rather than "nothing happened".

`row.reordered` carries one id — the row the person moved. A reorder shifts the position of every row between the old slot and the new one, so calling it row.updated with a single id would say something false about the rest. If you render an ordered list, refetch the order.

Which writes announce

All of them. A write through the query API, a form submission, the portal, an automation's insert or update node, a bulk import, a delete — each one announces when it commits, and each one commits before it announces. A change that was rolled back is never published.

This is worth stating because it used to be otherwise: for a while only writes arriving through the query API announced, and a topic bound to a table stayed silent for automations, forms and the portal, with nothing in the logs to say so. If you built a workaround for that, you can take it out.

The reconnect window

The bus stores nothing — except here, and only a little.

A topic that binds source tables keeps a small replay window: the last 50 events, for 5 minutes, per bus. Pass the cursor you last saw when you join, and you get back what you missed:

JoinBus("orders:*", null, lastCursor)  ->  { ok, peers[], missed[] }

Every publish hands you the cursor to keep:

Publish(...)  ->  { ok, recipients, cursor }

and each entry in missed[] carries its own cursor alongside the event, so you can store the last one and resume from it.

This exists because "nobody was listening, so nothing happened" is the right answer for a cursor and the wrong one for a dropped connection. A client watching a table through a deploy did not choose to miss those rows; it was disconnected for eight seconds.

Be precise about what it is not:

  • Not at-least-once. A client further behind than the window gets everything still held, so it can tell "I missed nothing" from "I fell behind" — but nothing replays what the window already dropped.

  • Not history. It is capped and expires on its own. It can never be read as an audit trail.

  • Not a queue. No acknowledgements, no retries. A client that reads and then crashes reads the same events again on its next join, which is exactly why every entry carries a cursor.

Anything that must survive a five-minute outage still belongs in a table.

Only topics that bind source tables keep a window. A topic without them is peer traffic — cursors, typing, presence — where an event is stale the moment the next one lands, and buffering it would cost a write per mouse movement to replay a position nobody wants.

Reacting from an automation

A bus event can start an automation. Add a trigger of type BusEvent and tell it which bus and which event to listen for:

{ "bus": "orders:*", "event": "row.created" }
  • bus is required. A trailing * matches every instance of a topic, and * alone matches every bus in the workspace.

  • event is optional. Leave it out to hear everything on that bus.

  • Both are matched without regard to case.

`bus` is mandatory for a reason. A trigger without one would match the cursor and presence traffic the bus was built for, and turn a single mis-saved trigger into an automation run per keystroke. If you genuinely want the firehose, ask for it with "bus": "*".

Watch for cycles. A BusEvent trigger whose automation writes back to a table the same topic binds is a loop: the write announces, the announcement runs the automation, the automation writes again. Nothing in the configuration can rule that out, because the binding and the automation are set up independently. The platform caps how often one trigger may be enqueued per minute and logs loudly when the cap bites — so a loop is visible and bounded rather than merely expensive — but the cap is a seatbelt, not a fix. Break the cycle, or narrow the trigger's filter.

A BusEvent trigger cannot be created from the portal yet. The trigger type picker does not list it. Create it through the API or a connector tool in the meantime.

Next

  • The Event Bus

  • Automations: trigger nodes

  • Table rows and columns

  • Table auditing