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:
- Projects
- Messages
- Proposed plans
- Activities
- Sessions
- Turns
- Checkpoints
- Pending approvals
- 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.
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.
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.
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,
}),
),
);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.
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;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.
- Projects
projection_projects· project fields · snapshot-required - Messages
projection_thread_messages· message bodies · snapshot-required - Plans
projection_thread_proposed_plans· proposed plans · snapshot-required - Activities
projection_thread_activities· activity log · snapshot-required - Sessions
projection_thread_sessions· runtime session · snapshot-required - Turns
projection_turns· turns + checkpoint metadata · auxiliary cursor - Checkpoints
no table· registered no-op · snapshot-required - Approvals
projection_pending_approvals· approval state · auxiliary cursor - Threads
projection_threads· shell summary (last) · snapshot-required
On a normal command, all nine folds run sequentially before the command transaction commits.
The event store is at 1,001, but bootstrap calls its reader with the default total limit of 1,000. The stream completes with every projector cursor one event behind; this path has no second catch-up call in the same bootstrap.
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.
| Order | Projector | Read side | Watermark role | At sequence 3 |
|---|---|---|---|---|
| 1 | Projects | projection_projects | required | 3 |
| 2 | Messages | projection_thread_messages | required | 3 |
| 3 | Plans | projection_thread_proposed_plans | required | 3 |
| 4 | Activities | projection_thread_activities | required | 3 |
| 5 | Sessions | projection_thread_sessions | required | 3 |
| 6 | Turns | projection_turns | auxiliary | 3 |
| 7 | Checkpoints | no table | required | 3 |
| 8 | Approvals | projection_pending_approvals | auxiliary | 3 |
| 9 | Threads | projection_threads | required | 3 |
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.
| Reader | Purpose | What it intentionally leaves out |
|---|---|---|
| Full hydrated snapshot | authoritative composed read for complete state | nothing from its selected projection families; it is the expensive shape |
| Command read model | engine bootstrap and command decisions | messages, activities, and checkpoints |
| Shell snapshot | project/thread navigation and live shell refetches | thread bodies, activity history, plans, checkpoint/turn detail |
| Thread detail snapshot | one selected conversation, optionally windowed | older 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.
[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" },