Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/aggregate-snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
"@bounda-dev/postgresql": minor
---

Snapshots of an aggregate's state, opt-in per aggregate with `runtime.overrides.<aggregate>.snapshot: { every }`. A `snapshot:<aggregate>` subscriber writes them in the background, and a command loads the snapshot and folds only the events after it. A snapshot is a cache of the fold and never the source of truth: it is valid only for the `stateFingerprint` the generator now writes to the registry, a digest of the modules that shape the state and of the type inferred for it, plus the configured `snapshot.version`; one that is missing, from other code, unreadable or past the stream's head is ignored, and a share of the loads (`snapshot.verify`, `0.01` by default) is checked against a fold of the whole stream, which drops a snapshot that folds to another state. An app from `createTestApp` checks every load and throws `SnapshotError`. The state is kept with its types (`Date`, `Map`, `Set`, `bigint`). Every adapter gains a `snapshots` table and `Storage.snapshotStore`; `load` with `fromVersion` in the SQL event stores now reads the stream's head before the events, bounded by it, so an append landing between the two reads can no longer report a head past the events returned.
Snapshots of an aggregate's state, opt-in per aggregate with `runtime.overrides.<aggregate>.snapshot: { every }`. A `snapshot:<aggregate>` subscriber writes them in the background, and a command loads the snapshot and folds only the events after it. A snapshot is a cache of the fold and never the source of truth: it is valid only for the `stateFingerprint` the generator now writes to the registry, a digest of the modules that shape the state and of the type inferred for it, plus the configured `snapshot.version`; one that is missing, from other code, unreadable or past the stream's head is ignored, and a share of the loads (`snapshot.verify`, `0.01` by default) is checked against a fold of the whole stream, which drops a snapshot that folds to another state. An app from `createTestApp` checks every load and throws `SnapshotError`. The state is kept with its types (`Date`, `Map`, `Set`, `bigint`). Every adapter gains a `snapshots` table and `Storage.snapshotStore`, which keeps the newest snapshot of a fingerprint whatever order two writers put theirs in; `load` with `fromVersion` in the SQL event stores now reads the stream's head before the events, bounded by it, so an append landing between the two reads can no longer report a head past the events returned.
13 changes: 7 additions & 6 deletions .changeset/idempotent-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@

