VII Patterns

A List That Stays Current

Two screens of the shop show a list that changes while the user looks at it.

  • The order history. Every order the customer ever made, newest first. Nobody reads all of it: the page shows a hundred and loads more as the customer scrolls.
  • The notices behind the bell. “Your order is packed”, “your top-up arrived”. The panel shows the newest hundred and never anything older.

Both are a copy of a collection kept in the client, and both follow one rule:

The copy is loaded with a collection stream and kept current by realtime events. The stream is read to load. An event is applied to the copy, and nothing is read for it.

                 loads                                    keeps current
the client ── GET, a collection stream ──> the copy <── realtime events ── the application

They differ in what is loaded, and so in what the stream’s token is for.

The order historyThe notices
Loadeda window at a time, to the end if the user scrolls therethe newest window, with stop
The token continues withthe next windowwhat changed since
The token is readwhen the user scrollswhen the client starts
The token is keptwhile the page livesbetween launches, beside the copy
Current byeventsevents

§What not to build

A collection stream answers changes by a token, and that makes two shortcuts look right. Both are wrong.

Reading on an event. A client that takes an event as “something changed” and answers it with a request — for the entity, for the list, for the changes by its token — reads as often as the collection changes, every reader at once, for a change it was just handed. The event carries the change. Expose in it all the copy holds of an entity.

Asking the token on a timer. A client that reads ?token=… every few seconds to look live is polling. Each such read costs the storage what it wrote since the token, for every client, whether anything changed or not, and the user still waits for the next tick. The token of changes is for a client that was away, not for one that is connected.

§The declarations

store.orders is the component Create a Component writes; its orders have a customer, a status and a total. It gets a realtime section and a stream route. One list of properties serves both, so that an event and a read answer the same object:

# components/store.orders/manifest.toa.yaml
realtime:
  sync:
    key: customer
    expose: &order [id, status, total, VERSION, CREATED]

exposition:
  /of/:customer:
    auth:id: customer
    GET:
      endpoint: stream
      io:output: *order
      query:
        sort: CREATED:desc
        limit: { value: 100, range: [1, 100] }

inbox is a new component, the notices of a user. This manifest is all the article needs of it; what writes a notice — a receiver of store.orders.picked, say — calls the inherited transit:

# components/inbox/manifest.toa.yaml
name: inbox

entity:
  properties:
    recipient: { type: string }   # the identity the notice is for
    text: { type: string }
    seen: { type: boolean }
  required: [recipient, text]
  blank:
    seen: false

realtime:
  sync:
    key: recipient
    expose: &notice [id, text, seen, VERSION, CREATED]

exposition:
  /of/:recipient:
    auth:id: recipient
    GET:
      endpoint: stream
      io:output: *notice
      query:
        sort: CREATED:desc
        limit: { value: 100, range: [1, 100] }
  • endpoint: stream is the operation every component with an entity inherits.
  • auth:id admits the identity whose id the route variable holds, and the variable selects that identity’s entities.
  • sync is published on every change of an entity. Routed by a property that holds an identity’s id, it reaches that identity’s own stream, /realtime/<id>/, which needs no route.
  • VERSION tells an older state from a newer; CREATED tells the client where an entity it does not hold belongs.

The Context says where realtime streams are kept:

# context.toa.yaml
realtime:
  streams: redis://localhost:6379

§What both clients do

Open the realtime stream, then load. A stream starts when it is opened; a change made between a load and an opening that follows it reaches the client by neither.

GET /realtime/b70a7dbca6b14a2eaac8a9eb4b2ff4db/ HTTP/1.1
authorization: Token …
accept: application/json

One stream carries both lists and whatever else is routed to the user. The client dispatches on the name of the event, store.orders.sync or default.inbox.sync.

Apply what arrives by VERSION. An entry of the load and the data of an event are the same object, and one function takes both:

// the client
interface Entity { id: string, VERSION: number, CREATED: number }

export function apply<T extends Entity> (copy: Map<string, T>, entity: T) {
  const held = copy.get(entity.id)

  if (held === undefined || held.VERSION < entity.VERSION) copy.set(entity.id, entity)
}

An event may arrive twice, events arrive in no promised order, and a read that was in flight when an event arrived brings an older state after a newer one. Keeping the higher VERSION answers all three.

Reconnect the realtime stream with its own token. That stream gives a token after every event, and a client that lost its connection reconnects with the last one and is sent what it missed (Reconnecting). The application keeps a key’s events for expire seconds after its last reader left, 300 unless the Context says otherwise. This token and the token of a collection stream are two tokens: one continues a connection, the other a read.

§The order history

The page loads the newest window, and the next one each time the customer reaches the end of what is shown:

