Dox 3.1 is out
Dox 3.1 adds new routing tools, request helpers, new ORM features, access tokens, a new MCP package, mail and queue features, and many security and bug fixes.
This post covers 3.1.0 (November 8) and the two patch releases after it, 3.1.1 and 3.1.2 (November 9). A change that came in a patch has its version after it, like "(3.1.1)". Everything else came in 3.1.0.
How to update
-
Update the
doxCLI: -
In your
pubspec.yaml, set the Dox packages to^3.1.0(or newer), then get them: -
Make the generated code again. Relations and DTOs get new code in 3.1.
-
Read the next section. Some changes need a code change.
Read before you update
These changes can change how your app works.
ORM
- Models are guarded by default.
guardedis now['*']. A model with nofillablelist throwsMassAssignmentExceptionwhencreate(),update(),insert(),upsert(),firstOrCreate()or a similar method gets a map. Before, it took every key, soUser().create(req.body)could setidoris_admin. To fix it, addfillablewith the columns a map may set. Or setguardedtoconst <String>[]to take every key, as before.save()and fields you set in code do not change. See Mass assignment. - A write with no key and no
where()throws.Blog().delete(),Blog().update({...}),forceDelete(),increment()anddecrement()on a model with no primary key value and nowhere()used to change every row of the table. Now they throw aStateErrorand run no query. To change every row on purpose, useBlog().whereRaw('1 = 1').delete()(or.update()).restore()on a query with nowhere()throws too. findOrFail()andfirstOrFail()answer 404, not 500.ModelNotFoundExceptionis now an HTTP exception with code 404. Its constructor is no longerconst.- A digit in a model name gets no
_.Address2now gives the tableaddress2, the foreign keyaddress2_id, the pivot tableaddress2_tagand the count keyaddress2_count. Before, it wasaddress_2. If you use these default names, rename the table or column, or settableName,foreignKeyand the pivot names by hand. - New many to many methods on every model:
attach,detach,sync,syncWithoutDetaching,toggle,updateExistingPivotandpivot. A@Scope()with one of these names is hidden when you call it right on a model, likeSong().sync(). Rename such a scope. - A nullable list relation is
[]after a preload with no rows, notnull. For exampleList<Tag>? tags. Runbuild_runneragain. @Uniquetrusts the database. On MySQL and MariaDB, text compares without case. SoTAKEN@dox.devnow fails@Uniquewhentaken@dox.devis in the table.update()anddelete()withgroupBy()orhaving()throw anArgumentError. The SQL was never valid.
Routing and core
RateLimitcounts are shared by all isolates (without Redis).maxnow holds for the whole server, not for each isolate. An app with more than one isolate gets a lower real limit than before. Raisemaxif you need the old number.Route.mountmatches whole path parts. A mount at/admingets/adminand/admin/..., but no longer/administrator. When two mount prefixes match, the longest one wins. Before, the first one added won.- Removed helpers:
getDoxRequestandmiddlewareAndControllerHandlerare gone fromdox_core. They were not used. UseDoxRequest.
Validation
IpRuleis no longer aRegexRule, soIpRule().patternis gone.v.string().ip()now takes IPv6 too, like@Ip. Its JSON Schema isanyOfIPv4 and IPv6.ip()and@Ipnow say no to IPv4 addresses with leading zeros, like01.1.1.1.alphaDash()and@AlphaDashnow allow digits, likepost-123. The message and the JSON Schema pattern change too.email()now takes+and'before the@, likeann+tag@x.com.
CLI
doxexits with code 1 when there is no command, an unknown command, bad arguments, or a command that throws. Before, it exited with 0.dox make:model Postnow writes@DoxModel(table: 'posts'), the same plural table that-mmakes. The new model has an emptyfillablelist.migration:fresh,migration:reset,migration:refresh,migration:rollbackandkey:generateask first whenAPP_ENV=productionand you run them in a terminal.--forceskips the question. Without a terminal (CI, deploy scripts), they print a warning and run as before.dox make:serializermakes the file inlib/app/serializers/, notlib/app/http/serializers/(3.1.1).
Other packages
- Queue: deploy the queue worker together with the web app. An old worker does not know the new built-in mail job, so it fails queued emails.
dox_queuenow depends ondox_mailer. - Custom queue drivers:
release()must now also savejob.payload, so the worker can keep the exception count. The newclear()andpruneFailed()throw anUnsupportedErroron a custom driver that does not override them. - Storage:
url()on a local disk with nourloption gives/storage/<path>. Before, it threw. New signed URLs include the disk name, so they do not work on 3.0.1. During a rolling deploy, they can get 403 until they expire. A signedStorage.route()on a disk that is not local still gives 403, and now also reports anUnsupportedOperationException. - Mail:
Attachment.fromFilereads the file when the message is sent or queued, so a missing file now throws fromsend(). - Views: components always get
$slotand$slots.main, as empty text when there is no body. So{{ $slots.main ?? 'x' }}no longer printsx. An emptySafeStringis false in@if. - OpenAPI: a route with an optional param, like
/greet/{name?}, gives two paths:/greet/{name}and/greet. The short path gets its own summary and operation id. - Inertia: an empty
200answer to an Inertia request now goes back to the page it came from.
New
Routing
Route.match(methods, path, handler)andRoute.any(path, handler).Route.redirect(from, to)(302) andRoute.permanentRedirect(from, to)(301)..where(param, regex): the route matches only when the param matches the regex. The value is checked after%decoding, and a leading^and trailing$are fine.Route.fallback(handler)answers a GET or HEAD that matches no route. It follows the group prefix, also one with a param like{tenant}.Route.staticFiles(prefix, folder, maxAge:)serves a folder, withETag,Last-Modified,304and Range support. Files in.well-knownare served too. The folders reach every isolate.MethodOverridemiddleware: a plain HTML form can reachRoute.put,Route.patchandRoute.deletewith a_methodfield.MethodOverride(header: true)also reads theX-HTTP-Method-Overrideheader.Route.mount(prefix, handler, middleware: true)runs the global middleware before a raw handler.Route.websocket(maxMessageSize:).AppConfig.idleTimeoutandAppConfig.maxConnectionsfor the server.- In development, a warning when a middleware returns
null.
Route.redirect('/home', '/');
Route.get('/users/{id}', (UserController c) => c.show).where('id', '[0-9]+');
Route.staticFiles('/', 'public');
Route.fallback((DoxRequest req) {
return response(<String, String>{'message': 'Page not found'}).statusCode(404);
});
Docs: Routing, Static files, Native server
Requests and responses
req.input(key, defaultValue)with dot keys, likeuser.nameoritems.0.id.filled,exists,missing,except,booleanandinteger.file(key)andhasFile(key)for uploads.bearerToken(),path(),fullUrl(),pathIs(pattern)androuteIs(pattern).pathIs()cleans the path like the router, so/admin/and/admin//matchadmin.req.auth?.id()gives the id of the logged-in user.SessionMiddleware().fromCookie(value)loads a session with no request, for a Socket.IO login.DoxCookie.decrypt(name, value)decrypts a cookie value.RequestBodyandBodyKindare exported, soreq.requestBody.kindcan tell invalid JSON from no body (3.1.1).ValidationException.issueskeeps every issue of a failed check, for your own 422 shape (3.1.1).RequestFile.headkeeps more bytes of an upload, so image size checks work.
Security, encryption and cache
- WebSocket
Origincheck:Route.websocket(allowedOrigins: [...])orAppConfig.webSocketAllowedOrigins. It is off by default. A WebSocket route with no check prints a warning at start. - A warning when CORS has origin
*together withcredentials: true. Crypt.encryptString,Crypt.decryptString,Crypt.encryptandCrypt.decryptwith theAPP_KEY. A value that can not be decrypted throwsDecryptException(400).Hash.check, the same asHash.verify.Hash.sha256(value)to store API keys and tokens as a hash (3.1.1). Do not use it for passwords.Cache().many(keys)andCache().putMany(values).Cache().flexibletakes a nullstale: it keeps the last good value until a refresh replaces it (3.1.1).- The file cache driver can delete old files by itself, with the
prune_everyoption.
Docs: Encryption, Hashing, Cache
Validation and DTOs
- New rules, each as a fluent rule and a DTO annotation:
same/@Same,confirmed/@Confirmed,requiredIf/@RequiredIf,requiredWith/@RequiredWith,json/@Json,digits/@Digits,digitsBetween/@DigitsBetween,dateFormat/@DateFormattedandpassword/@Password. between(1, 10)onv.int()andv.num().exists('teams')andunique('users', column: 'email')in fluent schemas. The ORM answers them.- File rules:
maxSize,minSize/@MinSize,mimes,mimeTypes/@MimeTypesanddimensions/@Dimensions. The MIME type is checked against the real file bytes. hexColor()/@HexColor()(3.1.1).@Dto(emptyStringsToNull: true)reads""asnull(3.1.1).@OrDefault()gives a field its default for a bad value, not an error. Use it for query strings like?page=abc(3.1.1).@Describe('...')puts adescriptionin the JSON Schema of a field and in the OpenAPI docs (3.1.2).@Dto(messages:)matches the full dotted path first, liketags.*.required.RuleContext.sibling('key')reads another key of the same object.ImageSize.read(head),FileTypes.matchesMimeTypesandFileTypes.extensionsOffor your own file rules.- The build stops with a clear error when a rule names a Dart field instead of its input key.
@Dto()
class Register {
@Describe('Shown on the profile page.')
@Length(min: 1, max: 40)
final String name;
@Password(min: 8, mixedCase: true, numbers: true)
@Confirmed()
final String password;
@RequiredIf('type', 'company')
final String? vat;
// ...
}
Docs: Validation, Validation rules
ORM and database
- Many to many writes:
attach(),detach(),sync(),syncWithoutDetaching(),toggle()andupdateExistingPivot(). @ManyToMany(withPivot: [...], withTimestamps: true)reads extra pivot columns, withsong.pivot['role'].- Polymorphic relations:
@MorphMany,@MorphOneand@MorphTo, withModel.morphMap(). - Pages:
simplePaginate()(no total count) andcursorPaginate()(a page after a cursor, noOFFSET). - Sub queries:
whereIn()with a query or anyIterable(like aSet),joinSub(),leftJoinSub()andfromSub(). union()andunionAll(). Read a union withget(). Methods that need one row or one part, likefind()orpluck(), throw aStateErroron a union.- Dates:
whereDate(),whereTime(),whereYear(),whereMonth()andwhereDay(). - JSON:
whereJsonContains()andwhereJsonDoesntContain(). whereNot(column, value),orWhereNot(),unless()andwhenValue().first()anddoesntExist(), the same asgetFirst()andhasNoRows().insertGetId(),insertMany()andinsertOrIgnore().onlyTrashed(),touch(),touchQuietly()andreplicate().withAvg(),withMin()andwithExists().forceCreate(),forceUpdate(),forceFill()andModel.unguarded()skipfillableandguarded. Do not pass user input to them.forceFill()throws anArgumentErrorwhen it can not set a column.toRawSql()gives the SQL with the values in it, for logs.ConnectionConfig.logRedacthides values, like passwords, in the query log. Each named connection uses its own list.FileQueryPrinter(buffered: true)writes the query log in the background.- Schema helpers:
foreignIdFor(),ulid(),foreignUlid(),year(),ipAddress(),softDeletesTz(),nullableMorphs(),uuidMorphs(),dropTimestamps(),dropSoftDeletes(),dropConstrainedForeignId(),dropPrimary(),renameIndex(),fullText(),nullOnUpdate()andrestrictOnUpdate(). - Postgres with
useSSL: trueprints a warning that the server certificate is not checked. Setextra['sslMode'] = 'verify-full'to check it. - MySQL: the new
sslRootCertoption (URL optionssl_ca) trusts a private CA. Without TLS, it prints a warning that it is ignored. - MySQL: a pool that can not open now says why.
find(5, null)throws a clearArgumentError: the first arg must be a column name.package:dox_migration/env.dartexportsloadEnv.
// many to many
PivotSyncResult result = await artist.sync('songs', <int>[1, 2, 3]);
// a page after a cursor
CursorPagination<Blog> page = await Blog()
.orderBy('id')
.cursorPaginate(perPage: 10, cursor: req.input('cursor'));
Docs: Query builder, Model, Many to many, Polymorphic, Schema, Pagination
Auth
- Access tokens, opt-in. A token looks like
oat_<id>.<secret>. The table keeps only a hash of the secret. Each token has a name, abilities and an expiry. dox auth:tokens-tablewrites the migration for the access tokens table.AuthMiddleware(guards: ['web', 'api'])tries several guards in order.AuthMiddleware(optional: true)lets guests through too, soreq.can()works on a public page.GuestMiddlewarelets only guests through, for login and register pages.Auth().check(),Auth().id(),Auth().guest()andAuth().once(credentials).Auth().userFromToken(token)andAuth().userFromSession(session)check a login with no request, like in Socket.IO.- Opt-in login throttle:
Auth.throttle = LoginThrottle(...). Give the ip withAuth(ip: req.ip()). - Opt-in JWT revocation:
JwtAuthDriver(revocation: true), withrevokeToken,revokeAllTokensandforgetUser. - Opt-in small JWT:
JwtAuthDriver(minimalPayload: true)puts only the user id in the token. Auth.needsRehash(hash)andAuth.hashCost(hash)to raise the password hash cost.JwtAuthDriver.bearerToken(req)reads the token.AuthDriver.reportError()reports errors.- New JWTs carry a
jticlaim. Tokens made before still work. - The error of
logout()on a token guard now says to userevokeToken().
No auth change is breaking. Apps work the same with no code change, and no user is logged out.
AccessToken token = await Auth(guard: 'api').createAccessToken(
user,
name: 'cli',
abilities: <String>['posts:read'],
expiresIn: const Duration(days: 30),
);
return <String, dynamic>{'token': token.value}; // shown only once
Docs: Authentication
MCP
dox_mcp is a new package. It turns a Dox app into an MCP server, so AI tools can call your code.
McpServer(Streamable HTTP),McpToolwithdox_validationinput schemas,McpTool.of,McpToolResultandMcpContext.SignedMcpSessions: session ids signed with theAPP_KEY.McpOAuth: the OAuth 2.1 sign-in of the MCP spec, with PKCE and dynamic client registration (3.1.1). From 3.1.2, thecode_verifier,code_challengeand redirect address are checked strictly.McpDtoTool<T>: a tool that takes a@Dtoclass as its input.handlegets the typed object.McpToolResult.invalidArguments()gives the error result for bad arguments (3.1.2).
class CreateCategory extends McpDtoTool<NewCategory> {
@override
String get name => 'create_category';
@override
String get description => 'Add a category.';
@override
DtoSchema<NewCategory> get dto => NewCategory.schema;
@override
Future<McpToolResult> handle(NewCategory input, McpContext context) async {
return McpToolResult.text('Added ${input.name}.');
}
}
Docs: MCP
Queue
Queue.fake()for tests, withassertPushed,assertPushedTimes,assertNotPushedandassertNothingPushed.- Job settings:
retryUntil,maxExceptionsandfailOnTimeout. dispatch(delayUntil: DateTime),dispatchIf()anddispatchUnless().- Worker flags:
--backoff,--max-jobs,--max-time,--memoryand--timeout-grace. - Commands:
dox queue:clear,dox queue:restart,dox queue:prune-failedanddox queue:table. ShouldBeEncrypted: save the job data encrypted with theAPP_KEY.- A built-in job sends
Mail.queue()andMail.later(). QueueConfig.redisFromEnv()reads the Redis server fromREDIS_URL, or fromREDIS_HOST,REDIS_PORT,REDIS_USERNAME,REDIS_PASSWORDandREDIS_TLS.
Docs: Queue
Mail and notifications
- Mailables: one class per kind of email, with
send(),queue()andlater(). - Markdown mail, from a
dox_viewtemplate. A text value fromdatastays text. Pass aSafeStringto put Markdown from a value. MailMessage: an email made of lines and a button, with no template.- Notifications:
Notification,MailChannel,Notifiableand your own channels. Mail.queue()andMail.later()send the email from the job queue. Withencrypt: true, a send error in the log does not show the recipient.Message.toJson()andMessage.fromJson(), and theMailQueueinterface withMail.useQueue().Mail.raw(), andMail.to(...).cc(...).bcc(...).send(...).Mail.alwaysTo(address)sends every email to one address, for example on staging.Mail.onSending()andMail.onSent()hooks, andMail.clearHooks().- Test checks on
Mail.fake():assertSent,assertSentCount,assertNotSent,assertNothingSent,assertQueued,assertNotQueuedandassertNothingQueued.
Docs: Mail, Notifications
Scheduler
ScheduleConfig(timezone: ...): the default time zone of every task.environments(['production']): run a task only in these environments.unlessBetween(start, end).- New frequencies, like
everyTwoHours,twiceDailyAt,lastDayOfMonth,quarterly,yearlyOnandmondays()tosundays(). - A warning when
onOneServer()uses a lock store that is not shared between servers. Scheduler.close()frees the overlap locks.- When a provider's
setup()throws,SchedulerRunnershuts down the providers that already started.
Docs: Scheduler
Storage
append(),prepend(),size(),lastModified(),makeDirectory()anddirectoryExists().temporaryUrl(), the same assignedUrl().file.store(folder, disk: 's3'),file.storeAs(folder, name)andStorage().putRequestFileAs().atomic_writes: a failed upload never leaves a half-written file.assertExists()andassertMissing()on a fake disk, for tests.
Docs: File storage
Socket.IO
SocketAuthlogs a socket in at connect time. The user is insocket.user.handshake.token,handshake.cookie(name)andawait handshake.session().ServerOptions(allowedOrigins:)turns on an origin check.
Docs: Socket.IO
Views
- New tags:
@isset,@empty,@auth,@guest,@env,@production,@once,@verbatim,@class,@checked,@selectedand@disabled. - Components get
$attributes(withmerge()) and$slot. ViewEngine(environment:)sets the environment for@envand@production. By default it isAPP_ENV.
Docs: Views
Inertia
Inertia.render(), the same asinertia(), andInertia.lazy, the same asInertia.optional.Inertia.shareGlobal(key, value)shares a prop with every page.- A deferred prop can be merged:
Inertia.defer(fn).merge(). - The asset version is checked before the controller runs, so an old page gets
409at once. - The root view and the Vite manifest are kept in memory.
- A prop that can not become JSON gives an error that names the prop and its type.
Docs: Inertia
OpenAPI
paramResponsesonRouteDoc: answers that only happen when the path has a param, like404.namedResponses: responses with a text key, likedefaultor2XX.- The
bodyof a GET or DELETE route goes in the query string.
Docs: OpenAPI
CLI
- Package commands: a package adds its own
doxcommands with thedox: commands:key in itspubspec.yaml.dox helplists them. They work in a pub workspace too. package:dox_core/command.dartwithCommandandMigrationFile, to write these commands.dox migratemakesbin/migrate.dartwhen it is missing.dox key:generate --showprints a new key and writes nothing.--stepgiven twice is an error.dox serve --port=...prints a warning that says to setAPP_PORT.
Docs: CLI, Package commands
App template
The template that dox create uses got new files too.
- Safer Docker Compose defaults, and a queue worker service.
AGENTS.mdand the.agents/skills/doxskill: rules and recipes for AI coding tools.CLAUDE.mdpoints to the same files (3.1.1).- Serializers live in
lib/app/serializers/(3.1.2).
Redis client
RedisOptions.fromUrl(url)reads aredis://orrediss://URL.RedisSetOptionis deprecated. It was empty and not used.
Fixes
Security
back()only follows aRefererof the same host. Before, it was an open redirect.- ORM
guardedcould be skipped with a changed key, like' is_admin'or'IS_ADMIN'. Now the check trims the name and ignores case. - A local signed URL for one disk no longer works on the route of another disk.
- Inertia ignores the Vite
public/hotfile whenAPP_ENVisproduction. - On MySQL, a key with a letter that is not ASCII can no longer set a guarded column.
- Postgres: an unknown
verifyvalue now means the full certificate check.
Routing and core
- A bad multipart body gets 400, not 500.
abort()headers are kept on routes with a next middleware.- A mounted handler that throws gives 500. Before, the client could hang.
- A download name with quotes or non-ASCII letters gets a safe
Content-Disposition. res.withHeadersandres.headerno longer drop or double headers.- A resumed download with
If-Rangeno longer mixes two versions of a file.If-Rangeis used only when it matches the file'sLast-Modifieddate or its strongETag. - A
Rangeheader sent on two lines no longer gives 500. req.session.reflash()keeps the flash data on the first use of the session.
ORM and database
- A model write with no key and no
where()no longer changes every row. blog.update({...})on a loaded model changes only that row and setsupdated_at.save()keeps anupdated_atthat you set.- A soft delete and
restore()setupdated_attoo. restore()on a query restores the soft deleted rows of the query.reload()and preload clear a relation when the related rows are gone.find('email', null)finds the row whereemailis null.limit()with a negative number is ignored.having('x', '!=', null)buildsx IS NOT NULL.update()anddelete()withjoin(),orderBy()orlimit()work on Postgres and SQLite.foreignId('x').unique().constrained()compiles.- A failed nested transaction releases its savepoint.
@Uniqueand@Existson Postgres with a value that does not fit the column no longer give 500.@nameinside a quoted string or a comment is no longer read as a param on MySQL and SQLite.- The query log lists the values in the right order.
Logger.warnprints yellow.- Rollback goes on when the file of a migration that already ran is gone.
- MySQL 8.4 and Postgres: a text param is no longer stored with the wrong type after a table change.
- MySQL 8.4: the same SQL with a number param first and a text param later no longer gives wrong rows.
- Postgres: a broken server message gives an error, not a crash of the process.
- Postgres: a query in a transaction is not sent again after a "cached plan" error.
- MySQL: a slow DNS lookup when a pool opens no longer stops the other isolates.
Auth
- The
Bearerprefix is matched in any case. - A JWT check no longer hides unexpected errors. They go to the exception handler.
- A request with two
Authorizationheaders is a normal 401.
Other packages
- Cache:
FileDriver.prune()no longer deletes a value written at the same time. - Mail:
Attachment.fromFileno longer blocks the server while it reads the file. AnonErrorofFailoverTransportthat throws no longer stops the failover. - Scheduler:
schedule workends a few seconds after Ctrl+C or SIGTERM.dailyAt('13')means 13:00. The overlap lock stays until the work really ends. - Socket.IO: CORS origin matching ignores case and a trailing
/. - Views:
{{ errors.email }}works with no session. A global namederrorsshows. ThetoJson()cache lasts one render. - Inertia: flash messages are kept after a deploy. A function prop inside a map or a list runs too.
- CLI:
migration:rollback --step 2(with a space) works. - Redis client: the client waits for AUTH and SELECT before it sends commands. A failed AUTH on reconnect no longer crashes the app. The connect has a timeout. A server that closes the connection right after connect no longer crashes the app. The handshake tries again when the server is not ready yet (
LOADING,BUSY,MASTERDOWN,TRYAGAIN). The wait between tries grows, and a long run of tries no longer ends in a stack overflow.disconnect()stops a try that is waiting.
Full list
Every change is in the changelogs:
- The Changelog page of the docs (
dox_core,dox_builder,dox_annotation,dox_cache). - GitHub releases: dox, dox-orm, dox-auth, dox-validation, dox-mcp, dox-cli, dox-queue, dox-mailer, dox-scheduler, dox-storage, dox-socketio, dox-view, dox-inertia and dox-openapi.