Part III · Transactional domain core and post-commit deliveryProjections
Chapter 11source checked

Projection tables and read models

New events fold through ordered SQLite projectors before the enclosing command commits; bootstrap replays committed events from per-projector cursors, and queries expose deliberately different read-model shapes.

What this chapter resolves
  • Trace one durable event through every projection cursor and its SQL boundary.
  • Distinguish the safe snapshot watermark from the durable event-store head.
  • Separate full snapshots, command state, shell summaries, detail pages, and live replay.

An orchestration event is not directly a UI row. The durable event store records a global sequence; a projection pipeline folds that event into several SQLite read tables; query services compose those tables into views for a particular reader. This is a synchronous part of normal command acceptance, not a background eventually-consistent worker that happens after a command has returned.

Nine folds share one event order

Each named projector has its own projection_state cursor. For every event, the pipeline considers every projector in this exact sequence:

  1. Projects
  2. Messages
  3. Proposed plans
  4. Activities
  5. Sessions
  6. Turns
  7. Checkpoints
  8. Pending approvals
  9. Threads

The final placement is meaningful. The thread projection refreshes summary fields after the message, plan, activity, session, turn, and approval tables have been updated. It can therefore compute navigation-oriented facts such as pending approval/input counts and whether a plan is actionable from current dependent rows.

Figure 11.1 · A command folds one ordered event through SQLite read sidesdashed detours are filesystem work outside projector SQL rollback
Ordered projection foldDiagram loading

Zoom with the controls, +/, or Ctrl/ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.

Ordered projection fold
Text equivalent

The engine's outer SQL transaction appends an event, then runs projects, messages, plans, activities, sessions, turns, checkpoints, pending approvals, and threads in order. Each projector first completes its projection row and cursor SQL. After the messages projector, an optional attachment-pruning filesystem detour can occur before plans. After the final threads projector, an optional attachment-deletion detour can occur before the accepted receipt. The outer SQL transaction commits only after those steps, making the event, projection rows, cursors, and receipt durable. Filesystem mutations cannot be rolled back if later SQL fails. Committed events are then published to live consumers.

