Persistence

Kinetis connects to MySQL, Postgres, and Redis through clients matched to the runtime actually serving your application. Under a persistent worker (FrankenPHP), queries suspend only their own request’s Fiber — a request waiting on the database doesn’t stop the worker’s request from making progress on anything else it has in flight. Under PHP-FPM, where a worker serves exactly one request at a time from a fresh process, Kinetis uses a plain blocking PDO connection instead — measured to be the faster choice there by a wide margin, since nothing else could have used the wait time anyway and PDO’s native protocol handling costs a fraction of the CPU.

You never pick this per call site: SqlConnectionFactory selects the driver from the runtime (see “Driver selection” below), every driver implements the same Kinetis-owned Kinetis\Persistence\Contract\SqlLink/ MysqlLink/PostgresLink contracts, and application code, TransactionGuard, and the query builder are identical under all of them. Don’t construct PDO/mysqli/pgsql handles yourself, though — hand-rolled blocking calls in a persistent worker still block that whole worker thread; going through the factory is what keeps the blocking/non-blocking decision where the runtime knowledge lives.

MariaDB works too, everywhere this page says MySQL — mysqli and PDO-MySQL speak the wire protocol both databases share. The one place a specific minimum version matters is kinetis/queue-sql; see Queue (SQL).

Note

Core itself has no MySQL/Postgres/Redis dependency of its own — Kinetis\Persistence\TransactionGuard/SqlConnectionFactory live in the separate kinetis/persistence package, and Kinetis\SimpleCache\RedisSimpleCache/ClusteredRedisSimpleCache live in kinetis/cache-redis. composer require whichever you need; each is introduced with its own installation note below at first use.

Connecting

Setting DB_CONNECTION (plus the other DB_* keys — see Configuration) is the whole wiring: this package’s bootstrap class (declared via extra.kinetis, see CLI) builds the default connection and binds it under its dialect contract — Contract\MysqlLink for DB_CONNECTION=mysql, Contract\PostgresLink for pgsql — before AppScope::boot() locks bindings. The contract interface, not a concrete class, so the factory stays free to pick the right driver per runtime.

To choose your own pool options instead, register the binding yourself in bootstrap.php — an application registration wins over the package’s:

bootstrap.php
use Kinetis\Persistence\Contract\MysqlLink;
use Kinetis\Persistence\SqlConnectionFactory;

return static function (AppScope $app, Config $config): void {
    $app->instance(MysqlLink::class, SqlConnectionFactory::fromConfig($config, poolOptions: ['maxConnections' => 12]));
};

A controller or service then gets the client by constructor injection, like anything else registered on AppScope:

use Kinetis\Persistence\Contract\MysqlLink;

final readonly class OrderController
{
    public function __construct(
        private MysqlLink $db,
    ) {}

    #[Get('/orders')]
    public function index(): array
    {
        return iterator_to_array($this->db->execute('SELECT * FROM orders WHERE customer_id = ?', [$customerId]));
    }
}

RequestScope delegates to AppScope only for explicitly registered ids (see Container) — since MysqlLink::class was registered via instance() above, every request resolves back to that same shared client, not a fresh one per request.

The async drivers are themselves connection pools — lazily opened connections up to maxConnections, reused across requests under a persistent worker, with dead connections discarded and replaced. Kinetis’s own Kinetis\Persistence\Pool is not used by this integration — it stays available as generic infrastructure for protocol clients that don’t pool themselves.

Note

A pooled connection the server closes (an idle socket past wait_timeout, an administrative KILL, a network drop) costs exactly one query. Writing to a socket whose peer is already gone is buffered locally rather than failing, so the first query on a newly-dead connection dispatches successfully and only discovers the death while reading the result — surfacing as a QueryException the caller has to handle. Retrying it automatically is not an option: at that point the statement may already have executed, and replaying a non-idempotent one silently is worse than an error. The next query’s dispatch does fail immediately, and that is retried transparently on a fresh connection. Long-lived workers issuing queries after an idle stretch should expect this and retry at the application level, or keep connections warm.

Note

Each driver needs its extension: ext-mysqli or ext-pgsql for the native async drivers, ext-pdo_mysql/ext-pdo_pgsql for the PDO fallbacks. None is a hard Composer requirement — they’re listed under suggest, so installing Kinetis doesn’t force a MySQL-specific extension onto a Postgres-only deployment or vice versa.

