Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 2 additions & 1 deletion apps/server/src/mcpApps/McpAppModelContext.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ThreadId, TurnItemId } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -83,7 +84,7 @@ export const layer = Layer.effect(
OR run.thread_id <> context.thread_id
)
ORDER BY context.updated_at, context.item_id
`.pipe(Effect.mapError(fail));
`.pipe(NodeSqliteClient.readOnly, Effect.mapError(fail));
});

return McpAppModelContext.of({ set, forThread });
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/orchestration-v2/EffectOutbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ThreadId,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
Expand Down Expand Up @@ -420,6 +421,7 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
WHERE effect_id = ${effectId}
LIMIT 1
`.pipe(
NodeSqliteClient.readOnly,
Effect.flatMap((rows) => {
const row = rows[0];
return row === undefined
Expand All @@ -437,6 +439,7 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
WHERE command_id = ${commandId}
ORDER BY created_at ASC, effect_id ASC
`.pipe(
NodeSqliteClient.readOnly,
Effect.flatMap((rows) => decodeRows("list", rows)),
Effect.mapError((cause) =>
isEffectOutboxError(cause)
Expand Down Expand Up @@ -571,7 +574,7 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
SELECT MIN(candidate.available_at) AS available_at
FROM orchestration_v2_effect_outbox AS candidate
WHERE ${claimableCandidatePredicate()}
`.pipe(Effect.withTracerEnabled(false));
`.pipe(NodeSqliteClient.readOnly, Effect.withTracerEnabled(false));
const availableAt = rows[0]?.available_at;
if (availableAt === undefined || availableAt === null) return Option.none();
const parsed = DateTime.make(availableAt);
Expand Down
82 changes: 82 additions & 0 deletions apps/server/src/orchestration-v2/FoundationPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3218,3 +3218,85 @@ it.effect.each(["events", "receipts"] as const)(
}).pipe(Effect.provide(maintenanceLayer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("routes projection node lookups to the reader connection", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "projection-reader-" });
const database = SqlitePersistence.layerFromPath(path.join(directory, "statev2.sqlite"));
const projectionLayer = ProjectionStore.layer.pipe(Layer.provideMerge(database));
yield* Effect.gen(function* () {
const projections = yield* ProjectionStore.ProjectionStoreV2;
const now = yield* DateTime.now;
const nodeId = NodeId.make("node:reader-routing");
const node = {
id: nodeId,
threadId: ThreadId.make("thread:reader-routing"),
runId: null,
parentNodeId: null,
rootNodeId: nodeId,
kind: "subagent" as const,
status: "running" as const,
countsForRun: false,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
runtimeRequestId: null,
checkpointScopeId: null,
startedAt: now,
completedAt: null,
};
const spans: Array<Tracer.Span> = [];
const observe: Statement.Transformer = (statement, _sql, _fiber, span) => {
const [query] = statement.compile();
if (/orchestration_v2_projection_nodes WHERE node_id/.test(query)) {
spans.push(span);
}
return Effect.succeed(statement);
};
yield* Effect.gen(function* () {
assert.isFalse(yield* projections.isNodeStored(node));
}).pipe(
Effect.provideService(Statement.CurrentTransformer, observe),
Effect.provideService(Statement.SpanPropagationEnabled, true),
);
assert.equal(spans.length, 1);
assert.isTrue(
spans.every((span) => span.attributes.get("db.connection") === "reader"),
JSON.stringify(spans.map((span) => span.attributes.get("db.connection"))),
);
}).pipe(Effect.provide(projectionLayer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("loads a shell snapshot while the writer connection is occupied", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "shell-reader-" });
const database = SqlitePersistence.layerFromPath(path.join(directory, "statev2.sqlite"));
const projectionLayer = ProjectionStore.layer.pipe(Layer.provideMerge(database));
yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const held = yield* Deferred.make<void>();
const release = yield* Deferred.make<void>();
const writer = yield* sql
.withTransaction(
sql`SELECT 1`.pipe(
Effect.andThen(Deferred.succeed(held, undefined)),
Effect.andThen(Deferred.await(release)),
),
)
.pipe(Effect.forkChild);
yield* Deferred.await(held);
const snapshot = yield* projections
.getShellSnapshot()
.pipe(Effect.timeoutOption("2 seconds"));
yield* Deferred.succeed(release, undefined);
yield* Fiber.join(writer);
assert.isTrue(Option.isSome(snapshot));
}).pipe(Effect.provide(projectionLayer));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
5 changes: 4 additions & 1 deletion apps/server/src/orchestration-v2/ProjectStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ThreadEnvMode,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -191,6 +192,7 @@ export const make = Effect.gen(function* () {

const get: ProjectStoreV2["Service"]["get"] = (projectId, options) =>
selectRows({ projectId, includeDeleted: options?.includeDeleted === true }).pipe(
NodeSqliteClient.readOnly,
Effect.map((rows) => Option.fromUndefinedOr(rows[0])),
mapError("get"),
);
Expand All @@ -199,12 +201,13 @@ export const make = Effect.gen(function* () {
selectRows({
...(options?.projectIds === undefined ? {} : { projectIds: options.projectIds }),
includeDeleted: options?.includeDeleted === true,
}).pipe(mapError("list"));
}).pipe(NodeSqliteClient.readOnly, mapError("list"));

const findActiveByWorkspaceRoot: ProjectStoreV2["Service"]["findActiveByWorkspaceRoot"] = (
workspaceRoot,
) =>
selectRows({ workspaceRoot, includeDeleted: false }).pipe(
NodeSqliteClient.readOnly,
Effect.map((rows) => Option.fromUndefinedOr(rows[0])),
mapError("findActiveByWorkspaceRoot"),
);
Expand Down
83 changes: 46 additions & 37 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests";
import { projectTurnItemForWire } from "./WireProjection.ts";
import * as Stream from "effect/Stream";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import { makeThreadFind, findProjectedThreadItems } from "./ThreadFind.ts";
import type {
OrchestrationV2SearchThreadInput,
Expand Down Expand Up @@ -6511,47 +6512,55 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
)
.pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })));