Figure 11.1. A newly appended global event sequence enters nine sequential projector applications while the outer transaction is still open. Each projector completes its row and cursor SQL before any attachment effect it scheduled. A message fold can prune files before later projectors run; the final thread fold can delete files before the accepted receipt and outer commit. The sequence becomes durable only at that outer commit, while the earlier filesystem mutations are not enlisted in rollback.
apps/server/src/orchestration/Layers/ProjectionPipeline.ts:1609–1678 ↗verbatim · typescript · ec226c4b
    const projectors: ReadonlyArray<ProjectorDefinition> = [
      {
        name: ORCHESTRATION_PROJECTOR_NAMES.projects,
        apply: applyProjectsProjection,
      },
      {
        name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages,
        apply: applyThreadMessagesProjection,
      },
      {
        name: ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans,
        apply: applyThreadProposedPlansProjection,
      },
      {
        name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities,
        apply: applyThreadActivitiesProjection,
      },
      {
        name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions,
        apply: applyThreadSessionsProjection,
      },
      {
        name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns,
        apply: applyThreadTurnsProjection,
      },
      {
        name: ORCHESTRATION_PROJECTOR_NAMES.checkpoints,
        apply: applyCheckpointsProjection,
      },
      {
        name: ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals,
        apply: applyPendingApprovalsProjection,
      },
      {
        name: ORCHESTRATION_PROJECTOR_NAMES.threads,
        apply: applyThreadsProjection,
      },
    ];
 
    const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* (
      projector: ProjectorDefinition,
      event: OrchestrationEvent,
    ) {
      const attachmentSideEffects: AttachmentSideEffects = {
        deletedThreadIds: new Set<string>(),
        prunedThreadRelativePaths: new Map<string, Set<string>>(),
      };
 
      yield* sql.withTransaction(
        projector.apply(event, attachmentSideEffects).pipe(
          Effect.flatMap(() =>
            projectionStateRepository.upsert({
              projector: projector.name,
              lastAppliedSequence: event.sequence,
              updatedAt: event.occurredAt,
            }),
          ),
        ),
      );
 
      yield* runAttachmentSideEffects(attachmentSideEffects).pipe(
        Effect.catch((cause) =>
          Effect.logWarning("failed to apply projected attachment side-effects", {
            projector: projector.name,
            sequence: event.sequence,
            eventType: event.type,
            cause,
          }),
        ),
      );
Read this as: The projector work and its per-projector state cursor are in SQL work; filesystem attachment cleanup is deliberately invoked afterward and logs failures instead of undoing the projection.

Cursor means “safe through here,” not “the newest event”

The event store has a monotonically increasing, global sequence; each aggregate also has its own stream version. Projection restart uses the former. On bootstrap, each projector reads only events after its own last_applied_sequence and advances that cursor after its SQL fold. A projector that has nothing to do for an event still records that it safely considered the event.

The query layer does not advertise the highest event in the log as a snapshot sequence. It checks the required projector state and returns their minimum. If a required state row is missing, the safe sequence is zero. That conservative watermark prevents a snapshot composed from several tables from claiming it includes an event that one required read side has not reached.

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:195–258 ↗verbatim · typescript · 1e59d916
const REQUIRED_SNAPSHOT_PROJECTORS = [
  ORCHESTRATION_PROJECTOR_NAMES.projects,
  ORCHESTRATION_PROJECTOR_NAMES.threads,
  ORCHESTRATION_PROJECTOR_NAMES.threadMessages,
  ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans,
  ORCHESTRATION_PROJECTOR_NAMES.threadActivities,
  ORCHESTRATION_PROJECTOR_NAMES.threadSessions,
  ORCHESTRATION_PROJECTOR_NAMES.checkpoints,
] as const;
 
function maxIso(left: string | null, right: string): string {
  if (left === null) {
    return right;
  }
  return left > right ? left : right;
}
 
function escapeLikePattern(value: string): string {
  return value.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_");
}
 
function foldAsciiCase(value: string): string {
  return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
}
 
function buildSearchSnippet(text: string, query: string): string {
  const normalizedText = text.replace(/\s+/g, " ").trim();
  if (normalizedText.length <= 240) {
    return normalizedText;
  }
 
  const normalizedQuery = foldAsciiCase(query.replace(/\s+/g, " ").trim());
  const matchIndex = foldAsciiCase(normalizedText).indexOf(normalizedQuery);
  const bodyLength = 236;
  const idealStart = Math.max(0, matchIndex - 72);
  const start = Math.min(idealStart, normalizedText.length - bodyLength);
  const end = Math.min(normalizedText.length, start + bodyLength);
  return `${start > 0 ? "…" : ""}${normalizedText.slice(start, end)}${
    end < normalizedText.length ? "…" : ""
  }`;
}
 
function computeSnapshotSequence(
  stateRows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionStateDbRowSchema>>,
): number {
  if (stateRows.length === 0) {
    return 0;
  }
  const sequenceByProjector = new Map(
    stateRows.map((row) => [row.projector, row.lastAppliedSequence] as const),
  );
 
  let minSequence = Number.POSITIVE_INFINITY;
  for (const projector of REQUIRED_SNAPSHOT_PROJECTORS) {
    const sequence = sequenceByProjector.get(projector);
    if (sequence === undefined) {
      return 0;
    }
    if (sequence < minSequence) {
      minSequence = sequence;
    }
  }
 
  return Number.isFinite(minSequence) ? minSequence : 0;
Read this as: The snapshot helper requires its required projectors and returns the minimum sequence among them. It intentionally does not read the event-store head.
Interactive fold lab

One event stream, nine projector cursors

Step through three durable events. Every registered projector is considered in order, including no-ops; the safe snapshot watermark is the slowest required cursor, not the event-store head.

No sample event has been folded. All projector cursors are at 0.

Event-store head0
Safe snapshot sequence0
Current eventnone
Normal command control flowouter SQL begins → append → projector SQL + cursor → optional filesystem cleanup → accepted receipt → outer commitCleanup runs before commit but is not enlisted in SQL rollback.
  1. Projectsprojection_projects · project fields · snapshot-required
    0
  2. Messagesprojection_thread_messages · message bodies · snapshot-required
    0
  3. Plansprojection_thread_proposed_plans · proposed plans · snapshot-required
    0
  4. Activitiesprojection_thread_activities · activity log · snapshot-required
    0
  5. Sessionsprojection_thread_sessions · runtime session · snapshot-required
    0
  6. Turnsprojection_turns · turns + checkpoint metadata · auxiliary cursor
    0
  7. Checkpointsno table · registered no-op · snapshot-required
    0
  8. Approvalsprojection_pending_approvals · approval state · auxiliary cursor
    0
  9. Threadsprojection_threads · shell summary (last) · snapshot-required
    0
Start with the cursor, not a promise of eventual consistency.

On a normal command, all nine folds run sequentially before the command transaction commits.

Static reference: the normal third event leaves every required cursor at 3, so the safe snapshot sequence is 3.

1,001-event bootstrap case: the default total read limit stops every lane at 1,000 while the event-store head is 1,001.

OrderProjectorRead sideWatermark roleAt sequence 3
1Projectsprojection_projectsrequired3
2Messagesprojection_thread_messagesrequired3
3Plansprojection_thread_proposed_plansrequired3
4Activitiesprojection_thread_activitiesrequired3
5Sessionsprojection_thread_sessionsrequired3
6Turnsprojection_turnsauxiliary3
7Checkpointsno tablerequired3
8Approvalsprojection_pending_approvalsauxiliary3
9Threadsprojection_threadsrequired3

Checkpoint is a naming trap

Checkpoint data is currently carried by turn projection rows, rather than an active independent checkpoint table projector. The projector named projection.checkpoints returns no work. The detail snapshot query obtains checkpoint-oriented rows from the turn read side. Treat the name as a tracked pipeline slot, not evidence of a separate materialized checkpoint store.

One database, several deliberately incomplete views

“The snapshot” is not one universal object. The full hydrated snapshot composes projects, threads, messages, plans, activities, sessions, checkpoints, turns, and projector state in a single query transaction. The engine instead starts from a lighter command model that intentionally omits message bodies, activities, and checkpoints. The navigation shell is leaner still: project and active-thread summary rows. Thread detail is a dedicated, possibly paged read.

Comparison of projection query shapes
ReaderPurposeWhat it intentionally leaves out
Full hydrated snapshotauthoritative composed read for complete statenothing from its selected projection families; it is the expensive shape
Command read modelengine bootstrap and command decisionsmessages, activities, and checkpoints
Shell snapshotproject/thread navigation and live shell refetchesthread bodies, activity history, plans, checkpoint/turn detail
Thread detail snapshotone selected conversation, optionally windowedolder turn windows beyond its cursor until requested

The HTTP /orchestration/snapshot endpoint returns the command-model shape, not the full hydrated snapshot. That is an API-level reminder not to infer a UI payload just from a type named “read model.” Shell and thread readers have their own endpoints and WebSocket stream behavior.

Live repair uses global cursors; page merging has another watermark

WebSocket resume is bounded. A client’s afterSequence is a global event sequence. The handler attaches a live buffer before replay or snapshot work, then either replays a non-negative global gap through a captured head or falls back to a fresh snapshot when the gap is absent, impossible, or exceeds 1,000 events. Buffered live input can overlap the replay; client reducers deduplicate by global sequence.

Thread detail adds threadSequence to a windowed page. It is the greatest deliverable detail-event sequence for that thread at or below the snapshot sequence. Its purpose is to delay merging a historical page until the live reducer has caught up. It is not the afterSequence accepted by the subscription API, and it cannot replace the global cursor.

apps/server/src/ws.ts:1394–1521 ↗verbatim · typescript · 0f57dcc6
        [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) =>
          observeRpcStreamEffect(
            ORCHESTRATION_WS_METHODS.subscribeThread,
            Effect.gen(function* () {
              const isThisThreadDetailEvent = (event: OrchestrationEvent) =>
                event.aggregateKind === "thread" &&
                event.aggregateId === input.threadId &&
                isThreadDetailEvent(event);
 
              const liveStream = orchestrationEngine.streamDomainEvents.pipe(
                Stream.filter(isThisThreadDetailEvent),
                Stream.map((event) => ({
                  kind: "event" as const,
                  event: projectActivityEvent(event),
                })),
              );
 
              // Attach live delivery before reading either replay or snapshot state.
              // Otherwise an event published while the snapshot is loading is lost.
              const liveBuffer = yield* Queue.unbounded<OrchestrationThreadStreamItem>();
              yield* Effect.forkScoped(
                liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))),
              );
              const bufferedLiveStream = Stream.fromQueue(liveBuffer);
 
              // When the client already loaded the snapshot over HTTP it passes
              // that snapshot's sequence, and we resume the live subscription by
              // replaying persisted events after it instead of re-sending the
              // (potentially multi-KB) snapshot frame over the socket.
              //
              // The live PubSub subscription must be attached *before* draining
              // the catch-up replay, otherwise events published during the replay
              // window are dropped (they are past the persisted tail the replay
              // read, but the live stream is not yet subscribed). So fork the
              // live stream into a buffer bound to this stream's scope, then emit
              // catch-up followed by the buffered/ongoing live events. Overlapping
              // events are deduped by sequence on the client.
              //
              // The replay is bounded to the projection head captured below. The
              // catch-up range is normally tiny (a fresh HTTP snapshot sequence),
              // but a stale cached cursor can sit hundreds of thousands of global
              // events behind — replaying that decodes every intervening event
              // (including every other thread's tool payloads) only to discard
              // almost all of them, which has OOM-killed servers on large
              // databases. A truncated replay would silently drop this thread's
              // events, so past the gap cap we reset the client with a fresh
              // thread snapshot instead, exactly like subscribeShell above.
              if (input.afterSequence !== undefined) {
                const afterSequence = input.afterSequence;
                const headSequence = yield* orchestrationEngine.latestSequence;
                const replayGap = headSequence - afterSequence;
                if (replayGap >= 0 && replayGap <= THREAD_RESUME_MAX_GAP) {
                  const catchUpStream = orchestrationEngine
                    .readEvents(afterSequence, replayGap)
                    .pipe(
                      Stream.filter(isThisThreadDetailEvent),
                      Stream.map((event) => ({
                        kind: "event" as const,
                        event: projectActivityEvent(event),
                      })),
                      Stream.mapError(
                        (cause) =>
                          new OrchestrationGetSnapshotError({
                            message: `Failed to replay thread ${input.threadId} events`,
                            cause,
                          }),
                      ),
                    );
                  const afterCatchUp =
                    input.requestCompletionMarker === true
                      ? Stream.concat(
                          Stream.fromEffect(
                            Queue.offer(liveBuffer, { kind: "synchronized" as const }),
                          ).pipe(Stream.drain),
                          bufferedLiveStream,
                        )
                      : bufferedLiveStream;
                  return Stream.concat(catchUpStream, afterCatchUp);
                }
                // Gap too large (or cursor ahead of authoritative state): fall
                // through to the snapshot path so the client converges from a
                // fresh thread detail instead of an unbounded replay.
              }
 
              const snapshot = yield* projectionSnapshotQuery
                .getThreadDetailSnapshot(
                  input.threadId,
                  // Windowing the fallback snapshot is opt-in per subscription:
                  // clients that don't send turnLimit (including all
                  // pre-pagination clients) get the full thread, since they
                  // have no way to load older pages.
                  input.turnLimit === undefined ? undefined : { turnLimit: input.turnLimit },
                )
                .pipe(
                  Effect.mapError(
                    (cause) =>
                      new OrchestrationGetSnapshotError({
                        message: `Failed to load thread ${input.threadId}`,
                        cause,
                      }),
                  ),
                );
 
              if (Option.isNone(snapshot)) {
                return yield* new OrchestrationGetSnapshotError({
                  message: `Thread ${input.threadId} was not found`,
                  cause: input.threadId,
                });
              }
 
              const afterSnapshot =
                input.requestCompletionMarker === true
                  ? Stream.concat(
                      Stream.fromEffect(
                        Queue.offer(liveBuffer, { kind: "synchronized" as const }),
                      ).pipe(Stream.drain),
                      bufferedLiveStream,
                    )
                  : bufferedLiveStream;
              return Stream.concat(
                Stream.make({
                  kind: "snapshot" as const,
                  snapshot: projectThreadDetailSnapshot(snapshot.value),
                }),
                afterSnapshot,
              );
            }),
            { "rpc.aggregate": "orchestration" },
Read this as: Thread subscription attaches its scoped live input before choosing replay or snapshot. Its replay bound uses the global gap, then filters events for the selected thread.

Source trail

T3
Source-locked editionRead against fa219001d · 23 Aug 2026
Book search

Find a concept, module, or source path

Type two or more characters.