Examples

Copy, run, verify.

Every example below is taken from the project README or documentation. The browser examples assume the bundle and public key produced by the quickstart. The expected output is the output the project tests, not an illustration.

Before you copy: the bundle and the public key are separate installs. The bundle comes from edgeproc publish; the public key must be served from your own origin over HTTPS. Keep the private signing key off the web server.

Vite: sync a signed bundle in a Worker

Keep the Worker entry in your own source so Vite owns its URL. This is the documented Vite setup.

src/edgeproc.worker.tsWorker entry
import "@gainratio/browser/worker";
src/main.tsmain thread
import { EngineClient } from "@gainratio/browser";
import EdgeProcWorker from "./edgeproc.worker?worker";

const client = new EngineClient(new EdgeProcWorker(), { idleTimeoutMs: 60_000 });
const result = await client.sync("/bundle", "/public.key", {
  expectedBundleId: "prices",
  expectedChannel: "stable",
  wantedPaths: ["prices.json"],
  onProgress: ({ phase }) => renderPhase(phase),
});

const bytes = await client.readFile("prices.json");
console.log(`verified ${result.version} (${result.chunksFetched} fetched, ${result.chunksReused} reused)`);
console.log(new TextDecoder().decode(bytes));

expectedBundleId and expectedChannel only work if the publisher signed them into the pointer. undefined skips a pin; null requires the field to be absent. With the README's quickstart those pins are not signed, so drop both options or publish with --bind-identity --channel stable.

The first sync fetches the one data chunk. After that the checked copy is in OPFS:

consolefirst run, then reload
verified v1 (1 fetched, 0 reused)
{"sku": "A-100", "name": "Blue mug", "price": 12.5}

verified v1 (0 fetched, 1 reused)
{"sku": "A-100", "name": "Blue mug", "price": 12.5}

Node: the same check without a browser

syncIndex is the checking logic without the Worker. You inject the transport, so the example provably makes no network calls. This is the README's check.mjs; save it in the project that has public/bundle and public/public.key and run node check.mjs.

check.mjsNode 22.13 or newer
import { readFile } from "node:fs/promises";
import { MemoryCacheStore, materializeFile, syncIndex, verifyEd25519 } from "@gainratio/browser";

const key = new Uint8Array(await readFile("public/public.key"));
const verify = (message, signature) => verifyEd25519(key, message, signature);
// Stands in for a web server: "/bundle/latest" is read from public/bundle/latest.
const server = async (url) => new Uint8Array(await readFile(`public${url}`));
// A bad mirror: flips one byte in every data chunk it serves.
const tampered = async (url) => {
  const bytes = await server(url);
  if (url.includes("/chunk/")) bytes[9] ^= 1;
  return bytes;
};

const store = new MemoryCacheStore();
const ok = await syncIndex({ baseUrl: "/bundle", store, fetchBytes: server, verify });
const manifest = JSON.parse(new TextDecoder().decode(await store.getManifest(ok.manifestHash)));
const bytes = await materializeFile(store, manifest, "prices.json");
console.log(`accepted ${ok.version}: ${new TextDecoder().decode(bytes).trim()}`);

await syncIndex({ baseUrl: "/bundle", store: new MemoryCacheStore(), fetchBytes: tampered, verify })
  .catch((error) => console.log(`refused: ${error.name}: ${error.message}`));
outputunchanged, then tampered
accepted v1: {"sku": "A-100", "name": "Blue mug", "price": 12.5}
refused: IntegrityError: chunk 34fa454858ceada20cfa0cd5236eb70f90e1021ed3bd03a6c9d5332597b3cc12 failed content-address check

The repository's pnpm demo runs the same shape against a real committed 783-chunk fixture. It also proves a second sync over a filled store fetches no chunks, and that one bit flipped in the pinned key gives SignatureError.

Cache fallback: say what the browser did

Every sync result says where the checked chunks are kept. A memory backend is a warning, not a silent downgrade.

report the backendmain thread
const result = await client.sync("/bundle", "/public.key");
if (result.cacheBackend === "sqlite-memory") {
  showNotice("Offline copy will not survive a reload in this browser.");
}
if ("reason" in result.cacheStorage) {
  console.warn(`memory cache: ${result.cacheStorage.reason}`);
}

If your app must not run without persistent storage, refuse the fallback. When OPFS cannot be opened, the Worker then downloads nothing and rejects at once:

fail closedno in-memory cache
import { EngineStorageUnavailableError } from "@gainratio/browser";

try {
  await client.sync("/bundle", "/public.key", { cacheFallback: "none" });
} catch (error) {
  if (error instanceof EngineStorageUnavailableError) {
    showStorageBlocked(error.reason); // "opfs-unavailable" or "pool-in-use"
  } else throw error;
}

The Worker's first call fixes its cacheFallback; a later call with a different value is refused. If readFile can be the first call, pass the same option there.

Key rotation: two keys, then revoke the old one

