The guide's running example, a bike and a ride. A customer rents a bike at the rate they were shown, and the bike refuses the rate if it has changed since: a value one entity needs of another travels with the request. The ride's id is derived from the request id, so a rent sent twice creates one ride. The start email is sent at most once, and carried into the states after the ride's end while it is under way. A timer closes the window in which a cancellation is free. The customers are a table of the application, in the runtime's database. The bike's second version reads what the first wrote through a migration. A first version of the ride suspends on a message, and a fix deployed later resumes it.
bike.ts: the bike.ride.ts: the ride.boundary.ts: the application's code between a request handler and the runtime.scenarios.test.ts: the ride and the bike rehearsed, alone and together.ride.test.ts: the ride's properties, checked for generated states.bike.test.ts: the bike's migration, checked against the cases recorded.runtime.test.ts: the rental on the runtime, a suspension and its fix, and a rename.The guide writes this example from its requirement, one decision at a time.
/**
* A bike of the rental, docked or ridden. An operator sets its rate per minute. A ride unlocks it at the rate the
* customer was shown, and docks it at the end. The operator's page shows how many rides the bike has had, which
* version 2 added. The guide's running example, with `ride.ts`.
*/
import * as Match from 'effect/Match'
import * as Schema from 'effect/Schema'
import {
type Json,
type Origin,
program,
reference,
reject,
type Rejected,
rejectWith,
} from '@fgaudo/effect-tea-persistent/program'
import type { ride } from './ride.ts'
// The ride sends to the bike, so each imports only the other's types (decision 0183).
const Ride = reference<typeof ride>('Ride')
const Rate = Schema.Int.check(Schema.isGreaterThan(0))
// The rides a bike has had, counted when each one docks. A bike version 1 wrote has 0: the business counts from
// the deploy of version 2, so the migration below gives no number it does not know.
const Rides = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
const State = Schema.Union([
Schema.TaggedStruct('Docked', { centsPerMinute: Rate, rides: Rides }),
Schema.TaggedStruct('Ridden', { centsPerMinute: Rate, rides: Rides, ride: Schema.String }),
])
type State = typeof State.Type
const Flags = Schema.Struct({ centsPerMinute: Rate })
// A ride sends them as itself, so they name no ride.
const Inbound = Schema.Union([
// The rate the customer was shown: the bike refuses it once it has changed.
Schema.TaggedStruct('UnlockRequested', { centsPerMinute: Rate }),
Schema.TaggedStruct('DockRequested', {}),
Schema.TaggedStruct('RateSet', { centsPerMinute: Rate }),
])
// What a ride tells its customer. The other rejections are a ride's own mistakes.
const Rejection = Schema.Literals(['RateChanged', 'InUse'])
type Rejection = typeof Rejection.Type
/** Rides are not senders: they arrive as entities, from their operations. */
const Sender = Schema.TaggedStruct('Operator', {})
export type Sender = typeof Sender.Type
const Projection = Schema.Struct({
status: Schema.Literals(['Docked', 'Ridden']),
centsPerMinute: Schema.Int,
rides: Schema.Int,
canUnlock: Schema.Boolean,
})
export type Projection = typeof Projection.Type
// The return type is written. The ride's types are inferred from the bike's, so a type inferred here from `Ride`
// would make the bike's type depend on itself.
const rideOf = (from: Origin<Sender>): string | null =>
from._tag === 'Entity' && from.entity.name === Ride.name ? from.entity.id : null
const isOperator = (from: Origin<Sender>) => from._tag === 'Caller' && from.sender._tag === 'Operator'
/** A value version 1 wrote, with the count of rides it did not have. */
const withNoRides = (json: Json): Json => ({ ...json as Readonly<Record<string, Json>>, rides: 0 })
export const bike = program({
state: State,
flags: Flags,
inbound: Inbound,
rejection: Rejection,
sender: Sender,
projection: Projection,
})({
name: 'Bike',
init: ({ centsPerMinute }) => ({ _tag: 'Docked', centsPerMinute, rides: 0 }),
update: (message, { from }) => (state) => {
const ride = rideOf(from)
return Match.value(message).pipe(
Match.withReturnType<State | Rejected<Rejection>>(),
Match.tagsExhaustive({
UnlockRequested: ({ centsPerMinute }) =>
ride === null
? reject
: state._tag === 'Ridden'
? rejectWith('InUse')
: centsPerMinute !== state.centsPerMinute
? rejectWith('RateChanged')
: { ...state, _tag: 'Ridden', ride },
DockRequested: () =>
state._tag === 'Ridden' && ride === state.ride
? { _tag: 'Docked', centsPerMinute: state.centsPerMinute, rides: state.rides + 1 }
: reject,
// A ride under way keeps the rate it was unlocked at: the new rate is for the next one.
RateSet: ({ centsPerMinute }) => isOperator(from) ? { ...state, centsPerMinute } : reject,
}),
)
},
project: (state) => ({
status: state._tag,
centsPerMinute: state.centsPerMinute,
rides: state.rides,
canUnlock: state._tag === 'Docked',
}),
// Version 2 counts the rides. The projection's migration gives what `project` gives of the migrated state, so a
// row of the read model that version 1 wrote shows what version 2 would show. `bike.test.ts` records each case.
migrations: [{ state: withNoRides, projection: withNoRides }],
})
/**
* The rental's boundary: what the application runs for a customer's request, between its HTTP handler and the
* runtime. The handler authenticates the customer, and gives the identity it authenticated as the sender
* (decision 0124).
*/
import * as Effect from 'effect/Effect'
import * as Option from 'effect/Option'
import * as Schema from 'effect/Schema'
import { SqlClient, SqlSchema } from 'effect/sql'
import type { Handle, TypesOf } from '@fgaudo/effect-tea-persistent'
import type { ride } from './ride.ts'
// A customer has no life of its own, so it is a row of the application's table, and no entity. The table lives in
// the runtime's database, under a name that does not start with `effecttea_`, which the runtime keeps for its own
// (decision 0218).
export const createCustomers = Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE IF NOT EXISTS customers (id TEXT PRIMARY KEY, email TEXT NOT NULL)`
})
// A customer's row, read through a schema: a row the schema refuses fails the request, and is never cast.
const customerOf = Effect.fnUntraced(function*(id: string) {
const sql = yield* SqlClient.SqlClient
return yield* SqlSchema.findOneOption({
Request: Schema.String,
Result: Schema.Struct({ email: Schema.String }),
execute: (id) => sql`SELECT email FROM customers WHERE id = ${id}`,
})(id)
})
/**
* A customer's request to rent a bike, with the rate the bike's page showed them: the boundary read it off the
* bike's projection when it served the page.
*/
export type RentRequested = Readonly<{
customer: string
bike: string
centsPerMinute: number
requestId: string
}>
/**
* What a rent is answered: the ride the customer then reads, to learn whether the bike unlocked. A ride refused,
* because the rate changed or the bike is in use, is a ride too, whose projection says why.
*/
export type Rented =
| Readonly<{ _tag: 'Rented'; ride: string }>
| Readonly<{ _tag: 'NoSuchCustomer' }>
/**
* The id of a ride is derived from the request that creates it, so a retry of the request creates no second
* ride: it is answered `Created` again, from the stored reply. A request id is its sender's, so the id holds the
* customer too. It is their JSON, since a separator could be in either: `a/b` with `c` and `a` with `b/c` would
* be one ride.
*/
export const rideOf = (request: Pick<RentRequested, 'customer' | 'requestId'>) =>
JSON.stringify([request.customer, request.requestId])
export const rent = (rides: Handle<TypesOf<typeof ride>>) => (request: RentRequested) =>
Effect.gen(function*() {
const found = yield* customerOf(request.customer)
if (Option.isNone(found)) return { _tag: 'NoSuchCustomer' } as const
const customer = found.value
const id = rideOf(request)
// The ride needs the customer's email and the bike's rate, and a reply carries no data: both travel with the
// creation. The bike refuses the rate if it has changed since the customer was shown it.
yield* rides.create(id, {
flags: {
customer: request.customer,
email: customer.email,
bike: request.bike,
centsPerMinute: request.centsPerMinute,
},
requestId: request.requestId,
sender: { _tag: 'Customer', id: request.customer },
})
return { _tag: 'Rented', ride: id } as const
})
/**
* A ride of the rental, from the unlock of its bike to its dock. The customer pays the rate per minute they were
* shown when they end the ride. A customer who finds the bike unfit cancels the ride within two minutes, and pays
* nothing. An email tells them the ride has started. The guide's running example, with `bike.ts`.
*/
import * as Context from 'effect/Context'
import * as DateTime from 'effect/DateTime'
import * as Duration from 'effect/Duration'
import * as Effect from 'effect/Effect'
import * as Match from 'effect/Match'
import * as Schema from 'effect/Schema'
import {
defect,
noWork,
type Origin,
operation,
program,
reference,
reject,
type Rejected,
send,
timer,
} from '@fgaudo/effect-tea-persistent/program'
import type { bike } from './bike.ts'
// The bike imports the ride's name too, so each imports only the other's types (decision 0183).
const Bike = reference<typeof bike>('Bike')
// Checked with the flags, before the ride exists: the bike's schema refuses any other rate, and a ride that sent
// one would die in its operation (decision 0049).
const Rate = Schema.Int.check(Schema.isGreaterThan(0))
const Ride = Schema.Struct({
customer: Schema.String,
// Read by the boundary from the application's table of customers, and sent with the creation.
email: Schema.String,
// The bike's id.
bike: Schema.String,
// The rate the customer was shown, read by the boundary off the bike's projection.
centsPerMinute: Rate,
})
type Ride = typeof Ride.Type
// An email is not idempotent: after a crash while sending it, the customer may or may not have it.
const Email = Schema.Literals(['Sending', 'Sent', 'Unsure'])
type Email = typeof Email.Type
const State = Schema.Union([
// Only the bike's reply leaves it.
Schema.TaggedStruct('Unlocking', { ride: Ride }),
Schema.TaggedStruct('Refused', { ride: Ride, reason: Schema.Literals(['RateChanged', 'InUse', 'NoSuchBike']) }),
Schema.TaggedStruct('Riding', {
ride: Ride,
startedAt: Schema.DateTimeUtcFromMillis,
// Until when a cancellation is free, and null once that time has passed: a timer sets it, so that `project`
// reads it and no clock.
cancellableUntil: Schema.NullOr(Schema.DateTimeUtcFromMillis),
email: Email,
}),
// The ride is over, and its cost known: only the bike's reply to the dock leaves it.
Schema.TaggedStruct('Docking', { ride: Ride, costInCents: Schema.Int, email: Email }),
Schema.TaggedStruct('Ended', { ride: Ride, costInCents: Schema.Int, email: Email }),
])
export type State = typeof State.Type
const Inbound = Schema.Union([
Schema.TaggedStruct('EndRequested', {}),
Schema.TaggedStruct('CancelRequested', {}),
])
const Internal = Schema.Union([
Schema.TaggedStruct('Unlocked', {}),
Schema.TaggedStruct('UnlockRefused', { reason: Schema.Literals(['RateChanged', 'InUse', 'NoSuchBike']) }),
Schema.TaggedStruct('CancellationClosed', {}),
Schema.TaggedStruct('Docked', {}),
Schema.TaggedStruct('StartEmailed', {}),
Schema.TaggedStruct('StartEmailUnsure', {}),
])
type Internal = typeof Internal.Type
const Sender = Schema.TaggedStruct('Customer', { id: Schema.String })
export type Sender = typeof Sender.Type
// What callers read. It is JSON, so a time is its milliseconds (decision 0139).
const Projection = Schema.Struct({
status: Schema.Literals(['Unlocking', 'Refused', 'Riding', 'Docking', 'Ended']),
bike: Schema.String,
// Why the bike refused the ride, which the customer is told.
refusal: Schema.NullOr(Schema.Literals(['RateChanged', 'InUse', 'NoSuchBike'])),
cancellableUntil: Schema.NullOr(Schema.Number),
costInCents: Schema.NullOr(Schema.Int),
canEnd: Schema.Boolean,
canCancel: Schema.Boolean,
})
export type Projection = typeof Projection.Type
export class Mailer extends Context.Service<Mailer, {
readonly send: (email: Readonly<{ to: string; subject: string }>) => Effect.Effect<void>
}>()('Mailer') {}
const cancellationWindow = { minutes: 2 }
const isCustomer = (from: Origin<Sender>, ride: Ride) => from._tag === 'Caller' && from.sender.id === ride.customer
// A minute begun is a minute paid.
const costOf = (ride: Ride, startedAt: DateTime.Utc, at: DateTime.Utc) =>
Math.max(1, Math.ceil(Duration.toMillis(DateTime.distance(startedAt, at)) / 60_000)) * ride.centsPerMinute
const Operation = Schema.TaggedUnion({
Unlock: { bike: Schema.String, centsPerMinute: Rate },
Dock: { bike: Schema.String },
EmailStart: { to: Schema.String, bike: Schema.String },
})
type Operation = typeof Operation.Type
// The email under way, in every state that holds it. The same value under the same key keeps the email running:
// a state that dropped it while it runs would be a defect (decision 0005).
const emailing = (ride: Ride, email: Email) =>
email === 'Sending' ? { email: Operation.cases.EmailStart.make({ to: ride.email, bike: ride.bike }) } : noWork
// A request is the run of the operation and the name it gives, so an unlock run again after a crash is given the
// stored reply, and unlocks once. It may run at least once.
const unlock = Effect.fn('Unlock')(
function*({ bike, centsPerMinute }: Extract<Operation, { _tag: 'Unlock' }>) {
const reply = yield* send(Bike, bike, { _tag: 'UnlockRequested', centsPerMinute }, 'unlock')
// A rejection with no reason means the two entities disagree: a ride unlocks as itself.
return reply._tag === 'Accepted'
? { _tag: 'Unlocked' } satisfies Internal
: reply._tag === 'NotFound'
? { _tag: 'UnlockRefused', reason: 'NoSuchBike' } satisfies Internal
: reply.reason !== null
? { _tag: 'UnlockRefused', reason: reply.reason } satisfies Internal
: yield* Effect.die('the bike rejected an unlock for no reason')
},
)
// The bike docks the ride that unlocked it. Any other reply is a defect, and suspends the ride.
const dock = Effect.fn('Dock')(
function*({ bike }: Extract<Operation, { _tag: 'Dock' }>) {
const reply = yield* send(Bike, bike, { _tag: 'DockRequested' }, 'dock')
return reply._tag === 'Accepted'
? { _tag: 'Docked' } satisfies Internal
: yield* Effect.die('the bike did not accept the dock of its own ride')
},
)
const emailStart = Effect.fn('EmailStart')(
function*({ to, bike }: Extract<Operation, { _tag: 'EmailStart' }>) {
const mailer = yield* Mailer
yield* mailer.send({ to, subject: `Your ride on bike ${bike} has started` })
return { _tag: 'StartEmailed' } satisfies Internal
},
)
export const ride = program({
state: State,
flags: Ride,
inbound: Inbound,
internal: Internal,
operation: Operation,
sender: Sender,
projection: Projection,
})({
name: 'Ride',
keys: ['unlock', 'cancellation', 'email', 'dock'],
init: (ride) => ({ _tag: 'Unlocking', ride }),
update: (message, { from, at }) => (state) =>
Match.value(message).pipe(
Match.withReturnType<State | Rejected>(),
Match.tagsExhaustive({
// The email under way is carried into the next state, with its key.
EndRequested: () =>
state._tag === 'Riding' && isCustomer(from, state.ride)
? {
_tag: 'Docking',
ride: state.ride,
costInCents: costOf(state.ride, state.startedAt, at),
email: state.email,
}
: reject,
CancelRequested: () =>
state._tag === 'Riding' && state.cancellableUntil !== null && isCustomer(from, state.ride)
? { _tag: 'Docking', ride: state.ride, costInCents: 0, email: state.email }
: reject,
Unlocked: () =>
state._tag === 'Unlocking'
? {
_tag: 'Riding',
ride: state.ride,
startedAt: at,
cancellableUntil: DateTime.add(at, cancellationWindow),
email: 'Sending',
}
: defect('a bike was unlocked while nothing was unlocking it'),
UnlockRefused: ({ reason }) =>
state._tag === 'Unlocking'
? { _tag: 'Refused', ride: state.ride, reason }
: defect('an unlock was refused while nothing was unlocking'),
// The timer's message is the fact that the time has passed: `update` compares no clock with it.
CancellationClosed: () =>
state._tag === 'Riding' && state.cancellableUntil !== null
? { ...state, cancellableUntil: null }
: defect('the cancellation closed while it was not open'),
Docked: () =>
state._tag === 'Docking'
? { _tag: 'Ended', ride: state.ride, costInCents: state.costInCents, email: state.email }
: defect('a bike was docked while nothing was docking it'),
StartEmailed: () =>
(state._tag === 'Riding' || state._tag === 'Docking' || state._tag === 'Ended') && state.email === 'Sending'
? { ...state, email: 'Sent' }
: defect('a start email was sent while nothing was sending it'),
StartEmailUnsure: () =>
(state._tag === 'Riding' || state._tag === 'Docking' || state._tag === 'Ended') && state.email === 'Sending'
? { ...state, email: 'Unsure' }
: defect('a start email was unsure while nothing was sending it'),
}),
),
work: (state) =>
Match.valueTags(state, {
Unlocking: ({ ride }) => ({
unlock: Operation.cases.Unlock.make({ bike: ride.bike, centsPerMinute: ride.centsPerMinute }),
}),
Refused: () => noWork,
Riding: ({ ride, cancellableUntil, email }) => ({
...(cancellableUntil === null
? noWork
: { cancellation: timer(cancellableUntil, { _tag: 'CancellationClosed' }) }),
...emailing(ride, email),
}),
Docking: ({ ride, email }) => ({
dock: Operation.cases.Dock.make({ bike: ride.bike }),
...emailing(ride, email),
}),
Ended: ({ ride, email }) => emailing(ride, email),
}),
operations: {
Unlock: operation.atLeastOnce(unlock),
Dock: operation.atLeastOnce(dock),
// Sent at most once: a second email is worse than none, and the state says when it is not known.
EmailStart: operation.atMostOnce(emailStart, (): Internal => ({ _tag: 'StartEmailUnsure' })),
},
project: (state) => ({
status: state._tag,
bike: state.ride.bike,
refusal: state._tag === 'Refused' ? state.reason : null,
cancellableUntil: state._tag === 'Riding' && state.cancellableUntil !== null
? DateTime.toEpochMillis(state.cancellableUntil)
: null,
costInCents: state._tag === 'Docking' || state._tag === 'Ended' ? state.costInCents : null,
canEnd: state._tag === 'Riding',
canCancel: state._tag === 'Riding' && state.cancellableUntil !== null,
}),
})
/**
* The bike's migrations, checked against the cases recorded when each was written. A migration once deployed is
* never changed, since it is how the rows its version wrote are read, so a case holds for as long as the bike
* carries the migration.
*/
import { expect, test } from 'bun:test'
import { type MigrationCase, migrationViolations } from '@fgaudo/effect-tea-persistent/testing'
import { bike } from './bike.ts'
// What version 1 wrote, and what version 2 reads of it: a bike of version 1 has had no ride counted.
const cases: ReadonlyArray<MigrationCase> = [
{
from: 1,
of: 'state',
written: { _tag: 'Docked', centsPerMinute: 20 },
read: { _tag: 'Docked', centsPerMinute: 20, rides: 0 },
},
{
from: 1,
of: 'state',
written: { _tag: 'Ridden', centsPerMinute: 20, ride: 'ride-1' },
read: { _tag: 'Ridden', centsPerMinute: 20, rides: 0, ride: 'ride-1' },
},
{
from: 1,
of: 'projection',
written: { status: 'Ridden', centsPerMinute: 20, canUnlock: false },
read: { status: 'Ridden', centsPerMinute: 20, rides: 0, canUnlock: false },
},
]
test('the bike reads what each version before it wrote', () => {
expect(migrationViolations(bike, cases)).toEqual([])
})
/**
* The ride's properties, for generated states.
*/
import { describe, expect, test } from 'bun:test'
import * as DateTime from 'effect/DateTime'
import fc from 'fast-check'
import { projectionViolations } from '@fgaudo/effect-tea-persistent/testing'
import { ride, type Sender, type State } from './ride.ts'
const at = DateTime.makeUnsafe('2026-11-08T10:00:00Z')
const time = fc.date({ min: new Date('2026-01-01'), max: new Date('2027-01-01'), noInvalidDate: true })
.map(DateTime.fromDateUnsafe)
const aRide = fc.record({
customer: fc.constantFrom('ann', 'bob'),
email: fc.constant('someone@example.com'),
bike: fc.constantFrom('bike-7', 'bike-8'),
centsPerMinute: fc.integer({ min: 1, max: 100 }),
})
const email = fc.constantFrom('Sending', 'Sent', 'Unsure')
// Every case of the ride's states, with any data.
const state: fc.Arbitrary<State> = fc.oneof(
fc.record({ _tag: fc.constant('Unlocking'), ride: aRide }),
fc.record({
_tag: fc.constant('Refused'),
ride: aRide,
reason: fc.constantFrom('RateChanged', 'InUse', 'NoSuchBike'),
}),
fc.record({ _tag: fc.constant('Riding'), ride: aRide, startedAt: time, cancellableUntil: fc.option(time), email }),
fc.record({ _tag: fc.constant('Docking'), ride: aRide, costInCents: fc.nat(), email }),
fc.record({ _tag: fc.constant('Ended'), ride: aRide, costInCents: fc.nat(), email }),
)
describe('ride', () => {
// The projection reads no clock, so `canCancel` agrees with `update` at any time the test hands it.
test('the projection allows what update accepts, all of it', () => {
const check = projectionViolations(ride, {
EndRequested: (projection) => projection.canEnd,
CancelRequested: (projection) => projection.canCancel,
})
fc.assert(fc.property(state, (state) => {
const customer: Sender = { _tag: 'Customer', id: state.ride.customer }
expect(check(state, [
{ message: { _tag: 'EndRequested' }, sender: customer },
{ message: { _tag: 'CancelRequested' }, sender: customer },
], at)).toEqual([])
}))
})
})
/**
* The rental on the runtime, in one database with the application's table of customers. The boundary rents a bike
* as a request handler would, and a retry of its request creates no second ride.
*/
import { SqliteClient } from '@effect/sql-sqlite-bun'
import { expect, test } from 'bun:test'
import * as Deferred from 'effect/Deferred'
import * as Effect from 'effect/Effect'
import * as Layer from 'effect/Layer'
import type * as Scope from 'effect/Scope'
import { SqlClient } from 'effect/sql'
import { check, type Handle, launch, type TypesOf } from '@fgaudo/effect-tea-persistent'
import { bike } from './bike.ts'
import { createCustomers, rent, rideOf } from './boundary.ts'
import { Mailer, ride } from './ride.ts'
const operator = { _tag: 'Operator' } as const
const mailer = Layer.succeed(Mailer, Mailer.of({ send: () => Effect.void }))
const database = <A, E>(test: Effect.Effect<A, E, SqlClient.SqlClient | Scope.Scope>) =>
Effect.runPromise(
Effect.scoped(test).pipe(
Effect.provide(SqliteClient.layer({ filename: ':memory:' })),
Effect.orDie,
),
)
/** The runtime, the rental's programs and a customer, Ann, in the application's table. */
const rental = Effect.gen(function*() {
const runtime = yield* launch()
const sql = yield* SqlClient.SqlClient
// The application's table is written on the client the runtime was launched with, in the same database.
yield* createCustomers
yield* sql`INSERT INTO customers (id, email) VALUES ('ann', 'ann@example.com')`
const bikes = yield* runtime.deploy(bike)
const rides = yield* Effect.provide(runtime.deploy(ride), mailer)
yield* bikes.create('bike-7', { flags: { centsPerMinute: 20 }, requestId: 'add-bike-7', sender: operator })
return { runtime, bikes, rides }
})
/** The rate the bike's page shows: the boundary reads it off the bike's projection. */
const rateShown = (bikes: Handle<TypesOf<typeof bike>>) =>
Effect.flatMap(bikes.read('bike-7'), (shown) =>
shown === null ? Effect.die('bike-7 was never added') : Effect.succeed(shown.centsPerMinute))
test('a rent sent twice creates one ride, which unlocks the bike', () =>
database(Effect.gen(function*() {
const { runtime, bikes, rides } = yield* rental
const request = { customer: 'ann', bike: 'bike-7', centsPerMinute: yield* rateShown(bikes), requestId: 'r-1' }
// The customer's app sent the request again, since it never received the first answer.
const first = yield* rent(rides)(request)
const again = yield* rent(rides)(request)
expect(again).toEqual(first)
yield* runtime.settled
expect(yield* rides.count({})).toBe(1)
expect(first._tag === 'Rented' ? yield* rides.read(first.ride) : null).toMatchObject({ status: 'Riding' })
expect(yield* bikes.read('bike-7')).toMatchObject({ status: 'Ridden' })
})))
// What the boundary must not do: an id drawn at each try is a second ride on a retry.
test('a ride id drawn anew at each try creates a second ride, which the bike refuses', () =>
database(Effect.gen(function*() {
const { runtime, rides } = yield* rental
const flags = { customer: 'ann', email: 'ann@example.com', bike: 'bike-7', centsPerMinute: 20 }
const sender = { _tag: 'Customer', id: 'ann' } as const
for (const id of ['drawn-1', 'drawn-2']) {
expect(yield* rides.create(id, { flags, requestId: 'r-1', sender })).toEqual({ _tag: 'Created' })
}
yield* runtime.settled
expect(yield* rides.count({})).toBe(2)
expect(yield* rides.read('drawn-2')).toMatchObject({ status: 'Refused', refusal: 'InUse' })
})))
test('a rate changed after the customer was shown it refuses the ride', () =>
database(Effect.gen(function*() {
const { runtime, bikes, rides } = yield* rental
const shown = yield* rateShown(bikes)
yield* bikes.send('bike-7', { _tag: 'RateSet', centsPerMinute: 25 }, { requestId: 'rate-1', sender: operator, keep: 'forever' })
const rented = yield* rent(rides)({
customer: 'ann',
bike: 'bike-7',
centsPerMinute: shown,
requestId: 'r-1',
})
yield* runtime.settled
expect(rented._tag === 'Rented' ? yield* rides.read(rented.ride) : null).toMatchObject({
status: 'Refused',
refusal: 'RateChanged',
})
expect(yield* bikes.read('bike-7')).toMatchObject({ status: 'Docked', centsPerMinute: 25 })
})))
test('a customer the application does not know rents nothing', () =>
database(Effect.gen(function*() {
const { rides } = yield* rental
expect(yield* rent(rides)({ customer: 'bob', bike: 'bike-7', centsPerMinute: 20, requestId: 'r-1' }))
.toEqual({ _tag: 'NoSuchCustomer' })
expect(yield* rides.count({})).toBe(0)
})))
// The ride's flags check the rate as the bike's schema does, so a rate no bike holds stores nothing.
test('a rent at a rate no bike can hold dies at the boundary, and creates no ride', () =>
database(Effect.gen(function*() {
const { rides } = yield* rental
const exit = yield* Effect.exit(
rent(rides)({ customer: 'ann', bike: 'bike-7', centsPerMinute: 0, requestId: 'r-1' }),
)
expect(exit._tag).toBe('Failure')
expect(yield* rides.count({})).toBe(0)
})))
// The ride as one would write it at first: the end holds no email, so a ride that ends while its email is sent
// drops an operation under way, which is a defect, and suspends the ride.
const forgetful: typeof ride = {
...ride,
work: (state) =>
state._tag === 'Docking' || state._tag === 'Ended' ? ride.work({ ...state, email: 'Sent' }) : ride.work(state),
}
test('a ride the first version suspends is inspected, and resumed by the fix', () =>
database(Effect.gen(function*() {
const ann = { _tag: 'Customer', id: 'ann' } as const
const request = { customer: 'ann', bike: 'bike-7', centsPerMinute: 20, requestId: 'r-1' }
const end = { _tag: 'EndRequested' } as const
const id = rideOf(request)
// The first process runs the forgetful ride, and its mail server never answers.
yield* Effect.scoped(Effect.gen(function*() {
const mailing = yield* Deferred.make<void>()
const silent = Layer.succeed(
Mailer,
Mailer.of({ send: () => Effect.andThen(Deferred.succeed(mailing, undefined), Effect.never) }),
)
const runtime = yield* launch()
const sql = yield* SqlClient.SqlClient
yield* createCustomers
yield* sql`INSERT INTO customers (id, email) VALUES ('ann', 'ann@example.com')`
const bikes = yield* runtime.deploy(bike)
const rides = yield* Effect.provide(runtime.deploy(forgetful), silent)
yield* bikes.create('bike-7', { flags: { centsPerMinute: 20 }, requestId: 'add-bike-7', sender: operator })
yield* rent(rides)(request)
yield* Deferred.await(mailing)
// The customer ends the ride while its email is under way: the step dies, and the caller has no reply yet.
expect(yield* rides.send(id, end, { requestId: 'end-1', sender: ann, keep: 'forever' })).toEqual({ _tag: 'Suspended' })
const [suspension] = yield* rides.suspended
expect(suspension).toMatchObject({ id, on: { _tag: 'Message', kind: 'Inbound' } })
expect(suspension?.cause).toContain("the step dropped the operation under 'email' before its completion")
// What the operator reads to decide: the state before the message, the message next, and the work the state
// holds, the email under way and the cancellation's timer, which waits disarmed until the ride is resumed.
const inspected = yield* rides.inspect(id)
expect(inspected?.state).toMatchObject({ _tag: 'Riding', email: 'Sending' })
expect(inspected?.mailbox).toMatchObject([{ _tag: 'Inbound', message: end }])
expect(inspected?.work.map(({ key }) => key.name)).toEqual(['cancellation', 'email'])
// The fix is checked against the stored rides before its deploy, while this process still holds the database.
// A resume by the fix gets past the ride's activation, and the end it died on is handled after the check.
expect(yield* check(ride, { limit: 10 })).toEqual({
active: { checked: 0, failing: 0, failed: [] },
suspended: { checked: 1, failing: 0, failed: [] },
})
}))
// The fix is a deploy, by a new process on the same database: the ride stays suspended through it.
yield* Effect.scoped(Effect.gen(function*() {
const runtime = yield* launch()
const bikes = yield* runtime.deploy(bike)
const rides = yield* Effect.provide(runtime.deploy(ride), mailer)
expect((yield* rides.suspended).map((suspension) => suspension.id)).toEqual([id])
expect(yield* rides.resume(id)).toEqual({ _tag: 'Resumed' })
yield* runtime.settled
// The end is applied by the fix. The email was under way when the first process stopped, and is sent at most
// once, so the ride records that it is not known.
expect(yield* rides.inspect(id)).toMatchObject({ state: { _tag: 'Ended', email: 'Unsure' }, suspension: null })
expect(yield* bikes.read('bike-7')).toMatchObject({ status: 'Docked', rides: 1 })
// The customer's retry is answered from the reply the fix stored, and ends nothing twice.
expect(yield* rides.send(id, end, { requestId: 'end-1', sender: ann, keep: 'forever' })).toEqual({ _tag: 'Accepted' })
}))
})))
// A rename is the launch's: the rows of the old name move to the new one before any deploy, and the bike keeps its
// state under the name it now carries.
test('a bike renamed by the launch keeps its state under its new name', () =>
database(Effect.gen(function*() {
yield* Effect.scoped(Effect.gen(function*() {
const runtime = yield* launch()
const bikes = yield* runtime.deploy(bike)
yield* bikes.create('bike-7', { flags: { centsPerMinute: 20 }, requestId: 'add-bike-7', sender: operator })
}))
yield* Effect.scoped(Effect.gen(function*() {
const runtime = yield* launch({ renames: { Bike: 'Cycle' } })
const cycles = yield* runtime.deploy({ ...bike, name: 'Cycle' })
expect(yield* cycles.read('bike-7')).toMatchObject({ status: 'Docked', centsPerMinute: 20 })
}))
})))
/**
* The rental's scenarios, rehearsed: the ride alone, with the test answering for the bike; the bike alone, with
* the test sending as the rides would; and both together.
*/
import { describe, expect, test } from 'bun:test'
import * as Cause from 'effect/Cause'
import * as DateTime from 'effect/DateTime'
import * as Effect from 'effect/Effect'
import * as Exit from 'effect/Exit'
import * as Layer from 'effect/Layer'
import type { Address } from '@fgaudo/effect-tea-persistent'
import { accepted, rehearse, rejected } from '@fgaudo/effect-tea-persistent/testing'
import { bike } from './bike.ts'
import { Mailer, ride, type Sender } from './ride.ts'
const at = DateTime.makeUnsafe('2026-11-08T10:00:00Z')
const flags = { customer: 'ann', email: 'ann@example.com', bike: 'bike-7', centsPerMinute: 20 }
const start = { flags, at }
const ann: Sender = { _tag: 'Customer', id: 'ann' }
describe('ride', () => {
test('a ride is paid by the minute begun, at the rate the customer was shown', () =>
Effect.runPromise(rehearse(
ride,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('unlock', { _tag: 'Unlocked' })
yield* rehearsal.advance({ minutes: 10, seconds: 30 })
expect(yield* rehearsal.send({ _tag: 'EndRequested' }, { sender: ann })).toEqual(accepted)
yield* rehearsal.answer('dock', { _tag: 'Docked' })
expect(yield* rehearsal.projection).toMatchObject({ status: 'Ended', costInCents: 11 * 20 })
}),
)))
test('a ride the bike refuses never starts', () =>
Effect.runPromise(rehearse(
ride,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('unlock', { _tag: 'UnlockRefused', reason: 'RateChanged' })
expect(yield* rehearsal.state).toMatchObject({ _tag: 'Refused', reason: 'RateChanged' })
expect(yield* rehearsal.send({ _tag: 'EndRequested' }, { sender: ann })).toMatchObject(rejected)
}),
)))
// The projection reads no clock: the timer sets the state, and `canCancel` follows it.
test('a cancellation is free for two minutes, and refused once the timer has fired', () =>
Effect.runPromise(rehearse(
ride,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('unlock', { _tag: 'Unlocked' })
expect(yield* rehearsal.projection).toMatchObject({
canCancel: true,
cancellableUntil: DateTime.toEpochMillis(DateTime.add(at, { minutes: 2 })),
})
yield* rehearsal.advance({ minutes: 2 })
expect(yield* rehearsal.projection).toMatchObject({ canCancel: false, cancellableUntil: null })
expect(yield* rehearsal.send({ _tag: 'CancelRequested' }, { sender: ann })).toMatchObject(rejected)
}),
)))
test('a cancellation within two minutes costs nothing', () =>
Effect.runPromise(rehearse(
ride,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('unlock', { _tag: 'Unlocked' })
yield* rehearsal.advance({ minutes: 1 })
expect(yield* rehearsal.send({ _tag: 'CancelRequested' }, { sender: ann })).toEqual(accepted)
yield* rehearsal.answer('dock', { _tag: 'Docked' })
expect(yield* rehearsal.projection).toMatchObject({ status: 'Ended', costInCents: 0 })
}),
)))
// The phone never got the first answer: the same request id is a retry, and a new one is a new request.
test('an end sent again under its request id is answered as the first, and one under a new id is rejected', () =>
Effect.runPromise(rehearse(
ride,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('unlock', { _tag: 'Unlocked' })
const end = { sender: ann, requestId: 'end-1' }
expect(yield* rehearsal.send({ _tag: 'EndRequested' }, end)).toEqual(accepted)
// The phone never got the answer, and sends the end again: the ride ended once, and is told so again.
expect(yield* rehearsal.send({ _tag: 'EndRequested' }, end)).toEqual(accepted)
expect(yield* rehearsal.send({ _tag: 'EndRequested' }, { sender: ann, requestId: 'end-2' })).toMatchObject(rejected)
expect((yield* rehearsal.state)._tag).toBe('Docking')
}),
)))
test('only the customer of the ride ends it', () =>
Effect.runPromise(rehearse(
ride,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('unlock', { _tag: 'Unlocked' })
expect(yield* rehearsal.send({ _tag: 'EndRequested' }, { sender: { _tag: 'Customer', id: 'bob' } }))
.toMatchObject(rejected)
}),
)))
// The email is an operation under way: only its completion takes it out of the state.
test('an email under way when the ride ends is carried into the end, under its key', () =>
Effect.runPromise(rehearse(
ride,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('unlock', { _tag: 'Unlocked' })
const email = yield* rehearsal.pending('email')
yield* rehearsal.send({ _tag: 'EndRequested' }, { sender: ann })
yield* rehearsal.answer('dock', { _tag: 'Docked' })
expect((yield* rehearsal.pending('email')).idempotencyKey).toBe(email.idempotencyKey)
expect(yield* rehearsal.answer(email, { _tag: 'StartEmailed' })).toBe('Delivered')
expect(yield* rehearsal.state).toMatchObject({ _tag: 'Ended', email: 'Sent' })
}),
)))
// The ride as one would write it at first: the end holds no email, so a ride that ends while its email is sent
// drops an operation under way, which is a defect.
test('a ride whose end drops the email under way is a defect', async () => {
const forgetful: typeof ride = {
...ride,
work: (state) =>
state._tag === 'Docking' || state._tag === 'Ended' ? ride.work({ ...state, email: 'Sent' }) : ride.work(state),
}
const exit = await Effect.runPromiseExit(rehearse(
forgetful,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('unlock', { _tag: 'Unlocked' })
yield* rehearsal.send({ _tag: 'EndRequested' }, { sender: ann })
}),
))
expect(Exit.isFailure(exit) ? Cause.squash(exit.cause) : null).toMatchObject({
reason: "the step dropped the operation under 'email' before its completion",
})
})
test('an email under way when the process dies is not sent again', () =>
Effect.runPromise(rehearse(
ride,
start,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.answer('unlock', { _tag: 'Unlocked' })
yield* rehearsal.restart
expect(yield* rehearsal.underWay('email')).toBeNull()
expect(yield* rehearsal.state).toMatchObject({ _tag: 'Riding', email: 'Unsure' })
}),
)))
})
const rideOne: Address = { name: 'Ride', id: 'ride-1' }
const rideTwo: Address = { name: 'Ride', id: 'ride-2' }
describe('bike', () => {
const docked = { flags: { centsPerMinute: 20 }, at }
// A reply carries no data, so the ride sends the rate it was given, and the bike compares it with its own.
test('a bike refuses a rate that changed since the customer was shown it', () =>
Effect.runPromise(rehearse(
bike,
docked,
Effect.fnUntraced(function*(rehearsal) {
expect(yield* rehearsal.send({ _tag: 'RateSet', centsPerMinute: 25 }, { sender: { _tag: 'Operator' } })).toEqual(accepted)
expect(yield* rehearsal.sendFrom({ _tag: 'UnlockRequested', centsPerMinute: 20 }, rideOne))
.toEqual({ _tag: 'Rejected', reason: 'RateChanged' })
expect(yield* rehearsal.sendFrom({ _tag: 'UnlockRequested', centsPerMinute: 25 }, rideOne)).toEqual(accepted)
}),
)))
test('a ridden bike is refused to a second ride, and docked by its own', () =>
Effect.runPromise(rehearse(
bike,
docked,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.sendFrom({ _tag: 'UnlockRequested', centsPerMinute: 20 }, rideOne)
expect(yield* rehearsal.sendFrom({ _tag: 'UnlockRequested', centsPerMinute: 20 }, rideTwo))
.toEqual({ _tag: 'Rejected', reason: 'InUse' })
expect(yield* rehearsal.sendFrom({ _tag: 'DockRequested' }, rideTwo)).toMatchObject(rejected)
expect(yield* rehearsal.sendFrom({ _tag: 'DockRequested' }, rideOne)).toEqual(accepted)
expect(yield* rehearsal.projection).toMatchObject({ status: 'Docked', canUnlock: true })
}),
)))
})
describe('ride and bike together', () => {
// The email's runner is never run here: a mailer whose members die fails the test if it is.
const mailer = Layer.mock(Mailer, {})
test('a ride unlocks its bike, and docks it at the end', () =>
Effect.runPromise(rehearse.together(
at,
Effect.fnUntraced(function*(world) {
const bike7 = yield* world.create(bike, 'bike-7', { centsPerMinute: 20 })
const annRide = yield* world.create(ride, 'ride-1', flags)
yield* annRide.run('unlock')
// A ride is counted when it docks, so the ride under way is not counted yet.
expect(yield* bike7.projection).toMatchObject({ status: 'Ridden', rides: 0, canUnlock: false })
yield* annRide.send({ _tag: 'EndRequested' }, { sender: ann })
yield* annRide.run('dock')
expect(yield* bike7.projection).toMatchObject({ status: 'Docked', rides: 1, canUnlock: true })
expect((yield* annRide.state)._tag).toBe('Ended')
}),
).pipe(Effect.provide(mailer))))
test('a ride at a rate the bike no longer has is refused, and the bike stays docked', () =>
Effect.runPromise(rehearse.together(
at,
Effect.fnUntraced(function*(world) {
const bike7 = yield* world.create(bike, 'bike-7', { centsPerMinute: 20 })
yield* bike7.send({ _tag: 'RateSet', centsPerMinute: 25 }, { sender: { _tag: 'Operator' } })
const annRide = yield* world.create(ride, 'ride-1', flags)
yield* annRide.run('unlock')
expect(yield* annRide.state).toMatchObject({ _tag: 'Refused', reason: 'RateChanged' })
expect(yield* bike7.projection).toMatchObject({ status: 'Docked' })
}),
).pipe(Effect.provide(mailer))))
test('a ride of a bike that does not exist is refused', () =>
Effect.runPromise(rehearse.together(
at,
Effect.fnUntraced(function*(world) {
const annRide = yield* world.create(ride, 'ride-1', flags)
yield* annRide.run('unlock')
expect(yield* annRide.state).toMatchObject({ _tag: 'Refused', reason: 'NoSuchBike' })
}),
).pipe(Effect.provide(mailer))))
})