gate() and import

Last updated 29 October 2026

View .md

gate() holds the outcome of a unit of work until synced(), and importSqlite() loads a plain SQLite file into a new database.

gate()

From @tonbo/sqlite-ursula and /tonbo.

function gate<T>(
  db: UrsulaDatabase | readonly UrsulaDatabase[],
  work: () => T | Promise<T>,
  options?: GateOptions,
): Promise<Awaited<T>>

interface GateOptions {
  signal?: AbortSignal;
}

Runs work and settles with its outcome only once a synced() call made after work settled resolved, on every database in db, in parallel. It applies the rule to whatever acts on the outcome: a handler’s Response, a job’s promise a queue framework acknowledges, a message you acknowledge yourself.

import { gate, open } from "@tonbo/sqlite-ursula/tonbo";

const db = await open("notes");

// A fetch-style handler: the status, the headers and every body chunk wait.
const handler = (request) => gate(db, () => router.fetch(request), { signal: request.signal });

// A queue you acknowledge yourself.
for await (const message of subscription) {
  await gate(db, () => applyMessage(db, message)); // records message.id in the same transaction
  await message.ack(); // a crash before this redelivers, and applyMessage skips a seen id
}
  • Reads wait too: a result may have read another call’s pending commit, or state this process is about to lose to a takeover.
  • work’s errors wait as its results do, except an UrsulaError, which is thrown at once, also when a library wrapped it (as a cause, or in an AggregateError). A reset during the wait rejects with the UrsulaResetError and drops the outcome.
  • When work returns a Response with a body, the gate resolves with one of the same status and headers whose body passes each chunk, and its end, on only once a synced() made after the chunk was read resolved. One call covers every chunk read while the previous one was in flight, and the gate reads ahead at most 64 KiB plus one chunk. A reset errors the body.
  • signal bounds the wait for the outcome, which for a Response is its status and headers: the gate then rejects with signal.reason, cancels a returned body, and the commits keep replicating. It does not cut short a body that has started: cancel the body to stop it.
  • An empty array throws TypeError. A node:http response is not gated by itself: write it after the gate, as in res.end(await gate(db, () => save(req))).

The gate does not cover effects inside work before it returns, such as an outbound call or an acknowledgement in the middle of a handler (await db.synced() before them); WebSocket frames, raw sockets, timers, background jobs and child processes; or exactly-once effects. A crash after the release but before an acknowledgement runs the work again, so record message ids and idempotency keys in the same transaction as the work.

GateOptions

OptionDefaultWhat it does
signalNoneStops waiting for the outcome, as synced()’s signal does. A signal that already fired rejects before work runs.

importSqlite()

From @tonbo/sqlite-ursula only.

function importSqlite(url: string | URL, file: string, options?: ImportOptions): Imported

interface ImportOptions {
  token?: string;
  timeoutMs?: number;
}

interface Imported {
  offset: number;
  digest: string;
  pages: number;
  bytes: number;
}

Imports the plain SQLite database file into the stream at url, as the stream’s first snapshot, page for page. Then open the database as any other. On Tonbo, create the database first (see Manage databases): Tonbo creates its stream empty, and the import fills it.

import { importSqlite } from "@tonbo/sqlite-ursula";
import { resolveDatabase } from "@tonbo/sqlite-ursula/tonbo";

// From the resolve on, the key's Streams tokens serve the process's requests, the import's too.
const { url } = await resolveDatabase("notes");
importSqlite(url, "/tmp/notes-import.db");

The file must have 4096-byte pages, with no -wal beside it and no hot journal. It is only read. VACUUM INTO, run on the source database with any SQLite, writes such a copy:

PRAGMA page_size = 4096;
VACUUM INTO '/tmp/notes-import.db';
  • The stream must be missing or empty. A stream of another content type fails with stream_format, anything else with stream_state, and nothing is published.
  • While the import runs, opens of the stream fail with stream_state. An import that fails after it claimed the stream leaves one that refuses opens and imports until it is deleted or recreated, so an UrsulaUnavailableError from an import says fix.
  • A snapshot above the request limit goes up in parts. A server without a cold store fails a file whose compressed snapshot is above that limit with invalid_argument, naming it (see Limits).
  • It blocks the calling thread until the import is done, for at most timeoutMs.

ImportOptions

OptionDefaultWhat it does
tokenNoneThe bearer token for the import alone. A string only: the import blocks its thread, so no provider could refresh it. With a provider, pass (await provider({ signal })).token. Without one, the process’s token serves the import: after a resolveDatabase(), the API key’s tokens, which the extension keeps fresh while the import runs (since 0.11.2).
timeoutMs10 minutesHow long the import may take: a positive whole number of milliseconds.

Imported

FieldWhat it holds
offsetThe snapshot's stream offset, the end of the import's claim.
digestThe snapshot's digest.
pagesHow many pages it holds.
bytesThe compressed snapshot's size.

It throws UrsulaSetupError (such as invalid_argument for the file, stream_format, stream_state, protocol_mismatch or no_server_identity) and UrsulaUnavailableError. To export a database to a plain SQLite file, see Export a copy.