Skip to main content

Handlers: what runs for a call, and what may it touch?

A handler answers one request or command class of the protocol. It is a function, built by one of seven factories on DwCallHandler — one per request kind and one for commands — and registered in its feature, DwServerFeature(handlers: …). The server refuses to start when a registered class has no handler or two, so a missing handler is a deploy error rather than the first user's.

The factories bound the call class by kind: single takes a DwSingleRequest, command a DwActionCommand. A handler of the wrong kind does not compile.

What happens before the handler​

POST /dw/<WireName> is checked cheapest first, and nothing touches the database until the call is known to be well-formed:

  1. Compatibility — Dw-Protocol and Dw-App-Version (426 when the client cannot talk to this server);
  2. Shape — method, wire name (404 for an unknown one), content type, idempotency key (required for a command, forbidden for a request), headers (a Dw-Utc-Offset that is not whole minutes within 18 hours included), query and body — each a malformed call (400) when wrong, logged with an incident id and never alerted;
  3. The token — unknown or revoked is 401 on any call, even one that needs no account: a client holding a dead token must learn it;
  4. Sign-in required by the access rule (401), then validation, then the access check (403);
  5. The handler.

Sign-in comes before validation, so an anonymous caller is told to sign in rather than which field is wrong. Validation comes before the access check because it is pure and a check may query — a check written against the call's fields should not run on invalid ones.

Validation is the call's own: a class implementing DwSelfValidating has its validate() run on the server before the handler (the first refusal it returns is the answer), and a table request's page and page size are checked. The client ran the same code before sending; running it again is what makes it a rule instead of a courtesy of well-behaved clients. See refusals and statuses.

Access is required on every factory, with no default, so a handler cannot be open by omission: DwAccessRule.anonymous, DwAccessRule.signedIn or DwAccessRule.check<C>((ctx, call) async => …). See access and roles.

The seven factories​

Every factory takes access and an optional maxBodyBytes, which overrides DwServerSettings.maxBodyBytes for this call's body (a positive number; a larger limit for a call that carries a lot, a smaller one for a command that has no business receiving much).

single — absent is a refusal​

DwCallHandler.single<Q extends DwSingleRequest<T>, T extends DwDataObject>({
required DwAccessRule access,
int? maxBodyBytes,
required Future<T?> Function(DwCallContext ctx, Q request) handle,
})

Return null when the object does not exist, or the caller may not know that it does: the framework refuses dw.notFound (404). No handler can forget to.

maybe — absent is an answer​

Same signature over DwMaybeRequest<T>; null is sent to the client as a value.

list — the whole list​

DwCallHandler.list<Q extends DwListRequest<T>, T extends DwDataObject>({
required DwAccessRule access,
int? maxBodyBytes,
required Future<List<T>> Function(DwCallContext ctx, Q request) handle,
})

Whole-list repository reads use request.readWhole((limit) => repo.find(limit: limit)). The nullable limit probes one row past maxItems; overflow throws in strict mode, while production re-reads once without a limit and warns with the whole answer's count and size. The callback must apply null as no limit and perform only reads. See a list's bound.

page — an offset feed​

DwCallHandler.page<Q extends DwPageRequest<T>, T extends DwDataObject>({
required DwAccessRule access,
int? maxBodyBytes,
required Future<List<T>> Function(DwCallContext ctx, Q request, DwPageInput page) handle,
})

