Windows
elements man livetable/windows Read as markdownA page of a long table that stays live, and loads more on request.
export default function route(req) {
return new html({
messages: messages.view({ roomId: req.params.id }, { orderBy: "createdAt desc", limit: 50 }),
});
}
<li e:for={m of messages.toReversed()}>{m.body}</li>
<button e:if={messages.hasMore} onclick={() => messages.more()}>older</button>
orderBy is one or more columns with a direction, "createdAt desc" or
["createdAt desc", "id desc"]. id is always the final tie-break so a page
boundary is unique; it is appended when you leave it out. limit is the page
size.
The first page is the newest fifty by that order, and the view holds its rows
in that order from then on. The template can still shape them, toReversed()
above puts oldest first for a chat, but the window's order is what defines
"the next fifty".
What the window admits
A row arriving over the wire is admitted if it sorts inside the loaded range: before the last loaded row, or anywhere once every row has been loaded. So a new message lands at the top. An edit to a message far in the past, one the page never loaded, is dropped. A loaded row whose sort key changes moves, or leaves the window if it now sorts past the end. Nothing is held for rows the page has not asked for, so a chat that loaded fifty and received two hundred holds two hundred and fifty rows, never the whole history.
more()
messages.more() asks the server for the next page past the last loaded row
and appends it. It is sync-style like the mutators, resolves to the rows that
arrived, and a second call while one is in flight joins it. hasMore is true
while the last page came back full.
The request is addressed by the view's listener id, which the route minted for this view. The server already holds the partition and window the route opened, so a page request cannot widen either: if you hold the id, the route let you see this view.
Custom select
Auto-select writes the order, the limit and the keyset clause for later pages.
A custom select receives the window as its second argument and drops the
same three in as fragments:
let messages = new LiveTable<Message>({
select: ({ roomId }, w) => sql<Message>(`
select m.id, m.body, m.createdAt, u.name as author
from messages m join users u on u.id = m.user_id
where m.roomId = ${roomId} and ${w.keyset("m")}
order by ${w.order("m")} ${w.page()}
`),
});
w.keyset(alias) renders the cursor clause on later pages and true on the
first. w.order(alias) renders the ORDER BY columns. w.page() renders the
LIMIT. With no window on the view they render true, true and nothing, so a
select written this way also serves an unwindowed view. They are sql.raw
fragments; see database/sql.
A select that ignores the window is refused on its first page: rows out of
order, rows at or before the cursor, or more rows than the limit each throw a
ValidationError naming the fragment that is missing.
Optimistic inserts
A row you insert is placed by the same order. If it does not carry its sort
key yet, createdAt before the server fills it in, it sorts as newest, which
for a descending window is the top, where a new row belongs. Pass a placeholder
such as createdAt: new Date() so it renders a time rather than nothing.