Skip to content

    Adapters01

    Storage adapters

    One database holds events, checkpoints, scheduled commands, dead letters and read models. An adapter tells Bounda which one.

    Bounda runs on a single database. Events, the checkpoints of every subscriber, scheduled commands, dead letters and the tables of your read models all live there, so there is no broker or queue to operate. An adapter is the piece that knows how to talk to one engine.

    bounda.config.tsTypeScript
    import { defineConfig } from "@bounda-dev/core/config";
    import { sqlite } from "@bounda-dev/sqlite";
    export default defineConfig({
    storage: sqlite({ path: "./data/app.db" }),
    });

    storage is where the write side lives. Read models use the same adapter unless you point one of them elsewhere:

    TypeScript
    export default defineConfig({
    storage: postgresql({ url: process.env.DATABASE_URL! }),
    readModels: {
    orderSummary: sqlite({ path: "./data/reporting.db" }),
    },
    });
    Adapter Package Engine
    SQLite @bounda-dev/sqlite A local file, memory, or a libSQL server such as Turso
    PostgreSQL @bounda-dev/postgresql PostgreSQL 14 or newer
    Cloudflare @bounda-dev/cloudflare A Durable Object’s own SQLite, one object per tenant
    In-memory @bounda-dev/core/memory Nothing; for tests and for trying Bounda without a database

    Every adapter passes the same contract test suites, so an app behaves the same way on each of them; which one to deploy compares them.

    On first use the adapter creates its tables, prefixed with bounda_ by default, and on every start it brings them up to the version the installed Bounda expects (schema):

    Table Holds
    bounda_events Every event, with its stream version, a global position and the person it names, if any, which exportPersonalData reads by
    bounda_checkpoints How far each projection, the policy runner, the process runner, the retention and the forget ledger’s copy have read, where a paused rebuild stands, the time of the last ledger entry the database applied, when the last reconciliation ran and the wait of one that failed, when the key rotation was last found complete for a set of master keys, the leases of the background jobs, and the counters that tell a restore
    bounda_inbox Which handler already completed for which event, so a retry does not run it again
    bounda_scheduled_commands Scheduled commands, process deadlines and timeouts, and delayed policy runs
    bounda_dead_letters Handler runs that gave up, with the error and the attempt count
    bounda_idempotency_keys What a command dispatched with an idempotencyKey answered, until the key expires
    bounda_personal_keys One key per person, which their personal fields are encrypted with, wrapped under the master key whose id it keeps, and the key’s fingerprint, and the wait of a rewrap that failed; forget deletes it
    bounda_persons One row per person the runtime knows something about: since when their processing is restricted, NULL while it is not, and, with a retention, when they were last active and last forgotten, the position of the last event recorded for them, and the wait of a forget the retention could not do
    bounda_snapshots The last snapshot of every stream of an aggregate that takes them, with the fingerprint it is valid for
    bounda_subscriber_failures The last failure of each subscriber, with its text encrypted under the person the event names, and whether the subscriber got past it (observability)
    bounda_migrations The version of each set of tables Bounda versions, storage, checkpoints and forget-ledger, one row per migration with when it ran; a forget ledger or read models in a database of their own keep theirs there

    Read models get one table each, named after the read model in snake_case behind rm_, so no read model can take one of the names above: orderSummary becomes bounda_rm_order_summary. Its columns come from the fields of the view, also in snake_case. While a read model is being rebuilt there is also bounda_rm_order_summary__rebuild, and for an instant during the swap bounda_rm_order_summary__retired.

    Every adapter reads all of them back with inspect(), each row as stored, for expectForgotten to search; an adapter of your own proves it with inspectionContract from @bounda-dev/core/adapter/testing.

    Adding a field to a view adds a nullable column the next time the app starts; existing rows keep working. Marking a field unique() or index() adds its index, and a unique() refuses to start while two rows share the value. A string column made under the database’s collation on PostgreSQL takes the "C" collation in place, keeping its rows. Removing a field, changing its type, moving the primary key or dropping unique() is refused with an error that names the read model and the command that rebuilds it, bounda rebuild <read-model>, which does so without taking the read model offline (rebuilding a read model).

    A query’s repository receives client for SQL you write yourself. Rows come back with the column names in camelCase and the values decoded from the view’s field types; client.raw is the driver, for anything the adapter does not cover.

    TypeScript
    export const repository: Query.RepositoryFunction = ({ customerId, client }) =>
    client.all(
    "SELECT order_id, total FROM bounda_rm_order_summary WHERE customer_id = ? ORDER BY total DESC",
    [customerId],
    );

    Placeholders are the driver’s: ? on SQLite, $1, $2 on PostgreSQL. A query written in SQL is tied to one of them; table (findMany, count, …) works on both.

    Projections receive client too. There it belongs to the batch’s transaction, and so does client.raw: the Postgres.js TransactionSql, the libSQL Transaction, or the Durable Object’s sql. SQL written through it commits and rolls back with the rest of the batch. A connection the projection opens by other means does not, and can even wait forever on a row the batch has locked.

    SQLite and libSQL PostgreSQL Cloudflare
    Writes SQLite’s single writer one append at a time per store, under an advisory lock the store’s Durable Object
    Background work runs in one process any number of instances, each run claimed by one the object’s alarm
    It wakes up every pollInterval on NOTIFY at each append, and every idleInterval when the alarm the object armed comes due
    Fits one process, a local app several instances, web and worker roles a store per tenant on Workers

    The in-memory adapter is for tests and for trying Bounda: one process, which it notifies on each append. Its tables and stores behave as SQLite’s: the same refusals, the same null and date semantics, payloads read back as JSON, and the same order, so a test that passes on it passes on SQLite. PostgreSQL orders read models alike: strings by code point whatever the database’s collation, rows without a value where SQLite puts them. Start on SQLite and move to PostgreSQL when the background work needs more than one process; the domain code does not change. Deployment covers the roles and what the instances share, and How Bounda runs the ceiling of one store.