DwPageInput carries offset (rows already loaded), pageSize (the request class's page size, or the size the call asked for clamped to its maxPageSize) and fetchLimit (pageSize + 1). Read up to fetchLimit rows after offset, in a total order. The framework trims the extra row and sets hasMore — which is how it knows another page exists without counting. Reading more than fetchLimit is a handler bug and fails the call loudly instead of being trimmed: it means a handler reads a table where it should read a page.

table — numbered pages with a total​

DwCallHandler.table<Q extends DwTableRequest<T>, T extends DwDataObject>({
required DwAccessRule access,
int? maxBodyBytes,
required Future<List<T>> Function(DwCallContext ctx, Q request, DwTableInput table) rows,
required Future<int> Function(DwCallContext ctx, Q request) count,
})

DwTableInput carries page (from 1), pageSize (clamped to maxPageSize), offset and fetchLimit. rows reads up to fetchLimit rows after offset; count counts every row the request matches. The framework calls count only when the rows cannot tell the total — a full page (more may follow) or an empty page past the first. A short page is the last one, and its total is offset + rows. The common small table costs one query, and the total never disagrees with the rows: a count that comes back lower than the rows already seen is raised to them.

From example/dartway_example_server/lib/src/admin/admin_handlers.dart:

DwCallHandler.table<ListUserProfiles, UserProfile>(
access: ProfileAccess.admin,
rows: (ctx, request, table) async => [
for (final row in await ctx.db.userProfiles.find(
where: _membersFilter(request),
orderBy: (t) => [t.firstName.asc(), t.id.asc()],
limit: table.fetchLimit,
offset: table.offset,
))
ProfileObjects.profile(row),
],
count: (ctx, request) =>
ctx.db.userProfiles.count(where: _membersFilter(request)),
),

window — an anchored window, newest first​

DwCallHandler.window<Q extends DwWindowRequest<T, S, I>, T extends DwDataObject,
S extends Object, I extends Object>({
required DwAccessRule access,
int? maxBodyBytes,
required Future<List<T>> Function(DwCallContext ctx, Q request, DwWindowInput<S, I> window) handle,
})

The request class names a row's place in the sequence (positionOf): a sort value S (int, String, DateTime or DwCalendarDay) and an id I (int or String). A calendar day sorts by its Gregorian year, month and day and is encoded in a cursor as canonical YYYY-MM-DD. Other types throw ArgumentError when the handler is declared, because a cursor of them could not be encoded.

The handler reads one direction at a time. DwWindowInput carries:

  • direction — DwWindowDirection.older (rows below position, newest first) or newer (rows above it, oldest first);
  • position — the (sortValue, id) pair to start from; null only for the newest rows;
  • includesPosition — whether the row at position itself is read (the anchor of a window opened around it);
  • fetchLimit.

The framework composes the rest: around an anchor it reads newer rows for half the page, then the anchor and older rows, and a third read only when older rows run short. It sets the cursors. A cursor from the client that does not decode to S and I is a malformed call. The example's chat (ListChatMessages in example/dartway_example_server/lib/src/chat/chat_handlers.dart) compares (sentAt, id) as a pair in both directions. See requests and updates and the window list view.

command — a change​

DwCallHandler.command<C extends DwActionCommand<R>, R>({
required DwAccessRule access,
bool transactional = true,
int? maxBodyBytes,
required Future<R> Function(DwCallContext ctx, C command) handle,
})

transactional: true (the default) runs the idempotency lookup, the access check, the handler and the outcome record in one database transaction. ctx.db is that transaction. Like every top-level transaction, it is retried whole on a serialization failure or a deadlock, up to three attempts, with the per-call memo cleared; what the failed attempt published was discarded with it. So a transactional handler must do nothing a retry would repeat outside the database.

transactional: false is for handlers that call external services — a payment provider, a storage request — which must not run inside a transaction that may be retried or held open for seconds. ctx.db is then the pool; open ctx.transaction around the writes.

The outcome — the result, or a refusal — is stored under the idempotency key and replayed to a retry of the same key; a failure is not stored, so it can be retried. See commands and idempotency.

recordsSuccess: false skips storing a successful outcome — only for a command whose own unique keys already make a repeat harmless and which is sent often enough for an outcome row per call to matter. The analytics batch is the one in the framework: its events are unique by install and sequence (analytics).

DwCallContext​

One context per call. Everything a handler may touch is on it.

MemberWhat it is
accountIdthe caller's account, or null
requireAccountIdthe account; throws DwNotAuthenticatedException, answered 401
sessionKeythe DwSessionKeyInfo that authenticated the call — the server's record, never the client's word (keys)
dbthe DwDatabaseHandle: the transaction inside a transactional command or ctx.transaction, the pool otherwise
protocolthe DwWireProtocol (for DwDeletedObject.of<T>(id, ctx.protocol))
nowthe current instant, UTC, from the server's clock — the one way server code reads the time (below)
callerUtcOffsetthe caller's UTC offset as its app sent it, a Duration, or null when unknown
callerLocalTimea DwCallerLocalTime: what the caller's clock reads now — date, hour, weekday, the instant their day began — or null when the offset is unknown
transaction(body)runs body in a transaction, a savepoint when already inside one; publications, revocations and jobs made inside take effect only if it commits
publish(channel, item)sends a data object or a DwDeletedObject to a channel after commit (channels)
revoke(channel, accountId)closes an account's subscriptions to a channel after commit
refuse(code, {params, field})throws the refusal; returns Never
jobsthe DwJobQueue; an enqueue joins the current transaction (jobs)
accountsa DwAccountService bound to this call (auth)
filesthe DwFileService (uploads)
settingsthe app's settings objects: read<S>(), save(value), update<S>(change) (database)
loga DwServerLogger scoped to the call (command BookSession)
processexternal commands through DwProcessRunner, bounded and replaced by the test server
httpthe DwOutboundHttp: requests to other services' HTTP APIs, below
memo(key, create)a per-call cache: create runs at most once per key per call

Time: ctx.now and the caller's offset​

ctx.now is the time. It reads the server's clock — DwAppServer(clock: …), the system's by default — on every access, in UTC, the same way in a handler, a job, a route, a channel rule and a startup step. A test gives the server a DwTestClock and sets or moves it; the job queue decides due times by the same clock, so a job enqueued for tomorrow runs when the test moves the clock to tomorrow (jobs). DateTime.now() anywhere in a server's lib/ is refused by dartway check (forbiddenDateTimeNow): a time no test can set, and one that disagrees with the job queue the moment a test moves the clock.

The caller's offset travels with every call. The app reads its device's UTC offset at each call and sends it as Dw-Utc-Offset, whole minutes east of UTC (the wire); the handler reads ctx.callerUtcOffset, and ctx.callerLocalTime for what the caller's clock reads — year, month, day, hour, minute, weekday, and startOfDayUtc, the instant their day began:

final local = ctx.callerLocalTime;
if (local == null) ctx.refuse(DwCoreRefusal.invalid); // an app too old to send it
// "The caller's today" as instants, for a query:
final from = local.startOfDayUtc;
final to = from.add(const Duration(days: 1));

DwCallerLocalTime is a reading, not an instant, and deliberately not a DateTime: a DateTime holding local fields would pass for an instant in a DTO field, a timestamptz column or isBefore and be wrong by the offset each time. startOfDayUtc is its one way back to an instant. A command does not carry the offset as a field of its own — the framework already does.

It is null wherever no device stands behind the context: in jobs, routes, startup steps, channel subscription checks and DwAppServer.callAs, and on a call from an app that sent none. It is an offset, not a time zone: there are no IANA zones, so the offset says what the caller's clock reads now, not what it will read after the next daylight-saving change. Work that runs later for a person — a job, a reminder at their 8 a.m. — needs an offset the project stored itself (the last one a call of theirs carried, say); the framework keeps none.

A request cannot publish​

A request is a read. The client retries it and caches its answer, so a side effect in one would happen again for every retry and never reach anyone reliably. publish, revoke and files.delete throw StateError in a request. Publish from the command that made the change.

ctx.transaction, not ctx.db.transaction​

Both open a transaction, but only ctx.transaction ties the call's effects to it and makes ctx.db the transaction inside the body. Inside ctx.db.transaction((tx) …), ctx.db is still the outer handle — a write through it escapes the transaction — and a publication made there is delivered even when that transaction rolls back.

Outbound HTTP​

A request to somebody else's API — an SMS gateway, a CRM, a model provider — is ctx.http, in a handler, a job, a route or a startup step alike. There is no second client: dartway check refuses HttpClient(, package:http and DwOutboundHttp.network in lib/ (forbiddenHttpClient).

final response = await ctx.http.post(
Uri.parse('https://sms.example.com/send'),
headers: {'authorization': 'Bearer ${sms.token}'},
json: {'phone': phone, 'text': text},
);
if (!response.isSuccess) throw SmsException(response.statusCode);
  • get, post, put, patch, delete and send(method, url) to an http or https URL (anything else is an ArgumentError); a body is json: (encoded, with its content type) or body: — a String, bytes, or a Map<String, String> sent as a form.
  • Bounded. The whole exchange, from connecting to the last byte of the answer, by DwServerSettings.outboundTimeout (30 s), or the call's own timeout:.
  • Capped. A response body larger than DwServerSettings.outboundMaxResponseBytes (10 MiB), or the call's maxResponseBytes:, is not read further: DwOutboundException.
  • Logged through ctx.log: method, origin, status and time. Never the user info, the path, the query, a header or a body — that is where credentials travel (/bot<token>/, ?psw=). Transport failure text is untrusted too: DwOutboundException.reason retains only fixed no-response/network-failure categories or framework-owned numeric timeout/size diagnostics. The optional constructor cause text is discarded; network client and I/O exception messages are not retained. Exception strings and framework incidents therefore do not echo that text. Provider response bodies are separate application data: do not include them in thrown errors.
  • An answer is an answer. Any status comes back as a DwOutboundResponse (statusCode, isSuccess, body, json, headers). What throws is not getting one: DwOutboundException, with timedOut telling a timeout from an unreachable host.
  • followRedirects: false for a request whose headers carry a credential, which a redirect would carry to wherever it points.
  • Not inside a transaction. The transaction holds its locks and its connection for as long as the other side takes. Commit first — a transactional: false command or a job — then call out.

In a test, every DwTestServer answers ctx.http from its DwFakeOutboundHttp, server.http, and refuses a request no rule or cassette answers with a StateError — no test reaches the network by accident, and nothing is threaded through the project's server factory to arrange it:

server.http.when(
(request) => request.url.host == 'sms.example.com',
(request) => DwOutboundResponse(200, json: {'id': 7}),
);
// … the command that sends the SMS
expect(server.http.requests.single.json, {'phone': '79990000001', 'text': 'Code: 1234'});

For coverage of the external seam, attach a recorded real answer with server.http.useCassette(DwSeamCassette('test/cassettes/provider.json')). Handwritten rules are for exceptional cases; they do not prove the real provider's contract. Recordings replay offline unless explicitly recording or verifying live shapes; see recorded external seams.

The rule added last is asked first, so a test overrides what its harness scripted; a rule that throws DwOutboundException makes the provider unreachable. A class that talks to a provider takes a DwOutboundHttp (ctx.http) per call, so it is unit-tested without a server over the same fake: DwFakeOutboundHttp().client().

A standalone entry point in bin/ or tool/ can share the provider class without starting a server:

final http = DwOutboundHttp.network(
settings: const DwServerSettings(outboundMaxResponseBytes: 64 << 20),
);
try {
await gateway.send(http, phone, text);
} finally {
http.close();
}

This client owns its connections. Its defaults match the server's timeout and response cap; log: supplies the tool's logger. Borrowed context and fake clients reject close because their transport belongs to their owner. Server handlers keep using ctx.http so tests use the fake. For base64 image responses, the cap includes the encoded text and JSON overhead. Prefer a per-call maxResponseBytes: 64 << 20 over raising every request's limit: each whole response is buffered in memory.

Framework satellites keep their own clients for now — push delivery (dartway_push_server) and the sign-in providers' key fetches (dartway_auth_providers_server) — as does the storage client inside the core.

A project's own notions of the caller​

The framework knows an account. What the account is to the project — a profile, a role — is the project's, added by extension and cached per call with memo. It is the profile feature's surface every other feature imports — core/ imports no feature. From the skeleton (template/dartway_starter_server/lib/src/profile/profile_access.dart):

extension ProfileCallContext on DwCallContext {
/// The caller's profile, read once per call.
Future<UserProfileRow> get profile => memo(#profile, () async {
final accountId = requireAccountId;
final profile = await db.userProfiles.findFirst(
where: (t) => t.accountId.equals(accountId),
);
// Created in the same transaction as the account: absence is a broken
// invariant, not a state a caller can be in.
return profile ?? (throw StateError('Account $accountId has no profile'));
});

Future<bool> get isAdmin async => (await profile).role == UserRole.admin;
}