Query Builder builds on this same registered client — pass it to new Query($db) instead of calling ->query() directly.

Multiple databases: named connections

composer require kinetis/persistence

Kinetis\Persistence\SqlConnectionFactory builds a driver client straight from Config, aware of Configuration’s named-connection convention:

use Kinetis\Persistence\SqlConnectionFactory;

$default = SqlConnectionFactory::fromConfig($config);          // DB_*
$reporting = SqlConnectionFactory::fromConfig($config, 'db2'); // DB_DB2_*
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PASSWORD=secret

DB_DB2_CONNECTION=pgsql
DB_DB2_HOST=reporting.internal
DB_DB2_PASSWORD=secret

Register each client under its own id if you want both reachable through the container:

$app->instance(MysqlLink::class, SqlConnectionFactory::fromConfig($config));
$app->instance('db.reporting', SqlConnectionFactory::fromConfig($config, 'db2'));

Only the first is autowireable by constructor type-hinting — a named, non-default connection is always retrieved explicitly ($app->get('db.reporting')), never injected by type.

Driver selection: DB_DRIVER

SqlConnectionFactory::fromConfig() picks the client implementation via DB_DRIVER (connection-scoped like every other DB_* key):

value

what you get

auto (default)

FrankenPHP worker mode → native; PHP-FPM → pdo.

native

mysqli’s MYSQLI_ASYNC (Driver\MysqliAsyncClient) or ext-pgsql’s pg_send_query (Driver\PgsqlAsyncClient): the wire protocol runs at C speed inside the extension, queries overlap across connections, and each waits by suspending only its own Fiber — full concurrently() support.

pdo

One blocking PDO connection (Driver\PdoMysqlClient/PdoPgsqlClient). concurrently() fan-outs still produce correct results; the queries simply run sequentially.

Every driver returns fully-buffered results (part of the SqlResult contract — stop iterating whenever you like, nothing is left to drain), and parameterized calls go through execute(): real server-side binding on Postgres (pg_send_query_params) and PDO, escaped client-side interpolation on native MySQL (whose async mode has no bind step; the client pins the connection charset explicitly so escaping is always performed against a known charset).

The two native drivers scan the SQL text itself to find each ? placeholder — a dialect-aware pass recognizing '...'/"..."/`...` quoting, --/#//* */ comments, and Postgres’s $$...$$/$tag$...$tag$ dollar-quoted strings, so a ? inside any of those is data, never a slot. Postgres’s own jsonb containment/existence operators (?, ?|, ?&) are lexically identical to a placeholder at the position they appear — write ??, ??|, ??& under native/DB_DRIVER=auto to mean the literal operator rather than a bind slot (the PDO driver has no such ambiguity, since it never scans the SQL text itself).