const readOnReader =
<Args extends ReadonlyArray<unknown>, A, E, R>(
method: (...args: Args) => Effect.Effect<A, E, R>,
) =>
(...args: Args): Effect.Effect<A, E, R> =>
method(...args).pipe(NodeSqliteClient.readOnly);

return {
apply,
getShellSnapshot,
readShellSnapshot,
getThreadShell,
getThread,
getSettlementCandidates,
getThreadsWithPullRequests,
getThreadWorktreePaths,
getShellSnapshot: readOnReader(getShellSnapshot),
readShellSnapshot: readOnReader(readShellSnapshot),
getThreadShell: readOnReader(getThreadShell),
getThread: readOnReader(getThread),
getSettlementCandidates: readOnReader(getSettlementCandidates),
getThreadsWithPullRequests: readOnReader(getThreadsWithPullRequests),
getThreadWorktreePaths: readOnReader(getThreadWorktreePaths),
getThreadProjection,
getTurnStartContext,
getTurnStartHistory,
getRuntimeRecoveryProjection,
getRunningTurnContext,
getThreadProviderContext,
getRuntimeResponseContext,
getCheckpointContext,
getCheckpointCaptureContext,
getCheckpointRollbackContext,
getRunMessage,
canStartQueuedRun,
getPendingNativeUserInputs,
hasUnpairedRunInterruptRequest,
isNodeStored,
getMessageCount,
getNextTurnItemOrdinal,
getTurnItem,
getThreadRecords,
getRuntimeRequest,
getPlan,
getProviderControlContext,
getLimitRecoveryCandidates,
getRecoveryThreadIds,
getUnreadableThreadIds,
getThreadSnapshot,
getThreadSnapshotWindow,
getTimelinePage,
getThreadHistoryPage,
searchThread,
getTurnStartContext: readOnReader(getTurnStartContext),
getTurnStartHistory: readOnReader(getTurnStartHistory),
getRuntimeRecoveryProjection: readOnReader(getRuntimeRecoveryProjection),
getRunningTurnContext: readOnReader(getRunningTurnContext),
getThreadProviderContext: readOnReader(getThreadProviderContext),
getRuntimeResponseContext: readOnReader(getRuntimeResponseContext),
getCheckpointContext: readOnReader(getCheckpointContext),
getCheckpointCaptureContext: readOnReader(getCheckpointCaptureContext),
getCheckpointRollbackContext: readOnReader(getCheckpointRollbackContext),
getRunMessage: readOnReader(getRunMessage),
canStartQueuedRun: readOnReader(canStartQueuedRun),
getPendingNativeUserInputs: readOnReader(getPendingNativeUserInputs),
hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest),
isNodeStored: readOnReader(isNodeStored),
getMessageCount: readOnReader(getMessageCount),
getNextTurnItemOrdinal: readOnReader(getNextTurnItemOrdinal),
getTurnItem: readOnReader(getTurnItem),
getThreadRecords: (threadId, fields, filter) =>
getThreadRecords(threadId, fields, filter).pipe(NodeSqliteClient.readOnly),
getRuntimeRequest: readOnReader(getRuntimeRequest),
getPlan: readOnReader(getPlan),
getProviderControlContext: readOnReader(getProviderControlContext),
getLimitRecoveryCandidates: readOnReader(getLimitRecoveryCandidates),
getRecoveryThreadIds: readOnReader(getRecoveryThreadIds),
getUnreadableThreadIds: readOnReader(getUnreadableThreadIds),
getThreadSnapshot: readOnReader(getThreadSnapshot),
getThreadSnapshotWindow: readOnReader(getThreadSnapshotWindow),
getTimelinePage: readOnReader(getTimelinePage),
getThreadHistoryPage: readOnReader(getThreadHistoryPage),
searchThread: readOnReader(searchThread),
searchThreadStream,
getThreadAttachmentIds,
getThreadAttachmentIds: readOnReader(getThreadAttachmentIds),
} satisfies ProjectionStoreV2Shape;
}),
);
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/orchestration-v2/ThreadFind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as SqlClient from "effect/sql/SqlClient";
import {
ProjectionStoreReadError,
Expand Down Expand Up @@ -345,7 +346,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* (
}
return { payloads, cwd, sequence, cacheKey, sourceThreadIds };
},
sql.withTransaction,
(effect) => sql.withTransaction(effect).pipe(NodeSqliteClient.readOnly),
(effect, input) =>
effect.pipe(
Effect.mapError((cause) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
TurnItemId,
} from "@t3tools/contracts";
import * as KeyedLock from "@t3tools/shared/KeyedLock";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -359,7 +360,7 @@ const make = Effect.gen(function* () {
WHERE thread_id = ${threadId}
AND role IN ('user', 'assistant')
ORDER BY created_at ASC, message_id ASC
`;
`.pipe(NodeSqliteClient.readOnly);

const listShellMessages = Effect.fn("LegacyV1ThreadImporter.listShellMessages")(function* (
threadId: ThreadId,
Expand Down Expand Up @@ -669,6 +670,7 @@ const make = Effect.gen(function* () {
WHERE legacy_import.transcript_imported_at IS NULL
)
`.pipe(
NodeSqliteClient.readOnly,
Effect.map((rows) => rows[0]?.count ?? 0),
Effect.mapError(
(cause) => new LegacyV1ThreadImportError({ operation: "inspect pending", cause }),
Expand All @@ -690,7 +692,7 @@ const make = Effect.gen(function* () {
FROM orchestration_v2_legacy_imports
WHERE thread_id = ${threadId}
LIMIT 1
`;
`.pipe(NodeSqliteClient.readOnly);
const imported = imports[0];
if (imported === undefined || imported.transcript_imported_at !== null) {
if (imported !== undefined) {
Expand All @@ -708,7 +710,7 @@ const make = Effect.gen(function* () {
AND aggregate_kind = 'thread'
AND stream_id = ${threadId}
AND event_id LIKE ${`${IMPORT_EVENT_PREFIX}:message:%`}
`;
`.pipe(NodeSqliteClient.readOnly);
const existing = new Set(existingRows.map((row) => row.event_id));
const missing = messages.filter(
(message) => !existing.has(`${IMPORT_EVENT_PREFIX}:message:${message.message_id}`),
Expand Down Expand Up @@ -772,7 +774,7 @@ const make = Effect.gen(function* () {
FROM orchestration_v2_legacy_imports
WHERE transcript_imported_at IS NULL
ORDER BY shell_imported_at ASC, thread_id ASC
`;
`.pipe(NodeSqliteClient.readOnly);
let importedThreadCount = 0;
let importedMessageCount = 0;
for (const row of rows) {
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/persistence/AuthPairingLinks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Context from "effect/Context";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -294,6 +295,7 @@ export const make = Effect.gen(function* () {

const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) =>
listActivePairingLinkRows(input).pipe(
NodeSqliteClient.readOnly,
Effect.mapError(
toPersistenceSqlOrDecodeError(
"AuthPairingLinkRepository.listActive:query",
Expand Down Expand Up @@ -329,6 +331,7 @@ export const make = Effect.gen(function* () {

const getByCredential: AuthPairingLinkRepository["Service"]["getByCredential"] = (input) =>
getPairingLinkRowByCredential(input).pipe(
NodeSqliteClient.readOnly,
Effect.mapError(
toPersistenceSqlOrDecodeError(
"AuthPairingLinkRepository.getByCredential:query",
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/persistence/AuthSessions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Context from "effect/Context";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -416,6 +417,7 @@ export const make = Effect.gen(function* () {

const getById: AuthSessionRepository["Service"]["getById"] = (input) =>
getSessionRowById(input).pipe(
NodeSqliteClient.readOnly,
Effect.mapError(
toPersistenceSqlOrDecodeError(
"AuthSessionRepository.getById:query",
Expand Down Expand Up @@ -443,6 +445,7 @@ export const make = Effect.gen(function* () {

const listActive: AuthSessionRepository["Service"]["listActive"] = (input) =>
listActiveSessionRows(input).pipe(
NodeSqliteClient.readOnly,
Effect.mapError(
toPersistenceSqlOrDecodeError(
"AuthSessionRepository.listActive:query",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/OrchestrationCommandReceipts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CommandId, IsoDateTime, NonNegativeInt, ProjectId, ThreadId } from "@t3tools/contracts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as SqlClient from "effect/sql/SqlClient";
import * as SqlSchema from "effect/sql/SqlSchema";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -149,6 +150,7 @@ const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () {
input,
) =>
findReceiptByCommandId(input).pipe(
NodeSqliteClient.readOnly,
Effect.mapError(
toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"),
),
Expand Down
Loading
Loading