A command can be dispatched again without running twice: `commands.x(payload, { idempotencyKey })`
remembers what the first dispatch under a key answered, in the same transaction as the events it
stores, and every later dispatch with the same key, command and payload answers the same, its
rejection included, without running the handler; two racing for one key store the events once.
The same key for another command or payload rejects with `IdempotencyKeyConflictError`
stores, and every later dispatch with the same key, command, payload and `delay` answers the same,
its rejection included, without running the handler; two racing for one key store the events once.
The same key for another command, payload or `delay` rejects with `IdempotencyKeyConflictError`
(`IDEMPOTENCY_KEY_CONFLICT`). A key lives `runtime.commands.idempotencyKeyLifetime`, 24 hours by
default, in a new `bounda_idempotency_keys` table of every adapter, which the scheduled-command
worker purges. `Storage` and `StorageTransaction` gain an `idempotencyStore`, with its contract
test in `@bounda-dev/core/adapter/testing`. The React Router middleware passes a request's
`Idempotency-Key` header to every command of the request, and `failure()` answers a conflict with
a 422. The in-memory adapter now answers, inside a transaction, the positions its events commit
test in `@bounda-dev/core/adapter/testing`. The React Router middleware gives every command of a request
a key derived from its `Idempotency-Key` header and the command's name, `idempotencyKeyFor(header,
name)`, so an action that dispatches two commands keys each apart, and `failure()` answers a
conflict with a 422. The in-memory adapter now answers, inside a transaction, the positions its events commit
with, as the SQL adapters do.
2 changes: 1 addition & 1 deletion .changeset/personal-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
"@bounda-dev/react-router": minor
---

Personal data in events, forgotten by crypto-shredding. An event's `payload` receives `personal` and `person` next to `z`: `personal(schema)` marks a field as personal data, stored encrypted (AES-256-GCM through WebCrypto, on Node and in `workerd`) with a key of the person the event names, `person(schema)` marks the field that names them, and without it the aggregate's id does. `app.forget(personId)`, on every `BoundaClient`, deletes the key and appends `PersonalDataForgotten` to the stream `bounda:<personId>`, in one transaction: from then on every personal field of theirs reads as `null`, typed `T | null` in every handler. A read model that projects an event with personal fields must handle it from `projections/bounda/personal-data-forgotten.ts`, which the generator checks in `.bounda/types.ts`; a policy may react to it from `policies/bounda/`. The adapters keep the keys, with their fingerprints, in a new `personal_keys` table, `Storage` and `StorageTransaction` gain `personalKeys`, and the storage contracts cover it. The Cloudflare Worker answers `POST /forget/<personId>`. The global stream shows the fields as stored, encrypted. A command that loaded a person's data before `forget` and appends after it fails with `ConcurrencyError` and is decided again, instead of writing their data back under the new key another command may have given them meanwhile: the state remembers the key each person's data was read under, and `AppendArgs` gains `persons` (`{ person, fingerprint }`) for this, which a store ignores. Snapshots and remembered rejections name the same and are written only while that key is still the person's.
Personal data in events, forgotten by crypto-shredding. An event's `payload` receives `personal` and `person` next to `z`: `personal(schema)` marks a field as personal data, stored encrypted (AES-256-GCM through WebCrypto, on Node and in `workerd`) with a key of the person the event names, `person(schema)` marks the field that names them, and without it the aggregate's id does. `app.forget(personId)`, on every `BoundaClient`, deletes the key and appends `PersonalDataForgotten` to the stream `bounda:<personId>`, in one transaction: from then on every personal field of theirs reads as `null`, typed `T | null` in every handler. A read model that projects an event with personal fields must handle it from `projections/bounda/personal-data-forgotten.ts`, which the generator checks in `.bounda/types.ts`; a policy may react to it from `policies/bounda/`. The adapters keep the keys, with their fingerprints, in a new `personal_keys` table, `Storage` and `StorageTransaction` gain `personalKeys`, and the storage contracts cover it. The global stream shows the fields as stored, encrypted. A command that loaded a person's data before `forget` and appends after it fails with `ConcurrencyError` and is decided again, instead of writing their data back under the new key another command may have given them meanwhile: the state remembers the key each person's data was read under, and `AppendArgs` gains `persons` (`{ person, fingerprint }`) for this, which a store ignores. Snapshots and remembered rejections name the same and are written only while that key is still the person's; in PostgreSQL a GIN index on their `persons` lets `forget` find them without reading every row.
7 changes: 4 additions & 3 deletions docs/src/content/docs/adapters/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,15 @@ global stream.
| --- | --- |
| `POST /commands/<name>` with the payload as the body | The dispatch result, once the read models reflect it, or once its events are stored with `createWorker({ config, tenantOf, consistency: "eventual" })`. `?delay=10m` schedules it |
| `POST /queries/<name>` with the payload as the body | The query's result |
| `POST /forget/<personId>` | [Forgets](/guides/personal-data/) the person, answering like a command |

Refusals come back as `{ "error": { "code", "message" } }` with the statuses in
[errors over HTTP](/reference/errors/#over-http); anything else is a 500 whose message goes to the
logs, not to the caller.

It has **no authentication** and no operator endpoint, on purpose: it is a starting point. An
app with users writes its own `fetch` and talks to a store with `connect`:
It has **no authentication** and no operator endpoint, on purpose: it is a starting point. For
the same reason it never [forgets](/guides/personal-data/) anyone, which cannot be undone. An app
with users writes its own `fetch` and talks to a store with `connect`, whose `forget` it calls
behind its own authentication:

```ts
import { connect } from "@bounda-dev/cloudflare";
Expand Down
36 changes: 21 additions & 15 deletions docs/src/content/docs/guides/idempotent-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ webhook was delivered again. The aggregate rejects what it must (`placeOrder` ag
`AlreadyPlaced`), but a rejection is the wrong answer to a retry of a command that went through,
and a command the aggregate takes twice, such as adding a line, does its work twice. An
**idempotency key** names one dispatch, so a second arrival under it answers what the first did
instead of running again.
instead of running again. What the runtime already guarantees for the events and reactions inside
the app is in [delivery guarantees](/concepts/delivery-guarantees/); this page is about commands
that come from outside it.

## The `idempotencyKey` option

