Primate 0.45: SQL transactions, Kotlin backend and iOS apps
Primate 0.45 brings SQL transactions, a Kotlin backend, and native iOS apps. It also adds in-process request testing, expands the API reference, and provides actionable deprecation diagnostics for upgrades.
SQL transactions
One transaction can span multiple stores using the same PostgreSQL, MySQL, Oracle, or SQLite database instance. Existing store imports participate when called in the transaction's asynchronous context. This withdrawal route validates its request body, locks the account for the balance check, and updates the account and its ledger together:
// routes/accounts/[id]/withdrawals.ts
import p from "pema";
import route from "primate/route";
import response from "primate/response";
import db from "@/config/db";
import Account from "@/stores/Account";
import LedgerEntry from "@/stores/LedgerEntry";
export default route({
post: route.with({
contentType: "application/json",
body: p({ amount_cents: p.u32.min(1) }),
}, async request => {
const account_id = request.path.get("id");
const { amount_cents } = await request.body.json();
await using tx = await db.transaction();
const account = await Account.lock(account_id);
if (account.balance_cents < amount_cents) {
return response.json({ error: "Insufficient funds" }, { status: 409 });
}
const balance_cents = account.balance_cents - amount_cents;
await LedgerEntry.insert({ account_id, amount_cents, type: "withdrawal" });
await Account.update(account_id, { set: { balance_cents } });
await tx.commit();
return response.json({ balance_cents }, { status: 201 });
}),
});db.transaction() starts a transaction. await tx.commit() saves its changes.
If the scope ends without a commit, whether through an early return or an
error, await using waits for rollback and lock release before the handler
completes or error handling begins. Once committed, the SQL changes remain
durable even if HTTP delivery later fails.
PostgreSQL, MySQL, and Oracle provide held row locks with their native
lock strengths. SQLite uses a database-wide BEGIN IMMEDIATE fence, so its
Store.lock() returns the requested row under that fence. See the
Store transaction reference for lifecycle, errors, and backend details.
Kotlin backend
Primate 0.45 adds Kotlin as a supported backend. Install @primate/kotlin, add
the run.primate:kotlin SDK to a Kotlin Multiplatform wasmJs executable, and
write ordinary .kt routes with suspending handlers:
// routes/extended-route/Index.kt
package routes.extended_route
import kotlinx.coroutines.delay
import run.primate.Route
fun main() {
Route.get {
delay(10)
mapOf("message" to "Hello from Kotlin")
}
}Kotlin routes support all Primate HTTP methods, content types, repeated filenames, and dynamic paths. During development, Primate reuses the project's Gradle connection across route edits.
The SDK exposes requests, redirects, errors, views, and suspending sessions with ordinary Kotlin maps. Request, transaction, and session context remains attached after coroutine suspension and host Promises. The same production artifact runs on Node, Bun, and Deno. Start with Add a Kotlin backend, then see the Kotlin backend reference for API details.
Kotlin, Go, Python, and Ruby routes can also return explicit binary, JSON, text,
null, and abort responses. Their SSE and WebSocket callbacks stay live across
the Wasm boundary while Primate's existing Core transports handle streaming,
disconnect cleanup, and socket upgrades.
All four SDKs can now return a page response with props, selecting the frontend
component collocated with the active route without specifying its filename.
For example, a Kotlin Response.Page(mapOf("name" to "Ada")) in Index.kt
renders its adjacent Index.svelte.
For Kotlin, register kotlin() before the frontend module in app configuration
when both files share the Index basename.
iOS apps
Native apps can now target iOS. primate dev --target=ios boots an iOS
Simulator and shows your app in it while the development server keeps running on
your machine, so live reload works the same way it does in a desktop window.
bunx --bun primate dev --target=ios
bunx --bun primate build --target=ios # offline simulator app
bunx --bun primate build --target=ios-device # signed for a paired iPhoneAn iOS build produces an offline app by rendering parameter-free GET pages
that return HTML and bundling their navigation data with the client output and
your static/ files. Non-HTML responses are skipped. Routes with parameters
and features that need a live server — forms, sessions, API routes, and stores —
are not available in the built app.
ios-device builds for arm64, signs the app, and installs and launches it on
a paired iPhone. Signing requires an Apple Development certificate and a
provisioning profile for the bundle identifier; Primate discovers both and
reports what is missing when it cannot.
ios.plist.info sets the Info.plist of the app — name, bundleIdentifier,
version and build as shortcuts, any other key verbatim — merged over the
defaults a bundle needs to launch. See Building for the full configuration.
Backend SDK updates
Primate 0.45 pairs with the Go v0.7.0 module and Python and Ruby 0.7.0
packages.
Session reads and writes now use asynchronous Store operations. Create and read a session in your backend:
import session from "@/config/session";
import route from "primate/route";
export default route({
async get() {
await session.create({ user_id: 42 });
return await session.get();
},
});package main
import (
"primate.run/go/route"
"primate.run/go/session"
"primate.run/go/types"
)
var _ = route.Get(func(request route.Request) any {
s := session.From(request)
if err := s.Create(types.Dict{"user_id": 42}); err != nil {
panic(err)
}
data, err := s.Get()
if err != nil {
panic(err)
}
return data
})package routes.session
import run.primate.Route
fun main() {
Route.get { request ->
request.session.create(mapOf("userId" to 42))
request.session.get()
}
}from primate import Route, Session
@Route.get
async def get(request):
await Session.create({"user_id": 42})
return await Session.get()require 'primate/route'
require 'primate/session'
Route.get do |request|
Session.create(user_id: 42)
Session.get
endSessions join an active transaction on the same database instance. A rolled-back session change is discarded with the transaction; outside one, each write commits independently. See Sessions for cookie and transaction details.
In-process tests
The new primate/test harness builds and initializes the real application,
then sends requests through its normal request-to-response pipeline without
binding a network port:
import test from "primate/test";
await using app = await test(import.meta.dirname);
const response = await app.request("https://example.test/api/items");Use it to test routing, hooks, request data and schemas, static assets,
response conversion, and error handling. Startup hooks still run, so configure
test dependencies and dispose each harness. For WebSockets, TLS, timeouts, and
other transport behavior, use a served application or browser fixture instead.
See Testing and the primate/test API reference for details.
More improvements
The API overview now links to expanded references for Stores, routes,
requests, responses, sessions, and more, alongside the primate
changelog.
The homepage, documentation, guides, and blog have a refreshed design.
Production route validation now retains native add-on resolution, fixing builds
that failed when a route imported a native add-on such as @resvg/resvg-js.
Angular client navigation also initializes with Angular 22.2.6, including client-only rendering and hydration.
Migration
Runtime requirements
Primate now requires Node.js 26 or later. Upgrade Node environments before moving an application to Primate 0.45. Bun and Deno remain supported. The Go SDK requires Go 1.27 or later, the Python SDK requires Python 3.14 or later, and the Ruby SDK requires Ruby 4.0 or later.
Backend SDKs and routes
Upgrade the matching SDK when upgrading an application's backend. The Go SDK's
module path changes from github.com/primate-run/go to primate.run/go; update
the module requirement and imports:
go get primate.run/go@latest
go mod tidy
// Before
import "github.com/primate-run/go/route"
// After
import "primate.run/go/route"The Go, Python, and Ruby SDKs remove their Pema-specific Parse or parse
methods and bundled Pema modules. Replace their imports and request-bag parsing
with direct value access and your application's validation library.
Route paths now have one lowercase identity across backends. Source filenames keep their spelling:
| Source file | Route path |
|---|---|
Route.js |
/route |
ExtendedRoute.kt |
/extendedroute |
extended-route/Index.kt |
/extended-route |
Static matching is case-insensitive, while dynamic values retain their spelling. Before upgrading, resolve filenames that map to the same lowercase route, including route/index conflicts across extensions or HTTP methods.
PostgreSQL raw client
@primate/postgresql now uses postgres.js 4. If you use db.client directly,
replace db.client.array(values) with the array itself in interpolated queries.
Cast the parameter when PostgreSQL cannot infer its array type, for example
${values}::int[]. Store operations do not require this change.
Deprecation diagnostics
primate update now shows guidance for the packages in your application. Use
primate update --all for every package; API names are highlighted in a
terminal and stay backticked in plain output. Use primate update --json for
scripts.
Primate can now report deprecated API use with app.log.deprecate(). Each
diagnostic names the supported replacement and appears every time the deprecated
operation runs. Calls made during a build are identified separately so build
tooling can enforce them, while runtime warnings remain warnings. Interactive
terminals show deprecations in orange; redirected and NO_COLOR output stays
plain and script-friendly.
Use primate build --strict to make deprecations actually called during the
build fail with a nonzero exit status. Strict mode does not scan route handlers
for references and does not turn later runtime warnings into failures.
Request-scoped values now live under request.context: use
request.context.has(), try(), get(), set(), and delete(). The direct
request methods remain functional deprecated aliases and warn on every actual
call. Move those calls to request.context when upgrading.
Application configuration now uses the primate/app factory. Replace imports
from primate/config with primate/app; the old entrypoint remains functional
but emits a deprecation each time its factory is called. Ordinary builds report
that use and continue, while primate build --strict fails when configuration
evaluation calls the deprecated factory.
The primate/AppFacade type-only entrypoint is removed. Replace
import type AppFacade from "primate/AppFacade" with
import type { AppFacade } from "primate/app" for typed helpers.
Builds now reject route.hook() exports outside +hook files. Use
app.views.get() in place of app.view().
The .sequence() Store field helper introduced in 0.44 is removed. If you used
it, replace it with an ordinary unsigned field and supply its values yourself;
preserve any previously issued sequence values when changing allocators.
Fin
If you like Primate, consider joining our Discord server, visiting us in #primate on irc.libera.chat, or starring us on GitHub.