GET /store/orders/of/b70a…f4db/                 the page loads: the newest 100, then a token T1
GET /store/orders/of/b70a…f4db/?token=T1        scrolled: the next 100, then T2
GET /store/orders/of/b70a…f4db/?token=T2        scrolled: 42 — a short window, so these are the last
// the client: the order history
const orders = new Map<string, Order>()
let token: string | undefined     // the next window
let last = Infinity               // CREATED of the oldest order loaded
let complete = false
let early: Order[] | undefined = []   // events that came before the first window

export async function more () {
  if (complete) return

  let count = 0

  for await (const part of stream(`/store/orders/of/${customer}/`, { token })) {
    if (part.entry !== undefined) {
      apply(orders, part.entry)
      last = Math.min(last, part.entry.CREATED)
      count++
    } else if (part.token !== undefined) token = part.token
  }

  complete = count < 100

  const held = early ?? []

  early = undefined
  held.forEach(event)
}

export function event (order: Order) {
  if (early !== undefined) early.push(order)
  else if (complete || order.CREATED >= last) apply(orders, order)
}

stream stands for the client’s own reader of a multipart reply, which yields its parts; Order is Entity with a status and a total.

  • more is called by the page and by the scroll, never by event.
  • An event for an order deeper than what is loaded is dropped. Its order arrives with its window, in the state it has then. That is what last is for.
  • A new order is newer than everything loaded, so event adds it and it shows on top.
  • An event that comes before the first window is held until the window is read: only then is it known whether its order is deeper.
  • A reply that ends without a token was cut. Call more again; the token is the one held.
  • The token lives as long as the page. A page that is loaded again starts from the first window.
  • A client that was away for longer than expire — a laptop that slept — may have missed events. It drops the copy and loads the first window, as a page that is loaded again does.

§The notices

The panel shows the newest hundred. stop reads that window and nothing deeper, and its token continues with changes instead of the next window:

GET /inbox/of/b70a…f4db/?stop                   the first launch: the newest 100, then a token T1
GET /inbox/of/b70a…f4db/?token=T1               a later launch: what changed since, then T2

A client that keeps its copy between launches — an installed app, a page with a local store — keeps the token beside it. What it reads when it starts depends on whether it has one:

// the client: the notices
const notices = new Map<string, Notice>(saved.notices)
let token: string | undefined = saved.token

export async function start () {
  const query = token === undefined ? { stop: true } : { token }

  try {
    for await (const part of stream(`/inbox/of/${recipient}/`, query)) {
      if (part.entry !== undefined) apply(notices, part.entry)
      else if (part.removed !== undefined) notices.delete(part.removed)
      else if (part.token !== undefined) token = part.token
    }
  } catch (error) {
    if (error.status !== 410) throw error

    notices.clear()
    token = undefined

    return await start()
  }

  trim()
  save({ notices, token })
}

export function event (notice: Notice) {
  apply(notices, notice)
  trim()
}

function trim () {
  const oldest = [...notices.values()].sort((a, b) => b.CREATED - a.CREATED).slice(100)

  for (const notice of oldest) notices.delete(notice.id)
}

saved and save stand for the client’s own store; Notice is Entity with a text and a seen.

  • start is called when the client starts, never by event. While the client runs, every change reaches it as an event.
  • A read by the token that carries a hundred changes has more behind it. Read with the new token until a reply carries fewer.
  • A change arrives for any notice of the user, also one older than the hundred, by the token and by an event alike. trim drops what the panel has no place for.
  • 410 Gone is a token too old to continue from. The client drops the copy and the token, and starts as one that has none.
  • A client that was away for longer than expire calls start: it has a token, and reads what it missed.
  • A client that keeps nothing between launches reads ?stop every time it starts and has no use for the token.

A token of changes needs the storage to keep what an entity was before each change. Turn that on with a migration; without it the read ends with token: null, and the client reads ?stop at every start:

# components/inbox/migrations/0001-images.yaml
- images: true

What that costs is in Read a Large Collection.

§A list that selects

Both routes above answer everything their owner has. A route with criteria of its own — criteria: status!=cancelled — answers a collection an entity can leave, while the event about it arrives all the same. The client then decides, from the event, whether the entity is still of what it read: An entity that left the collection.

§Summary

  • A list in the client is loaded with a collection stream and kept current by realtime events.
  • An event is applied. It is never answered with a read, and a token is never asked on a timer.
  • A list the user scrolls through is read a window at a time; its token is the next window and lives with the page.
  • A list of the newest entries is read with stop; its token is what changed since and is read when the client starts.
  • Either client keeps the higher VERSION, and opens its realtime stream before it loads.
Search
Search the documentation