Expand All @@ -19,8 +21,8 @@ const placed = await app.commands.placeOrder(payload, { idempotencyKey: key });
```

The first dispatch under a key runs the command and remembers what it answered, in the same
transaction as the events it stores. Every later dispatch with the same key, the same command and
the same payload answers the same without running the handler: the same `StoredDispatch`, its
transaction as the events it stores. Every later dispatch with the same key, the same command,
the same payload and the same `delay` answers the same without running the handler: the same `StoredDispatch`, its
`position` included, so [reading your own writes](/guides/deployment/#reading-your-own-writes)
waits for the same read models; the same `ScheduledDispatch` for a command with `delay`, which is
scheduled once; or the same rejection, thrown again as a `DomainError` with its code. A dispatch
Expand All @@ -30,10 +32,11 @@ handler's, is not remembered, so the client's retry runs it.
Two dispatches racing for one key both run; one records its outcome and the other loses on the
key, rolls back whatever it stored, and answers what the key holds. The events land once.

The key names one command with one payload. The same key with another command, or with the same
command and another payload, is a bug of the caller, not a retry: the dispatch rejects with
The key names one command with one payload and one `delay`. The same key with another command,
with another payload, or scheduled when it ran at once (or the other way round, or with another
delay), is a bug of the caller, not a retry: the dispatch rejects with
`IdempotencyKeyConflictError` (`IDEMPOTENCY_KEY_CONFLICT`) and runs nothing. The payload is
compared as JSON, keys in any order.
compared as JSON, keys in any order, with maps, sets and bigints compared by what they hold.

A key is a string of 1 to 255 characters that the caller chooses, one per operation the client
means to do once, such as a UUID made when the form is submitted and sent again on every retry of
Expand All @@ -52,9 +55,10 @@ does not: a replay never runs it.

## The `Idempotency-Key` header in React Router

The `boundaMiddleware` reads the `Idempotency-Key` header of every request and passes it as
`idempotencyKey` to every command the request dispatches, on Node and on Cloudflare alike, so an
action has nothing to add:
The `boundaMiddleware` reads the `Idempotency-Key` header of every request and gives every command
the request dispatches an `idempotencyKey` of its own, `idempotencyKeyFor(header, commandName)`, on
Node and on Cloudflare alike, so an action has nothing to add, even one that dispatches two
commands:

```tsx
// app/routes/register.tsx
Expand All @@ -78,15 +82,17 @@ const key = crypto.randomUUID();
fetcher.submit(form, { method: "post", headers: { "Idempotency-Key": key } });
```

A key names one command, so an action that dispatches two under one header gets a conflict on the
second: derive one per command with `idempotencyKeyFor` from `@bounda-dev/core`, or pass
`idempotencyKey` yourself, which wins over the header.
A key names one command, so an action that dispatches the same command twice shares one key
between them: the second answers what the first did with the same payload, and conflicts with
another. Pass `idempotencyKey` yourself, which wins over the header, derived with
`idempotencyKeyFor` from `@bounda-dev/core`:

```ts
const key = request.headers.get("Idempotency-Key");
const keyed = (effect: string) => (key === null ? {} : { idempotencyKey: idempotencyKeyFor(key, effect) });
await commands.reserveSeats(seats, keyed("reserve"));
await commands.placeOrder(order, keyed("place"));
for (const [index, seat] of seats.entries()) {
const keyed = key === null ? {} : { idempotencyKey: idempotencyKeyFor(key, `seat-${index}`) };
await commands.reserveSeat(seat, keyed);
}
```

`failure()` answers a conflict with a 422 ([errors from the domain](/guides/react-router/#errors-from-the-domain)).
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/guides/personal-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ them. What it cannot see is personal
data that came in the command's payload, which is never marked: keep that out of a rejection's
message and say what went wrong with its code and the ids involved.

Command payloads are never sealed anywhere, so `forget` does not reach what holds one: a command
scheduled with `delay` keeps its payload until it runs, a
[dead letter](/guides/dead-letters/) of a scheduled command keeps it until it is retried or
discarded, and your logs keep whatever you wrote to them. Pass ids in those payloads, not the
data itself: the handler reads the email from the state, where it is sealed and forgotten.

## Handling it in a read model

A read model that projects an event with personal fields has those fields in its rows. It must
Expand Down
8 changes: 4 additions & 4 deletions docs/src/content/docs/guides/react-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ when its handler rejects it, with the code in `rejected`. Return `failure(error)
|---|---|---|
| `ValidationError` | 400 | `{ error, issues }`, each issue with the `path` of the field and its `message` |
| `DomainError` | 409 | `{ error, issues: [], rejected }`, the code the command declares |
| `IdempotencyKeyConflictError` | 422 | `{ error, issues: [] }`, the request's `Idempotency-Key` was used for another command |
| `IdempotencyKeyConflictError` | 422 | `{ error, issues: [] }`, the request's `Idempotency-Key` came back with another payload |

`failure` goes by the error's `code`, `VALIDATION_FAILED`, `DOMAIN_ERROR` or
`IDEMPOTENCY_KEY_CONFLICT`, so it answers the same for the refusals a Durable Object sends back on
Expand Down Expand Up @@ -134,9 +134,9 @@ cannot carry it into the Durable Object, where the command runs to the end.
## Retrying a request safely