The trust-root URL serves either exactly 32 bytes (a raw Ed25519 key) or a strict edgeproc.keyring/v1 JSON keyring. A keyring lets you rotate without breaking clients that still hold an old cache.

public.keykeyring form
{
  "schema": "edgeproc.keyring/v1",
  "keys": [
    { "key_id": "34750f98bd59fcfc", "public_key": "8a88e3dd…6f5c" },
    { "key_id": "6a3803d5f059902a", "public_key": "8139770e…b394" }
  ],
  "revoked": []
}
load and verifycore helpers
import { loadTrustRoot, verifyWithKeyring } from "@gainratio/browser";

const keyring = await loadTrustRoot("/public.key", fetchBytes);
await verifyWithKeyring(keyring, message, signatureBase64, keyId);

To rotate: publish a keyring with both keys, sign the next pointer with the new key at a higher sequence, then revoke the old key. key_id is the first 16 lowercase hex characters of the SHA-256 of the raw 32-byte key (deriveKeyId). The last promoted pointer stays the rollback floor throughout, so a key change never lets an older release back in.

SQL: FTS5, JSON1, and vectors in one database

openSqlDatabase opens a real SQLite database in the library's Worker, on OPFS, with FTS5, JSON1, and sqlite-vector on the same connection. Typed arrays such as Float32Array are bound as raw bytes, the BLOB layout sqlite-vector reads.

FTS5 quickstart@gainratio/browser/sql
import { openSqlDatabase } from "@gainratio/browser/sql";

const db = await openSqlDatabase({ name: "notes" });
await db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes USING fts5(body)");
await db.executeMany("INSERT INTO notes(body) VALUES (?)", [["buy milk"], ["call mum"]]);
const hits = await db.query("SELECT body FROM notes WHERE notes MATCH ? ORDER BY bm25(notes)", ["milk"]);
await db.close();

The persistent database uses the OPFS opfs-sahpool VFS, so one tab owns it; a second tab gets a clear open error after a bounded retry. fallback: "memory" is opt-in and suits only small data you rebuild on every boot. Without it, an unavailable pool fails closed.

Vectors: exact cosine, stored in SQLite

Search is exact FLOAT32 cosine distance; filters are parameterized equality checks ANDed together. This example keeps the index in SQLite in OPFS. For small data, FlatVectorIndex uses the same contract without WASM startup.

vector index@gainratio/browser/vector/sqlite
import { createSqliteVectorIndex } from "@gainratio/browser/vector/sqlite";

const index = await createSqliteVectorIndex({ name: "my-catalog", dimension: 384 });
await index.insert([{ id: "sku-1", vector: embedding, metadata: { tenant: "a" } }]);
const nearest = await index.search(query, 10, { tenant: "a" });
const candidates = await index.searchByIds(query, candidateIds); // one exact batch score
await index.deleteWhere({ tenant: "a" }); // needs a non-empty metadata filter
await index.clear(); // returns the exact count; removes every local vector
await index.dispose();

One index has one owner: a second tab gets a clear open error after a bounded retry. The same SQLite runtime is available in Node through createNodeSqliteVectorIndex, in memory by design.

Seal: passphrase-encrypted files in the age format

@gainratio/browser/seal writes standard age v1 files. A user can open one without your app with age -d backup.age > backup.sqlite. Seal and open take whole Uint8Arrays; there is no streaming and no public-key recipient mode.

seal a backuprun in a Worker
import { checkNewPassphrase, openWithPassphrase, sealWithPassphrase } from "@gainratio/browser/seal";

const check = checkNewPassphrase(passphrase, confirmation);
if (!check.ok) throw new Error(check.reason); // "empty" | "too_short" | "mismatch"

const sealed = await sealWithPassphrase(bytes, passphrase);
if (!sealed.ok) throw new Error(sealed.reason);

const opened = await openWithPassphrase(sealed.bytes, passphrase);
if (!opened.ok) throw new Error(opened.reason);

scrypt is synchronous: at the default work factor 17 it holds 128 MiB and blocks its thread for about half a second. Run it in a Worker. Passing workFactor changes the cost (10 to 20); opening refuses work factors above 18 by default with too_costly, before any scrypt runs. Pass maxWorkFactor: 20 for files written by tools that pick a higher factor.

Tamper detection in a real browser

After the quickstart has cached the bundle, flip one byte of the first stored data piece. Open the page in a new private window, because the normal window already holds the checked copy and will not download that piece again.

tamper.shone byte
node -e "const fs=require('fs'),d='public/bundle/chunk/',p=d+fs.readdirSync(d)[0],b=fs.readFileSync(p);b[9]^=1;fs.writeFileSync(p,b)"

The page refuses the data instead of showing it:

consolerefused
refused (integrity): chunk 34fa454858ceada20cfa0cd5236eb70f90e1021ed3bd03a6c9d5332597b3cc12 failed content-address check

Run the same line once more to undo the change. This is the README's test: a changed chunk fails its content-address check; it is not served with a warning.

More