effect-tea-persistent
    Preparing search index...

    Bikes rented by the minute

    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))))
    })