Skip to content
Open
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
11 changes: 9 additions & 2 deletions apps/mobile/src/connection/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Connection } from "@t3tools/client-runtime/connection";
import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
import {
environmentShellMembershipLayer,
shellSnapshotLoaderLayer,
} from "@t3tools/client-runtime/state/shell";
import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
import * as Layer from "effect/Layer";
import { Atom } from "effect/unstable/reactivity";
Expand All @@ -11,7 +14,11 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
Layer.provide(runtimeContextLayer),
);

const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
const snapshotLoaderLayer = Layer.mergeAll(
threadSnapshotLoaderLayer,
shellSnapshotLoaderLayer,
environmentShellMembershipLayer,
);

type ConnectionLayerSource =
| typeof Connection.layer
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ describe("CheckpointDiffQuery.layer", () => {
});
}),
getThreadShellById: () => Effect.succeed(Option.none()),
hasThreadById: () => Effect.succeed(false),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
Expand Down Expand Up @@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
hasThreadById: () => Effect.succeed(false),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
Expand Down Expand Up @@ -282,6 +284,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
hasThreadById: () => Effect.succeed(false),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
Expand Down Expand Up @@ -350,6 +353,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
hasThreadById: () => Effect.succeed(false),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
Expand Down Expand Up @@ -403,6 +407,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
hasThreadById: () => Effect.succeed(false),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ describe("OrchestrationEngine", () => {
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
hasThreadById: () => Effect.succeed(false),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
[ThreadId.make("thread-archived")],
);
assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z");

assert.isTrue(yield* snapshotQuery.hasThreadById(ThreadId.make("thread-active")));
assert.isTrue(yield* snapshotQuery.hasThreadById(ThreadId.make("thread-archived")));
assert.isFalse(yield* snapshotQuery.hasThreadById(ThreadId.make("thread-missing")));
}),
);

Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,19 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
`,
});

const getNonDeletedThreadIdById = SqlSchema.findOneOption({
Request: ThreadIdLookupInput,
Result: ProjectionThreadIdLookupRowSchema,
execute: ({ threadId }) =>
sql`
SELECT thread_id AS "threadId"
FROM projection_threads
WHERE thread_id = ${threadId}
AND deleted_at IS NULL
LIMIT 1
`,
});

const listThreadMessageRowsByThread = SqlSchema.findAll({
Request: ThreadIdLookupInput,
Result: ProjectionThreadMessageDbRowSchema,
Expand Down Expand Up @@ -1895,6 +1908,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
} satisfies OrchestrationThreadShell);
});

const hasThreadById: ProjectionSnapshotQueryShape["hasThreadById"] = (threadId) =>
getNonDeletedThreadIdById({ threadId }).pipe(
Effect.map(Option.isSome),
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProjectionSnapshotQuery.hasThreadById:query",
"ProjectionSnapshotQuery.hasThreadById:decodeRow",
),
),
);

const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) =>
Effect.gen(function* () {
const [
Expand Down Expand Up @@ -2076,6 +2100,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
getThreadCheckpointContext,
getFullThreadDiffContext,
getThreadShellById,
hasThreadById,
getThreadDetailById,
getThreadDetailSnapshot,
} satisfies ProjectionSnapshotQueryShape;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ export interface ProjectionSnapshotQueryShape {
threadId: ThreadId,
) => Effect.Effect<Option.Option<OrchestrationThreadShell>, ProjectionRepositoryError>;

/**
* Check whether a non-deleted thread projection exists. Archived threads are
* included so lifecycle transitions cannot be mistaken for deletion.
*/
readonly hasThreadById: (threadId: ThreadId) => Effect.Effect<boolean, ProjectionRepositoryError>;

/**
* Read a single active thread detail snapshot by id.
*/
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
getThreadCheckpointContext: () => Effect.die("unused"),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.die("unused"),
hasThreadById: () => Effect.die("unused"),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ describe("ProviderSessionReaper", () => {
? Option.some(input.readModel.threads.find((thread) => thread.id === threadId)!)
: Option.none(),
),
hasThreadById: () => Effect.die("unused"),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
}),
Expand Down
81 changes: 81 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ const buildAppUnderTest = (options?: {
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
getProjectShellById: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
hasThreadById: () => Effect.succeed(false),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
Expand Down Expand Up @@ -5662,6 +5663,86 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("tags a missing thread subscription as not found", () =>
Effect.gen(function* () {
yield* buildAppUnderTest({
layers: {
projectionSnapshotQuery: {
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
},
},
});

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

assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "OrchestrationThreadNotFoundError");
assert.equal(result.failure.threadId, defaultThreadId);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("tags a missing resumed thread subscription as not found", () =>
Effect.gen(function* () {
yield* buildAppUnderTest({
layers: {
projectionSnapshotQuery: {
hasThreadById: () => Effect.succeed(false),
getThreadDetailSnapshot: () => Effect.die("unused for resumed subscriptions"),
},
},
});

const wsUrl = yield* getWsServerUrl("/ws");
const result = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[ORCHESTRATION_WS_METHODS.subscribeThread]({
threadId: defaultThreadId,
afterSequence: 7,
}).pipe(Stream.runCollect),
).pipe(Effect.result),
);

assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "OrchestrationThreadNotFoundError");
assert.equal(result.failure.threadId, defaultThreadId);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("keeps an archived resumed thread subscription alive", () =>
Effect.gen(function* () {
yield* buildAppUnderTest({
layers: {
projectionSnapshotQuery: {
hasThreadById: () => Effect.succeed(true),
getThreadShellById: () =>
Effect.die("active shell lookup must not determine existence"),
getThreadDetailSnapshot: () => Effect.die("unused for resumed subscriptions"),
},
},
});

const wsUrl = yield* getWsServerUrl("/ws");
const firstItem = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[ORCHESTRATION_WS_METHODS.subscribeThread]({
threadId: defaultThreadId,
afterSequence: 7,
requestCompletionMarker: true,
}).pipe(Stream.runHead),
),
);

assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" });
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("buffers shell events published while the fallback snapshot loads", () =>
Effect.gen(function* () {
const liveEvents = yield* PubSub.unbounded<OrchestrationEvent>();
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/serverRuntimeStartup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
hasThreadById: () => Effect.succeed(false),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
Expand Down Expand Up @@ -158,6 +159,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.die("unused"),
hasThreadById: () => Effect.die("unused"),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
}),
Expand Down Expand Up @@ -202,6 +204,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.die("unused"),
hasThreadById: () => Effect.die("unused"),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
}),
Expand Down Expand Up @@ -252,6 +255,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.die("unused"),
hasThreadById: () => Effect.die("unused"),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
}),
Expand Down
23 changes: 20 additions & 3 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
type OrchestrationThreadStreamItem,
OrchestrationGetFullThreadDiffError,
OrchestrationGetSnapshotError,
OrchestrationThreadNotFoundError,
OrchestrationGetTurnDiffError,
ORCHESTRATION_WS_METHODS,
type ProjectId,
Expand Down Expand Up @@ -1387,6 +1388,23 @@ const makeWsRpcLayer = (
// snapshot sequence) and the per-thread filter runs after reading,
// so a global cap could otherwise omit this thread's events.
if (input.afterSequence !== undefined) {
const threadExists = yield* projectionSnapshotQuery
.hasThreadById(input.threadId)
.pipe(
Effect.mapError(
(cause) =>
new OrchestrationGetSnapshotError({
message: `Failed to load thread ${input.threadId}`,
cause,
}),
),
);
if (!threadExists) {
return yield* new OrchestrationThreadNotFoundError({
threadId: input.threadId,
});
}
Comment thread
cursor[bot] marked this conversation as resolved.

const afterSequence = input.afterSequence;
const catchUpStream = orchestrationEngine
.readEvents(afterSequence, Number.MAX_SAFE_INTEGER)
Expand Down Expand Up @@ -1426,9 +1444,8 @@ const makeWsRpcLayer = (
);

if (Option.isNone(snapshot)) {
return yield* new OrchestrationGetSnapshotError({
message: `Thread ${input.threadId} was not found`,
cause: input.threadId,
return yield* new OrchestrationThreadNotFoundError({
threadId: input.threadId,
});
}

Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/connection/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Connection } from "@t3tools/client-runtime/connection";
import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
import {
environmentShellMembershipLayer,
shellSnapshotLoaderLayer,
} from "@t3tools/client-runtime/state/shell";
import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
import * as Layer from "effect/Layer";
import { Atom } from "effect/unstable/reactivity";
Expand All @@ -11,7 +14,11 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
Layer.provide(runtimeContextLayer),
);

const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
const snapshotLoaderLayer = Layer.mergeAll(
threadSnapshotLoaderLayer,
shellSnapshotLoaderLayer,
environmentShellMembershipLayer,
);

type ConnectionLayerSource =
| typeof Connection.layer
Expand Down
Loading
Loading