A request that times out on the way back, or a form submitted twice, dispatches its command
twice. When the client sends an `Idempotency-Key` header, the middleware passes it as
`idempotencyKey` to every command the request dispatches, and the second arrival answers what the
first did, its rejection included, without running the command again. The host never makes a key
twice. When the client sends an `Idempotency-Key` header, the middleware gives every command the
request dispatches an `idempotencyKey` derived from it and the command's name, and the second
arrival answers what the first did, its rejection included, without running the command again. The host never makes a key
up: a request without the header dispatches as before. What the key means, its lifetime, and an
action that dispatches two commands under one header are in
[idempotent commands](/guides/idempotent-commands/).
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/guides/reacting-to-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,9 @@ if (paid.rejected === "NotOpen") {
}
```

A rejection nobody looks at is logged (`command rejected`, at `info`) and recorded on the command's
span; a test asserts the ones it expects from what
A rejection nobody looks at is logged (`command rejected`, at `info`, with its code and the
command's type and aggregate but not its message, which may quote personal data) and recorded on
the command's span; a test asserts the ones it expects from what
[`runUntilIdle()`](/guides/testing/#rejections-inside-reactions) returns. A scheduled command that
is rejected when it runs changes nothing in the same way. While the run lasts, only a failure
rejects the `await`: a payload that does not validate, a concurrency conflict that outlasts its
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ dead letter with `errorType: "retriable_exhausted"`.
than `runtime.policies.maxChainDepth`. `depth`, `maxDepth`.
- **`IDEMPOTENCY_KEY_CONFLICT`** (`IdempotencyKeyConflictError`; terminal). A dispatch's
`idempotencyKey` was already used, within `runtime.commands.idempotencyKeyLifetime`, for
another command or for the same command with another payload. Nothing runs. `idempotencyKey`.
another command or for the same command with another payload or `delay`. Nothing runs.
`idempotencyKey`.
See [idempotent commands](/guides/idempotent-commands/).
- **`CREATION_ORDER`** (`CreationOrderError`; terminal). A command returned events that do not fit
whether its aggregate exists: the first event is not one that exports `begin`, or an event that
Expand Down
21 changes: 5 additions & 16 deletions packages/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,8 @@ const optionsOf = (url: URL): DispatchOptions => {
};

const route = (pathname: string): { readonly kind: string; readonly name: string } | null => {
const match =
/^\/(commands|queries)\/([A-Za-z][A-Za-z0-9]*)\/?$/.exec(pathname) ??
/^\/(forget)\/([^/]+)\/?$/.exec(pathname);
if (match === null) return null;
try {
return { kind: match[1] as string, name: decodeURIComponent(match[2] as string) };
} catch {
// A malformed escape names no one.
return null;
}
const match = /^\/(commands|queries)\/([A-Za-z][A-Za-z0-9]*)\/?$/.exec(pathname);
return match === null ? null : { kind: match[1] as string, name: match[2] as string };
};

/**
Expand All @@ -92,8 +84,7 @@ const route = (pathname: string): { readonly kind: string; readonly name: string
*
* - `POST /commands/<name>` with the payload as the body, `?delay=10m` to schedule it, answers
* the dispatch result, once the read models reflect it unless `consistency` is `"eventual"`;
* - `POST /queries/<name>` with the payload as the body answers the query's result;
* - `POST /forget/<personId>` forgets a person, as `forget` does, and answers like a command.
* - `POST /queries/<name>` with the payload as the body answers the query's result.
*
* Refusals come back as `{ error: { code, message } }`: 400 for a payload that fails validation,
* with `issues`, 404 for an unknown command, query or row, 409 for a command's rejection, with its
Expand All @@ -120,7 +111,7 @@ export const createWorker: CreateWorkerFunction = ({
}
let payload: unknown;
try {
payload = target.kind === "forget" ? undefined : await readPayload(request);
payload = await readPayload(request);
} catch {
return failure("INVALID_JSON", "The body is not JSON", 400);
}
Expand All @@ -136,9 +127,7 @@ export const createWorker: CreateWorkerFunction = ({
const result = await unwrap(
target.kind === "commands"
? stub.command(target.name, payload, optionsOf(url), consistency)
: target.kind === "queries"
? stub.query(target.name, payload)
: stub.forget(target.name, consistency),
: stub.query(target.name, payload),
);
return json(result ?? null);
} catch (error) {
Expand Down
Loading
Loading