Skip to content

What it looked like last Tuesday

A post after the series CQRS with effect-cqrs, on its lending library. November 2026.

A librarian lays a transparent sheet over one card in a ribbon of loan cards on the desk, while a puzzled member waits beside her and a past day is circled on the wall calendar

Monday morning, Bob is at the desk. Last Tuesday he asked for Dune on the website, and the library refused. Today Dune is on the shelf. Was the website broken? Is he being treated differently? He would like to know, and so would the librarian.

Support tickets look like this everywhere: why did the system do that, back then? This post answers Bob’s from the library’s log. Every snippet runs in examples/blog/last-tuesday.test.ts, which lets the days pass in between.

This is what happened, with the library and the borrowing saga of the series:

examples/blog/last-tuesday.ts
yield* books.commands.add("book-1", { title: "Dune", author: "Frank Herbert" })
yield* members.commands.join("alice", { name: "Alice Rossi" })
yield* members.commands.join("bob", { name: "Bob Bianchi" })
yield* members.commands.requestLoan("alice", { loanId: "loan-1", bookId: "book-1" })
yield* settled("alice", "loan-1")
yield* members.commands.requestLoan("bob", { loanId: "loan-2", bookId: "book-1" })
yield* settled("bob", "loan-2")

Alice asked first and got the book. Bob asked a moment later, and the saga cancelled his loan. On Friday Alice brought it back.

The books table says Dune is available, lent_to empty. That is true, and it is all the table knows. Was it available last Tuesday at 10:04? It doesn’t say. Why was Bob refused? A request log might have a line, if it is still kept, if it was logged at all, and if someone can find it among a million others. Most support tickets like Bob’s end with “we can’t tell, sorry”, or with a developer reading code and guessing.

The log can say more, because it never threw anything away.

examples/blog/last-tuesday.ts
const today = yield* books.state("book-1")
// { status: "available", loan: null, … }: on the shelf

Bob’s own history says what happened to his request, with the reason the saga recorded:

examples/blog/last-tuesday.ts
const bob = yield* members.history("bob", { payload: true })
const refusal = bob.find(({ type }) => type === "LoanCancelled")!
// { type: "LoanCancelled", payload: { loanId: "loan-2", reason: "BookAlreadyLent" },
// timestamp: Tuesday, causationId: "member/bob@2", correlationId: "…" }

LoanCancelled, reason BookAlreadyLent, on Tuesday. Two more fields come with every event:

  • the causation id names what directly caused it: member/bob@2, the second event in Bob’s stream, his own LoanRequested. The saga that cancelled the loan was started by his request, and said so.
  • the correlation id is shared by everything that followed from the same original request, however many steps and aggregates away.

Was the book really lent? Ask the book, as it was at the moment of the refusal:

examples/blog/last-tuesday.ts
// The book as it was when Bob was refused. `finishTime` leaves out events from that
// instant on, so take the next millisecond.
const backThen = yield* books.state("book-1", { finishTime: DateTime.add(refusal.timestamp, { milliseconds: 1 }) })
// { status: "lent", loan: { loanId: "loan-1", memberId: "alice", dueOn: … }, … }

state with a finishTime folds the book’s events up to that instant, the same fold that gives today’s state, cut short. At the moment Bob was refused, the book was lent to Alice, with its due date. The refusal was right, and the librarian can show why, with the evidence.

This isn’t a feature added for support. It falls out of keeping events: a state is a fold of events, and a fold can stop anywhere. Every aggregate can be seen as it was at any instant, for free, from the day the first event was written.

The correlation id goes further. Gather every event that shares it, from every aggregate:

examples/blog/last-tuesday.ts
// Everything one request caused, across aggregates, by its correlation id.
const causedBy = (correlationId: string | null) =>
store.read().pipe(
Stream.filter((event) => event.correlationId === correlationId),
Stream.map(({ aggregateId, aggregateName, type, version }) =>
`${aggregateName}/${aggregateId}@${version} ${type}`
),
Stream.runCollect
)
const bobsRequest = yield* causedBy(refusal.correlationId)
// ["member/bob@2 LoanRequested", "member/bob@3 LoanCancelled"]
const alicesRequest = yield* causedBy(
(yield* members.history("alice")).find(({ type }) => type === "LoanRequested")!.correlationId
)
// ["member/alice@2 LoanRequested", "book/book-1@2 BookLent", "member/alice@3 LoanConfirmed"]

Bob’s request is two events: asked, cancelled. The book has none, because it refused and a refusal writes nothing. Alice’s request, read the same way, is three events in two aggregates: she asked, the book was lent, her loan was confirmed. The whole saga, in order, from a single id.

The same two tools, a state at an instant and events by correlation, answer questions that are hard or impossible with tables:

  • Disputes. “I returned it on time.” The BookReturned has a timestamp and the late decision, and the state just before it has the due date.
  • Audits. Who changed what, when, as a result of which request. With metadata, also by which user or script.
  • Bug reports. “It said available, then refused me.” The events of that request, and the state each aggregate was in, are the reproduction.
  • Reports as of a date. Fold the events before a date into a report, and it says what the numbers were then, not what they are now with today’s corrections mixed in.
  • Record the reason in the event. LoanCancelled { reason } answers Bob in one line. A bare LoanCancelled sends someone to read code.
  • Keep correlation and causation flowing. effect-cqrs carries them through commands, view models and sagas. Bring the correlation id in from the edge, the HTTP request or the message, and the trail starts where the user did.
  • Answer support from the log, not from logs. Application logs are for operators, rotate away, and say what the code thought. The event log says what the system decided, and it is kept.

Sagas in the series builds the borrowing process this post investigates, and the aggregates guide covers state, history, correlation and causation.