On-device data
A bext-lite app carries its own data. KV, a per-app database, a queue, a cache, a sandboxed filesystem, and secrets are all backed by self-contained SQLite files (secrets are env-backed) that live next to the app. There is no external database to provision. The data is offline-first and copyable to any target - move the app, move its data with it.
The store is a slice of the bext SDK, re-implemented in-process so a single-tenant
runtime backs it without a masquerade. The contracts are byte-compatible with the
full server, so an app that runs on *.bext.dev runs unchanged here.
Where the data lives#
Everything is written under the app's persistent data directory, which is kept outside the swappable bundle so an OTA update can never wipe it:
<data_dir>/
sdk-kv-<app_id>.db KV store
<app_id>.db per-app database
sdk-queue-<app_id>.db queue
sdk-cache.db cache
fs/ filesystem sandbox
ota/ OTA bundle store (if OTA is enabled)
By default data_dir is <dist>/.data; the desktop and mobile shells point it at
the OS app-data directory so it survives updates. The per-tenant orchestrator can
override it explicitly.
How a PRISM app reaches it#
Your app reaches the data layer the same way it does on the full server: by
fetching /__bext/sdk/*. During SSR or an API render, the runtime intercepts
that fetch in-process - no socket round trip - and dispatches it straight to
the SQLite-backed store. The bext.kv / bext.db client helpers wrap this exact
channel.
A loader reading and writing KV:
// A tiny wrapper over the in-process SDK channel.
async function sdk(path: string, body: unknown) {
const res = await fetch(`/__bext/sdk/${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
}
export async function loader() {
await sdk("kv/set", { key: "visits", value: 1, ttl: 3600 }); // ttl is seconds, optional
const { value } = await sdk("kv/get", { key: "visits" });
return { visits: value };
}
The per-app database takes arbitrary SQL:
// SELECT -> { rows, columns }; anything else -> { changes, last_insert_id }
await sdk("db/execute", {
sql: "CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)",
});
await sdk("db/execute", { sql: "INSERT INTO notes(body) VALUES(?1)", params: ["hello"] });
const { rows } = await sdk("db/query", { sql: "SELECT id, body FROM notes" });
The client goes through /api, never straight to the SDK#
There is a security boundary here, and it matters. bext-lite also runs as a
public-proxied untrusted-hosting tenant, so the loopback HTTP server routes only
the native device/* namespace to a bridge. The data SDK (kv, db, queue, cache,
fs, secrets) is in-process only - it is reachable while your loader or API
handler executes, but it is not reachable over the socket. A fetch to
/__bext/sdk/kv/get from a browser island returns 404 by design.
So client islands never touch the SDK directly. They call the app's own /api/*
routes, and those routes touch the SDK server-side where it belongs:
// src/app/api/notes/route.ts - runs server-side, may use the SDK
export async function GET() {
const { rows } = await sdk("db/query", { sql: "SELECT id, body FROM notes" });
return Response.json(rows);
}
// a client island - goes through the app's API, not the SDK
const notes = await fetch("/api/notes").then((r) => r.json());
Do not try to expose /__bext/sdk/* to the browser. The data SDK is deliberately
in-process only so that a web visitor on an untrusted-hosting instance cannot read
or write another tenant's data. Reach it from a loader or an /api/* handler, not
from client code.
The stores#
| Store | Wire path | Backing |
|---|---|---|
| KV | kv/{get,set,delete,list} |
sdk-kv-<app_id>.db, key/value/expiry, TTL in seconds |
| Database | db/{query,execute} |
<app_id>.db, arbitrary SQL |
| Queue | queue/{push,pull,ack,stats} |
sdk-queue-<app_id>.db, 30 s visibility, 5 attempts |
| Cache | cache/{set,get,invalidate} |
sdk-cache.db, TTL + JSON tags or path-glob invalidation |
| Filesystem | fs/{write,read,list,exists,delete,mkdir,move} |
real files under <data_dir>/fs |
| Secrets | secrets/get?name=NAME |
environment variables |
Filesystem writes accept either content (text) or content_base64 (binary,
which is how uploads arrive). Every path is contained under <data_dir>/fs;
.., absolute paths, and drive prefixes are stripped, so nothing an app names can
escape the sandbox.
// store an upload
await sdk("fs/write", { path: "/uploads/a.png", content_base64: "<...>" });
const { content } = await sdk("fs/read", { path: "/uploads/a.png" });
Secrets resolve from the environment in order: BEXT_SECRET_<APPID>_<NAME>, then
BEXT_SECRET_<NAME>, then <NAME>. Set them where the runtime process can see
them; the app reads them by name.
Optional sync to a remote bext#
The data layer is local-first, but an app can sync. A server-side fetch to an
external URL is a real blocking HTTP call, so a loader or API handler can push
local changes to a remote bext or pull down updates.
External fetches pass through an SSRF guard by default: requests that resolve to a
non-public address (loopback, private, link-local, cloud-metadata) are refused,
and the resolved IP is pinned for the connection so a rebinding host cannot swap in
an internal address at connect time. A trusted single-app deployment that must
reach LAN services can opt out with "egress": "all" in the manifest. The app's
own /api self-loopback is always allowed.
Related#
- Architecture - the host layer this sits in
- Deploying - where
data_dirlands per target - Over-the-air updates - why data lives outside the bundle