The ORM
Code-first models with a compile-time macro instead of runtime metaprogramming. Embedded Swift has no runtime reflection, so @Model reads the type at compile time and emits, as plain static code, everything ActiveRecord would generate at runtime. The ORM talks only to the Database/SQLDatabase protocols, so the same model runs on D1 and native SQLite.
ORM calls use the current database automatically, so you write
post.save(), not.save(in: db). Every method also takes an optionalin db:for a test, which has no ambient database.
What @Model generates
@Model
final class Post: Model {
var id: Int // `id` = primary key by convention
var title: String
var views = 0
var published = false
}static let schema: TableSchema: table (pluralisedsnake_case), columns (snake_caseby default, overrideable with@Column), types, PK.- A row codec:
init(row:)decodes by index (row.string(1)), never by column name, because name matching needs Unicode-awareString ==, which doesn't link in the Wasm build. The query builder always projects columns in schema order, so positions line up. columnValues(): values in schema order.- A memberwise
init(non-PK fields), and dirty tracking (an original-value snapshot, sosavewrites only the changed columns). - Typed query columns:
static let title = Column<Post, String>("title").
Persistence (active record)
let post = Post(title: "Hi", views: 5, published: false)
let errors = try await post.save() // INSERT; populates post.id from lastInsertID
guard errors.isEmpty else { … } // validation failures come back as values
post.published = true
_ = try await post.save() // UPDATE: only the changed columns (dirty tracking)
_ = try await post.upsert() // INSERT, or UPDATE if the primary key already exists
try await post.delete()
let one = try await Post.find(42) // Post?save() (and upsert()) validates first and returns the validation errors, persisting nothing when any rule fails; real database errors still throw. The result is deliberately not discardable: write _ = when the model has no validations, check the array when it does. See Validations.
save/delete/find are written once, generically over Model + the Database protocol. All SQL is built by ASCII append; values are always bound ? parameters, never interpolated. A minimal createTable(in:) (create-if-not-exists from the schema) exists for tests and local dev; it is not the migration system (see Migrations).
Integer var id: Int remains the default database-generated primary key. UUID primary keys are first-class as app-generated keys:
@Model
final class AccessToken: Model {
var id: UUID // PlumeORM.UUID, stored as TEXT/UUID by dialect
var label: String
}
let token = AccessToken(label: "primary") // id defaults to UUID()
_ = try await token.save(in: db) // INSERT includes the UUID key
let found = try await AccessToken.find(token.id, in: db)Inside a handler, find also binds straight from the route (route model binding): the read-:id-parse-find preamble becomes one guard:
guard let post = try await Post.find(request) else { return .status(404) }It reads request.parameters["id"] and parses the integer key; pass parameter: "post_id" for nested routes. Like every lookup, it respects the model's default scope, so a soft-deleted row is not found.
Typed query builder
Post.query() starts a builder; Post.where(...) starts it filtered. The executing terminals are all(), first(), count(), exists() and friends, so which call hits the database is always visible. Post.all() / Post.count() run directly for the everyday whole-table cases.
let everything = try await Post.all()
let recent = try await Post
.where(Post.published == true && Post.views > 100)
.order(by: Post.views, .descending)
.limit(10)
.all()
let n = try await Post.where(Post.published == false).count()
let latest = try await Post.query().order(by: Post.id, .descending).first() // Post?
let page3 = try await Post.query().order(by: Post.id).offset(40).limit(20).all()
// Chained `order` calls compose into a multi-column ORDER BY.
let ranked = try await Post.query().order(by: Post.published).order(by: Post.views, .descending).all()
// Cheap probes and projections — no row decoding:
let any = try await Post.where(Post.published == true).exists() // SELECT 1 … LIMIT 1
let ids = try await Post.where(Post.published == true).pluckInts(Post.id)
let hits = try await Post.where(Post.id.within(ids)).all() // typed IN (…)
// Aggregates run in SQL:
let views = try await Post.query().sum(Post.views)
let top = try await Post.query().maximum(Post.views) // Int? (nil with no rows)
// Bulk writes: one statement, no per-row loads (callbacks don't run):
try await Post.where(Post.published == false).updateAll(Post.views.set(0))
try await Post.where(Post.views == 0).delete()
// Pagination: order by a stable column for deterministic pages. The Page gives
// views everything they need: page.items, page.page, page.previousPage/nextPage,
// page.previousURL("/posts")/nextURL("/posts"), and (with `withTotal: true`, one
// extra COUNT) page.total/totalPages. Return it with `.json(page)` for the
// standard paginated envelope.
let page = try await Post.query().order(by: Post.id).paginate(page: 2, per: 20, withTotal: true)
// Or straight from the request: reads the `page`/`per` query params (the same
// names Page.url emits), clamping `per` to `maxPer`.
let fromRequest = try await Post.query().order(by: Post.id).paginate(request)Column<Root, Value> and Predicate<Root> are concrete generic value types. Operators only type-check against matching value types, so a wrong-type predicate is a compile error:
Post.published > 100 // error: binary operator '>' cannot be applied to
// 'Column<Post, Bool>' and 'Int'Predicates lower to parameterised SQL; text collation/ordering is delegated to the database (the guest has no Unicode tables).
Relationships
@Model final class Post: Model {
var id: Int
var title: String
@HasMany var comments: [Comment] // → Comment.post_id
}
@Model final class Comment: Model {
var id: Int
var body: String
@BelongsTo var post: Post? // → a `post_id` column
}
let comments = try await post.$comments.load(in: db) // explicit, one query
let parent = try await comment.$post.load(in: db)@BelongsTo/@HasMany are property wrappers giving $post / $comments handles. Loading is explicit and async: no property access silently fires a query. @Model reads the wrapper attributes and generates the FK column (post_id) for belongs-to and injects the owner id into has-many handles via refreshRelations().
Relations work with any primary-key type. The foreign key stores the parent's raw key, so a
@BelongsTo/@HasManyon a UUID- or String-PK model resolves correctly (the child's FK column mirrors the parent key's type). Read$post.idfor the integer FK in the common case, or$post.keyfor the rawSQLValueof any key type.
An unloaded relation reads as empty (post.comments is [], comment.post is nil) — it never auto-loads. When "not loaded" and "none" must be told apart, check $comments.isLoaded / $post.isLoaded, or just load first.
Eager loading is batched (no N+1): for many owners it issues one child query, then groups and assigns. @Model generates a typed helper per has-many, so a page preloads an association in one line:
let posts = try await Post.all()
try await Post.preloadComments(posts) // ONE query fills every post's $commentsThe underlying seam is eagerLoad(_:foreignKey:assign:in:) for hand-written stores; the generated helper supplies the FK name and the assignment, so a typo can't reach it.
Keypaths don't compile under embedded Wasm, which shapes two corners of this API:
- Eager loading uses a closure assignment, not
.with(\.$comments)keypaths (and the enclosing-self wrapper subscript, which needsReferenceWritableKeyPath, is out, hencerefreshRelations()). @BelongsTo var author: User?is optional (nil until loaded), notUser.
Auto-managed timestamps
A model opts in by declaring createdAt/updatedAt as Int64 (epoch millis would overflow the 32-bit Int on wasm, so @Model rejects a non-Int64 field):
@Model final class Post: Model {
var id: Int
var title: String
var createdAt: Int64 = 0 // set on INSERT
var updatedAt: Int64 = 0 // set on every save
}save() sets them from ORMClock, a wall-clock seam installed per platform (Foundation Date natively via PlumeServer; a host_now → JS Date.now() import on Workers).
Transactions
db.transaction { tx in … } runs its body atomically: the writes commit together, a thrown error rolls every one of them back (then rethrows), and the body's return value becomes the call's value:
let order = try await db.transaction { tx in
_ = try await order.save(in: tx)
_ = try await tx.query("UPDATE inventory SET held = held + 1 WHERE sku = ?", [sku])
return order
}Queries on tx, and ambient ORM calls made inside the body, join the transaction (it's task-local), while other requests' queries wait on a connection lock, so no statement from another request can slip inside an open transaction. A nested transaction joins the outer one. Available on native SQLite and Postgres. Cloudflare D1 has no interactive transactions (each statement is atomic on its own), so calling transaction there is a programming error that traps with a clear message.
Soft deletes
Conform a model to SoftDeletable and declare a deletedAt column (epoch seconds; 0 = live):
@Model
final class Post: Model, SoftDeletable {
var id: Int
var title: String
var deletedAt = 0 // epoch seconds; 0 = live
}Every query, and find, hides trashed rows automatically:
try await post.softDelete() // hide: stamps deletedAt, keeps the row
try await post.restore() // bring it back
try await post.forceDelete() // actually DELETE the row
try await Post.withTrashed().all() // everything
try await Post.onlyTrashed().all() // just the hidden onesAny query opts out per call with .unscoped().
Query scopes
A model can pre-filter every query with a default scope; soft deletes are implemented on exactly this hook:
@Model final class Post: Model {
var id: Int
var title: String
var published = false
static var defaultScope: Predicate<Post>? { Post.published == true }
}Every where/all/count, and find, starts from the scope; .unscoped() on any query bypasses it.
Lifecycle callbacks
Override the persistence hooks on the model. All are async throws, no-ops by default:
@Model final class Post: Model {
var id: Int
var title: String
var slug = ""
func willSave() async throws { // after validation, before the write
slug = slugify(title) // your own helper; mutate fields here.
} // throwing aborts the save
}willSave runs after validation and before the write: the place to derive fields like a slug; throwing aborts the save. willDelete runs before a delete and throwing aborts it; didSave / didDelete run after the row is written / removed.
How the ambient database works (and when in: db is required)
Inside a handler, ORM calls take the current request's database implicitly: the framework binds the request's context around dispatch. On native builds that binding is task-local (RequestContext.withValue), so several apps dispatching in one process (e.g. parallel test suites, each with its own TestApp) never see each other's database. Long-lived non-request code (server startup, migrations, the console, schedule ticks) assigns RequestContext.current = context instead; that writes a process-global fallback which reads fall back to when no task-local binding is in scope. (The embedded-wasm guest keeps a plain global for both because @TaskLocal.withValue doesn't compile under embedded wasm: safe there, since the guest handles one request per instance. A transaction, which needs per-task routing even within one app, uses a task-local that only exists in the native build; see Transactions.)
Migrations, seeders, background jobs and the console get the same ambient binding (the runner binds the context before dispatch), so in: db there is optional too. Tests are the one place with no ambient database, which is why test code passes the handle explicitly: post.save(in: app.database).