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.
import { defineConfig } from "@bounda-dev/core/config";import { sqlite } from "@bounda-dev/sqlite";
export default defineConfig({ storage: sqlite({ path: "./data/app.db" }),});import { defineConfig } from "@bounda-dev/core/config";import { postgresql } from "@bounda-dev/postgresql";
export default defineConfig({ storage: postgresql({ url: process.env.DATABASE_URL! }),});import { cloudflare } from "@bounda-dev/cloudflare";import { defineConfig } from "@bounda-dev/core/config";
export default defineConfig({ storage: cloudflare(),});storage is where the write side lives. Read models use the same adapter unless you point one
of them elsewhere:
export default defineConfig({ storage: postgresql({ url: process.env.DATABASE_URL! }), readModels: { orderSummary: sqlite({ path: "./data/reporting.db" }), },});Available adapters
Section titled “Available adapters”| 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.
What an adapter creates
Section titled “What an adapter creates”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.
Evolving a read model
Section titled “Evolving a read model”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).
Hand-written SQL
Section titled “Hand-written SQL”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.
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.
Which one to deploy
Section titled “Which one to deploy”| 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.