Semitexa Grid / SSE-driven
SSE-Driven Data Grid: The Table Is a Subscription
Most live tables are a client store with a WebSocket bolted on. This one has no client-side dataset at all. The grid subscribes to a query over Server-Sent Events; the server holds its page, sort and filters, re-runs it when the data changes and sends only the rows that moved. The panel under the table shows every frame as it crosses the wire.
On the wire this page's real traffic, newest first
Sort a column, pick a filter or turn a page: the view goes up as a small POST and the rows come down the stream. Then open a second tab and place a trade there. This tab never asked for anything, yet a patch arrives with the one new row. The prices are invented; the frames are real.
The table is a subscription
A conventional grid owns its data. It fetches a page, keeps it in a client store, and something else, usually a WebSocket handler, tries to keep that store honest. Every live table ends up as two systems that must agree: the server's truth and the browser's copy.
The Semitexa grid does not keep a copy. When it starts, it subscribes to a named feed with its current view: page, page size, sort, search and filters. From then on the server holds that subscription. Changing the sort does not trigger a fetch; it sends the new view upstream and waits:
POST (the page's control channel)
{"stream": {"op": "view", "feed": "site.showroom.exchange",
"subscriptionId": "sse_7f3c…",
"params": {"sort": "-amount", "perPage": "10"}}}
The answer does not come back on that POST. It arrives on the stream, as a ui.collection.data frame carrying the same {data, meta} envelope a plain JSON request would return. The browser holds exactly one page, the one on screen, and renders whatever the server sends.
Only the rows that moved
When the data changes, the server re-runs each subscriber's own view and compares the result with the page that subscriber last received. If nothing on that page changed, nothing is sent. If something did, the server sends a keyed patch instead of the page:
event: ui.collection.patch
data: {"patch": {"key": "id",
"upsert": [{"id": "TRX-985F23", "asset": "gold", "amount": "$108,268.81", …}],
"remove": ["TRX-1A0C7E"],
"order": ["TRX-985F23", "TRX-4C2BBB", "TRX-673208", …]},
"meta": {"pagination": {"total": 38, …}}}
The grid applies it to the rows it already has: insert or replace by key, drop the removed ids, follow the new order. Rows that did not change keep their DOM nodes, so focus, selection and an open menu survive the update. If the patch does not fit what the grid holds, the two have drifted, and the grid asks for its view again. The server only sends a patch when it is smaller than the full page; the panel above shows both sizes side by side.
One stream per page
Browsers cap concurrent HTTP/1.1 connections per origin, and an EventSource per widget burns through that budget fast. This page opens one stream. The grid, deferred blocks and any other live component share it: each frame carries the id of the subscription it belongs to, and the runtime routes it there. Subscribing, changing a view and leaving are small POSTs on a control channel; everything that flows to the browser flows down the single stream.
If the stream drops and reconnects, the runtime re-arms every subscription with the same id, and the server answers with the current page. If the page has no live session at all, the same grid falls back to plain GET requests against the same endpoint and still pages, sorts and filters.
Invalidation, not polling
Nothing on this page polls. The feed declares what it depends on, and the code that changes that data says so:
#[AsPublicPayload(path: '/_feed/exchange', name: 'site.showroom.exchange',
transport: TransportType::Sse, ...)]
#[WatchScopes('site_showroom_exchange')]
final class ExchangeFeedPayload implements SseCollectionFeedPayloadInterface
// in the trade handler
$trade = $this->trades->place($payload->getAsset());
$this->invalidator->touch('site_showroom_exchange');
A publish on the scope wakes every subscription that watches it. Each one re-runs with its own view, so a visitor on page three filtered to gold gets the patch for page three filtered to gold, and a visitor whose page did not change gets nothing. The trade store and the publish both cross Swoole workers, so the tab that placed the trade and the tab that sees it do not need to land on the same process.
The contract is the config
The page template holds an endpoint and three buttons. No columns, no sort keys, no filter definitions:
{% include '@platform-ui/components/runtime/grid-v2.html.twig' with {
gridId: 'showroom-exchange',
endpoint: '/_feed/exchange',
actions: [ { label: '🪙 Buy gold', route: '/_feed/exchange/trade?asset=gold', method: 'POST' } ]
} only %}
The grid's first request is OPTIONS /_feed/exchange. The answer is the route contract, built from attributes on the collection response, and it tells the grid everything: the fields, the pager, what may be sorted and filtered, and that the feed can be served live.
{
"name": "site.showroom.exchange",
"modes": ["plain", "sse", "sse-update"],
"collection": {
"pagination": { "modes": ["page"], "defaultPerPage": 10, "perPageOptions": [10, 20, 40] },
"sort": { "fields": ["at", "asset", "trader", "amount", "status"] },
"filter": { "fields": { "asset": ["eq"], "side": ["eq"], "status": ["eq"] } },
"live": { "scopes": ["site_showroom_exchange"] }
}
}
The same declarations guard the way back in. A view that asks for something the contract does not offer is refused before any row is read:
$ curl "https://semitexa.com/_feed/exchange?sort=lot"
HTTP 400
{"error": "invalid_sort", "allowed_fields": ["at", "asset", "trader", "amount", "status"]}
Costs and limits
Server-owned views are not free, and a showroom should say where the bill goes.
- One query per subscriber per publish. A scope with a thousand open grids re-runs a thousand views. Patches keep the wire small, not the database work. Scope your publishes narrowly.
- Every open tab holds a connection. One stream per page instead of one per widget, but still one per tab. Size your workers and proxies for it, and turn off proxy buffering, or frames sit in a buffer instead of reaching the browser.
- The grid itself is plain. No currency formatting (the amounts here are formatted on the server and sorted by raw cents), no charts in cells, no column resize or inline editing yet.
- This demo sorts in memory. The tape is eighty rows in one MySQL table, sorted in PHP. A real feed hands the same validated criteria to its query.