Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5607,6 +5607,64 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("buffers thread events published while the initial snapshot loads", () =>
Effect.gen(function* () {
const thread = makeDefaultOrchestrationReadModel().threads[0]!;
const liveEvents = yield* PubSub.unbounded<OrchestrationEvent>();
const messageEvent = {
sequence: 2,
eventId: EventId.make("event-message"),
aggregateKind: "thread",
aggregateId: defaultThreadId,
occurredAt: "2026-01-01T00:00:01.000Z",
commandId: null,
causationEventId: null,
correlationId: null,
metadata: {},
type: "thread.message-sent",
payload: {
threadId: defaultThreadId,
messageId: MessageId.make("message-1"),
role: "user",
text: "First message",
turnId: null,
streaming: false,
createdAt: "2026-01-01T00:00:01.000Z",
updatedAt: "2026-01-01T00:00:01.000Z",
},
} satisfies Extract<OrchestrationEvent, { type: "thread.message-sent" }>;

yield* buildAppUnderTest({
layers: {
orchestrationEngine: {
streamDomainEvents: Stream.fromPubSub(liveEvents),
},
projectionSnapshotQuery: {
getThreadDetailSnapshot: () =>
Effect.gen(function* () {
yield* Effect.sleep("25 millis");
yield* PubSub.publish(liveEvents, messageEvent);
return Option.some({ snapshotSequence: 1, thread });
}),
},
},
});

const wsUrl = yield* getWsServerUrl("/ws");
const items = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[ORCHESTRATION_WS_METHODS.subscribeThread]({
threadId: defaultThreadId,
}).pipe(Stream.take(2), Stream.runCollect),
),
).pipe(Effect.timeout("2 seconds"));

assert.equal(items[0]?.kind, "snapshot");
assert.equal(items[1]?.kind, "event");
assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 2);
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
);

it.effect("enriches replayed project events with repository identity metadata", () =>
Effect.gen(function* () {
const repositoryIdentity = {
Expand Down
46 changes: 23 additions & 23 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,14 @@ const makeWsRpcLayer = (
})),
);

// 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
Expand All @@ -1185,28 +1193,20 @@ const makeWsRpcLayer = (
// so a global cap could otherwise omit this thread's events.
if (input.afterSequence !== undefined) {
const afterSequence = input.afterSequence;
return Stream.unwrap(
Effect.gen(function* () {
const liveBuffer = yield* Queue.unbounded<OrchestrationThreadStreamItem>();
yield* Effect.forkScoped(
liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))),
);
const catchUpStream = orchestrationEngine
.readEvents(afterSequence, Number.MAX_SAFE_INTEGER)
.pipe(
Stream.filter(isThisThreadDetailEvent),
Stream.map((event) => ({ kind: "event" as const, event })),
Stream.mapError(
(cause) =>
new OrchestrationGetSnapshotError({
message: `Failed to replay thread ${input.threadId} events`,
cause,
}),
),
);
return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer));
}),
);
const catchUpStream = orchestrationEngine
.readEvents(afterSequence, Number.MAX_SAFE_INTEGER)
.pipe(
Stream.filter(isThisThreadDetailEvent),
Stream.map((event) => ({ kind: "event" as const, event })),
Stream.mapError(
(cause) =>
new OrchestrationGetSnapshotError({
message: `Failed to replay thread ${input.threadId} events`,
cause,
}),
),
);
return Stream.concat(catchUpStream, bufferedLiveStream);
}

const snapshot = yield* projectionSnapshotQuery
Expand All @@ -1233,7 +1233,7 @@ const makeWsRpcLayer = (
kind: "snapshot" as const,
snapshot: snapshot.value,
}),
liveStream,
bufferedLiveStream,
);
}),
{ "rpc.aggregate": "orchestration" },
Expand Down
Loading