@bounda-dev/cli installs the bounda command.
pnpm add -D @bounda-dev/cli typescriptTypeScript 7 or newer is an optional peer: the generator needs it only to infer the state of
aggregates without state.ts.
Output
Section titled “Output”Every command prints its result on standard output and everything else on standard error: the
app’s log lines, through Bounda’s logger or straight to the console, the
access log records, warnings, progress and errors.
Standard output carries nothing but the result, so bounda dead-letters list --json and
bounda forgotten pipe into another program as they are:
bounda dead-letters list --json | jq '.[].id'The app’s logger writes one line each, [bounda] <level> <message> <fields as JSON>, at info and
above.
Error text
Section titled “Error text”Standard error often ends up in a log, and the message of an error a handler throws may quote
personal data it read. So a command that fails on such an error prints its class and code, not its
message, and the app’s log lines carry no error’s text, as
runtime.telemetry.errorText says by default:
error: ValidationError (VALIDATION_FAILED); its message may quote personal data: run again with --error-text to print it--error-text, on every command that loads the project, prints the message and lets the log lines
carry the text of errors for that run; BOUNDA_ERROR_TEXT=include in the environment or in .env,
or a configuration that sets errorText: "include", does the same without it, and
BOUNDA_ERROR_TEXT=omit keeps the text out whatever the configuration says. What fails before any handler runs is printed in full: a configuration or a
registry that cannot be loaded, a read model that is not in the registry, a list forget cannot
read, and errors whose message Bounda writes from names and ids alone, such as a dead letter that is
not found or was already settled. To read what a reaction failed on, read its letter:
bounda dead-letters list opens the text it keeps encrypted, an access the access log records.
What a subscriber, a projection say, failed on last is in bounda status --error-text, recorded
the same way.
Who runs a command
Section titled “Who runs a command”--actor <id> names who runs a command that acts or reads personal data: rebuild,
dead-letters list, retry and discard, status, export and forget. The command acts as that id, as
client.as(actor) does in code: the events it
writes carry it in their metadata, its log lines name it, and so does every access log record it
causes.
bounda dead-letters retry 019a0c4e-3c9e-7a1b-9f1e-2b3c4d5e6f70 --actor ops-anabounda forget --from forgotten.jsonl --actor ops-anaGive an id, such as the operator’s account, never a name or an email: events keep it in the clear
and are never deleted. Without --actor the command names nobody, as before. An id that is not 1
to 255 characters is refused with exit code 1. bounda forgotten takes none: it prints ids the
stream holds in the clear and records nothing, and bounda generate loads no app.
bounda generate
Section titled “bounda generate”Reads the project layout and writes .bounda/registry.ts, .bounda/register.d.ts,
.bounda/types.ts and one +types/<name>.ts next to every module. The registry carries, for every
aggregate, a stateFingerprint of the modules that shape its state and of the type inferred for
it, which its snapshots are valid for.
Files whose content did not change are left alone; +types files whose module is gone are removed, and so are the folders
their removal empties; a folder holding nothing but +types (and Finder’s .DS_Store) is not
taken for a module.
bounda generatebounda generate --watchbounda generate --root ./apps/shop --app-dir src --no-infer| Option | Default | Meaning |
|---|---|---|
--root <dir> |
current directory | The project root: where .bounda/ goes and tsconfig.json is looked for |
--app-dir <dir> |
app |
The application directory under the root, with domain/ and read/ |
--tsconfig <file> |
<root>/tsconfig.json |
The TypeScript project used to infer state |
--no-infer |
Do not start TypeScript; aggregates without state.ts get UnknownState |
|
--watch |
Regenerate after each burst of changes under the application directory |
Output lists every file written or removed, then a summary:
written .bounda/registry.ts written app/domain/order/+types/order-placed.ts1 aggregate, 1 read model, 10 files (2 written, 8 unchanged, 0 removed)Warnings go to stderr and do not change the exit code. They come from a layout that is most likely wrong without breaking a convention (see what the generator warns about) and from state inference:
warning: order: field "cancellation" (set by orderCancelled) has a type that is not visiblefrom .bounda/types.ts (Cannot find name 'Cancellation'.); it is typed as unknown. Export the typeor add state.tsExit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 |
Generated, possibly with warnings |
1 |
The layout breaks a convention. Every problem is listed with its path; nothing is written |
2 |
Something else failed, such as a file where .bounda/ should be |
Watch mode
Section titled “Watch mode”--watch regenerates after each burst of changes, 100 ms after the last one, and ignores changes
to +types and .bounda directories. It makes its first run only once it is listening, so a module saved while
that run is going is regenerated right after it, and prints watching app/ for changes once that
run has succeeded, on standard error. To know it is listening, it writes a file named .bounda-watch-<uuid> into
the application directory until it sees the change come back, then removes it: the file system can
start listening a moment after it is asked to, and miss what changes before. When it has not seen
it after 20 tries, a second, it prints a warning that watching may miss changes, removes the file
and goes on. A run that fails prints its problems and watching goes on; a first run that fails
outright, such as a file where .bounda/ should be, ends it. Ctrl-C ends it too.
How state is inferred
Section titled “How state is inferred”For an aggregate without state.ts, the generator writes a first pass in which the state is
UnknownState, opens the project with TypeScript, reads the return type of every event’s
begin and evolve and unions the fields it finds. The fields every begin always sets are
required once the aggregate exists, and a command handler sees that state or the one of an
aggregate that does not exist yet, every field undefined:
export type OrderCreatedState = { readonly customerId: string; readonly lines: readonly import("../app/domain/order/order-placed.ts").Line[]; readonly paidWith?: "card" | "transfer"; readonly status: "cancelled" | "paid" | "placed";};export type OrderState = core.NotCreated<OrderCreatedState> | OrderCreatedState;evolve gets OrderCreatedState. Without any begin, there is only OrderState, every field
optional, since any event could come first.
A field an evolve computes from the state, such as reminders: state.reminders + 1, has no type
in that first pass. The generator reads every evolve again against the state it has inferred so
far, until nothing changes, so the field takes the type the other events give it. One that only
ever comes from itself, with no begin or other event to give it a type, becomes unknown, and a
warning names it: set it in a begin, or add state.ts. So does a chain of fields computed one
from another that has not settled after a few passes. A field that is any on purpose, set from
JSON.parse or a z.any() payload, stays as it is.
Types exported from your modules are referenced through import(...). A type that is not
exported cannot be named from .bounda/types.ts; that field becomes unknown and a warning says
which field and which events set it. Export the type, or add state.ts, to fix it. Without
TypeScript installed, or when it cannot open the project, the state stays UnknownState and the
warning says so.
bounda rebuild
Section titled “bounda rebuild”Rebuilds one read model from the whole global stream without taking it offline. It loads
bounda.config.ts and the generated registry the way boot() does, so it runs from the project
root with the same environment as the app.
bounda rebuild orderSummarybounda rebuild orderSummary --root ./apps/shop --config bounda.config.ts --registry .bounda/registry.ts| Option | Default | Meaning |
|---|---|---|
--root <dir> |
current directory | The project root, where the configuration and .bounda/ are |
--config <file> |
bounda.config.ts |
The configuration module under the root |
--registry <file> |
.bounda/registry.ts |
The generated registry module under the root |
--actor <id> |
Who runs it, named in its log lines; see who runs a command | |
--error-text |
Print the message of the error it fails on, and log the text of errors; see error text |
The projections run into a fresh table that takes the live table’s place once it has caught up; an interrupted rebuild resumes where it stopped, and a second one started meanwhile takes over. Rebuilding a read model says how it works and when to run it in a deploy.
rebuilt read model "orderSummary": 48213 events, checkpoint at position 48213Use it after fixing a projection, and when a view loses a field or changes a field’s type, which the
app refuses to do on start. Exit codes: 0 rebuilt, 1 bad arguments, 2 the project could not be
loaded, the read model is not in the registry, or a projection failed.
bounda dead-letters
Section titled “bounda dead-letters”Lists, retries or discards the handler runs that gave up. It boots the project the way boot()
does, without starting the background work, so it runs from the project root with the app’s
environment.
bounda dead-letters listbounda dead-letters list --kind policy --handler order.notifyOnOrderPlacedbounda dead-letters list --status retried --limit 20 --jsonbounda dead-letters retry <id>bounda dead-letters discard <id>| Option | Applies to | Meaning |
|---|---|---|
--kind <kind> |
list |
policy, process or scheduled |
--status <status> |
list |
failed (default), retried or discarded |
--handler <name> |
list |
The policy or process name as the letter records it, e.g. order.notifyOnOrderPlaced, or the scheduled command’s type, e.g. PlaceOrder |
--limit <n> |
list |
At most this many letters: a whole number, 0 or more |
--json |
list |
Print the letters as JSON |
--root, --config, --registry, --error-text |
all | As for bounda rebuild |
--actor <id> |
all | Who lists, retries or discards: named in the access log record each one writes, in the events a retry writes and in the log line of a retry or discard |
019a0c4e-3c9e-7a1b-9f1e-2b3c4d5e6f70 failed policy order.notifyOnOrderPlaced OrderPlaced on order:o-1, 1 attempt, last 2026-09-22T14:03:11.402Z (terminal) ValidationError (VALIDATION_FAILED): mail server rejects it019a0c51-8d2f-7c4e-a1b2-3c4d5e6f7a80 failed process order.orderPayment OrderPaid on order:o-2, 3 attempts, last 2026-09-22T14:05:40.118Z (retriable_exhausted) Error: payment provider timed out 2 events are parked behind it; retrying it handles them in order2 dead lettersA letter retried or discarded shows when after its status (retried 2026-09-23T08:00:00.000Z).
The third line of each letter is the error’s class, its code when it is one of
Bounda’s, and its message. The message is kept encrypted with the key of
the person the work names, so it is left out once that person was
forgotten; see what a letter holds.
When retry fails, because the handler fails again or the letter cannot be retried or was settled
first, the error is printed, as error text says, and the exit code is 2. For a
process letter it also says when one of the parked events failed the process again. What a retry runs for each kind is in
Dead letters.
bounda status
Section titled “bounda status”Prints where the runtime’s background work stands, as the database says. It boots the project as
bounda dead-letters does.
bounda statusbounda status --jsonbounda status --error-text --actor ops-anatables storage storage 3 of 3 storage checkpoints 1 of 1 forgetLedger forget-ledger 4 of 4restores found while running: 0subscribers (head at 1204) projection:customerDirectory at 1197, 7 behind failing: Error on CustomerRegistered at 1198, 4 attempts since 2026-11-09T10:02:11.000Z policies at 1204, 0 behindpersonal data backfill: sealed through position 1204, caught upforget ledger: reconciled, applied through 2026-11-08T22:15:00.000Z, last run 2026-11-09T10:00:00.000Zkey rotation: under 7Kx0Qw9z (previous Wn3eT1aa), 12 keys left to rewrapretention default 2y, 0 persons past it, next due 2028-01-04T09:13:00.000Z marketing 18mo, 3 keys past it, next due 2026-11-09T11:40:00.000Z invoices 6y, 0 keys past it, next due 2031-02-12T16:20:00.000Zdead letter retention: 30d, next purge 2026-11-20T08:00:00.000Ztables: each scope of Bounda’s tables, in each database the configuration names (storage,readModels.<name>for read models in a database of their own,forgetLedger), at the version the database holds of the one this release migrates it to; one behind says(migrations pending: bounda migrate). A store without tables, asmemory(), has none.restores found while running: how many times a running instance found the database gone back to an earlier copy (restores), or declared bybounda restored.- each subscriber’s checkpoint and lag, and its last failure, by any instance:
failingwhile it is still stuck on it,last failed, resolved …once it got past. Its class and code, the event, how many deliveries in a row failed and since when; never its text. - the backfill of personal data, the forget
ledger’s reconciliation, the
rotation of the master key, the
retention and the purge of resolved dead
letters, each
not configuredwhen it is off. The retention has a line for the app’s, which forgets persons as a whole, underdefault, and one for each category ofapp/personal-data.ts, which forgets its keys, with how many are past it now and when the next comes due, and the keys a forgetting kept that no legal obligation keeps any more.
Under the rule of error text set to "include", by --error-text,
BOUNDA_ERROR_TEXT=include or the configuration, it also prints, under each failure, the text it
keeps, encrypted under the person the event names: it is read through app.subscriberFailures.list(), which records it in the
access log with the persons and --actor. A failure whose
person was forgotten says its text went with them.
| Option | Meaning |
|---|---|
--json |
Print app.status() as JSON; with --error-text, subscriberFailures holds the failures with their text |
--root, --config, --registry, --error-text |
As for bounda rebuild |
--actor <id> |
Who reads the failures’ text with --error-text, named in the access log |
bounda restored
Section titled “bounda restored”Says the database was restored from a copy while the app was stopped, for an app without a
forget ledger, whose mark tells it on its own. It boots the
project as bounda dead-letters does.
bounda restoredrestore recorded: 1 found so far; the background jobs start over from the copy, without the holds it brought backThe restore is counted, so every running instance starts its background jobs over; the holds of
failures the copy brought back are cleared; and with a ledger, it is reconciled at once. Run it
after bounda forget --from when restoring without a ledger (see
restores). Options: --root, --config, --registry,
--error-text, as for bounda rebuild. Exit codes: 0 recorded, 2 the project could not be
loaded.
bounda migrate
Section titled “bounda migrate”Brings Bounda’s tables to this release’s version in every database the configuration names, as the first instance to start would, and prints the versions after:
bounda migratemigrated storage storage 3 of 3 storage checkpoints 1 of 1 readModels.reports checkpoints 1 of 1It reads only the configuration, never the registry, and starts no app: run it as a step of the
deploy, after stopping the previous version and before starting the new one (see
Schema). Each database migrates under its own lock, as a start does,
so running it twice, or while an instance starts, migrates once. Tables a newer release migrated
are refused untouched with STORAGE_SCHEMA_TOO_NEW.
| Option | Meaning |
|---|---|
--root <dir> |
The project root, the current directory by default |
--config <file> |
The configuration module under the root, bounda.config.ts by default |
Exit codes: 0 migrated, 1 bad arguments, 2 the configuration could not be loaded or a
migration failed.
bounda export and bounda forget <person>
Section titled “bounda export and bounda forget <person>”Answer a person’s request from the command line. Both boot the project as bounda dead-letters
does, and act as --actor.
bounda export c-42 --actor ops-ana > c-42.jsonbounda forget c-42 --category marketing --actor ops-anabounda forget c-42 --actor ops-anabounda export <person> prints, as JSON, what app.exportPersonalData answers: every record that
names the person, with each field’s category, and every category’s basis and retention (see
Exporting a person’s data). It is recorded in the
access log like any export.
bounda forget <person> forgets the person as a whole, as app.forget does, and prints
forgot c-42; with --category, repeated for several, only those categories, as when a consent is
withdrawn, and prints forgot marketing of c-42. A category whose basis is legal-obligation is
kept, restricted, until its retention ends, either way. A category the app does not have fails with
VALIDATION_FAILED and nothing is forgotten.
bounda forgotten and bounda forget --from
Section titled “bounda forgotten and bounda forget --from”Apply again, by hand, the forgettings a restored backup undid, for an app without a
forget ledger. bounda forget boots the project as
bounda dead-letters does; bounda forgotten only opens the project’s storage and reads its
event store, without starting the app, so a strict forget ledger that is down does not stop it.
bounda forgotten --since 2026-11-01T03:00:00Z > forgotten.jsonlbounda forgotten --since 18250 > forgotten.jsonlbounda forget --from forgotten.jsonlbounda forgotten reads the stream and prints, one line of JSON each, the PersonalDataForgotten
of every person forgotten since --since: a date, compared with each event’s time, which reads the
whole stream, or a position of the stream to read after. Each line is
{"personId":…,"eventId":…,"forgottenAt":…,"reason":…,"fingerprints":[…],"categories":…}, the
entry a ledger would hold: categories null for a person forgotten as a whole and else the
categories the forgetting asked for, and no fingerprints for a forgetting recorded before Bounda
0.4.0; the count goes to standard error, so standard output is the list alone. Run it against the
database before it is restored, or regularly next to the backups.
bounda forget --from <file> commits again each forgetting listed, of the categories it asked for
or of the person as a whole, under its own id, time and reason, unless the database holds it, and
only while the person holds a key in force the line covers and names, as a reconciliation would;
with a forget ledger configured, it records each in the ledger first. A line may also be a bare
person’s id, forgotten as a whole, as a request, whenever they hold a key; blank lines are skipped.
It prints each person it forgot and how many of those listed:
forgot again c-42forgot again 1 of 3 listed; the rest hold no key the line names, or this store holds their forgetting| Option | Applies to | Meaning |
|---|---|---|
--since <date|position> |
forgotten |
A date such as 2026-11-01 or 2026-11-01T03:00:00Z, or a position: a whole number |
--from <file> |
forget |
What bounda forgotten printed, or one person’s id a line; not with a person |
--category <name> |
forget <person> |
Forget only this category of the person; repeat it for several |
--root, --config, --registry, --error-text |
all | As for bounda rebuild |
--actor <id> |
export, forget |
Who acts, named in each PersonalDataForgotten, its log line and the access log |
Exit codes: 0 done, 1 bad arguments, or a person and --from both or neither, 2 the project
could not be loaded, the export or the forget failed, or the file could not be read or has a line
that is neither a person’s id nor an entry, in which case nobody is forgotten.
bounda forget-ledger expire
Section titled “bounda forget-ledger expire”Gives every entry of a Workers KV forget ledger
the expiration keepFor sets: KV expires a key only as it was written, so the keys written before
keepFor was set, or while it was longer, keep the expiration they had, none at first. Run it once
after setting or shortening keepFor, from the project’s root.
bounda forget-ledger expire --binding FORGET_LEDGER --keep-for 35dIt runs the project’s own wrangler, logged in to the account: it lists every object’s keys under
the ledger, writes again with their expiration those that expire later than keepFor after their
forgetting or never, and removes those already past it, through wrangler kv bulk. A key that is
not an entry of the ledger is never touched. It prints what it changed:
gave 1204 entries the expiration keepFor sets; removed 37 already past it| Option | Meaning |
|---|---|
--binding <name> |
The KV namespace’s binding in wrangler.jsonc, as kvForgetLedger({ binding }) names it |
--keep-for <duration> |
The configuration’s keepFor, such as 35d |
--local |
The local state of wrangler dev instead of the deployed namespace |
--env <name>, --wrangler-config <path>, --persist-to <dir> |
As wrangler takes --env, --config and --persist-to |
--root <dir> |
The project’s root, where wrangler is installed |
--error-text |
Print the message of the error it fails on, as error text says |
Exit codes: 0 done, 1 bad arguments, 2 wrangler failed, is not installed, or
@bounda-dev/cloudflare is not; the failure is printed as error text says.
Programmatic use
Section titled “Programmatic use”bounda generate is exported from @bounda-dev/cli as generate:
import { generate } from "@bounda-dev/cli";
const report = await generate({ root: process.cwd() });report.written; // absolute paths written this runreport.warnings; // layout and inference warnings, per aggregate or read modelIt throws ConventionError when the layout breaks a convention; formatConventionError and
formatWarnings format the error and the warnings as the command prints them. bounda rebuild is rebuildReadModel from
@bounda-dev/core over a project loaded with loadProject from @bounda-dev/core/node, with
actor for --actor; an app exposes the same as app.rebuildReadModel(name), and
app.as(actor).rebuildReadModel(name). bounda status is app.status(), and its failures’ text
app.as(actor).subscriberFailures.list(); bounda migrate is migrateTables({ config }) from
@bounda-dev/core; bounda restored is app.restored(). bounda dead-letters is app.deadLetters on a booted app,
or app.as(actor).deadLetters, bounda export is app.as(actor).exportPersonalData(person),
bounda forget <person> is app.as(actor).forget(person, { categories }), and
bounda forget --from is app.forgetAgain(entries, { actor }). The
commands boot the app with createConsoleLogger({ stream: process.stderr }) from
@bounda-dev/core/node, which writes every level to the stream it is given.