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.
cart.ts: the cart, the parent.line.ts: a line, the child.scenarios.test.ts: the cart's scenarios, rehearsed, each line addressed by its id.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')
}),
)))
})