libSQL / Turso (non-Postgres)
Copy page
Nizhal ships one storage adapter — postgresStorage. But the server is decoupled from the
database behind the StorageAdapter interface (the in-code comment is explicit: “Default impl =
postgresStorage; alternates (d1/sqlite/mysql) are adapters”). So running on libSQL / Turso is a
matter of providing that seam. Nothing about the protocol is Postgres-specific — cursor pull over a
monotonic change sequence, tombstones via deleted_at, and idempotent push keyed by clientMutationId
all map cleanly onto SQLite.
This guide targets the common case: you already have a Hono API and a hosted Turso (libSQL) DB.
Honest scope: a libSQL adapter is your code today — Nizhal does not ship or test one. The interface is small and SQLite has every primitive it needs (triggers, transactions, integer timestamps), so it’s a contained job. If you’d rather not, Path B avoids the adapter entirely.
Nizhal does ship and test libsqlAuditStorage, the dialect-specific append/query primitive for the
optional audit log. It is designed to be composed into a complete libSQL
StorageAdapter; it does not turn the incomplete sample below into a sync adapter.
Path A — a libSQL StorageAdapter, mounted in your Hono app (recommended)
Section titled “Path A — a libSQL StorageAdapter, mounted in your Hono app (recommended)”createNizhalServer({ storage }) returns { app } — a Hono app. Mount it inside your existing
API and you reuse the whole engine (scoped cursor pull, idempotency, sync-rule evaluation, the
/sync/* endpoints, realtime):
import { Hono } from "hono";import { createNizhalServer, bearerTokenAuth } from "@nizhal/server";import { libsqlStorage } from "./libsql-storage"; // your adapter (below)import { schema, mutators, syncRules } from "./model";
const nizhal = createNizhalServer({ schema, mutators, syncRules, auth: bearerTokenAuth({ secret: process.env.JWT_SECRET! }), storage: libsqlStorage({ url: process.env.TURSO_URL!, authToken: process.env.TURSO_TOKEN! }),});
const api = new Hono();api.get("/healthz", (c) => c.text("ok")); // your existing routesapi.route("/", nizhal.app); // Nizhal's /sync/pull, /sync/push, /nizhal/contractexport default api;Implement the seam over @libsql/client
Section titled “Implement the seam over @libsql/client”StorageAdapter also requires authorizeMutatorTx: resolve the actor’s sync-rule buckets inside the
same write transaction, then validate inserted rows plus update/delete pre-images and post-images.
SQLite translations of the Postgres-isms:
| Postgres | libSQL / SQLite |
|---|---|
_nizhal_row_version_seq | use an INTEGER PRIMARY KEY AUTOINCREMENT change sequence; encode the sequence as an opaque cursor |
select … for update | BEGIN IMMEDIATE — libSQL serializes the write transaction |
| tombstones | a deleted_at integer column (NULL = live) — identical model |
| change-tracking triggers | SQLite triggers append row changes, tombstones, and bucket exits to the same ordered change log |
_nizhal_mutations / _nizhal_clients | same tables, SQLite syntax |
import { createClient } from "@libsql/client";import type { StorageAdapter } from "@nizhal/server/adapters";
export function libsqlStorage(opts: { url: string; authToken?: string }): StorageAdapter { const db = createClient({ url: opts.url, authToken: opts.authToken }); return { async getChanges({ actor, syncRules, cursor }) { // resolve the actor's buckets from syncRules, then per synced table: // Read change-log entries with seq > decoded cursor under the actor's bucket scope. // Return changed rows, tombstones, and bucket-exit removals, then encode the last seq. }, async transaction(fn) { /* db.transaction("write") → run fn with a StorageTx over the tx */ }, async authorizeMutatorTx({ tx, mutatorTx, actor, syncRules }) { /* return a fail-closed, bucket-scoped MutatorTx */ }, async claimMutation(tx, id) { /* insert into _nizhal_mutations(client_mutation_id) … on conflict do nothing; return inserted */ }, async isApplied(id) { /* select 1 from _nizhal_mutations where client_mutation_id = :id */ }, async recordApplied(id, map) { /* upsert _nizhal_mutations with result/error */ }, async provision({ schema, syncRules }) { /* add sync columns plus ordered change/removal triggers and _nizhal_ tables */ }, // optional: checkMutationSequence (per-client LMID ordering), getActorBuckets, appliedMutationError };}Mirror postgresStorage in @nizhal/server for the exact query shapes — the logic is identical; only
the dialect changes. Run your provision() once at deploy (your equivalent of nizhal migrate).
Path B — implement the wire protocol in your Hono routes (no adapter)
Section titled “Path B — implement the wire protocol in your Hono routes (no adapter)”If you’d rather not write a full adapter, the client only needs the wire protocol, which you can serve from your existing Hono routes over Turso however you like:
POST /sync/pull— body{ cursor, syncRule, deviceId }→{ changed: [{table, rows}], tombstoned, removedBuckets, cursor }.POST /sync/push— body{ name, args, clientMutationId }→ apply the mutation idempotently (dedupe byclientMutationId), return the new cursor / result.(optional) /sync/stream— a WS that emitsrepull:<bucket>on commit (a hint only).
Implement those three against Turso following cursor-pull + idempotent-push + tombstones. You lose the engine’s sync-rule linting and realtime adapters, but you fully own the semantics — good when your sync rules are bespoke.
Realtime without Postgres
Section titled “Realtime without Postgres”listenNotifyRealtime is Postgres-only. On libSQL/Turso: use inProcessRealtime for a single
instance, and rely on the client’s pull.intervalMs fallback for multi-instance — a missed ping
self-heals on the next pull (the cursor pull is authoritative, the ping is only a latency hint). Bring
your own pub/sub (e.g. Redis) only if you want instant cross-instance pushes.
The client is unchanged
Section titled “The client is unchanged”Either path, the client is the same: createNizhalClient + nizhalCollectionOptions + TanStack DB +
waSqlitePersistence / opSqlitePersistence. It speaks the protocol; it doesn’t care the backend is
Turso. See the Client docs.