Two comment rules match real MySQL rather than a generic reading of the syntax. -- only opens a comment under native/DB_DRIVER=auto against MySQL when the second dash is followed by whitespace, a control character, or the end of the string — 5--? is 5 - - ?, not a comment (Postgres has no such condition; a bare -- always opens one there). And MySQL/MariaDB’s executable comments (/*! ... */, /*M! ... */) are copied through verbatim, left for the connected server to interpret on its own — whether one is even live SQL depends on its version gate against that server’s actual version (and, for /*M!, whether it’s MariaDB at all), which Kinetis has no way to check client-side. A ? inside one is rejected outright rather than guessed at, on native and pdo alike: the two would otherwise silently require a different number of bound parameters for the same query depending on the connected server’s version, since pdo’s native prepare defers the question to the real server while native’s own scanner never could. Move a bound value outside the comment instead.

The auto split is measured, not aesthetic: under boot-and-die PHP-FPM, per-request connection handshakes and per-query client CPU dominate, and an async client’s I/O overlap cannot pay for them (sub-millisecond queries leave nothing to overlap); under a persistent worker, connections amortize across requests and native async fan-out keeps its benefits at native protocol cost.

The PDO drivers run with native (non-emulated) prepares, where every prepare() is its own server round trip — so execute() memoizes prepared statements per SQL string for the connection’s lifetime. A loop issuing the same parameterized statement N times costs N+1 round trips instead of 2N; against a sub-millisecond database that’s the difference between paying the network once or twice per query. The cache holds at most 256 statements (workloads that interpolate values into their SQL text instead of binding reset it on overflow rather than growing it forever) and is dropped with the connection on close().

Warning

Server-side prepared statements are scoped to a database connection — which is exactly the cache’s lifetime, so direct connections are always safe. But a proxy that multiplexes one client connection across several server connections (PgBouncer in transaction pooling mode being the classic case) breaks that assumption for any client using native prepares, this one included. Behind such a proxy, use session pooling mode, or a proxy version that tracks prepared statements itself.

Two runtime notes for native: mysqli cannot expose its socket to the event loop, so while its queries are in flight the client polls with a short (1 ms) blocking window per loop turn — indistinguishable from a blocking wait when the request’s only outstanding work is the database, and at worst a 1 ms delay per turn for anything else scheduled concurrently. ext-pgsql does expose its socket (pg_socket()), so the Postgres native driver is fully event-driven with no polling at all.

Connection options

One canonical, driver-neutral option set — discrete, connection-scoped keys, each translated by whichever driver gets built:

DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci
DB_SSLMODE=verify-full
DB_SSL_CA=/etc/ssl/certs/db-ca.pem
DB_SSL_CERT=/etc/ssl/certs/db-client.pem
DB_SSL_KEY=/etc/ssl/private/db-client.key
DB_CONNECT_TIMEOUT=5
DB_APP_NAME=myapp
DB_COMPRESSION=false
DB_MAX_CONNECTIONS=12

canonical key

native mysqli

PDO mysql

native pgsql

PDO pgsql

DB_CHARSET

set_charset()

DSN charset=

client_encoding

client_encoding

DB_COLLATION

SET NAMES ... COLLATE

SET NAMES ... COLLATE

DB_SSLMODE

MYSQLI_CLIENT_SSL + verify flag

Pdo\Mysql::ATTR_SSL_*

sslmode

sslmode

DB_SSL_CA

ssl_set()

Pdo\Mysql::ATTR_SSL_CA

sslrootcert

sslrootcert

DB_SSL_CERT

ssl_set()

Pdo\Mysql::ATTR_SSL_CERT

sslcert

sslcert

DB_SSL_KEY

ssl_set()

Pdo\Mysql::ATTR_SSL_KEY

sslkey

sslkey

DB_CONNECT_TIMEOUT

MYSQLI_OPT_CONNECT_TIMEOUT

PDO::ATTR_TIMEOUT

connect_timeout

connect_timeout

DB_APP_NAME

application_name

application_name

DB_COMPRESSION

MYSQLI_CLIENT_COMPRESS

Pdo\Mysql::ATTR_COMPRESS

Pdo\Mysql::ATTR_*, not the equivalent, deprecated-as-of-PHP-8.5 PDO::MYSQL_ATTR_* constants — identical underlying values, just without the deprecation notice.

A “—” is not a silent ignore: setting an option the selected driver cannot honor throws at construction, naming both the option and the driver — a config that works on one runtime never silently means something different on another.

DB_SSLMODE takes libpq’s vocabulary on every driver: disable, require (encrypt, don’t verify the peer), verify-ca, and verify-full (verify against DB_SSL_CA). The opportunistic allow/ prefer modes are libpq-only — MySQL clients have no opportunistic TLS, so those two values throw at construction on the MySQL drivers. Three more MySQL-side rules, all loud construction errors rather than silently weakened connections: a verify mode without DB_SSL_CA (there is nothing to verify against), a DB_SSL_CA without a verify mode (it would be silently ignored), and — a mysqlnd behavior, not a choice — verify-ca verifies the hostname too, so it acts as verify-full: stricter than asked, never looser.

Mutual TLS: client certificates

Where the server authenticates the client too — MySQL’s REQUIRE X509, Postgres’s clientcert=verify-ca in pg_hba.conf — point DB_SSL_CERT and DB_SSL_KEY at the client certificate and its private key. Every driver supports this:

DB_SSLMODE=verify-full
DB_SSL_CA=/etc/ssl/certs/db-ca.pem
DB_SSL_CERT=/etc/ssl/certs/db-client.pem
DB_SSL_KEY=/etc/ssl/private/db-client.key

Presenting a client certificate is independent of server verification, so it is valid under any mode that performs a handshake, require included. Two rules are construction-time errors rather than a connection that quietly means something else: the certificate and the key must be set together (one without the other is unusable), and either one requires TLS — under disable, or with DB_SSLMODE unset, a client certificate would never be presented at all.

Warning

Postgres refuses a client key that is readable beyond its owner: it must be 0600 (or 0640 when owned by root). The message names the file, but it comes from libpq at connect time, so it surfaces as a connection failure rather than a configuration error. MySQL imposes no such requirement — a deployment that works against MySQL can fail against Postgres for this reason alone.

MySQL charset defaults to utf8mb4 on every driver when DB_CHARSET is unset — never the server’s own default, since the native driver’s client-side escaping is charset-dependent and must run against a known charset.

The legacy DB_OPTIONS string is still accepted as a migration path: key=value pairs whose keys have canonical equivalents (charset, collate, sslmode, sslrootcert, connect_timeout, applicationName, compress, …) are translated automatically, with a discrete key winning over a DB_OPTIONS spelling of the same option. Untranslatable keys pass through raw only to the Postgres drivers (libpq natively accepts free-form connection-string keys and validates them itself at connect time) and are rejected loudly by the MySQL drivers, which have no free-form surface to pass them to.

$poolOptions, an optional fromConfig() argument, carries the one pool-level knob:

$db = SqlConnectionFactory::fromConfig($config, poolOptions: [
    'maxConnections' => 6,
]);

maxConnections (default 8) bounds an async driver’s fan-out width — connections open lazily up to the cap, and callers beyond it wait for a free connection inside the pool. The PDO drivers are a single lazy connection, trivially within any cap. The connection-scoped DB_MAX_CONNECTIONS key sets the same width from the environment — a deployment tunes pool sizing without editing bootstrap code — with an explicit $poolOptions value winning over the key when both are set.

warmConnections opens that many connections at construction instead of on first use (clamped to maxConnections); the connection-scoped DB_WARM_CONNECTIONS key does the same from the environment, with the same explicit-value-wins precedence. Every driver also exposes the underlying call directly — warmUp(?int $connections = null), where null warms the whole pool. Warming makes a wrong database configuration fail at boot instead of on the first query, and under FrankenPHP worker mode it is load-bearing for the native MySQL driver, not just a latency optimization — see Performance tuning’s “mysqli’s poll limit” for why boot-time connecting is what keeps that driver’s sockets pollable at all.

Sizing maxConnections under worker mode

Under FpmAdapter, auto selects the PDO driver — one connection per worker process — so maxConnections doesn’t apply at all there; concurrently() fan-outs run their queries sequentially on that one connection, which for typical sub-millisecond queries is the faster trade (measured, not assumed: per-request handshakes and per-query client CPU dominate under boot-and-die).

Under FrankenPhpAdapter’s worker mode it’s a genuinely different shape, not just a bigger version of the same thing: the bootstrap chain (package bootstraps and bootstrap.php alike) runs once per worker thread (see Runtime Adapters’s “Sizing FrankenPHP’s worker threads” section), so every worker thread builds its own separate pool — there is no single, process-wide shared pool the phrase “a persistent worker” might suggest. The real ceiling on simultaneous database connections is num_workers × maxConnections, not maxConnections alone: 128 worker threads each configured with maxConnections: 256 can open up to 32,768 real connections, not 256 — almost certainly far more than your database allows, and every one of them costs the database real memory and setup work even when the client survives the rejection.

Size maxConnections so num_workers × maxConnections stays comfortably under your database’s own max_connections — not so that maxConnections alone matches your expected total concurrency. If a single request’s concurrently() fan-out needs more connections than that per-worker budget allows, the excess queries queue inside the pool instead, adding latency to that one request — a far softer failure mode than a rejected connection that can take the whole worker thread down for good.

Warning

During an open transaction, run every statement through the transaction object — never through the client. The two driver families give client-level calls opposite semantics there: a PDO driver is a single connection, so a client execute() while a transaction is open silently joins it (and rolls back with it), while an async driver runs the same call on a different pooled connection, entirely outside the transaction. Code that mixes the two behaves differently between runtimes under DB_DRIVER=auto. The transaction object pins one connection and is the only portable way to address it.

TransactionGuard — the request-scoped safety net

Kernel degrades gracefully when kinetis/persistence isn’t installed (no dispose hook registered, no error), so an application with no database at all can skip it entirely.

Connection pooling is the drivers’ own job. What no driver can know about is Kinetis’s RequestScope (see Container): if application code begins a transaction and something throws before it’s explicitly committed or rolled back, nothing closes it — and it leaks into whatever the next thing to borrow that pooled connection does.

Kinetis\Persistence\TransactionGuard is the request-scoped safety net for exactly this. It’s autowired fresh per request, like any other class you haven’t explicitly registered on AppScope, and tracks every transaction it starts.

The safety net for everything else

public function rollbackDangling(): void

For the case the pattern above doesn’t cover — a transaction begun directly via beginTransaction() and held open across multiple calls, that never reaches either commit() or rollback() before the request ends — Kernel registers rollbackDangling() as a RequestScope dispose hook, unconditionally, on every request:

$scope->onDispose($scope->get(Kinetis\Persistence\TransactionGuard::class)->rollbackDangling(...));

This is a genuine no-op for the overwhelming majority of requests that never open a transaction at all — it costs nothing to wire in universally, which is exactly why it’s unconditional rather than opt-in the way, say, MCP support is (see Model Context Protocol (MCP)). When it does find one to close, it logs a warning through whatever logger you’ve registered (see Logging) — a genuine anomaly signal, since it means a transaction was left open somewhere it shouldn’t have been.

Both beginTransaction() and transaction() work identically for MySQL and Postgres: all drivers implement the same Contract\SqlLink/ SqlTransaction abstraction, so TransactionGuard never needs to know which one it’s actually talking to.

Redis

use function Amp\Redis\createRedisClient;

$redis = createRedisClient('redis://localhost:6379');

$redis->set('session:abc123', $payload);
$value = $redis->get('session:abc123');

amphp/redis’s client already provides everything needed, including automatic reconnection via ReconnectingRedisLink. Redis has no comparable request-spanning transaction concept the way SQL does, so nothing like TransactionGuard applies here.

Psr\SimpleCache\CacheInterface — a PSR-16 cache

composer require kinetis/cache-redis

Kinetis\SimpleCache\RedisSimpleCache/ClusteredRedisSimpleCache (below) live in this separate package — core ships only NullSimpleCache and the CacheInterface binding itself, so an application with no Redis at all can skip this entirely; AppScope::boot() falls back to NullSimpleCache automatically. Configuring Redis (REDIS_HOST/REDIS_URL/ REDIS_CLUSTER) without this package installed binds a cache whose every operation throws SimpleCacheUnavailableException naming the package — so an application that never touches the cache still boots and runs (a leftover REDIS_* in a .env is not a fatal condition), while one that does use it fails loudly at the first call rather than silently degrading to NullSimpleCache.

A general-purpose PSR-16 cache — not the raw Redis client above, and not Caching & AOT Compilation’s AOT compilation artifacts, a completely different kind of “cache” despite the shared word. Resolvable anywhere via constructor injection with zero setup, like Config/LoggerInterface:

use Psr\SimpleCache\CacheInterface;

final readonly class RateLimiter
{
    public function __construct(
        private CacheInterface $cache,
    ) {}

    public function tooManyAttempts(string $key, int $max): bool
    {
        return ($this->cache->get($key, 0)) >= $max;
    }
}

Optional — Redis is never touched unless configured. If you set REDIS_URL or REDIS_HOST, this connects to Redis automatically with no further setup. If you set neither, CacheInterface resolves to NullSimpleCache — it always misses and never stores, fine for anything where a cache miss just means recomputing. Features where a silent no-op would mean silently not enforcing anything reject it at construction instead: RateLimitMiddleware (see Middleware) and kinetis/auth-jwt’s RevocationStore (see JWT Authentication) both require a real cache.

REDIS_URL=redis://:password@localhost:6379/0
# — or —
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=0
REDIS_TIMEOUT=5

REDIS_URL, if set, wins outright over the discrete parts. Values are serialized with the same Amp\Serialization\NativeSerializer Amp\Redis\RedisCache itself uses internally, so any serializable PHP value — not just strings — can be stored, per the PSR-16 contract.

Both fromConfig() and buildRedisConfig() take an optional string $connection = 'default', following Configuration’s named-connection convention:

$default = RedisSimpleCache::fromConfig($config);            // REDIS_*
$sessions = RedisSimpleCache::fromConfig($config, 'sessions'); // REDIS_SESSIONS_*

To make CacheInterface use a named connection instead of 'default', register it yourself before boot() — your own registration is always kept, never overwritten:

$app->instance(CacheInterface::class, RedisSimpleCache::fromConfig($config, 'sessions'));
$app->boot();

clear() flushes the entire currently selected Redis database — not just keys this cache wrote. Correct when, as recommended above, REDIS_DATABASE points at a database dedicated to Kinetis’s cache; one shared with unrelated data loses it too.

Fetch keys in batches, not one at a time

getMultiple()/setMultiple()/deleteMultiple() are worth reaching for whenever you need several keys. On a single-node cache getMultiple() issues one MGET, which costs roughly a tenth of the client CPU per key that the same keys fetched one get() at a time do — one round trip and one reply parsed, instead of N of each. It is the single largest performance lever this cache has.

// One round trip.
$rows = $this->cache->getMultiple(['user.1', 'user.2', 'user.3']);

// N round trips, each with its own protocol overhead.
foreach ([1, 2, 3] as $id) {
    $rows[] = $this->cache->get("user.{$id}");
}

Note

The Redis client is amphp/redis, a pure-PHP implementation of the protocol on the Revolt event loop. Its overhead is paid per event-loop wakeup rather than per command, so it amortizes across whatever else is in flight at the same time. Under a persistent worker that is the normal state: once around eight concurrent requests hold an outstanding Redis command, per-operation client CPU settles to roughly a quarter of what a single isolated command costs, and no request blocks the worker thread while it waits.

Under PHP-FPM a process handles exactly one request at a time, so there is nothing to amortize against and every cache operation pays the full per-wakeup cost. Batching, as above, is the lever that matters there.

Connecting over TLS

Add REDIS_TLS=true to any of the connections above — single-node or cluster — to connect over TLS:

REDIS_HOST=cache.example.com
REDIS_PORT=6380
REDIS_TLS=true
REDIS_TLS_CA_FILE=/etc/ssl/certs/redis-ca.crt

REDIS_TLS_CA_FILE points at a CA certificate to verify the server against; omit it to use the system’s default trust store. Set REDIS_TLS_VERIFY_PEER=false to skip verification entirely — useful against a self-signed certificate in development, not recommended in production.

Redis Cluster

Set REDIS_CLUSTER=true and REDIS_CLUSTER_SEEDS (a comma-separated list of host:port addresses) instead of REDIS_HOST/REDIS_URL:

REDIS_CLUSTER=true
REDIS_CLUSTER_SEEDS=10.0.0.1:6379,10.0.0.2:6379,10.0.0.3:6379
REDIS_PASSWORD=

Multiple seeds let Kinetis discover the cluster’s layout even if one particular seed happens to be down. Every key is routed to whichever node actually owns it; REDIS_TLS/REDIS_PASSWORD apply to every node the same way. Redis Cluster only supports database 0, so there’s no REDIS_DATABASE option here.

CacheInterface resolves to the same interface either way — application code never needs to know whether it’s talking to a single node or a cluster.

Note

getMultiple()/deleteMultiple()/clear() each dispatch several Redis commands concurrently internally. Don’t call any of them from inside a task you’re already running through concurrently() yourself — nesting one Fiber-driven event loop run inside another isn’t supported.

See also

  • Concurrencyconcurrently(), and how the persistence drivers’ Fiber-suspending calls compose with Kinetis\Async’s own primitives on the same Revolt loop.

  • Container — how TransactionGuard (and any other class you haven’t explicitly registered) actually gets resolved per request.

  • Logging — registering the logger rollbackDangling() warns through.

  • Configuration$config above, typed environment access in full, and the named-connection convention SqlConnectionFactory/RedisSimpleCache both build on.

  • Caching & AOT Compilation — the other “cache” in this codebase: build-time AOT compilation of routes/validation/OpenAPI, unrelated to CacheInterface above beyond the shared word.

  • Query Builder — a thin, parameterized SQL builder on top of the same MySQL/Postgres clients, composing directly with TransactionGuard. A separate kinetis/query-builder package, not core.

  • Performance tuning — the worker-threads x connections budget, what to observe under load, and tuning by workload shape.