Manual LiveTable Windows

Windows

elements man livetable/windows Read as markdown

A 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.