This page holds items inside an entity, each with a life of its own. Its code is
examples/cart, a cart that holds a line for each article, which bun test runs. Read the
guide's first page before it: this page uses its words for a state, work, a key and an operation.
The words in bold are defined in glossary.md, and design.md is the
specification behind each rule.
A shop sells articles by the unit. The requirement, as the business writes it:
A line of a cart could be an entity of its own, a field of the cart's state, or a child of the cart. The choice follows two questions.
The first is whether a rule holds across the item and the rest within one step. The checkout is such a rule: in the step that checks the cart out, every reserved line is sold, and no line is added after it. Were each line an entity, the checkout would reach a line by a request after the cart's step. A customer could remove a reserved line in between, and the cart would count as sold a stock already released. So a line is inside the cart. An item is an entity of its own when others address it by itself, or when its parent would hold more than one step should write.
The second question is whether the item is a concern of its own. A line reserves its stock, releases it and is sold, with an operation and states of its own. The cart never reads a line's state to decide anything. So a line is a child, which the cart hears of only through the events the line raises. An item the parent reads to decide is a field of the parent's own state instead.
A child is declared as a part, with part(schemas)(…). A part is a program without a name, topics, indexes
or migrations, which are its parent's:
// 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', {})
// …
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 }),
// …, then update, work, operations and project, as a program's
})
A part's schemas are a program's, with two more that nothing stores, since they only type the part:
event is what the line tells its parent, a raised event. A line that is gone raises Removed.external is what only its parent sends the line, an external message. A caller cannot send one. The
checkout is a fact of the cart's, so it reaches each line from the cart alone.A part's init never rejects. The parent calls it, and decides in its own init or update whether the child
starts. A part may hold children of its own, so a tree is as deep as a program needs.
The cart keeps its lines in a record by article, and declares them as children by id:
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),
})
// …
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: {} }),
// …
})
children names each child. each(part, { id, at }) declares children by id, and its at is an optic of Effect
that focuses the entries of the record, each an id and a child's state. One optic says both where a line is and
which lines are there. child(part, { at }) declares one child instead, with an optic that focuses its state, and
.compose(Optic.some()) makes one that may not be there. An id has a schema, and two ids are one when they encode
alike. A part's schemas hold its state, inbound, internal, operation and rejection, and no projection: a
parent that shows a child's projection declares its schema in its own.
The child's state is part of the parent's, written in the same step. A line starts when the cart puts the line's first state in its own:
case 'ItemAdded':
return state.phase === 'Open' && !Object.hasOwn(state.lines, message.sku)
? { ...state, lines: { ...state.lines, [message.sku]: line.init(message, at) } }
: reject
A line's inbound messages are the cart's, wrapped with the child's name and its id. A customer removes the mug with one message to the cart:
yield* rehearsal.send({ _tag: 'Line', id: 'mug', message: { _tag: 'RemoveRequested' } })
The library routes the message to the line under mug, with the same meta.from the cart received. The line's
update decides, as any program's does. A line that is not in the cart rejects what it is sent, and so does a line
whose state does not take the message. A cart cannot declare a message of its own under a child's name, since the
library would route it to the child: the compiler refuses one.
A reason a line gives reaches the caller wrapped the same way, as { _tag: 'Line', id: 'mug', reason }.
The checkout is the cart's message, and every line learns of it in the same step:
case 'CheckoutRequested':
return state.phase === 'Open'
? forEach(
{ ...state, phase: 'CheckedOut' },
Object.keys(state.lines),
(sku) => children.Line.update(sku, { _tag: 'CheckedOut' }),
)
: reject
children is the third argument of the cart's update. children.Line.update(id, message) sends a line an
external message, as a function from the cart's state to the cart's step: children.Line.update(id, message)(state)
for one line. The line's update gets the cart's meta, with its from and its at. forEach starts from a
step and adds one more for each item, each made from the state the one before left. It puts the lines back in the
cart's state once, and not once per line.
A line takes an external message in every state, since its parent decides when to send 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,
A child that rejects an external message is a defect, and so is a child that is not there. A caller may send
a message that does not apply, but the parent's own step does not. A child whose external message does not apply in
a state returns its state unchanged, as the line does once it is sold. To combine steps by hand, andThen(step, next) makes the next step from the state of the first, and keeps the events both raised.
A line that is gone raises Removed, and the cart drops it:
// in the line's update
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'),
// in the cart
onEvent: {
Line: (sku) => (state) => {
const { [sku]: _, ...kept } = state.lines
return { ...state, lines: kept satisfies Readonly<Record<string, LineState>> }
},
},
raise(event)(state) is a step of the line: its next state, with an event for the cart. Its type is
Step<State, Event>, of @fgaudo/effect-tea-persistent/program, so an update that writes its return type, as
Match.withReturnType does, writes Step<State, Event> | Rejected<Reason>. The cart's onEvent has a handler for
each child that raises events, and is required exactly when one does. A handler of children by id takes the id, the
event and children, as (id, event, children) => (state) => step, and the cart's reads only the id. It runs in
the step that raised the event, and nothing stores the event.
The line raises Removed only in a step whose next state is Removed, which holds no work. A child leaves its
work before its parent drops it. A reserved line that is removed first releases its stock in Releasing, and only
the release's answer moves it to Removed. The cart drops it in that step. Had the line raised Removed while
releasing, the cart would have dropped a key whose operation still runs, and that is a defect. Had the line raised
Removed and stayed in Releasing, the release already done would still be its work, and a cart that kept the line
would run it again. Removed holds no work, so the cart may keep a line or drop it, and nothing runs twice.
A line removed and added again comes back under new incarnations, so an answer of its earlier run is dropped.
A line's keys are the cart's, under the child's name. A line by id takes its id as the key's parameter:
const reserve = yield* rehearsal.pending('Line.reserve', 'mug')
expect(reserve.work).toEqual({
_tag: 'Line.Reserve',
id: 'mug',
operation: { _tag: 'Reserve', sku: 'mug', quantity: 2 },
})
A key of a child by id that has a parameter of its own takes the id and the parameter as a pair. The line's
operation reaches the runtime under the tag Line.Reserve, with the id, and the line's runner runs it with the
line's delivery. The cart declares no runner for it, and the application provides Stock, the service the line's
runner asks for, as it provides any program's. A name with a . belongs to this composition, so a parent whose own
key, operation or child has one is refused where it is declared.
The cart's state holds every line, so each step of the cart writes every line, and lists the work of every line. A step costs more with every child. A cart of a few dozen lines is cheap. An item a parent would hold by the thousand is an entity of its own, which others address with a request (entities that talk).
A rehearsal answers a line's work with a message wrapped as the line's, and reads the line in the cart's state:
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({})
The cart's projection holds each line's, through children in project:
project: (state, children) => ({
phase: state.phase,
lines: Object.keys(state.lines).map((sku) => ({ sku, ...children.Line(state, sku) })),
canCheckout: state.phase === 'Open',
}),
children.Line(state, id) is the line's projection. Asking for the projection of a line that is not there is a
defect.