Transactions, Model Middleware & Soft Delete
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools
version 6.3, as the official template does), with fluent 4.13.0, fluent-kit 1.57.0, fluent-sqlite-driver 4.9.0 and
sql-kit 3.36.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with
zero warnings in a scratch package, and the routes were run against an in-memory SQLite database and exercised with
curl; the outputs shown are captured from those runs. Nothing here was run against a live PostgreSQL or MySQL server:
where behaviour on those drivers is described, it comes from reading the driver sources, and the page says so. Vapor 5
is at 5.0.0-beta.4 and is not released yet; Vapor 5 and its Fluent were not exercised for this page; check
What’s New, Upgrading and Vapor 5 before porting.
The page extends the Bookshelf models of Fluent Models and the earlier pages of
this section. It adds two columns to books and one table. These are page-local additions; the canonical models are
not renamed:
-
books.deleted_at(datetime, nullable), mapped as@Timestamp(key: "deleted_at", on: .delete) var deletedAt: Date?; -
books.version(int, not null, default 1), mapped as@Field(key: "version") var version: Int, initialised to1by the model’sinit; -
an
audit_entriestable (entity,entity_id,action,detail,created_at) written by the model middleware.
Swift itself is covered by the Swift Reference; closures that cross
isolation domains and Sendable by Actors,
Isolation and Sendable. The same topics in Rust are in Migrations &
Transactions.
Where these features sit
None of the three is part of the HTTP pipeline. They live in the Fluent layer, below the handler:
-
a transaction is a database connection held for the duration of a closure; everything run on the
Databasevalue the closure receives belongs to oneBEGIN…COMMIT/ROLLBACK; -
model middleware wraps the persistence operations of a model instance (
create,update,delete,restore). Each call such asbook.save(on:)builds a chain from the middleware registered on the database and ends at the built-in implementation that runs the query; -
soft delete is not a separate mechanism: it is a
@Timestampwith trigger.delete. Fluent turnsdeleteinto anUPDATEof that column and addsdeleted_at IS NULLto queries.
In a request the order is: the global middleware chain of the application, the router, the handler, and from the handler the Fluent calls, each passing through the model middleware to the driver.
Transactions
The API
Database has two transaction methods in fluent-kit 1.57.0 (Database.swift and
Concurrency/Database+Concurrency.swift):
// FluentKit 1.57.0, abridged
extension Database {
func transaction<T: Sendable>(
_ closure: @escaping @Sendable (any Database) async throws -> T
) async throws -> T
}
// the requirement every driver implements (legacy, EventLoopFuture based):
// func transaction<T>(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture<T>) -> EventLoopFuture<T>
Semantics, from the SQLite and PostgreSQL driver sources:
-
the driver checks out one connection from the pool and issues
BEGIN; -
it calls your closure with a new
Databasevalue,db, bound to that connection; -
if the closure returns, the driver issues
COMMITand returns your value; if it throws, the driver issuesROLLBACKand rethrows the same error.
The consequence that matters most: only queries run on db are inside the transaction.
Rollback on throw
The first example creates an author and all of their books atomically. The second book repeats an ISBN, the unique constraint fails, and the author row and the first book vanish with the rollback:
import Fluent
import Vapor
struct NewBook: Content, Sendable {
var title: String
var isbn: String
var publishedYear: Int
var priceCents: Int
}
struct NewAuthorWithBooks: Content, Sendable {
var name: String
var books: [NewBook]
}
struct CreatedAuthor: Content, Sendable {
var id: UUID
}
func transactionRoutes(_ app: Application) {
app.post("authors") { req async throws -> Response in
let input = try req.content.decode(NewAuthorWithBooks.self)
do {
let authorID = try await req.db.transaction { db in
let author = Author(name: input.name)
try await author.save(on: db)
let id = try author.requireID()
for b in input.books {
try await Book(title: b.title, isbn: b.isbn, publishedYear: b.publishedYear,
priceCents: b.priceCents, authorID: id).save(on: db)
}
return id
}
return try await CreatedAuthor(id: authorID).encodeResponse(status: .created, for: req)
} catch let error as any DatabaseError where error.isConstraintFailure {
throw Abort(.conflict, reason: "A book with that ISBN already exists")
}
}
}
Source: Vapor docs: Fluent, Transaction
Run against SQLite with two books that normalise to the same ISBN, then with two different ISBNs:
curl -s -i -X POST http://127.0.0.1:8137/authors -H 'Content-Type: application/json' -d '{"name":"Ada","books":[
{"title":"A1","isbn":"978-0-306-40615-7","publishedYear":2001,"priceCents":1000},
{"title":"A2","isbn":"9780306406157","publishedYear":2002,"priceCents":1200}]}'
# HTTP/1.1 409 Conflict
# {"error":true,"reason":"A book with that ISBN already exists"}
curl -s http://127.0.0.1:8137/authors/count # 0 - the author row was rolled back
curl -s http://127.0.0.1:8137/books # [] - so was the first book
With the second request using a different ISBN, the response is 201 with the author id, the author count is 1 and
/books lists both books.
Points about the listing:
-
dbis the closure parameter;req.dbis not used inside the closure (see the next section). -
The closure is
@Sendable, so it may capture onlySendablevalues.inputis aSendableContentstruct. Capturingreqcompiles, becauseRequestisSendablein Vapor 4.122.2, but is best avoided; copy out what the closure needs. The result type must beSendable: returning the UUID is fine, returning a Fluent model needs the model to beSendable(the Bookshelf models are declared@unchecked Sendable). -
A unique violation surfaces as an error whose
DatabaseError.isConstraintFailureistrue(anany DatabaseError, cast withas?). Mapping it to409there keeps the driver’s message, which names tables and columns, out of the response. Without thecatchthe same request answers500withconstraintUniqueFailed: UNIQUE constraint failed: books.isbnin the body when the application runs in a development environment; production builds hide the reason of non-Aborterrors.
Use the closure’s db, never the outer database
The transaction owns a connection. Work done on any other Database value, including the req.db the transaction was
started from, runs outside it. Two failure shapes follow, and both were reproduced:
-
Writes escape the transaction. On a driver with a pool of several connections the outer write runs on another connection, commits on its own and is not rolled back when the closure throws.
-
Deadlock on a one-connection pool. The SQLite driver’s pool holds one connection per event loop by default (
maxConnectionsPerEventLoop: 1), so a write on the outer database inside the closure waits for the connection the transaction holds. Against SQLite this produced, after the 10 second defaultconnectionPoolTimeout:[ ERROR ] Connection request timed out. This might indicate a connection deadlock in your application. If you have long-running requests, consider increasing your connection timeout. [database-id: sqlite, ...]
The experiment was:
app.post("x", "outer-db") { req async throws -> Int in
let outer = req.db
do {
try await outer.transaction { _ in
try await Author(name: "written with the outer db").save(on: outer) // wrong: outer, not the closure's db
throw Abort(.badRequest)
}
} catch {}
return try await Author.query(on: outer).count() // 0: the write never got a connection
}
Source: Vapor docs: Fluent, Transaction
The same holds for helper functions: a service called from inside the closure must take the Database as a parameter
instead of reading req.db itself.
Nested transactions
Calling transaction on a Database that is already in a transaction does not start a new one. The SQLite and
PostgreSQL drivers check inTransaction and call the closure directly on the same db; there is no savepoint. (The
MySQL driver was not read for this page.) So an inner failure that the outer code catches does not undo the inner
writes:
app.post("x", "nested") { req async throws -> Int in
try await req.db.transaction { db in
try await Author(name: "outer").save(on: db)
do {
try await db.transaction { inner in
try await Author(name: "inner").save(on: inner)
throw Abort(.badRequest)
}
} catch {} // swallowed: the outer transaction still commits
}
return try await Author.query(on: req.db).count()
}
// POST /x/nested -> 2 (the "inner" author was committed although the inner closure threw)
Source: fluent-sqlite-driver 4.9.0: FluentSQLiteDatabase.swift and fluent-postgres-driver 2.14.0: FluentPostgresDatabase.swift
Only an error that leaves the outermost closure rolls back. If you need partial rollback, use raw SAVEPOINT
statements through SQLKit, which Fluent does not wrap.
More rules
-
Keep it short. A transaction pins a pooled connection and, in most databases, holds row locks until it ends. Do not call other services, read request bodies or sleep inside it.
-
No external side effects. Sending an e-mail or enqueueing a job inside the closure and then rolling back leaves the side effect behind. Do it after the transaction returns.
-
Retries. The closure may run again if you retry on a serialization failure; make it free of side effects.
-
Isolation level. Fluent has no API for it. Run
SET TRANSACTION ISOLATION LEVEL ...through SQLKit as the first statement on the closure’sdbif you need something other than the database default (not exercised here). -
Hooks run inside. Model middleware registered on the database also runs for models saved on the transaction’s
db, and its writes (the audit entries below) roll back with everything else.
Model middleware
The protocol
Model middleware lets you run code around the create/update/delete/restore operations of a model. In fluent-kit 1.57.0 the async form is:
// FluentKit 1.57.0, Concurrency/AsyncModelMiddleware.swift
public protocol AsyncModelMiddleware: AnyModelMiddleware {
associatedtype Model: FluentKit.Model
func create(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws
func update(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws
func delete(model: Model, force: Bool, on db: any Database, next: any AnyAsyncModelResponder) async throws
func softDelete(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws
func restore(model: Model, on db: any Database, next: any AnyAsyncModelResponder) async throws
}
All five have default implementations that call straight through to next, so a middleware implements only the hooks it
needs. The legacy ModelMiddleware (futures) has the same shape; new code uses the async one. A middleware is
Sendable (it is shared by every request), so it must be a struct with immutable Sendable state or an actor.
A hook receives the model instance, the Database to use, and next, the rest of the chain. Calling
next.create(model, on: db) (or next.update, next.delete(_:force:on:), next.softDelete, next.restore) performs
the operation. Code before the call is a "before" hook, code after it an "after" hook, and not calling next (throwing
instead) vetoes the operation.
Which method triggers which hook
| Call | Hook(s) | Notes |
|---|---|---|
|
|
Timestamps with |
|
|
Skipped silently by the built-in step if no field has changed. |
|
|
The timestamp is set before the chain runs; the row stays. |
|
|
The row is removed. |
|
|
The row is removed even if the model has a |
|
|
Clears the |
|
|
The array is then written with one batch query. |
|
none |
Query-builder operations never run model middleware. |
The last row is the pitfall that makes middleware unsuitable as the only place for an invariant. Reproduced on the running service:
app.delete("x", "batch") { req async throws -> HTTPStatus in
try await Book.query(on: req.db).filter(\.$publishedYear > 0).delete() // soft-deletes every book
return .noContent
}
// GET /books -> []
// GET /books/trash -> [{"title":"T", "deletedAt":"2026-10-17T15:31:09Z", ...}]
// GET /audit -> ["create 9780306406157"] no "softDelete" entry was written
A lifecycle middleware for Book
The next listing normalises and validates the ISBN before the insert, and writes an audit row after each operation. The
audit row is saved with the db the hook received, so it is part of the caller’s transaction when there is one.
import Fluent
import Vapor
final class AuditEntry: Model, @unchecked Sendable {
static let schema = "audit_entries"
@ID(key: .id) var id: UUID?
@Field(key: "entity") var entity: String
@OptionalField(key: "entity_id") var entityID: UUID?
@Field(key: "action") var action: String
@Field(key: "detail") var detail: String
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
init(entity: String, entityID: UUID?, action: String, detail: String = "") {
self.entity = entity; self.entityID = entityID; self.action = action; self.detail = detail
}
}
struct BookLifecycle: AsyncModelMiddleware {
typealias Model = Book
func create(model: Book, on db: any Database, next: any AnyAsyncModelResponder) async throws {
model.isbn = model.isbn.filter { $0.isNumber || $0 == "X" }
guard model.isbn.count == 10 || model.isbn.count == 13 else {
throw Abort(.unprocessableEntity, reason: "ISBN must have 10 or 13 characters")
}
try await next.create(model, on: db)
try await AuditEntry(entity: "book", entityID: model.id, action: "create", detail: model.isbn).save(on: db)
}
func update(model: Book, on db: any Database, next: any AnyAsyncModelResponder) async throws {
try await next.update(model, on: db)
try await AuditEntry(entity: "book", entityID: model.id, action: "update", detail: model.title).save(on: db)
}
func softDelete(model: Book, on db: any Database, next: any AnyAsyncModelResponder) async throws {
try await next.softDelete(model, on: db)
try await AuditEntry(entity: "book", entityID: model.id, action: "softDelete").save(on: db)
}
func restore(model: Book, on db: any Database, next: any AnyAsyncModelResponder) async throws {
try await next.restore(model, on: db)
try await AuditEntry(entity: "book", entityID: model.id, action: "restore").save(on: db)
}
func delete(model: Book, force: Bool, on db: any Database, next: any AnyAsyncModelResponder) async throws {
try await next.delete(model, force: force, on: db)
try await AuditEntry(entity: "book", entityID: model.id, action: "delete", detail: "force=\(force)").save(on: db)
}
}
The migration that adds the page-local columns and table (an AsyncMigration; see
Migrations & Schema):
import Fluent
import FluentSQL
struct AddSoftDeleteVersionAndAudit: AsyncMigration {
func prepare(on db: any Database) async throws {
try await db.schema("books").field("deleted_at", .datetime).update()
try await db.schema("books").field("version", .int, .required, .sql(.default(1))).update()
try await db.schema("audit_entries").id()
.field("entity", .string, .required).field("entity_id", .uuid)
.field("action", .string, .required).field("detail", .string, .required, .sql(.default("")))
.field("created_at", .datetime).create()
}
func revert(on db: any Database) async throws {
try await db.schema("audit_entries").delete()
try await db.schema("books").deleteField("version").update()
try await db.schema("books").deleteField("deleted_at").update()
}
}
Two separate update() calls add the two columns because SQLite’s ALTER TABLE adds one column at a time. The
.sql(.default(1)) constraint is what allows adding a NOT NULL column to a table that already has rows. It needs
import FluentSQL; with MemberImportVisibility enabled (as in the template) omitting the import is a compile error
(static method 'sql' is not available due to missing import of defining module 'FluentSQL').
Registering middleware and ordering
Middleware is registered per database in configure, with app.databases.middleware.use(_:on:):
import Fluent
import FluentSQLiteDriver
import Vapor
func configure(_ app: Application) async throws {
app.databases.use(.sqlite(.memory), as: .sqlite)
app.databases.middleware.use(BookLifecycle(), on: .sqlite)
app.migrations.add(CreateBookshelf())
app.migrations.add(AddSoftDeleteVersionAndAudit())
try await app.autoMigrate()
try routes(app)
}
Registered middleware sees every model saved on that database; the generated handle method passes models of other
types straight through, so BookLifecycle (whose Model is Book) ignores AuditEntry. That is also why the audit
write inside the hook does not recurse.
Order: the first registered middleware is the outermost. fluent-kit builds the chain by wrapping the built-in step
with the registered middleware in reverse order. To see it, a logging middleware was registered before and after
BookLifecycle:
struct TraceMiddleware: AsyncModelMiddleware {
typealias Model = Book
let name: String
func create(model: Book, on db: any Database, next: any AnyAsyncModelResponder) async throws {
db.logger.info("\(name) before create")
try await next.create(model, on: db)
db.logger.info("\(name) after create")
}
}
// app.databases.middleware.use(TraceMiddleware(name: "outer"), on: .sqlite)
// app.databases.middleware.use(BookLifecycle(), on: .sqlite)
// app.databases.middleware.use(TraceMiddleware(name: "inner"), on: .sqlite)
[ INFO ] outer before create [database-id: sqlite, request-id: DEA65298-...]
[ INFO ] inner before create [database-id: sqlite, request-id: DEA65298-...]
[ INFO ] inner after create [database-id: sqlite, request-id: DEA65298-...]
[ INFO ] outer after create [database-id: sqlite, request-id: DEA65298-...]
The outer middleware sees the model before BookLifecycle normalises the ISBN and the inner one sees it after. Put
validation middleware first, and middleware that reacts to a successful write last (inner).
app.databases.middleware.clear(on:) removes all registered middleware for a database. Middleware is per database ID:
a model saved on a second database (for example a read replica registered as another ID) does not run it unless you
register it there too.
Rules and pitfalls of hooks
-
Throwing before
nextvetoes the write; throwing afternextis too late for the row, but inside a transaction it rolls the row back. Outside a transaction a failing "after" hook leaves the write committed, so audit writes that must not be lost belong in a transaction. -
The
updatehook runs forsaveon an existing model even when no field changed; the built-in step then does nothing. Compare againstmodel.hasChangesif the hook should only fire on real changes. -
A hook that saves or deletes the same model calls the hooks again; guard against it or write through the query builder.
-
Do not do slow I/O in a hook; it runs on the request’s connection path.
-
An
Abortthrown from a hook propagates to the error middleware, so422for a bad ISBN needs no extra code (listingPOST /authors/:authorID/booksanswers422 {"reason":"ISBN must have 10 or 13 characters","error":true}). -
Hooks are code, not constraints. Keep real invariants (unique ISBN, foreign keys) in the schema; the hook is for normalisation, auditing and notifications.
Soft delete
What it is and how Fluent implements it
Soft delete marks a row as deleted instead of removing it, so it can be audited and restored. Fluent has no separate
property wrapper: a model opts in by having a @Timestamp with trigger .delete:
// in Book (page-local addition to the canonical model)
@Timestamp(key: "deleted_at", on: .delete) var deletedAt: Date?
With that property present, fluent-kit 1.57.0 changes behaviour in four places (Concurrency/Model+Concurrency.swift
and QueryBuilder+Concurrency.swift):
-
model.delete(on:)setsdeletedAtto now and issues anUPDATE books SET deleted_at = ... WHERE id = ?; the hook issoftDelete.delete(force: true, on:)issues a realDELETE, with hookdelete(force: true). -
Every query on the model adds
deleted_at IS NULL(or a later date), soall(),first(),find, aggregates, joins and eager loads skip soft-deleted rows. -
withDeleted()on a query builder turns that filter off.Book.query(on:).withDeleted()returns both live and deleted rows. -
model.restore(on:)sets the timestamp back tonil(querying withwithDeleted()internally to find the row) and reloads the model’s state; the hook isrestore.
The timestamp is optional (Date?): nil means live. Calling restore on a model without a .delete timestamp traps
(fatalError("no delete timestamp on this model") in the source), so guard with deletedAt != nil and a model that has
the property.
Routes: delete, trash, restore, force delete
import Fluent
import Vapor
struct BookDTO: Content, Sendable {
var id: UUID?
var title: String
var isbn: String
var version: Int
var deletedAt: Date?
init(_ b: Book) { id = b.id; title = b.title; isbn = b.isbn; version = b.version; deletedAt = b.deletedAt }
}
func softDeleteRoutes(_ app: Application) {
app.get("books") { req async throws -> [BookDTO] in
try await Book.query(on: req.db).sort(\.$title).all().map(BookDTO.init)
}
app.get("books", "trash") { req async throws -> [BookDTO] in
try await Book.query(on: req.db).withDeleted().filter(\.$deletedAt != nil).all().map(BookDTO.init)
}
app.delete("books", ":bookID") { req async throws -> HTTPStatus in
let force = (try? req.query.get(Bool.self, at: "force")) ?? false
guard let book = try await Book.query(on: req.db).withDeleted()
.filter(\.$id == (req.parameters.get("bookID", as: UUID.self) ?? UUID())).first()
else { throw Abort(.notFound) }
try await book.delete(force: force, on: req.db)
return .noContent
}
app.post("books", ":bookID", "restore") { req async throws -> BookDTO in
guard let book = try await Book.query(on: req.db).withDeleted()
.filter(\.$id == (req.parameters.get("bookID", as: UUID.self) ?? UUID())).first(),
book.deletedAt != nil
else { throw Abort(.notFound) }
try await book.restore(on: req.db)
return BookDTO(book)
}
}
The lookup uses withDeleted() because a soft-deleted row is invisible to Book.find and to a plain query; without it
the second DELETE of a book (to force-delete something already in the trash) and every restore would answer 404.
In a real service these routes sit behind an authorisation check: restoring and force-deleting are librarian or admin
operations (see Authorization: Roles, Ownership and Policies).
Run on SQLite, with the two books created earlier (B1 is "A1"):
curl -s -i -X DELETE $U/books/$B1 | head -1 # HTTP/1.1 204 No Content
curl -s $U/books # only A2
curl -s $U/books/trash
# [{"isbn":"9780306406157","id":"0811...","version":1,"deletedAt":"2026-10-17T15:28:29Z","title":"A1"}]
curl -s -X POST $U/books/$B1/restore # A1 back, no deletedAt field in the JSON
curl -s -i -X POST $U/books/$B1/restore | head -1 # HTTP/1.1 404 Not Found (it is not deleted any more)
curl -s -i -X DELETE "$U/books/$B2?force=true" | head -1 # HTTP/1.1 204 No Content, the row is gone for good
curl -s $U/audit
# ["create 9780306406157","create 9781402894626","softDelete ","restore ","delete force=true"]
The audit trail shows which hook ran for each call: a plain delete fired only softDelete, not delete, and the forced
one fired delete with force=true.
Pitfalls of soft delete
-
Unique constraints keep seeing deleted rows.
books.isbnis unique in the schema, and the soft-deleted row still holds it. Creating a book with the ISBN of a trashed one failed on SQLite withconstraintUniqueFailed: UNIQUE constraint failed: books.isbn(an HTTP500until the route maps it). Options: restore the old row instead of creating a new one, force-delete before re-creating, or use a partial unique index (CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL, PostgreSQL and SQLite; raw SQL through SQLKit, since Fluent’sunique(on:)cannot express it). -
Foreign keys and cascades.
onDelete: .cascadefires only for a realDELETE. Soft-deleting an author does not delete or hide its books; do that explicitly (in a transaction), or filter with a join. Conversely, a force delete cascades to reviews and loans. -
Eager loading.
with(\.$author)does not load a soft-deleted parent; the property then fails to unwrap. The eager loaders in fluent-kit’sParent,OptionalParentandChildrenhave awithDeletedflag internally; the Fluent Relations page covers relations and eager loading. It was not exercised here. -
Batch operations. As shown above,
query.delete()soft-deletes but skips middleware;query.delete(force: true)removes rows.restorehas no batch form: updatedeleted_atwith a query that useswithDeleted(). -
Counting and exports. Reports that must include deleted rows need
withDeleted()too; forgetting it in one place is the usual bug, so give the "include deleted" queries a named helper. -
Privacy. A soft-deleted row still holds personal data. Regulations that require erasure need a force delete or a scrubbing job, not only a timestamp.
-
Both timestamps in one model. A model can have several timestamps (
.create,.update,.delete); only the one with.deletedrives soft delete, andtouchTimestampssets.updateas well when a soft-delete query is run through the builder (visible as a changedupdated_at).
Optimistic concurrency with a version column
The problem
Two librarians open the same book, change different fields and save. With a plain book.save(on:) the second write
silently overwrites the first (a lost update). Locking the row while a person edits is not an option over HTTP; the
answer is optimistic concurrency: the client sends the version it read, and the server applies the change only if the
row still has that version.
Fluent does not provide this; the model gets a plain version column (the page-local books.version, default 1) and
the update is a single compare-and-set statement:
UPDATE books SET title = ?, version = ? WHERE id = ? AND version = ? RETURNING id
If another writer got there first, the WHERE matches no row and nothing is returned. A separate SELECT followed by
an UPDATE would leave a window between them; a single statement does not.
The route
The statement is built with SQLKit (see SQLKit and Raw SQL), reached from
Fluent with req.db as? any SQLDatabase:
import Fluent
import SQLKit
import Vapor
struct BookPatch: Content, Sendable {
var title: String
var version: Int
}
func concurrencyRoutes(_ app: Application) {
app.patch("books", ":bookID") { req async throws -> BookDTO in
let patch = try req.content.decode(BookPatch.self)
guard let id = req.parameters.get("bookID", as: UUID.self) else { throw Abort(.badRequest) }
guard let sql = req.db as? any SQLDatabase else { throw Abort(.internalServerError) }
let rows = try await sql.update("books")
.set("title", to: patch.title)
.set("version", to: patch.version + 1)
.where("id", .equal, id)
.where("version", .equal, patch.version)
.returning("id")
.all()
if rows.isEmpty {
guard try await Book.find(id, on: req.db) != nil else { throw Abort(.notFound) }
throw Abort(.conflict, reason: "The book was changed by someone else; reload and retry")
}
guard let book = try await Book.find(id, on: req.db) else { throw Abort(.notFound) }
return BookDTO(book)
}
}
Run on SQLite:
curl -s -X PATCH $U/books/$B1 -H 'Content-Type: application/json' -d '{"title":"A1 second ed.","version":1}'
# {"isbn":"9780306406157","id":"0811...","version":2,"title":"A1 second ed."}
curl -s -i -X PATCH $U/books/$B1 -H 'Content-Type: application/json' -d '{"title":"stale write","version":1}'
# HTTP/1.1 409 Conflict
# {"reason":"The book was changed by someone else; reload and retry","error":true}
curl -s -X PATCH $U/books/$B1 -H 'Content-Type: application/json' -d '{"title":"A1 third ed.","version":2}'
# {"isbn":"9780306406157","id":"0811...","version":3,"title":"A1 third ed."}
The stale request carried the version it had read (1), the row was already at 2, so no row matched and the route
answered 409. The distinction between 404 and 409 comes from the follow-up find.
Design notes
-
Where the version travels. In the body, as here, or in the HTTP
ETag/If-Matchpair. WithIf-Matchthe version is sent as a header and a mismatch is412 Precondition Failed(RFC 9110, section 13.1.1); the PATCH above chose409because the version is part of the payload. -
Who bumps it. Every writer must increment it, including the other routes and background jobs. If any code path updates the row without touching
version, the check no longer protects against it. Putting the increment into theupdatemodel-middleware hook does not help for this statement because raw SQL (and query-builder updates) skip the hooks; this route is also why the audit log has noupdateentry for it. -
Timestamps are bypassed too. Raw SQL does not touch
updated_at; set it in the statement if you need it. -
Portability.
RETURNINGworks on SQLite 3.35+ and PostgreSQL. MySQL has noRETURNINGonUPDATE; there, run the update through Fluent’squery.update()and verify the outcome by re-reading the row with a unique token column (for example a UUIDrevisionset to a fresh value on every successful update): a re-read that returns your token proves that you won, which a bare incremented integer cannot (a concurrent winner may have produced the same number). The MySQL variant was not run. -
A pessimistic alternative. Fluent’s
QueryBuilderhas no locking clause. Inside a transaction, a row lock goes through SQLKit on the closure’sdb, whose select builder has.for(.update)(and.for(.share)):let version = try await req.db.transaction { db -> Int in guard let sql = db as? any SQLDatabase else { throw Abort(.internalServerError) } guard let row = try await sql.select().column("version").from("books") .where("id", .equal, id) .for(.update) // SELECT ... FOR UPDATE .first() else { throw Abort(.notFound) } // the row stays locked until the closure returns; update it on `db` or `sql` here return try row.decode(column: "version", as: Int.self) }PostgreSQL and MySQL render
FOR UPDATE; SQLite has no row locks, and SQLKit silently drops the clause on a dialect that does not support locking reads. Use it for short critical sections such as lending a copy; use the version for user-driven edits. -
Client handling. On
409the client reloads the resource, shows the diff or merges, and resends with the new version. Do not auto-retry the same body.
Common errors
| Symptom | Cause and fix |
|---|---|
Compile error |
|
Compile error |
A dictionary with non- |
|
Work on the outer database inside |
Rollback does not undo some writes |
They ran on |
|
The soft-deleted row still holds the unique value. Restore, force delete, or use a partial unique index. |
|
The lookup omitted |
Fatal error |
The model has no |
Hook did not run |
The change was made with a query builder ( |
Hook ran twice for one request |
The middleware is registered twice, or the hook saves the same model again. |
|
The client sends a stale or missing |
Where the books are outdated
-
Condon et al. (Server-Side Swift with Vapor, Kodeco), chapter 30, teaches soft delete and restore with a
deletedAttimestamp andwithDeleted(). That design is unchanged in 4.122: the property is still@Timestamp(key:on: .delete),restoreanddelete(force:)exist as described. What changed is the call style (the book’sflatMapchains are nowtry await) and that model middleware has an async protocol (AsyncModelMiddleware), so hooks are written asasync throwsmethods that calltry await next.create(model, on: db)instead of returning a future. -
The book’s transactions, where covered, use
database.transaction { database in ... }returning anEventLoopFuture. In 4.122 the same method has anasyncoverload; the rule about using the closure’s database instead of the request’s is unchanged. -
Yeung (Packt) and Patel (Packt) predate Fluent 4 (they use Vapor 2 and 3
Modelprotocol conformances and.softDeletablestyle traits); their ideas of lifecycle callbacks map toAsyncModelMiddleware, and soft delete to the timestamp trigger, but none of their code compiles.
References
-
Vapor docs: Fluent, Model (timestamps, soft delete, model middleware)
-
vapor/fluent-kit 1.57.0: Concurrency (
AsyncModelMiddleware.swift,Model+Concurrency.swift,Database+Concurrency.swift) and Properties/Timestamp.swift -
fluent-sqlite-driver 4.9.0: FluentSQLiteDatabase.swift and fluent-postgres-driver 2.14.0: FluentPostgresDatabase.swift
-
Kodeco: Server-Side Swift with Vapor (Condon et al.)