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());
Warning

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.