How it’s built

The GTD Task Manager stores history, not state

A normal task app keeps one row per task and overwrites it on every edit — the past is lost. This tool never updates a task in place. Each change is an immutable event appended to that task’s stream, and the board you see is a projection rebuilt from those events.

The events

The whole domain is five facts that can happen to a task. The aggregate emits them; it never exposes setters.

EventEmitted whenPayload
ItemCapturedYou capture a thought into the inbox.title
ItemClarifiedYou decide the list and context it belongs on.status, context
ItemRewordedYou rename the task.title
ItemCompletedYou finish it.
ItemDroppedYou bin it.

The write model: rebuilt by replay

There is no ORM row for a task. To handle a command, the repository loads the event stream and folds it into current state with a pure evolve function. The command is then validated against that rebuilt state — an inbox item can’t be completed, a closed item can’t be re-clarified — and, if accepted, produces new events.

function evolve(state, event) {
  switch (event.type) {
    case 'ItemCaptured':  return { exists: true, title: event.title, status: 'inbox' };
    case 'ItemClarified': return { ...state, status: event.status, context: event.context };
    case 'ItemCompleted': return { ...state, status: 'done' };
    // …
  }
}

const state = events.reduce(evolve, NO_ITEM);   // replay the whole stream
complete(state);                                 // throws unless the item is actionable

Because setup is just a list of prior events, the aggregate is unusually easy to test: given these events, when this command, then those new events (or a rejection).

The read model: a projection (CQRS)

You can’t SQL-query an append-only log for “all next actions grouped by list”. So the write side and the read side are separated — Command Query Responsibility Segregation. When events are appended, a projector applies them to a denormalized gtd_item_view table in the same database transaction. The board reads that table, so it always reflects your own writes — no eventual-consistency lag for a single user.

withTransaction(async (client) => {
  for (const event of newEvents) insertEvent(client, event);   // append-only log
  await applyToItemView(client, userId, itemId, newEvents);    // update the projection
});

Optimistic concurrency

Every event carries a per-task version, unique in the store. An append expects the version it read; if another request slipped in first, the unique constraint rejects the write instead of silently clobbering it. No row locks, no lost updates.

UNIQUE (aggregate_id, version)   -- two writers at version N → one wins, the other retries

Why this matters beyond tasks

The same architecture — an immutable event log as the source of truth, aggregates rebuilt by replay, and projections tailored per query — is how serious systems model money movements, order lifecycles, and audit-critical workflows. A GTD list is just the smallest honest demo: you get a perfect audit trail and time-travel for free, and the read side can be reshaped or rebuilt without touching the history.

How It’s Built: Event Sourcing + CQRS — stop.procrastin.ar