Skip to content

Relational persistence ​

examples/billing-relational stores the Order aggregate from the billing domain in four tables, through Kysely. The specs run against PGlite, real Postgres compiled to WASM, so they exercise genuine transactions and constraints inside the ordinary pnpm test, with no server and no Docker.

sh
pnpm --filter @btravstack/entity-example-billing-relational test

Three tables for one aggregate, one for another ​

orders and order_lines are one aggregate, always written in one transaction. customers is the other aggregate, referenced by a foreign key and never joined into an order. The billTo snapshot lives in columns on orders, copied when the order is placed, and a spec proves it does not follow the customer when the customer's row changes.

outbox holds the events a save announces.

No casts ​

Kysely's table types are plain strings and numbers, which is what the driver returns. Writes come from toJSON(), whose branded values fit those columns as they are. Reads assemble the rows into a plain object and pass it to Order.make, which brands and validates it. A row the domain considers impossible, such as half a billing snapshot, comes back as an InvalidEntity, and a spec pins that.

What the specs pin ​

SpecWhat it proves
an aggregate round-trips through three tablesbranded values, owned lines and the snapshot survive storage, no cast
the snapshot is copied, not joinedupdating the customer row leaves a placed order's billTo unchanged
two saves from the same versionone succeeds, the other is ConcurrentModification, no silent overwrite
creating an order whose id is already storedan insert that affects no row is a conflict too
a reference to a missing customera foreign key violation is triaged into CustomerNotFound
a failed transaction commits neither the new state nor its outbox rowsan outbox insert failing last rolls back the order update before it
events are delivered after committhe relay publishes only committed rows, once
a version-1 row is migrated at the repository boundarya raw legacy row loads through a pure migrate, and heals on the next save
a row no migration can savecorrupt data is an InvalidEntity, not an exception

Errors are values ​

Every repository method returns an AsyncResult. Driver rejections enter through fromPromise: the one constraint with a domain meaning becomes a typed error, and everything else is a Defect. There is no try/catch in the adapter.

Related how-to: Persist an aggregate relationally.

Released under the MIT License.