Changelog
Release notes for the PlumeKit 3.0 series, newest first.
3.0.0
This release renames the query builder entry points, makes persistence results must-check, and rebuilds the Postgres driver around a connection pool. The native server gains keep-alive, compression and streaming.
Every source-breaking change fails loudly at compile time. Fix what the compiler points at, in the order below.
Query entry points: query() starts a builder, all() executes
Model.all() used to return a query builder, so fetching everything read Post.all().all(in: db). The entry point is now query(), and all() always executes:
// before
let posts = try await Post.all().order(by: Post.id).all(in: db)
let n = try await Post.all().count(in: db)
// after
let posts = try await Post.query().order(by: Post.id).all(in: db)
let n = try await Post.count(in: db) // or Post.query()...count(in: db)
let everything = try await Post.all(in: db) // the whole table, directlyPost.where(...) is unchanged. Old call sites fail to compile (an array has no .order), so the compiler finds them all for you.
save() and upsert() results must be read
Both return [ValidationError] and are no longer discardable, because ignoring the result silently persisted nothing when a validation failed. Where the model has no validations, discard explicitly; otherwise check:
_ = try await post.save() // no validations on this model
guard try await user.save().isEmpty else { … } // handle validation failuresOne related behaviour change: softDelete() and restore() now skip validations, as Rails does on destroy, so a legacy row that predates a newer rule can still be deleted.
Removed APIs
Column.in(_:)is gone. Use the genericwithin(_:), as inPost.id.within(ids). Same SQL, and it works for any bindable value type.eagerLoadlost itschildKey:parameter; the child's foreign key is read from its schema. Better still, use the typed helper@Modelnow generates per has-many relation,Post.preloadComments(posts), and the stringly-typed seam disappears from your code entirely. A misspelled foreign-key name now surfaces natively as a thrown error (one 500) rather than a malformed-SQL error.- The
PostgresDatabaseclass is gone. Construct throughPostgresDriver.connect(url:poolSize:preparedStatements:), which returns the pooledDatabase. Generated composition roots already do. StaticFiles.response(for:in:)in the native server was replaced by a stat-plus-stream design (resolveRootandlookup). Only relevant if you called it directly.RouteMatch.foundcarries a third associated value, the route's request body mode. Only relevant if you match onRouterresults yourself.
Migrations now run in transactions
Each migration and its ledger row commit together on the native drivers, so a failed script no longer leaves half-applied DDL behind. If a migration uses a statement Postgres refuses inside a transaction block, such as CREATE INDEX CONCURRENTLY or VACUUM, mark it out:
let addIndex = Migration.sql(
version: "20260801_concurrent_index",
transactional: false,
up: "CREATE INDEX CONCURRENTLY idx_posts_slug ON posts (slug);"
)D1 migrations are unchanged: forward-only SQL batches, no transactions there.
The Postgres driver is a pool now
Native Postgres runs on a pool of asynchronous connections instead of one locked connection, so a long transaction no longer blocks every other request. The defaults are sensible, and two environment switches exist:
DATABASE_POOL_SIZEsets the pool size (default 8).DATABASE_PREPARED_STATEMENTS=offis required behind a transaction-mode pooler (pgbouncer's default mode, Supabase's pooler on port 6543), where server-side prepared statements can't be reused across backends. Session-mode poolers and direct connections need nothing.
Native server behaviour changes
Usually no action needed, but worth knowing:
- HTTP/1.1 keep-alive is on for apps without realtime channels. Apps with channels keep closing per request: a WebSocket upgrade can only happen on a connection's first request, and some clients reuse pooled connections for the handshake.
- Responses and static files are gzipped automatically for clients that accept it (text-ish content types only), and static files carry
ETagandLast-Modifiedvalidators with 304 handling. A handler that sets its ownContent-Encodingis passed through untouched. - A request body that stalls for 60 seconds between chunks closes the connection. Tune with
PlumeServer.requestBodyIdleTimeoutMillis. - Default 405s from unimplemented controller actions now respect
app.errorPage(405).
One-time edits to files your app owns
The new plumekit migrate --rollback [N] and --status commands drive the Server binary with --rollback and --migration-status flags. Apps scaffolded before this release don't parse them; the CLI detects that and refuses, rather than accidentally booting your server. Copy the two argument cases and their handling blocks from a freshly scaffolded app's Server main into your Sources/Server/main.swift.
Enabling database or storage after scaffolding requires the matching driver product (PlumePostgres or PlumeS3) in the Lambda target of Package.swift. The generators patch this for you when they flip a capability; a manual flip that misses it fails with a #error naming the exact line to add.
New since the last release, no action needed
Worth adopting: streaming response bodies and streaming upload routes with storage sinks (Routing, Bindings), bulk delete() and updateAll with aggregates on the query builder, paginate(request), typed relation preloads, identityMiddleware() wired from bindings in one line, app.postForm in tests with automatic CSRF, plumekit migrate --status and --rollback, browser live-reload under plumekit dev, and generators that offer to enable missing capabilities.