/// Access rules of the app, in the words handlers read.
abstract final class ProfileAccess {
static final DwAccessRule admin = DwAccessRule.check<DwServerCall<Object?>>(
(ctx, _) => ctx.isAdmin,
);
}

The access check and the handler then share one profile read. The memo is cleared when a top-level transaction is retried, so a retry never sees a value read in the rolled-back attempt.

Rows become data objects, in batch​

A row never leaves the server; handlers map rows to the data objects clients see, explicitly. Do it for a list at a time, with one query per relation — never one per row. The ORM has no joins by design (D-011); findByIds is the join.

example/dartway_example_server/lib/src/schedule/schedule_objects.dart maps sessions with their services and coaches:

static Future<List<ClubSession>> sessions(
DwDatabaseHandle db,
List<ClubSessionRow> rows,
) async {
if (rows.isEmpty) return const [];
final services = {
for (final row in await db.clubServices.findByIds(
rows.map((s) => s.serviceId).toSet(),
))
row.id: service(row),
};
final coaches = await ProfileObjects.rowsById(
db,
rows.map((s) => s.coachProfileId),
);
return [
for (final row in rows)
ClubSession(
id: row.id,
service: services[row.serviceId]!,
coach: switch (coaches[row.coachProfileId]) {
final coach? => ProfileObjects.person(coach),
null => null,
},
startsAt: row.startsAt,
capacity: row.capacity,
bookedCount: row.bookedCount,
),
];
}

