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 history | The notices | |
|---|---|---|
| Loaded | a window at a time, to the end if the user scrolls there | the newest window, with stop |
| The token continues with | the next window | what changed since |
| The token is read | when the user scrolls | when the client starts |
| The token is kept | while the page lives | between launches, beside the copy |
| Current by | events | events |
§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: ¬ice [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: streamis the operation every component with an entity inherits.auth:idadmits the identity whose id the route variable holds, and the variable selects that identity’s entities.syncis 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.VERSIONtells an older state from a newer;CREATEDtells 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.
moreis called by the page and by the scroll, never byevent.- 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
lastis for. - A new order is newer than everything loaded, so
eventadds 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
moreagain; 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.
startis called when the client starts, never byevent. 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.
trimdrops what the panel has no place for. 410 Goneis 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
expirecallsstart: it has a token, and reads what it missed. - A client that keeps nothing between launches reads
?stopevery 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.