effect-tea-persistent
    Preparing search index...

    A cart

    A cart whose lines reserve their stock. It shows children: each line is a child by id, declared as a part, with its own state, messages and work, kept inside the cart's state. The cart starts a line when an article is added, and tells every line of the checkout in the step that takes it. A line decides its own removal: it releases its stock, and then tells the cart that it is gone. The cart never reads a line's state to decide.

    The guide's page on children is written on this example.

    /**
    * A cart of lines, one for each article. The cart adds a line and drops one that tells it it is gone, and its
    * checkout is told to every line in the step that takes it. Each line is a child by id: its reservation, its removal
    * and its sale are its own, and the cart never reads a line's state to decide.
    */
    import * as Optic from 'effect/Optic'
    import * as Schema from 'effect/Schema'
    import { each, forEach, program, reject } from '@fgaudo/effect-tea-persistent/program'
    import { line, Projection as LineProjection, Quantity, type State as LineState } from './line.ts'

    const Line = line.schemas.state

    const State = Schema.Struct({
    phase: Schema.Literals(['Open', 'CheckedOut']),
    // By article: the line of an article is its child under the article's id.
    lines: Schema.Record(Schema.String, Line),
    })
    type State = typeof State.Type

    const Inbound = Schema.Union([
    // The line's own check: a quantity no line holds is refused with the message, before any step.
    Schema.TaggedStruct('ItemAdded', { sku: Schema.String, quantity: Quantity }),
    Schema.TaggedStruct('CheckoutRequested', {}),
    ])

    const Projection = Schema.Struct({
    phase: Schema.Literals(['Open', 'CheckedOut']),
    // A line's projection, with its article: a line in `Removed` is dropped in the step that removes it, so none shows.
    lines: Schema.Array(Schema.Struct({ sku: Schema.String, ...LineProjection.fields })),
    canCheckout: Schema.Boolean,
    })

    const lines = Optic.id<State>().key('lines').compose(Optic.entries())

    export const cart = program({
    state: State,
    flags: Schema.Null,
    inbound: Inbound,
    projection: Projection,
    })({
    name: 'Cart',

    children: { Line: each(line, { id: Schema.String, at: lines }) },

    init: () => ({ phase: 'Open', lines: {} }),

    update: (message, { at }, children) => (state) => {
    switch (message._tag) {
    // An article already in the cart is a line already: its quantity is the line's, which this cart never
    // changes.
    case 'ItemAdded':
    return state.phase === 'Open' && !Object.hasOwn(state.lines, message.sku)
    ? { ...state, lines: { ...state.lines, [message.sku]: line.init(message, at) } }
    : reject
    case 'CheckoutRequested':
    return state.phase === 'Open'
    ? forEach(
    { ...state, phase: 'CheckedOut' },
    Object.keys(state.lines),
    (sku) => children.Line.update(sku, { _tag: 'CheckedOut' }),
    )
    : reject
    }
    },

    project: (state, children) => ({
    phase: state.phase,
    lines: Object.keys(state.lines).map((sku) => ({ sku, ...children.Line(state, sku) })),
    canCheckout: state.phase === 'Open',
    }),

    onEvent: {
    Line: (sku) => (state) => {
    const { [sku]: _, ...kept } = state.lines
    return { ...state, lines: kept satisfies Readonly<Record<string, LineState>> }
    },
    },
    })
    /**
    * A line of a cart: one article, whose stock it reserves as soon as it is added. A line decides its own removal: a
    * reserved one releases its stock first, moves to `Removed`, a state that holds no work, and from there tells the
    * cart it is gone. So a parent that kept the line would not release its stock again. The cart hands down its
    * checkout, which every line takes in whatever state it is.
    */
    import * as Context from 'effect/Context'
    import * as Effect from 'effect/Effect'
    import * as Match from 'effect/Match'
    import * as Schema from 'effect/Schema'
    import { defect, type IdempotencyKey, noWork, operation, part, raise, reject } from '@fgaudo/effect-tea-persistent/program'

    // How many units of the article: a line of none would reserve nothing, and a negative one would give stock back.
    export const Quantity = Schema.Int.check(Schema.isGreaterThan(0))

    const State = Schema.Union([
    // Only the reservation's answer leaves it: a removal meanwhile is rejected.
    Schema.TaggedStruct('Reserving', { sku: Schema.String, quantity: Quantity }),
    Schema.TaggedStruct('Reserved', { sku: Schema.String, quantity: Quantity, reservation: Schema.String }),
    Schema.TaggedStruct('Unavailable', { sku: Schema.String, quantity: Quantity }),
    Schema.TaggedStruct('Releasing', { sku: Schema.String, quantity: Quantity, reservation: Schema.String }),
    // Checked out while its reservation was under way: the answer decides whether it is sold.
    Schema.TaggedStruct('Selling', { sku: Schema.String, quantity: Quantity }),
    Schema.TaggedStruct('Sold', { sku: Schema.String, quantity: Quantity, reservation: Schema.String }),
    // Gone, and waiting for the cart to drop it: the one state a line raises `Removed` from, and it holds no work.
    Schema.TaggedStruct('Removed', { sku: Schema.String, quantity: Quantity }),
    ])
    export type State = typeof State.Type

    const Flags = Schema.Struct({ sku: Schema.String, quantity: Quantity })

    const Inbound = Schema.TaggedStruct('RemoveRequested', {})

    const Internal = Schema.Union([
    Schema.TaggedStruct('StockReserved', { reservation: Schema.String }),
    Schema.TaggedStruct('StockRefused', {}),
    Schema.TaggedStruct('StockReleased', {}),
    ])
    type Internal = typeof Internal.Type

    // What the line tells the cart: it is gone, and the cart drops it from its state.
    const Event = Schema.TaggedStruct('Removed', {})

    // What the cart tells every line: the checkout is a fact of the cart's, which no caller sends a line.
    const External = Schema.TaggedStruct('CheckedOut', {})

    const Operation = Schema.TaggedUnion({
    Reserve: { sku: Schema.String, quantity: Quantity },
    Release: { reservation: Schema.String },
    })
    type Operation = typeof Operation.Type

    export const Projection = Schema.Struct({
    status: Schema.Literals(['Reserving', 'Reserved', 'Unavailable', 'Releasing', 'Selling', 'Sold', 'Removed']),
    quantity: Schema.Int,
    canRemove: Schema.Boolean,
    })

    export class Stock extends Context.Service<Stock, {
    readonly reserve: (
    request: Readonly<{ sku: string; quantity: number; idempotencyKey: IdempotencyKey }>,
    ) => Effect.Effect<string, 'OutOfStock'>
    readonly release: (request: Readonly<{ reservation: string; idempotencyKey: IdempotencyKey }>) => Effect.Effect<void>
    }>()('Stock') {}

    const reserve = Effect.fn('Reserve')(
    function*({ sku, quantity }: Extract<Operation, { _tag: 'Reserve' }>, idempotencyKey: IdempotencyKey) {
    const stock = yield* Stock
    return yield* stock.reserve({ sku, quantity, idempotencyKey })
    },
    Effect.match({
    onSuccess: (reservation): Internal => ({ _tag: 'StockReserved', reservation }),
    onFailure: (): Internal => ({ _tag: 'StockRefused' }),
    }),
    )

    const release = Effect.fn('Release')(
    function*({ reservation }: Extract<Operation, { _tag: 'Release' }>, idempotencyKey: IdempotencyKey) {
    const stock = yield* Stock
    yield* stock.release({ reservation, idempotencyKey })
    return { _tag: 'StockReleased' } satisfies Internal
    },
    )

    export const line = part({
    state: State,
    flags: Flags,
    inbound: Inbound,
    internal: Internal,
    operation: Operation,
    projection: Projection,
    event: Event,
    external: External,
    })({
    keys: ['reserve', 'release'],

    init: ({ sku, quantity }) => ({ _tag: 'Reserving', sku, quantity }),

    update: (message) => (state) =>
    Match.value(message).pipe(
    Match.tagsExhaustive({
    RemoveRequested: () =>
    state._tag === 'Reserved'
    ? { _tag: 'Releasing' as const, sku: state.sku, quantity: state.quantity, reservation: state.reservation }
    : state._tag === 'Unavailable'
    ? raise({ _tag: 'Removed' as const })({ _tag: 'Removed' as const, sku: state.sku, quantity: state.quantity })
    : reject,

    StockReserved: ({ reservation }) =>
    state._tag === 'Reserving'
    ? { _tag: 'Reserved' as const, sku: state.sku, quantity: state.quantity, reservation }
    : state._tag === 'Selling'
    ? { _tag: 'Sold' as const, sku: state.sku, quantity: state.quantity, reservation }
    : defect('a reservation was made while nothing was reserving'),

    StockRefused: () =>
    state._tag === 'Reserving' || state._tag === 'Selling'
    ? { _tag: 'Unavailable' as const, sku: state.sku, quantity: state.quantity }
    : defect('a reservation was refused while nothing was reserving'),

    StockReleased: () =>
    state._tag === 'Releasing'
    ? raise({ _tag: 'Removed' as const })({ _tag: 'Removed' as const, sku: state.sku, quantity: state.quantity })
    : defect('stock was released while nothing was releasing it'),

    // An external message is taken in every state: the cart decides when to send it.
    CheckedOut: () =>
    state._tag === 'Reserved'
    ? { ...state, _tag: 'Sold' as const }
    : state._tag === 'Reserving'
    ? { _tag: 'Selling' as const, sku: state.sku, quantity: state.quantity }
    : state,
    }),
    ),

    work: (state) =>
    Match.valueTags(state, {
    Reserving: ({ sku, quantity }) => ({ reserve: Operation.cases.Reserve.make({ sku, quantity }) }),
    Selling: ({ sku, quantity }) => ({ reserve: Operation.cases.Reserve.make({ sku, quantity }) }),
    Releasing: ({ reservation }) => ({ release: Operation.cases.Release.make({ reservation }) }),
    Reserved: () => noWork,
    Unavailable: () => noWork,
    Sold: () => noWork,
    Removed: () => noWork,
    }),

    operations: { Reserve: operation(reserve), Release: operation(release) },

    project: (state) => ({
    status: state._tag,
    quantity: state.quantity,
    canRemove: state._tag === 'Reserved' || state._tag === 'Unavailable',
    }),
    })
    /**
    * The cart's scenarios, rehearsed: each line is a child by id, addressed as `{ _tag: 'Line', id, message }`.
    */
    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 { accepted, rehearse, rejected } from '@fgaudo/effect-tea-persistent/testing'
    import { cart } from './cart.ts'

    const start = { at: DateTime.makeUnsafe('2026-11-05T10:00:00Z') }

    describe('cart', () => {
    test('a line reserves its stock, and is sold at checkout', () =>
    Effect.runPromise(rehearse(
    cart,
    start,
    Effect.fnUntraced(function*(rehearsal) {
    yield* rehearsal.send({ _tag: 'ItemAdded', sku: 'mug', quantity: 2 })
    const reserve = yield* rehearsal.pending('Line.reserve', 'mug')
    expect(reserve.work).toEqual({
    _tag: 'Line.Reserve',
    id: 'mug',
    operation: { _tag: 'Reserve', sku: 'mug', quantity: 2 },
    })
    yield* rehearsal.answer(reserve, {
    _tag: 'Line',
    id: 'mug',
    message: { _tag: 'StockReserved', reservation: 'r-1' },
    })
    expect(yield* rehearsal.send({ _tag: 'CheckoutRequested' })).toEqual(accepted)
    expect(yield* rehearsal.projection).toEqual({
    phase: 'CheckedOut',
    lines: [{ sku: 'mug', status: 'Sold', quantity: 2, canRemove: false }],
    canCheckout: false,
    })
    }),
    )))

    test('a line checked out while it reserves is sold when its reservation is made', () =>
    Effect.runPromise(rehearse(
    cart,
    start,
    Effect.fnUntraced(function*(rehearsal) {
    yield* rehearsal.send({ _tag: 'ItemAdded', sku: 'mug', quantity: 1 })
    const reserve = yield* rehearsal.pending('Line.reserve', 'mug')
    yield* rehearsal.send({ _tag: 'CheckoutRequested' })
    // The reservation under way is the same work: the checkout did not start it anew.
    expect((yield* rehearsal.pending('Line.reserve', 'mug')).idempotencyKey).toBe(reserve.idempotencyKey)
    yield* rehearsal.answer(reserve, {
    _tag: 'Line',
    id: 'mug',
    message: { _tag: 'StockReserved', reservation: 'r-1' },
    })
    expect((yield* rehearsal.state).lines['mug']).toMatchObject({ _tag: 'Sold', reservation: 'r-1' })
    }),
    )))

    test('a reserved line releases its stock before it leaves the cart', () =>
    Effect.runPromise(rehearse(
    cart,
    start,
    Effect.fnUntraced(function*(rehearsal) {
    yield* rehearsal.send({ _tag: 'ItemAdded', sku: 'mug', quantity: 1 })
    // A line reserving its stock cannot be removed: only the reservation's answer leaves that state.
    expect(yield* rehearsal.send({ _tag: 'Line', id: 'mug', message: { _tag: 'RemoveRequested' } }))
    .toMatchObject(rejected)
    yield* rehearsal.answer('Line.reserve', 'mug', {
    _tag: 'Line',
    id: 'mug',
    message: { _tag: 'StockReserved', reservation: 'r-1' },
    })
    yield* rehearsal.send({ _tag: 'Line', id: 'mug', message: { _tag: 'RemoveRequested' } })
    expect((yield* rehearsal.state).lines['mug']?._tag).toBe('Releasing')
    yield* rehearsal.answer('Line.release', 'mug', {
    _tag: 'Line',
    id: 'mug',
    message: { _tag: 'StockReleased' },
    })
    expect((yield* rehearsal.state).lines).toEqual({})
    }),
    )))

    test('a line that is not in the cart rejects what it is sent, and nothing is added after checkout', () =>
    Effect.runPromise(rehearse(
    cart,
    start,
    Effect.fnUntraced(function*(rehearsal) {
    expect(yield* rehearsal.send({ _tag: 'Line', id: 'mug', message: { _tag: 'RemoveRequested' } }))
    .toMatchObject(rejected)
    yield* rehearsal.send({ _tag: 'CheckoutRequested' })
    expect(yield* rehearsal.send({ _tag: 'ItemAdded', sku: 'mug', quantity: 1 })).toMatchObject(rejected)
    }),
    )))

    // A line of no unit would reserve nothing: the message's schema refuses it, and the cart takes no step.
    test('an article added with no unit, or a negative one, is refused, and adds no line', () =>
    Effect.runPromise(rehearse(
    cart,
    start,
    Effect.fnUntraced(function*(rehearsal) {
    for (const quantity of [0, -1]) {
    const exit = yield* Effect.exit(rehearsal.send({ _tag: 'ItemAdded', sku: 'mug', quantity }))
    // Refused as the message is sent, and not later by the work of a line made of it.
    const refusal = Exit.isFailure(exit) ? Cause.pretty(exit.cause) : 'accepted'
    expect(refusal).toContain('the inbound message does not encode: Expected a value greater than 0')
    expect((yield* rehearsal.state).lines).toEqual({})
    }
    // The refusal left the cart as it was: the same article with a unit is a line.
    expect(yield* rehearsal.send({ _tag: 'ItemAdded', sku: 'mug', quantity: 1 })).toEqual(accepted)
    expect((yield* rehearsal.state).lines['mug']?._tag).toBe('Reserving')
    }),
    )))
    })