BookingsObjects.bookings beside it (bookings/bookings_objects.dart) also takes the related objects the caller already holds (client:, session:), so a command that has just loaded the session does not load it again. The skeleton's template/dartway_starter_server/lib/src/profile/profile_objects.dart does the same with framework data: the identifiers of every profile in one ctx.accounts.listIdentitiesOf call, and avatar URLs in one ctx.files.publicUrls call.

Mapping one row is the list of one: (await ScheduleObjects.sessions(ctx.db, [row])).single. Then a handler that answers one object and a handler that answers a hundred build it the same way.

Publishing is mapping too. A changed row often goes to several channels, as several objects; the skeleton keeps that in one function per change (template/dartway_starter_server/lib/src/profile/profile_publications.dart, ProfilePublications.profile), so every command that changes a profile publishes the same set.

External commands​

Use await ctx.process.run('git', ['--version']) for server commands. The arguments are passed directly, without shell expansion. Optional workingDirectory, environment, timeout and maxOutputBytes control the call. The default limits are 30 seconds and 10 MiB across stdout and stderr. DwProcessResult carries exitCode, stdout and stderr; a nonzero exit is a result, while a failure to start, timeout or excessive output throws DwProcessException. Command errors never print arguments or output that could hold secrets. Keep external commands outside database transactions.

DwTestServer.process is offline by default. Attach real recorded answers with server.process.useCassette(DwSeamCassette('test/cassettes/command.json')), or add a when rule for an exceptional outcome. Direct Process.run, Process.runSync and Process.start in server lib/ fail the forbiddenProcessRun check, including tear-offs. Imports/exports of package:process and package:process_run also fail it.