Turso
@buntime/plugin-turso is the durable SQL provider for Buntime. It is a core
infrastructure plugin (enabled by default) that owns the Turso connection and
exposes a TursoService to other plugins. It has no base path — it is a
service provider; other plugins reach it via
ctx.getPlugin("@buntime/plugin-turso").
@buntime/plugin-turso is the durable SQL provider for Buntime.
@buntime/plugin-keyvalstores itskv_*schema on top ofplugin-turso.@buntime/plugin-gatewayand@buntime/plugin-proxyuse@buntime/plugin-tursodirectly for durable operational state.@buntime/plugin-gatewayand@buntime/plugin-proxydo not depend on@buntime/plugin-keyvaljust to persist their own state.- There is no in-memory durable mode.
localmode is the local/single-pod path;syncmode is the Kubernetes path.
Why not route gateway/proxy state through KeyVal
Section titled “Why not route gateway/proxy state through KeyVal”Durable gateway/proxy state is not implemented as
gateway/proxy -> plugin-keyval -> plugin-turso. That path would make KeyVal
mandatory infrastructure for plugins that should remain independently enableable,
and it would couple gateway/proxy failure modes, schema needs, and performance
profile to the generic KV abstraction.
Instead:
- gateway/proxy depend directly on
@buntime/plugin-tursofor their owngateway_*andproxy_rulestables; plugin-keyvalhas its own migration and test suite against@buntime/plugin-turso;- an integration smoke can exercise all three plugins in one environment, but that does not define the production dependency graph.
Responsibility boundary
Section titled “Responsibility boundary”plugin-turso owns infrastructure concerns:
- opening and closing Turso connections;
- configuring the local database path;
- configuring sync URL/token and sync lifecycle;
- applying Turso MVCC setup;
- providing helpers for
BEGIN CONCURRENTtransactions and retryable conflict errors; - exposing health/status about local and sync state.
Consumer plugins own domain concerns:
| Consumer | Owns |
|---|---|
plugin-keyval | kv_* schema, KV operations, TTL, queues, FTS, metrics |
plugin-gateway | gateway_* schema, metrics history, dynamic shell excludes |
plugin-proxy | proxy_rules schema, dynamic proxy/redirect rules |
This keeps one Turso connection/sync policy per runtime while avoiding a generic business API in plugin-turso.
Implementation
Section titled “Implementation”The service lives under plugins/plugin-turso/:
server/types.tsdefines the public service, database, health, sync stats, and transaction option contracts.server/adapter.tsopens either@tursodatabase/databaselocal mode or@tursodatabase/syncsync mode using the installed SDK option names (path,url,authToken) and appliesPRAGMA journal_mode = mvcc.server/service.tsexposes one runtime-wide adapter, tracks requested namespaces as ownership metadata, returns health state, and wrapsBEGIN CONCURRENTtransactions with retry handling for busy/conflict errors.plugin.tsis a hook-only persistent plugin that exposes the service throughprovides(). Its manifest intentionally omitsbase; hook-only plugins must not setbase: "".plugin.test.tscovers exports, lifecycle/provides behavior, config resolution, MVCC-backedBEGIN CONCURRENT, namespace validation, and a realPluginLoadersmoke test proving the hook-only plugin registers its provided service through manifest discovery.
| Mode | Target | Notes |
|---|---|---|
local | Local development, tests, single-pod deployments | Opens a local Turso database file. No remote sync server is required. |
sync (single-tenant) | Legacy multi-pod with one shared database | Each pod has its own local replica file and pulls from a single fixed TURSO_SYNC_URL. One adapter per process. |
sync (multi-tenant) | Default for multi-pod / lowcode multi-database | Set TURSO_SERVER_URL instead of TURSO_SYNC_URL. Each connect(name) opens a separate embedded replica synced with <TURSO_SERVER_URL>/<name>. Local replica files are scoped per-namespace. |
The Kubernetes baseline is multi-tenant sync against the in-cluster
turso-server (see Turso server). Turso concurrent writes
solve engine-level concurrency, but a shared file over Kubernetes storage still
depends on filesystem and locking semantics — each pod keeps its own local file
and syncs through the multi-tenant endpoint.
Switching modes
Section titled “Switching modes”The plugin auto-detects the mode from env vars. Precedence (first match wins):
TURSO_SERVER_URLset → multi-tenant sync. Eachconnect(name)→<server>/<name>. Pod-local replicas at<localPath dir>/<name>.db.TURSO_SERVER_TOKENcarries the data-plane bearer.TURSO_SYNC_URLset → legacy single-tenant sync. One adapter; thenamespaceargument toconnect()is recorded as ownership metadata but does not change the connection.- Otherwise →
localmode (file atTURSO_LOCAL_PATH).
Transaction semantics in sync mode
Section titled “Transaction semantics in sync mode”transaction({ type: "concurrent" }) is automatically downgraded to
BEGIN DEFERRED when running against a sync replica. tursodb rejects
BEGIN CONCURRENT (MVCC) while CDC is active, and the sync engine requires CDC.
The downgrade is transparent — callers still get serializable behavior, just
without MVCC retry semantics. Use explicit type: "exclusive" for DDL.
Push-after-commit (durability)
Section titled “Push-after-commit (durability)”In sync mode, transaction() pushes the replica to the sync server after a
successful COMMIT (best-effort — push failures are logged, not thrown; the
row lives locally and syncs on the next push). Without this, a committed write
only exists in the local replica (/data/turso/runtime.db) and is lost on pod
restart, because the replica pulls authoritative state from the server on
reconnect. This is what makes dynamic state written through a transaction —
plugin-proxy rules, plugin-gateway shell-excludes — survive a restart, the
same guarantee ApiKeyStore gets from its own push-after-write. Plain reads via
connect().prepare().all() do not transact and do not push.
Chart direction
Section titled “Chart direction”The Buntime chart exposes Turso settings from plugins/plugin-turso/manifest.yaml
under generated plugins.turso.* values.
When the in-cluster tursoServer.enabled=true, the chart auto-wires the
multi-tenant URL into the runtime env:
TURSO_SERVER_URL: http://<release>-turso:8080TURSO_SERVER_ADMIN_URL: http://<release>-turso-admin:8081# TURSO_SERVER_TOKEN sourced from a SecretIn this mode the plugins.turso.sync.url is unused — the plugin ignores it once
TURSO_SERVER_URL is present. Set tursoServer.enabled=false and configure
plugins.turso.sync.* explicitly when pointing at an external sync endpoint that
is not our own turso-server.
For pure single-pod local development, leave tursoServer.enabled=false and
either rely on the default local mode or set plugins.turso.mode=sync with
plugins.turso.sync.url pointing at a specific endpoint.
The chart README and Rancher questions still expose plugins.turso.mode,
plugins.turso.localPath, plugins.turso.sync.url, and
plugins.turso.sync.authToken for the single-tenant fallback path.
The runtime chart mounts /data/turso as emptyDir, so the local Turso file is
pod-local. In Kubernetes, use multi-tenant sync mode for durable cross-pod state.
Service contract
Section titled “Service contract”The service boundary is a provider of database primitives:
interface TursoService { connect(namespace?: string): Promise<TursoDatabase>; health(): Promise<TursoHealth>; transaction<T>( options: TursoTransactionOptions, callback: (db: TursoDatabase) => Promise<T>, ): Promise<T>;}A consumer plugin obtains it through the plugin context:
const turso = ctx.getPlugin<TursoService>("@buntime/plugin-turso");const db = await turso.connect("gateway");Namespaces should map to schema/table-prefix ownership, not to separate arbitrary
adapter types. Consumers should not receive plugin-specific storage APIs from
plugin-turso; they build their own repository layer on top of the database
primitives.
Turso for workers (apps) — openTurso
Section titled “Turso for workers (apps) — openTurso”The TursoService above is reachable only by plugins (via
ctx.getPlugin("@buntime/plugin-turso")). Workers (apps in the pool) run in
isolated Worker threads with no plugin context, so they cannot call
getPlugin. To give a worker first-class durable storage, the runtime forwards
the Turso connection into the worker env and @buntime/shared
ships a helper:
import { openTurso } from "@buntime/shared/turso";
const db = await openTurso("tenants"); // namespaced connectionawait db.exec("CREATE TABLE IF NOT EXISTS tenants (host TEXT PRIMARY KEY, realm TEXT NOT NULL)");await db.prepare("INSERT INTO tenants (host, realm) VALUES (?, ?)").run(host, realm);await db.push(); // sync mode: ship write to the primary (no-op in local)// reads: await db.pull(); then prepare().get()/.all()- Connection resolution (worker env, forwarded by
apps/runtime/src/libs/pool/instance.ts):RUNTIME_TURSO_SERVER_URLset → sync mode: per-namespace embedded replica at<dir>/<ns>.dbsynced with<serverUrl>/<ns>on the in-clusterturso-server.push()/pull()are real.- otherwise → local mode: standalone file
<dir>/<ns>.dbwith MVCC;push/pullare no-ops. Good for single-pod/dev. <dir>=opts.dir→RUNTIME_TURSO_DIR→./.cache/turso.
- The runtime derives
RUNTIME_TURSO_SERVER_URL/RUNTIME_TURSO_SERVER_TOKENfrom its ownTURSO_SERVER_URL/TURSO_SERVER_TOKEN(the chart auto-wires these whentursoServer.enabled=true). So a worker shares the same turso-server the runtime/plugins use, but opens its own namespace. - The caller owns the schema and the read/write/
pull/pushflow. The helper is intentionally thin (mirrorsApiKeyStore’s connection logic) — it does not add a sync timer; do an explicitpush()after writes (durability across pod restarts) andpull()before reads when another writer may have changed the namespace.
Consumer notes
Section titled “Consumer notes”- KeyVal wraps
TursoServicein a localTursoKeyValAdapter;plugin-tursostill exposes only database/transaction primitives. - DDL statements must run through an
exclusivetransaction.BEGIN CONCURRENTrejects DDL. - Turso MVCC rejects SQLite virtual tables, so KeyVal search uses regular
kv_fts_*tables instead of FTS5 virtual tables. - KeyVal orders encoded BLOB keys with
ORDER BY hex(key)for stable reverse pagination after deletes. - Gateway owns
gateway_metrics_historyandgateway_shell_excludes. - Proxy owns
proxy_rulesfor dynamic rules; static proxy rules remain manifest-only and work without durable storage.
Native binding notes
Section titled “Native binding notes”@tursodatabase/database and @tursodatabase/sync use native dependencies in
Node/Bun environments. The official Turso TypeScript reference classifies both
packages as native dependency packages (@tursodatabase/database:
Node.js/WASM, @tursodatabase/sync: Node.js native).
On macOS ARM64, a runtime boot can fail with Cannot find native binding if Bun
does not materialize the platform optional dependencies from the base packages.
The installed package manifests list these platform packages as optional
dependencies:
@tursodatabase/database-darwin-arm64@tursodatabase/sync-darwin-arm64
For local validation on Darwin ARM64, adding those packages explicitly to
plugins/plugin-turso resolved the runtime loader failure. Revisit this before
publishing cross-platform packages: the ideal chart/image path should install the
correct platform binding for the target OS/CPU without baking a Darwin-only
workaround into Linux images.
Runtime bundle notes
Section titled “Runtime bundle notes”The runtime loader uses manifest.pluginEntry when present, so core plugins load
dist/plugin.js in real runtime boots. After migrating a plugin’s storage to
Turso, rebuild the plugin bundle before validating through HTTP/UI. Otherwise the
source tests can pass while the runtime still executes stale dist/plugin.js code
with legacy error messages such as Dynamic rules not enabled (plugin-keyval not configured).