How does a read stay current?
A read is a request: a DTO whose fields are its complete filter and whose kind says the shape of its result. The app watches it; the client asks the server once, keeps the answer as the request's state, and applies objects published on the request's channels until nobody watches it any more. Equal requests (equality is generated from the fields) share one state and one call.
A request has no side effects. The client may retry it, cache it and read it again whenever it
likes; a handler that publishes from a request throws StateError. Changes are commands
(commands-and-idempotency.md).
The kinds
| Kind | Result | Server handler | Default update |
|---|---|---|---|
DwSingleRequest<T> | T — absent answers dw.notFound | DwCallHandler.single | update |
DwMaybeRequest<T> | T? | DwCallHandler.maybe | matches ? upsert : remove |
DwListRequest<T> | List<T>, the whole list | DwCallHandler.list | matches ? upsert : remove |
DwPageRequest<T> | DwPageResult<T>: items, hasMore | DwCallHandler.page | matches ? upsert : remove |
DwTableRequest<T> | DwTablePage<T>: items, total, page, pageSize | DwCallHandler.table | matches ? upsert : remove, applied to a page (below) |
DwWindowRequest<T, S, I> | DwWindowResult<T>: items newest first, olderCursor, newerCursor | DwCallHandler.window | matches ? upsert : remove, inside the loaded range (below) |
A deletion (DwDeletedObject) removes, except where the kind reads again: .refetchOnUpdate() and a
table page refetch.
- Single or maybe. A single request asks for an object that must exist — the handler returns
nulland the framework refusesdw.notFound, so no handler forgets to. A maybe request treats absence as an answer, and itsmatchesis required: no default can say which object on the channel is "the one" — "every object" would fill the state with the first stranger, "none" would empty it on every update of the held one. - List — the whole list, for what the domain bounds: a member's settings, a project's members,
a club's services. Bounded by
maxItems(below). - Page — an accumulating feed read by offset: "load more" appends.
- Table — numbered pages with a total, for an admin table.
- Window — a long newest-first sequence read both ways from an anchor: a chat, a log. The Flutter side is ../3-flutter/window-list-view.md.
The scenario of a list or a page is chosen by its named super constructor, so the class stays
const and says what it does in its declaration:
| Constructor | An object on the channel | A deletion |
|---|---|---|
DwListRequest() / DwPageRequest(pageSize: …) | matches ? upsert : remove | remove |
DwListRequest.updateOnly() / DwPageRequest.updateOnly(pageSize: …) | update — membership is the server's decision | remove |
DwListRequest.refetchOnUpdate() | refetch — a derived list the client cannot compute | refetch |
For anything else override onUpdate(Object item) and return a DwUpdateAction, keeping it a pure
function of the object and the fields.
matches, sort, positionOf and channels are pure
The client calls them on objects arriving at any moment and relies on an equal request answering
equally. They depend on the object and the request's fields only; DateTime.now() inside one is a
bug — the time belongs in a field (example/dartway_example_shared/lib/src/schedule.dart):
final class ListUpcomingSessions extends DwListRequest<ClubSession>
with _$ListUpcomingSessions {
const ListUpcomingSessions({required this.from});
final DateTime from;
List<DwLiveChannel> get channels => const [
DwLiveChannel(DartwayExampleChannel.schedule),
];
bool matches(ClubSession item) => !item.startsAt.isBefore(from);
int Function(ClubSession a, ClubSession b) get sort =>
(a, b) => a.startsAt.compareTo(b.startsAt);
}
matches should be the server's filter written for one object. A table whose matches is looser
than its handler keeps a row on the page that has left the filter.
How an update reaches a request
- A command publishes an object to a channel (channels-and-realtime.md). It reaches the client in that command's response, or over the live socket.
- Only requests that declare that channel hear it. A request without
channelshears nothing, including the responses of its own app's commands — which is right for a snapshot such as a search (ListChatMessagesMatchinginexample/dartway_example_shared/lib/src/chat/chat_messages.dart). - Only objects of the request's item type are offered (
acceptsItem), and only deletions of that type (acceptsDeletion). The exception is.refetchOnUpdate(): its channels are its whole scope, so an object or a deletion of another type on them re-runs it as well — without reachingonUpdate, which only ever sees the item type. onUpdateanswers aDwUpdateAction, and the client applies it:
| Action | What the client does |
|---|---|
upsert | Replace the object with the same id where it stands — never moved, so a liked post does not jump to the top of a feed. Absent: insert by sort, at the head without one |
update | Replace the object with the same id where it stands; never insert |
remove | Remove the object with that id |
refetch | Read the request again |
ignore | Nothing |
Each kind bounds that further:
- Single — a removal of the held object reads again, and the screen shows what the server now
answers (
dw.notFound): a single state cannot be empty. - Maybe — an upsert fills an empty state or replaces the held object; a removal empties it.
- Page — while more pages exist, an insert that sorts past the loaded rows is dropped: it arrives on scroll, and inserting it at the end would shift the offset of the next page.
- Table — a numbered page never grows by an update. An object on the page is replaced in place; an object not on the page and every removal read the page again, because a new row moves every later row and changes the total — and the client cannot tell a new row from one on another page. Rereads triggered together are coalesced into one call.
- Window — a new object is inserted where
positionOfputs it, only inside the range loaded. Newer than the newest row while newer rows exist (hasNewer), it is counted as unseen (DwWindowData.unseenNewerCount); older than the oldest while older rows exist, it is left for loading older rows to bring.
A load in flight does not lose updates. Objects that arrive while a call is on its way are applied to what is shown and applied again to the answer, because the server may have read before the update was committed; every action is idempotent, so applying one twice changes nothing.
A list's bound
A whole list is the default an agent reaches for, and nothing notices when it stops being small: a
list of tasks is ten rows in development and three megabytes a year later. So a DwListRequest has a
bound, maxItems — 200 (DwListRequest.defaultMaxItems) unless the class names another as a
super-constructor argument, a constant of the class like a page size, never serialised. A class that
raises it says why in one line of its doc comment
(example/dartway_example_shared/lib/src/schedule.dart):
/// … At most `maxItems`: a club schedules about three weeks ahead, twenty
/// sessions a day at the busiest.
final class ListUpcomingSessions extends DwListRequest<ClubSession>
with _$ListUpcomingSessions {
const ListUpcomingSessions({required this.from}) : super(maxItems: 500);
A list handler answering more items than maxItems is never cut short — a silently shorter list is
a wrong answer nobody notices:
- in tests and on a development server the call fails, naming the request and the count.
DwTestServeris strict whatever its settings say; a server started on a developer's machine is strict throughDW_STRICT_BOUNDS: trueindeploy/config.yaml > local(DwServerEnvironment.strictBounds→DwServerSettings.strictBounds); - in production the whole list is served, and the server logs a warning with the request, the
count and the response size, once per request per
DwServerSettings.boundWarningInterval(an hour).
Every whole-list handler uses the server helper, applying its nullable limit to the same query:
final rows = await request.readWhole((limit) => ctx.db.clubSessions.find(
where: (t) => t.startsAt.gte(request.from),
orderBy: (t) => [t.startsAt.asc(), t.id.asc()],
limit: limit,
));
readWhole first reads request.fetchLimit (maxItems + 1). On overflow, strict mode throws
with the request name and observed probe count; production re-reads once with limit: null (no
limit), serves the whole list and logs its true count and encoded size. The callback must only
read: it can run twice. Passing request.fetchLimit directly to find and returning that probe
would silently truncate production data. Anything that grows with use is a page, table or window.
dartway check warns about a read in a request handler with no bound (unboundedRead,
the checker).
Any call's response above DwServerSettings.largeResponseBytes (256 KB) is logged as a warning with
the call's name, once per call per the same interval — the same line in every project.
Page sizes
DwPageRequest and DwWindowRequest take pageSize and an optional maxPageSize as super-constructor
arguments — constants of the class, never serialised, so a client cannot ask the server for everything
(example/dartway_example_shared/lib/src/chat/chat_messages.dart):
const ListChatMessages({required this.channelId})
: super(pageSize: 40, maxPageSize: 160);
A call may send a pageSize query parameter; the server serves at most maxPageSize (the class's
pageSize when none is set). The client uses it to reload the rows it already shows in one call. A
pageSize below 1 is a malformed call (400).
A DwTableRequest is different: page and pageSize are fields, because page 2 and page 3 are
different states, and only maxPageSize is a super argument
(example/dartway_example_shared/lib/src/admin.dart):
const ListUserProfiles({
this.page = 1,
this.pageSize = 10,
this.search = '',
this.role,
}) : super(maxPageSize: 100);
The server serves servedPageSize — pageSize clamped to maxPageSize — and answers the size it
served in DwTablePage.pageSize. A page or page size below 1 is refused dw.invalid on both sides
(checkPage), because no clamping turns it into the page the caller meant.
Handlers read one row past the page — DwPageInput.fetchLimit, DwTableInput.fetchLimit — so the
framework learns whether more rows exist without counting. Returning more rows than fetchLimit
fails the call. A table handler also supplies count, asked only when the rows cannot tell the total
(a full page, or an empty page past the first). An offset feed needs a total order (createdAt, id),
or its pages skip and repeat rows; the client drops a row that arrives twice.
Window cursors
positionOf is the one definition of a window's order: the sort value S (int, String or
DateTime or DwCalendarDay) and the id I (int or String) of a row. A calendar-day cursor
uses canonical YYYY-MM-DD and compares by civil date, without converting to an instant.
DwWindowPosition<DateTime, int> positionOf(ChatMessage item) =>
(sortValue: item.sentAt, id: item.id);
The server builds cursors from it (DwWindowCursor), and the client orders live inserts by it. The
pair is the position: rows sharing a timestamp are ordered by id, so reading "before (t, 7)" neither
skips the other rows at t nor repeats row 7. On the wire a cursor is an opaque base64url string; a
cursor that does not decode to this request's S and I is a malformed call.
A window result has a cursor at an end exactly when rows exist past it: hasOlder and hasNewer are
the cursors' presence, so the flag and the way to load cannot disagree. A call reads the newest rows,
the rows around an anchor, rows older than olderCursor or rows newer than newerCursor
(DwWindowQuery). The handler reads one direction at a time (DwWindowInput) and the framework
composes the window: see ../4-server/handlers-and-context.md.