From 27b50550cce17ac98ce426c2184d60af4f36d1e9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 18:13:20 -0700 Subject: [PATCH 1/2] refactor persistence Effect services Co-authored-by: codex --- .../providerService.integration.test.ts | 4 +- apps/server/src/auth/PairingGrantStore.ts | 7 +- apps/server/src/auth/SessionStore.test.ts | 2 +- apps/server/src/auth/SessionStore.ts | 7 +- .../{Layers => }/AuthPairingLinks.ts | 112 +++- .../persistence/{Layers => }/AuthSessions.ts | 133 ++++- .../Layers/OrchestrationCommandReceipts.ts | 91 +--- .../Layers/OrchestrationEventStore.ts | 271 +-------- .../Layers/ProjectionCheckpoints.ts | 219 +------- .../Layers/ProjectionPendingApprovals.ts | 138 +---- .../persistence/Layers/ProjectionProjects.ts | 144 +---- .../src/persistence/Layers/ProjectionState.ts | 119 +--- .../Layers/ProjectionThreadActivities.ts | 160 +----- .../Layers/ProjectionThreadMessages.ts | 189 +------ .../Layers/ProjectionThreadProposedPlans.ts | 112 +--- .../Layers/ProjectionThreadSessions.ts | 113 +--- .../persistence/Layers/ProjectionThreads.ts | 190 +------ .../src/persistence/Layers/ProjectionTurns.ts | 356 +----------- .../Layers/ProviderSessionRuntime.ts | 210 +------ .../src/persistence/NodeSqliteClient.ts | 28 +- .../OrchestrationCommandReceipts.ts | 150 +++++ .../OrchestrationEventStore.test.ts | 13 +- .../persistence/OrchestrationEventStore.ts | 329 +++++++++++ .../src/persistence/ProjectionCheckpoints.ts | 295 ++++++++++ .../persistence/ProjectionPendingApprovals.ts | 217 ++++++++ .../src/persistence/ProjectionProjects.ts | 206 +++++++ .../ProjectionRepositories.test.ts | 57 +- .../server/src/persistence/ProjectionState.ts | 170 ++++++ .../persistence/ProjectionThreadActivities.ts | 230 ++++++++ .../ProjectionThreadMessages.test.ts | 11 +- .../persistence/ProjectionThreadMessages.ts | 265 +++++++++ .../ProjectionThreadProposedPlans.ts | 152 ++++++ .../persistence/ProjectionThreadSessions.ts | 177 ++++++ .../src/persistence/ProjectionThreads.ts | 273 ++++++++++ .../server/src/persistence/ProjectionTurns.ts | 513 ++++++++++++++++++ .../src/persistence/ProviderSessionRuntime.ts | 288 ++++++++++ .../persistence/Services/AuthPairingLinks.ts | 82 --- .../src/persistence/Services/AuthSessions.ts | 100 ---- .../Services/OrchestrationCommandReceipts.ts | 72 +-- .../Services/OrchestrationEventStore.ts | 73 +-- .../Services/ProjectionCheckpoints.ts | 96 +--- .../Services/ProjectionPendingApprovals.ts | 94 +--- .../Services/ProjectionProjects.ts | 82 +-- .../persistence/Services/ProjectionState.ts | 67 +-- .../Services/ProjectionThreadActivities.ts | 86 +-- .../Services/ProjectionThreadMessages.ts | 97 +--- .../Services/ProjectionThreadProposedPlans.ts | 57 +- .../Services/ProjectionThreadSessions.ts | 80 +-- .../persistence/Services/ProjectionThreads.ts | 104 +--- .../persistence/Services/ProjectionTurns.ts | 171 +----- .../Services/ProviderSessionRuntime.ts | 92 ---- .../provider/Layers/ProviderService.test.ts | 33 +- .../Layers/ProviderSessionDirectory.test.ts | 15 +- .../Layers/ProviderSessionDirectory.ts | 7 +- .../Layers/ProviderSessionReaper.test.ts | 31 +- apps/server/src/server.ts | 4 +- 56 files changed, 3622 insertions(+), 3772 deletions(-) rename apps/server/src/persistence/{Layers => }/AuthPairingLinks.ts (60%) rename apps/server/src/persistence/{Layers => }/AuthSessions.ts (65%) create mode 100644 apps/server/src/persistence/OrchestrationCommandReceipts.ts rename apps/server/src/persistence/{Layers => }/OrchestrationEventStore.test.ts (88%) create mode 100644 apps/server/src/persistence/OrchestrationEventStore.ts create mode 100644 apps/server/src/persistence/ProjectionCheckpoints.ts create mode 100644 apps/server/src/persistence/ProjectionPendingApprovals.ts create mode 100644 apps/server/src/persistence/ProjectionProjects.ts rename apps/server/src/persistence/{Layers => }/ProjectionRepositories.test.ts (67%) create mode 100644 apps/server/src/persistence/ProjectionState.ts create mode 100644 apps/server/src/persistence/ProjectionThreadActivities.ts rename apps/server/src/persistence/{Layers => }/ProjectionThreadMessages.test.ts (87%) create mode 100644 apps/server/src/persistence/ProjectionThreadMessages.ts create mode 100644 apps/server/src/persistence/ProjectionThreadProposedPlans.ts create mode 100644 apps/server/src/persistence/ProjectionThreadSessions.ts create mode 100644 apps/server/src/persistence/ProjectionThreads.ts create mode 100644 apps/server/src/persistence/ProjectionTurns.ts create mode 100644 apps/server/src/persistence/ProviderSessionRuntime.ts delete mode 100644 apps/server/src/persistence/Services/AuthPairingLinks.ts delete mode 100644 apps/server/src/persistence/Services/AuthSessions.ts delete mode 100644 apps/server/src/persistence/Services/ProviderSessionRuntime.ts diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 57e93c5acdd..e703af4b1f4 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -25,7 +25,7 @@ import { import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; import { makeTestProviderAdapterHarness, @@ -63,7 +63,7 @@ const makeIntegrationFixture = Effect.gen(function* () { }); const directoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), ); const shared = Layer.mergeAll( diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index e97696fbadd..c655a0f36b6 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -18,8 +18,7 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import { ServerConfig } from "../config.ts"; -import { AuthPairingLinkRepositoryLive } from "../persistence/Layers/AuthPairingLinks.ts"; -import { AuthPairingLinkRepository } from "../persistence/Services/AuthPairingLinks.ts"; +import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { readonly method: ServerAuthBootstrapMethod; @@ -126,7 +125,7 @@ const internalBootstrapCredentialError = (message: string, cause: unknown) => export const make = Effect.fn("makePairingGrantStore")(function* () { const crypto = yield* Crypto.Crypto; const config = yield* ServerConfig; - const pairingLinks = yield* AuthPairingLinkRepository; + const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; const seededGrantsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); const generatePairingToken = Effect.gen(function* () { @@ -417,5 +416,5 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); export const layer = Layer.effect(PairingGrantStore, make()).pipe( - Layer.provideMerge(AuthPairingLinkRepositoryLive), + Layer.provideMerge(AuthPairingLinks.layer), ); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 967766a7a4e..130222408a6 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -8,7 +8,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; -import * as AuthSessions from "../persistence/Services/AuthSessions.ts"; +import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 8de145ca338..e1064c27904 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -22,8 +22,7 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import { ServerConfig } from "../config.ts"; -import { AuthSessionRepositoryLive } from "../persistence/Layers/AuthSessions.ts"; -import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts"; +import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, @@ -195,7 +194,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig; const secretStore = yield* ServerSecretStore.ServerSecretStore; - const authSessions = yield* AuthSessionRepository; + const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); @@ -640,5 +639,5 @@ export const make = Effect.fn("makeSessionStore")(function* () { }); export const layer = Layer.effect(SessionStore, make()).pipe( - Layer.provideMerge(AuthSessionRepositoryLive), + Layer.provideMerge(AuthSessions.layer), ); diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts similarity index 60% rename from apps/server/src/persistence/Layers/AuthPairingLinks.ts rename to apps/server/src/persistence/AuthPairingLinks.ts index 9d2760d1449..add90f04803 100644 --- a/apps/server/src/persistence/Layers/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -1,24 +1,91 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { AuthEnvironmentScopes } from "@t3tools/contracts"; import { + type AuthPairingLinkRepositoryError, toPersistenceDecodeError, toPersistenceSqlError, - type AuthPairingLinkRepositoryError, -} from "../Errors.ts"; -import { - AuthPairingLinkRecord, +} from "./Errors.ts"; + +export const AuthPairingLinkRecord = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + scopes: Schema.fromJsonString(AuthEnvironmentScopes), + subject: Schema.String, + label: Schema.NullOr(Schema.String), + proofKeyThumbprint: Schema.NullOr(Schema.String), + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; + +export const CreateAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + scopes: AuthEnvironmentScopes, + subject: Schema.String, + label: Schema.NullOr(Schema.String), + proofKeyThumbprint: Schema.NullOr(Schema.String), + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; + +export const ConsumeAuthPairingLinkInput = Schema.Struct({ + credential: Schema.String, + proofKeyThumbprint: Schema.NullOr(Schema.String), + consumedAt: Schema.DateTimeUtcFromString, + now: Schema.DateTimeUtcFromString, +}); +export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type; + +export const ListActiveAuthPairingLinksInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type; + +export const RevokeAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; + +export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ + credential: Schema.String, +}); +export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; + +export class AuthPairingLinkRepository extends Context.Service< AuthPairingLinkRepository, - type AuthPairingLinkRepositoryShape, - ConsumeAuthPairingLinkInput, - CreateAuthPairingLinkInput, - GetAuthPairingLinkByCredentialInput, - ListActiveAuthPairingLinksInput, - RevokeAuthPairingLinkInput, -} from "../Services/AuthPairingLinks.ts"; + { + readonly create: ( + input: CreateAuthPairingLinkInput, + ) => Effect.Effect; + readonly consumeAvailable: ( + input: ConsumeAuthPairingLinkInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly listActive: ( + input: ListActiveAuthPairingLinksInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly revoke: ( + input: RevokeAuthPairingLinkInput, + ) => Effect.Effect; + readonly getByCredential: ( + input: GetAuthPairingLinkByCredentialInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + } +>()("t3/persistence/AuthPairingLinks/AuthPairingLinkRepository") {} function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): AuthPairingLinkRepositoryError => @@ -27,7 +94,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(sqlOperation)(cause); } -const makeAuthPairingLinkRepository = Effect.gen(function* () { +export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const createPairingLinkRow = SqlSchema.void({ @@ -154,7 +221,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { `, }); - const create: AuthPairingLinkRepositoryShape["create"] = (input) => + const create: AuthPairingLinkRepository["Service"]["create"] = (input) => createPairingLinkRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -164,7 +231,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { ), ); - const consumeAvailable: AuthPairingLinkRepositoryShape["consumeAvailable"] = (input) => + const consumeAvailable: AuthPairingLinkRepository["Service"]["consumeAvailable"] = (input) => consumeAvailablePairingLinkRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -174,7 +241,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { ), ); - const listActive: AuthPairingLinkRepositoryShape["listActive"] = (input) => + const listActive: AuthPairingLinkRepository["Service"]["listActive"] = (input) => listActivePairingLinkRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -184,7 +251,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { ), ); - const revoke: AuthPairingLinkRepositoryShape["revoke"] = (input) => + const revoke: AuthPairingLinkRepository["Service"]["revoke"] = (input) => revokePairingLinkRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -195,7 +262,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { Effect.map((rows) => rows.length > 0), ); - const getByCredential: AuthPairingLinkRepositoryShape["getByCredential"] = (input) => + const getByCredential: AuthPairingLinkRepository["Service"]["getByCredential"] = (input) => getPairingLinkRowByCredential(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -211,10 +278,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { listActive, revoke, getByCredential, - } satisfies AuthPairingLinkRepositoryShape; + } satisfies AuthPairingLinkRepository["Service"]; }); -export const AuthPairingLinkRepositoryLive = Layer.effect( - AuthPairingLinkRepository, - makeAuthPairingLinkRepository, -); +export const layer = Layer.effect(AuthPairingLinkRepository, make); diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts similarity index 65% rename from apps/server/src/persistence/Layers/AuthSessions.ts rename to apps/server/src/persistence/AuthSessions.ts index ab84e3fa041..e3e8a19f5d0 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -1,27 +1,109 @@ -import { AuthEnvironmentScopes, AuthSessionId, ServerAuthSessionMethod } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + AuthClientMetadataDeviceType, + AuthEnvironmentScopes, + AuthSessionId, + ServerAuthSessionMethod, +} from "@t3tools/contracts"; import { + type AuthSessionRepositoryError, toPersistenceDecodeError, toPersistenceSqlError, - type AuthSessionRepositoryError, -} from "../Errors.ts"; -import { - AuthSessionRecord, +} from "./Errors.ts"; + +export const AuthSessionClientMetadataRecord = Schema.Struct({ + label: Schema.NullOr(Schema.String), + ipAddress: Schema.NullOr(Schema.String), + userAgent: Schema.NullOr(Schema.String), + deviceType: AuthClientMetadataDeviceType, + os: Schema.NullOr(Schema.String), + browser: Schema.NullOr(Schema.String), +}); +export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type; + +export const AuthSessionRecord = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + scopes: AuthEnvironmentScopes, + method: ServerAuthSessionMethod, + client: AuthSessionClientMetadataRecord, + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthSessionRecord = typeof AuthSessionRecord.Type; + +export const CreateAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + scopes: AuthEnvironmentScopes, + method: ServerAuthSessionMethod, + client: AuthSessionClientMetadataRecord, + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; + +export const GetAuthSessionByIdInput = Schema.Struct({ + sessionId: AuthSessionId, +}); +export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; + +export const ListActiveAuthSessionsInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; + +export const RevokeAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; + +export const RevokeOtherAuthSessionsInput = Schema.Struct({ + currentSessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; + +export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ + sessionId: AuthSessionId, + lastConnectedAt: Schema.DateTimeUtcFromString, +}); +export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; + +export class AuthSessionRepository extends Context.Service< AuthSessionRepository, - type AuthSessionRepositoryShape, - CreateAuthSessionInput, - GetAuthSessionByIdInput, - ListActiveAuthSessionsInput, - RevokeAuthSessionInput, - RevokeOtherAuthSessionsInput, - SetAuthSessionLastConnectedAtInput, -} from "../Services/AuthSessions.ts"; + { + readonly create: ( + input: CreateAuthSessionInput, + ) => Effect.Effect; + readonly getById: ( + input: GetAuthSessionByIdInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly listActive: ( + input: ListActiveAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly revoke: ( + input: RevokeAuthSessionInput, + ) => Effect.Effect; + readonly revokeAllExcept: ( + input: RevokeOtherAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly setLastConnectedAt: ( + input: SetAuthSessionLastConnectedAtInput, + ) => Effect.Effect; + } +>()("t3/persistence/AuthSessions/AuthSessionRepository") {} const AuthSessionDbRow = Schema.Struct({ sessionId: AuthSessionId, @@ -68,7 +150,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(sqlOperation)(cause); } -const makeAuthSessionRepository = Effect.gen(function* () { +export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const createSessionRow = SqlSchema.void({ @@ -197,7 +279,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { `, }); - const create: AuthSessionRepositoryShape["create"] = (input) => + const create: AuthSessionRepository["Service"]["create"] = (input) => createSessionRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -207,7 +289,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { ), ); - const getById: AuthSessionRepositoryShape["getById"] = (input) => + const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -223,7 +305,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { ), ); - const listActive: AuthSessionRepositoryShape["listActive"] = (input) => + const listActive: AuthSessionRepository["Service"]["listActive"] = (input) => listActiveSessionRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -234,7 +316,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), ); - const revoke: AuthSessionRepositoryShape["revoke"] = (input) => + const revoke: AuthSessionRepository["Service"]["revoke"] = (input) => revokeSessionRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -245,7 +327,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { Effect.map((rows) => rows.length > 0), ); - const revokeAllExcept: AuthSessionRepositoryShape["revokeAllExcept"] = (input) => + const revokeAllExcept: AuthSessionRepository["Service"]["revokeAllExcept"] = (input) => revokeOtherSessionRows(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -256,7 +338,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => row.sessionId)), ); - const setLastConnectedAt: AuthSessionRepositoryShape["setLastConnectedAt"] = (input) => + const setLastConnectedAt: AuthSessionRepository["Service"]["setLastConnectedAt"] = (input) => setLastConnectedAtRow(input).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -273,10 +355,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { revoke, revokeAllExcept, setLastConnectedAt, - } satisfies AuthSessionRepositoryShape; + } satisfies AuthSessionRepository["Service"]; }); -export const AuthSessionRepositoryLive = Layer.effect( - AuthSessionRepository, - makeAuthSessionRepository, -); +export const layer = Layer.effect(AuthSessionRepository, make); diff --git a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts index 989f5d78f8d..355111aa6a7 100644 --- a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts @@ -1,90 +1 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { toPersistenceSqlError } from "../Errors.ts"; - -import { - GetByCommandIdInput, - OrchestrationCommandReceipt, - OrchestrationCommandReceiptRepository, - type OrchestrationCommandReceiptRepositoryShape, -} from "../Services/OrchestrationCommandReceipts.ts"; - -const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertReceiptRow = SqlSchema.void({ - Request: OrchestrationCommandReceipt, - execute: (receipt) => - sql` - INSERT INTO orchestration_command_receipts ( - command_id, - aggregate_kind, - aggregate_id, - accepted_at, - result_sequence, - status, - error - ) - VALUES ( - ${receipt.commandId}, - ${receipt.aggregateKind}, - ${receipt.aggregateId}, - ${receipt.acceptedAt}, - ${receipt.resultSequence}, - ${receipt.status}, - ${receipt.error} - ) - ON CONFLICT (command_id) - DO UPDATE SET - aggregate_kind = excluded.aggregate_kind, - aggregate_id = excluded.aggregate_id, - accepted_at = excluded.accepted_at, - result_sequence = excluded.result_sequence, - status = excluded.status, - error = excluded.error - `, - }); - - const findReceiptByCommandId = SqlSchema.findOneOption({ - Request: GetByCommandIdInput, - Result: OrchestrationCommandReceipt, - execute: ({ commandId }) => - sql` - SELECT - command_id AS "commandId", - aggregate_kind AS "aggregateKind", - aggregate_id AS "aggregateId", - accepted_at AS "acceptedAt", - result_sequence AS "resultSequence", - status, - error - FROM orchestration_command_receipts - WHERE command_id = ${commandId} - `, - }); - - const upsert: OrchestrationCommandReceiptRepositoryShape["upsert"] = (receipt) => - upsertReceiptRow(receipt).pipe( - Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")), - ); - - const getByCommandId: OrchestrationCommandReceiptRepositoryShape["getByCommandId"] = (input) => - findReceiptByCommandId(input).pipe( - Effect.mapError( - toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), - ), - ); - - return { - upsert, - getByCommandId, - } satisfies OrchestrationCommandReceiptRepositoryShape; -}); - -export const OrchestrationCommandReceiptRepositoryLive = Layer.effect( - OrchestrationCommandReceiptRepository, - makeOrchestrationCommandReceiptRepository, -); +export { layer as OrchestrationCommandReceiptRepositoryLive } from "../OrchestrationCommandReceipts.ts"; diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 18d0e9aa578..68a446c4c15 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -1,270 +1 @@ -import { - CommandId, - EventId, - IsoDateTime, - NonNegativeInt, - OrchestrationActorKind, - OrchestrationAggregateKind, - OrchestrationEvent, - OrchestrationEventMetadata, - OrchestrationEventType, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; - -import { - toPersistenceDecodeError, - toPersistenceSqlError, - type OrchestrationEventStoreError, -} from "../Errors.ts"; -import { - OrchestrationEventStore, - type OrchestrationEventStoreShape, -} from "../Services/OrchestrationEventStore.ts"; - -const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); -const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); -const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); - -const AppendEventRequestSchema = Schema.Struct({ - eventId: EventId, - aggregateKind: OrchestrationAggregateKind, - streamId: Schema.Union([ProjectId, ThreadId]), - type: OrchestrationEventType, - causationEventId: Schema.NullOr(EventId), - correlationId: Schema.NullOr(CommandId), - actorKind: OrchestrationActorKind, - occurredAt: IsoDateTime, - commandId: Schema.NullOr(CommandId), - payloadJson: UnknownFromJsonString, - metadataJson: EventMetadataFromJsonString, -}); - -const OrchestrationEventPersistedRowSchema = Schema.Struct({ - sequence: NonNegativeInt, - eventId: EventId, - type: OrchestrationEventType, - aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), - occurredAt: IsoDateTime, - commandId: Schema.NullOr(CommandId), - causationEventId: Schema.NullOr(EventId), - correlationId: Schema.NullOr(CommandId), - payload: UnknownFromJsonString, - metadata: EventMetadataFromJsonString, -}); - -const ReadFromSequenceRequestSchema = Schema.Struct({ - sequenceExclusive: NonNegativeInt, - limit: Schema.Number, -}); -const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; -const READ_PAGE_SIZE = 500; - -function inferActorKind( - event: Omit, -): Schema.Schema.Type { - if (event.commandId !== null && event.commandId.startsWith("provider:")) { - return "provider"; - } - if (event.commandId !== null && event.commandId.startsWith("server:")) { - return "server"; - } - if ( - event.metadata.providerTurnId !== undefined || - event.metadata.providerItemId !== undefined || - event.metadata.adapterKey !== undefined - ) { - return "provider"; - } - if (event.commandId === null) { - return "server"; - } - return "client"; -} - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown): OrchestrationEventStoreError => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -const makeEventStore = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const appendEventRow = SqlSchema.findOne({ - Request: AppendEventRequestSchema, - Result: OrchestrationEventPersistedRowSchema, - execute: (request) => - sql` - INSERT INTO orchestration_events ( - event_id, - aggregate_kind, - stream_id, - stream_version, - event_type, - occurred_at, - command_id, - causation_event_id, - correlation_id, - actor_kind, - payload_json, - metadata_json - ) - VALUES ( - ${request.eventId}, - ${request.aggregateKind}, - ${request.streamId}, - COALESCE( - ( - SELECT stream_version + 1 - FROM orchestration_events - WHERE aggregate_kind = ${request.aggregateKind} - AND stream_id = ${request.streamId} - ORDER BY stream_version DESC - LIMIT 1 - ), - 0 - ), - ${request.type}, - ${request.occurredAt}, - ${request.commandId}, - ${request.causationEventId}, - ${request.correlationId}, - ${request.actorKind}, - ${request.payloadJson}, - ${request.metadataJson} - ) - RETURNING - sequence, - event_id AS "eventId", - event_type AS "type", - aggregate_kind AS "aggregateKind", - stream_id AS "aggregateId", - occurred_at AS "occurredAt", - command_id AS "commandId", - causation_event_id AS "causationEventId", - correlation_id AS "correlationId", - payload_json AS "payload", - metadata_json AS "metadata" - `, - }); - - const readEventRowsFromSequence = SqlSchema.findAll({ - Request: ReadFromSequenceRequestSchema, - Result: OrchestrationEventPersistedRowSchema, - execute: (request) => - sql` - SELECT - sequence, - event_id AS "eventId", - event_type AS "type", - aggregate_kind AS "aggregateKind", - stream_id AS "aggregateId", - occurred_at AS "occurredAt", - command_id AS "commandId", - causation_event_id AS "causationEventId", - correlation_id AS "correlationId", - payload_json AS "payload", - metadata_json AS "metadata" - FROM orchestration_events - WHERE sequence > ${request.sequenceExclusive} - ORDER BY sequence ASC - LIMIT ${request.limit} - `, - }); - - const append: OrchestrationEventStoreShape["append"] = (event) => - appendEventRow({ - eventId: event.eventId, - aggregateKind: event.aggregateKind, - streamId: event.aggregateId, - type: event.type, - causationEventId: event.causationEventId, - correlationId: event.correlationId, - actorKind: inferActorKind(event), - occurredAt: event.occurredAt, - commandId: event.commandId, - payloadJson: event.payload, - metadataJson: event.metadata, - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "OrchestrationEventStore.append:insert", - "OrchestrationEventStore.append:decodeRow", - ), - ), - Effect.flatMap((row) => - decodeEvent(row).pipe( - Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")), - ), - ), - ); - - const readFromSequence: OrchestrationEventStoreShape["readFromSequence"] = ( - sequenceExclusive, - limit = DEFAULT_READ_FROM_SEQUENCE_LIMIT, - ) => { - const normalizedLimit = Math.max(0, Math.floor(limit)); - if (normalizedLimit === 0) { - return Stream.empty; - } - const readPage = ( - cursor: number, - remaining: number, - ): Stream.Stream => - Stream.fromEffect( - readEventRowsFromSequence({ - sequenceExclusive: cursor, - limit: Math.min(remaining, READ_PAGE_SIZE), - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "OrchestrationEventStore.readFromSequence:query", - "OrchestrationEventStore.readFromSequence:decodeRows", - ), - ), - Effect.flatMap((rows) => - Effect.forEach(rows, (row) => - decodeEvent(row).pipe( - Effect.mapError( - toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"), - ), - ), - ), - ), - ), - ).pipe( - Stream.flatMap((events) => { - if (events.length === 0) { - return Stream.empty; - } - const nextRemaining = remaining - events.length; - if (nextRemaining <= 0) { - return Stream.fromIterable(events); - } - return Stream.concat( - Stream.fromIterable(events), - readPage(events[events.length - 1]!.sequence, nextRemaining), - ); - }), - ); - - return readPage(sequenceExclusive, normalizedLimit); - }; - - return { - append, - readFromSequence, - readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), - } satisfies OrchestrationEventStoreShape; -}); - -export const OrchestrationEventStoreLive = Layer.effect(OrchestrationEventStore, makeEventStore); +export { layer as OrchestrationEventStoreLive } from "../OrchestrationEventStore.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts index cfa5658d0e9..63441735116 100644 --- a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts @@ -1,217 +1,2 @@ -import { OrchestrationCheckpointFile } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; - -import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; -import { - DeleteByThreadIdInput, - GetByThreadAndTurnCountInput, - ListByThreadIdInput, - ProjectionCheckpoint, - ProjectionCheckpointRepository, - type ProjectionCheckpointRepositoryShape, -} from "../Services/ProjectionCheckpoints.ts"; - -const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( - Struct.assign({ - files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), - }), -); - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown) => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -const makeProjectionCheckpointRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const clearCheckpointConflict = SqlSchema.void({ - Request: GetByThreadAndTurnCountInput, - execute: ({ threadId, checkpointTurnCount }) => - sql` - UPDATE projection_turns - SET - checkpoint_turn_count = NULL, - checkpoint_ref = NULL, - checkpoint_status = NULL, - checkpoint_files_json = '[]' - WHERE thread_id = ${threadId} - AND checkpoint_turn_count = ${checkpointTurnCount} - `, - }); - - const upsertProjectionCheckpointRow = SqlSchema.void({ - Request: ProjectionCheckpointDbRowSchema, - execute: (row) => - sql` - INSERT INTO projection_turns ( - thread_id, - turn_id, - pending_message_id, - assistant_message_id, - state, - requested_at, - started_at, - completed_at, - checkpoint_turn_count, - checkpoint_ref, - checkpoint_status, - checkpoint_files_json - ) - VALUES ( - ${row.threadId}, - ${row.turnId}, - NULL, - ${row.assistantMessageId}, - ${row.status === "error" ? "error" : "completed"}, - ${row.completedAt}, - ${row.completedAt}, - ${row.completedAt}, - ${row.checkpointTurnCount}, - ${row.checkpointRef}, - ${row.status}, - ${row.files} - ) - ON CONFLICT (thread_id, turn_id) - DO UPDATE SET - assistant_message_id = excluded.assistant_message_id, - state = excluded.state, - completed_at = excluded.completed_at, - checkpoint_turn_count = excluded.checkpoint_turn_count, - checkpoint_ref = excluded.checkpoint_ref, - checkpoint_status = excluded.checkpoint_status, - checkpoint_files_json = excluded.checkpoint_files_json - `, - }); - - const listProjectionCheckpointRows = SqlSchema.findAll({ - Request: ListByThreadIdInput, - Result: ProjectionCheckpointDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "status", - checkpoint_files_json AS "files", - assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" - FROM projection_turns - WHERE thread_id = ${threadId} - AND checkpoint_turn_count IS NOT NULL - ORDER BY checkpoint_turn_count ASC - `, - }); - - const getProjectionCheckpointRow = SqlSchema.findOneOption({ - Request: GetByThreadAndTurnCountInput, - Result: ProjectionCheckpointDbRowSchema, - execute: ({ threadId, checkpointTurnCount }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "status", - checkpoint_files_json AS "files", - assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" - FROM projection_turns - WHERE thread_id = ${threadId} - AND checkpoint_turn_count = ${checkpointTurnCount} - `, - }); - - const deleteProjectionCheckpointRows = SqlSchema.void({ - Request: DeleteByThreadIdInput, - execute: ({ threadId }) => - sql` - UPDATE projection_turns - SET - checkpoint_turn_count = NULL, - checkpoint_ref = NULL, - checkpoint_status = NULL, - checkpoint_files_json = '[]' - WHERE thread_id = ${threadId} - AND checkpoint_turn_count IS NOT NULL - `, - }); - - const upsertCheckpointRow = (row: Schema.Schema.Type) => - sql.withTransaction( - clearCheckpointConflict({ - threadId: row.threadId, - checkpointTurnCount: row.checkpointTurnCount, - }).pipe(Effect.flatMap(() => upsertProjectionCheckpointRow(row))), - ); - - const upsert: ProjectionCheckpointRepositoryShape["upsert"] = (row) => - upsertCheckpointRow(row).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionCheckpointRepository.upsert:query", - "ProjectionCheckpointRepository.upsert:encodeRequest", - ), - ), - ); - - const listByThreadId: ProjectionCheckpointRepositoryShape["listByThreadId"] = (input) => - listProjectionCheckpointRows(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionCheckpointRepository.listByThreadId:query", - "ProjectionCheckpointRepository.listByThreadId:decodeRows", - ), - ), - Effect.map((rows) => rows as ReadonlyArray>), - ); - - const getByThreadAndTurnCount: ProjectionCheckpointRepositoryShape["getByThreadAndTurnCount"] = ( - input, - ) => - getProjectionCheckpointRow(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionCheckpointRepository.getByThreadAndTurnCount:query", - "ProjectionCheckpointRepository.getByThreadAndTurnCount:decodeRow", - ), - ), - Effect.flatMap((rowOption) => - Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - Effect.succeed(Option.some(row as Schema.Schema.Type)), - }), - ), - ); - - const deleteByThreadId: ProjectionCheckpointRepositoryShape["deleteByThreadId"] = (input) => - deleteProjectionCheckpointRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionCheckpointRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - listByThreadId, - getByThreadAndTurnCount, - deleteByThreadId, - } satisfies ProjectionCheckpointRepositoryShape; -}); - -export const ProjectionCheckpointRepositoryLive = Layer.effect( - ProjectionCheckpointRepository, - makeProjectionCheckpointRepository, -); +/** @deprecated Compatibility alias for the excluded orchestration integration harness. */ +export { layer as ProjectionCheckpointRepositoryLive } from "../ProjectionCheckpoints.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts index 253f6e13b97..76be9684d47 100644 --- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts @@ -1,137 +1 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { toPersistenceSqlError } from "../Errors.ts"; -import { - GetProjectionPendingApprovalInput, - DeleteProjectionPendingApprovalInput, - ListProjectionPendingApprovalsInput, - ProjectionPendingApproval, - ProjectionPendingApprovalRepository, - type ProjectionPendingApprovalRepositoryShape, -} from "../Services/ProjectionPendingApprovals.ts"; - -const makeProjectionPendingApprovalRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionPendingApprovalRow = SqlSchema.void({ - Request: ProjectionPendingApproval, - execute: (row) => - sql` - INSERT INTO projection_pending_approvals ( - request_id, - thread_id, - turn_id, - status, - decision, - created_at, - resolved_at - ) - VALUES ( - ${row.requestId}, - ${row.threadId}, - ${row.turnId}, - ${row.status}, - ${row.decision}, - ${row.createdAt}, - ${row.resolvedAt} - ) - ON CONFLICT (request_id) - DO UPDATE SET - thread_id = excluded.thread_id, - turn_id = excluded.turn_id, - status = excluded.status, - decision = excluded.decision, - created_at = excluded.created_at, - resolved_at = excluded.resolved_at - `, - }); - - const listProjectionPendingApprovalRows = SqlSchema.findAll({ - Request: ListProjectionPendingApprovalsInput, - Result: ProjectionPendingApproval, - execute: ({ threadId }) => - sql` - SELECT - request_id AS "requestId", - thread_id AS "threadId", - turn_id AS "turnId", - status, - decision, - created_at AS "createdAt", - resolved_at AS "resolvedAt" - FROM projection_pending_approvals - WHERE thread_id = ${threadId} - ORDER BY created_at ASC, request_id ASC - `, - }); - - const getProjectionPendingApprovalRow = SqlSchema.findOneOption({ - Request: GetProjectionPendingApprovalInput, - Result: ProjectionPendingApproval, - execute: ({ requestId }) => - sql` - SELECT - request_id AS "requestId", - thread_id AS "threadId", - turn_id AS "turnId", - status, - decision, - created_at AS "createdAt", - resolved_at AS "resolvedAt" - FROM projection_pending_approvals - WHERE request_id = ${requestId} - `, - }); - - const deleteProjectionPendingApprovalRow = SqlSchema.void({ - Request: DeleteProjectionPendingApprovalInput, - execute: ({ requestId }) => - sql` - DELETE FROM projection_pending_approvals - WHERE request_id = ${requestId} - `, - }); - - const upsert: ProjectionPendingApprovalRepositoryShape["upsert"] = (row) => - upsertProjectionPendingApprovalRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")), - ); - - const listByThreadId: ProjectionPendingApprovalRepositoryShape["listByThreadId"] = (input) => - listProjectionPendingApprovalRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionPendingApprovalRepository.listByThreadId:query"), - ), - ); - - const getByRequestId: ProjectionPendingApprovalRepositoryShape["getByRequestId"] = (input) => - getProjectionPendingApprovalRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionPendingApprovalRepository.getByRequestId:query"), - ), - ); - - const deleteByRequestId: ProjectionPendingApprovalRepositoryShape["deleteByRequestId"] = ( - input, - ) => - deleteProjectionPendingApprovalRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"), - ), - ); - - return { - upsert, - listByThreadId, - getByRequestId, - deleteByRequestId, - } satisfies ProjectionPendingApprovalRepositoryShape; -}); - -export const ProjectionPendingApprovalRepositoryLive = Layer.effect( - ProjectionPendingApprovalRepository, - makeProjectionPendingApprovalRepository, -); +export { layer as ProjectionPendingApprovalRepositoryLive } from "../ProjectionPendingApprovals.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index c1ca6d3104e..de348f97543 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -1,143 +1 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; - -import { ModelSelection, ProjectScript } from "@t3tools/contracts"; -import { toPersistenceSqlError } from "../Errors.ts"; -import { - DeleteProjectionProjectInput, - GetProjectionProjectInput, - ProjectionProject, - ProjectionProjectRepository, - type ProjectionProjectRepositoryShape, -} from "../Services/ProjectionProjects.ts"; - -const ProjectionProjectDbRow = ProjectionProject.mapFields( - Struct.assign({ - defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), - scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), - }), -); -type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; - -const makeProjectionProjectRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionProjectRow = SqlSchema.void({ - Request: ProjectionProject, - execute: (row) => - sql` - INSERT INTO projection_projects ( - project_id, - title, - workspace_root, - default_model_selection_json, - scripts_json, - created_at, - updated_at, - deleted_at - ) - VALUES ( - ${row.projectId}, - ${row.title}, - ${row.workspaceRoot}, - ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, - ${JSON.stringify(row.scripts)}, - ${row.createdAt}, - ${row.updatedAt}, - ${row.deletedAt} - ) - ON CONFLICT (project_id) - DO UPDATE SET - title = excluded.title, - workspace_root = excluded.workspace_root, - default_model_selection_json = excluded.default_model_selection_json, - scripts_json = excluded.scripts_json, - created_at = excluded.created_at, - updated_at = excluded.updated_at, - deleted_at = excluded.deleted_at - `, - }); - - const getProjectionProjectRow = SqlSchema.findOneOption({ - Request: GetProjectionProjectInput, - Result: ProjectionProjectDbRow, - execute: ({ projectId }) => - sql` - SELECT - project_id AS "projectId", - title, - workspace_root AS "workspaceRoot", - default_model_selection_json AS "defaultModelSelection", - scripts_json AS "scripts", - created_at AS "createdAt", - updated_at AS "updatedAt", - deleted_at AS "deletedAt" - FROM projection_projects - WHERE project_id = ${projectId} - `, - }); - - const listProjectionProjectRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProjectionProjectDbRow, - execute: () => - sql` - SELECT - project_id AS "projectId", - title, - workspace_root AS "workspaceRoot", - default_model_selection_json AS "defaultModelSelection", - scripts_json AS "scripts", - created_at AS "createdAt", - updated_at AS "updatedAt", - deleted_at AS "deletedAt" - FROM projection_projects - ORDER BY created_at ASC, project_id ASC - `, - }); - - const deleteProjectionProjectRow = SqlSchema.void({ - Request: DeleteProjectionProjectInput, - execute: ({ projectId }) => - sql` - DELETE FROM projection_projects - WHERE project_id = ${projectId} - `, - }); - - const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) => - upsertProjectionProjectRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")), - ); - - const getById: ProjectionProjectRepositoryShape["getById"] = (input) => - getProjectionProjectRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), - ); - - const listAll: ProjectionProjectRepositoryShape["listAll"] = () => - listProjectionProjectRows().pipe( - Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), - ); - - const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) => - deleteProjectionProjectRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.deleteById:query")), - ); - - return { - upsert, - getById, - listAll, - deleteById, - } satisfies ProjectionProjectRepositoryShape; -}); - -export const ProjectionProjectRepositoryLive = Layer.effect( - ProjectionProjectRepository, - makeProjectionProjectRepository, -); +export { layer as ProjectionProjectRepositoryLive } from "../ProjectionProjects.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionState.ts b/apps/server/src/persistence/Layers/ProjectionState.ts index f630fe51e03..81d24e88ca8 100644 --- a/apps/server/src/persistence/Layers/ProjectionState.ts +++ b/apps/server/src/persistence/Layers/ProjectionState.ts @@ -1,118 +1 @@ -import { NonNegativeInt } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; - -import { toPersistenceSqlError } from "../Errors.ts"; - -import { - ProjectionStateRepository, - type ProjectionStateRepositoryShape, - GetProjectionStateInput, - ProjectionState, -} from "../Services/ProjectionState.ts"; - -const MinLastAppliedSequenceRowSchema = Schema.Struct({ - minLastAppliedSequence: Schema.NullOr(NonNegativeInt), -}); - -const makeProjectionStateRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionStateRow = SqlSchema.void({ - Request: ProjectionState, - execute: (row) => - sql` - INSERT INTO projection_state ( - projector, - last_applied_sequence, - updated_at - ) - VALUES ( - ${row.projector}, - ${row.lastAppliedSequence}, - ${row.updatedAt} - ) - ON CONFLICT (projector) - DO UPDATE SET - last_applied_sequence = excluded.last_applied_sequence, - updated_at = excluded.updated_at - `, - }); - - const getProjectionStateRow = SqlSchema.findOneOption({ - Request: GetProjectionStateInput, - Result: ProjectionState, - execute: ({ projector }) => - sql` - SELECT - projector, - last_applied_sequence AS "lastAppliedSequence", - updated_at AS "updatedAt" - FROM projection_state - WHERE projector = ${projector} - `, - }); - - const listProjectionStateRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProjectionState, - execute: () => - sql` - SELECT - projector, - last_applied_sequence AS "lastAppliedSequence", - updated_at AS "updatedAt" - FROM projection_state - ORDER BY projector ASC - `, - }); - - const readMinLastAppliedSequence = SqlSchema.findOne({ - Request: Schema.Void, - Result: MinLastAppliedSequenceRowSchema, - execute: () => - sql` - SELECT - MIN(last_applied_sequence) AS "minLastAppliedSequence" - FROM projection_state - `, - }); - - const upsert: ProjectionStateRepositoryShape["upsert"] = (row) => - upsertProjectionStateRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")), - ); - - const getByProjector: ProjectionStateRepositoryShape["getByProjector"] = (input) => - getProjectionStateRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.getByProjector:query")), - ); - - const listAll: ProjectionStateRepositoryShape["listAll"] = () => - listProjectionStateRows(undefined).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")), - ); - - const minLastAppliedSequence: ProjectionStateRepositoryShape["minLastAppliedSequence"] = () => - readMinLastAppliedSequence(undefined).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionStateRepository.minLastAppliedSequence:query"), - ), - Effect.map((row) => row.minLastAppliedSequence), - ); - - return { - upsert, - getByProjector, - listAll, - minLastAppliedSequence, - } satisfies ProjectionStateRepositoryShape; -}); - -export const ProjectionStateRepositoryLive = Layer.effect( - ProjectionStateRepository, - makeProjectionStateRepository, -); +export { layer as ProjectionStateRepositoryLive } from "../ProjectionState.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 2f4815f9654..df6e1d5ec73 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -1,159 +1 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { NonNegativeInt } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; - -import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; - -import { - DeleteProjectionThreadActivitiesInput, - ListProjectionThreadActivitiesInput, - ProjectionThreadActivity, - ProjectionThreadActivityRepository, - type ProjectionThreadActivityRepositoryShape, -} from "../Services/ProjectionThreadActivities.ts"; - -const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( - Struct.assign({ - payload: Schema.fromJsonString(Schema.Unknown), - sequence: Schema.NullOr(NonNegativeInt), - }), -); - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown) => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -const makeProjectionThreadActivityRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadActivityRow = SqlSchema.void({ - Request: ProjectionThreadActivity, - execute: (row) => - sql` - INSERT INTO projection_thread_activities ( - activity_id, - thread_id, - turn_id, - tone, - kind, - summary, - payload_json, - sequence, - created_at - ) - VALUES ( - ${row.activityId}, - ${row.threadId}, - ${row.turnId}, - ${row.tone}, - ${row.kind}, - ${row.summary}, - ${JSON.stringify(row.payload)}, - ${row.sequence ?? null}, - ${row.createdAt} - ) - ON CONFLICT (activity_id) - DO UPDATE SET - thread_id = excluded.thread_id, - turn_id = excluded.turn_id, - tone = excluded.tone, - kind = excluded.kind, - summary = excluded.summary, - payload_json = excluded.payload_json, - sequence = excluded.sequence, - created_at = excluded.created_at - `, - }); - - const listProjectionThreadActivityRows = SqlSchema.findAll({ - Request: ListProjectionThreadActivitiesInput, - Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - activity_id AS "activityId", - thread_id AS "threadId", - turn_id AS "turnId", - tone, - kind, - summary, - payload_json AS "payload", - sequence, - created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} - ORDER BY - CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, - sequence ASC, - created_at ASC, - activity_id ASC - `, - }); - - const deleteProjectionThreadActivityRows = SqlSchema.void({ - Request: DeleteProjectionThreadActivitiesInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_thread_activities - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadActivityRepositoryShape["upsert"] = (row) => - upsertProjectionThreadActivityRow(row).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionThreadActivityRepository.upsert:query", - "ProjectionThreadActivityRepository.upsert:encodeRequest", - ), - ), - ); - - const listByThreadId: ProjectionThreadActivityRepositoryShape["listByThreadId"] = (input) => - listProjectionThreadActivityRows(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionThreadActivityRepository.listByThreadId:query", - "ProjectionThreadActivityRepository.listByThreadId:decodeRows", - ), - ), - Effect.map((rows) => - rows.map((row) => ({ - activityId: row.activityId, - threadId: row.threadId, - turnId: row.turnId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - })), - ), - ); - - const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => - deleteProjectionThreadActivityRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadActivityRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - listByThreadId, - deleteByThreadId, - } satisfies ProjectionThreadActivityRepositoryShape; -}); - -export const ProjectionThreadActivityRepositoryLive = Layer.effect( - ProjectionThreadActivityRepository, - makeProjectionThreadActivityRepository, -); +export { layer as ProjectionThreadActivityRepositoryLive } from "../ProjectionThreadActivities.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 71919166886..e0f41326067 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -1,188 +1 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; -import { ChatAttachment } from "@t3tools/contracts"; - -import { toPersistenceSqlError } from "../Errors.ts"; -import { - GetProjectionThreadMessageInput, - ProjectionThreadMessageRepository, - type ProjectionThreadMessageRepositoryShape, - DeleteProjectionThreadMessagesInput, - ListProjectionThreadMessagesInput, - ProjectionThreadMessage, -} from "../Services/ProjectionThreadMessages.ts"; - -const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( - Struct.assign({ - isStreaming: Schema.Number, - attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), - }), -); - -function toProjectionThreadMessage( - row: Schema.Schema.Type, -): ProjectionThreadMessage { - return { - messageId: row.messageId, - threadId: row.threadId, - turnId: row.turnId, - role: row.role, - text: row.text, - isStreaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - ...(row.attachments !== null ? { attachments: row.attachments } : {}), - }; -} - -const makeProjectionThreadMessageRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadMessageRow = SqlSchema.void({ - Request: ProjectionThreadMessage, - execute: (row) => { - const nextAttachmentsJson = - row.attachments !== undefined ? JSON.stringify(row.attachments) : null; - return sql` - INSERT INTO projection_thread_messages ( - message_id, - thread_id, - turn_id, - role, - text, - attachments_json, - is_streaming, - created_at, - updated_at - ) - VALUES ( - ${row.messageId}, - ${row.threadId}, - ${row.turnId}, - ${row.role}, - ${row.text}, - COALESCE( - ${nextAttachmentsJson}, - ( - SELECT attachments_json - FROM projection_thread_messages - WHERE message_id = ${row.messageId} - ) - ), - ${row.isStreaming ? 1 : 0}, - ${row.createdAt}, - ${row.updatedAt} - ) - ON CONFLICT (message_id) - DO UPDATE SET - thread_id = excluded.thread_id, - turn_id = excluded.turn_id, - role = excluded.role, - text = excluded.text, - attachments_json = COALESCE( - excluded.attachments_json, - projection_thread_messages.attachments_json - ), - is_streaming = excluded.is_streaming, - created_at = excluded.created_at, - updated_at = excluded.updated_at - `; - }, - }); - - const getProjectionThreadMessageRow = SqlSchema.findOneOption({ - Request: GetProjectionThreadMessageInput, - Result: ProjectionThreadMessageDbRowSchema, - execute: ({ messageId }) => - sql` - SELECT - message_id AS "messageId", - thread_id AS "threadId", - turn_id AS "turnId", - role, - text, - attachments_json AS "attachments", - is_streaming AS "isStreaming", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projection_thread_messages - WHERE message_id = ${messageId} - LIMIT 1 - `, - }); - - const listProjectionThreadMessageRows = SqlSchema.findAll({ - Request: ListProjectionThreadMessagesInput, - Result: ProjectionThreadMessageDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - message_id AS "messageId", - thread_id AS "threadId", - turn_id AS "turnId", - role, - text, - attachments_json AS "attachments", - is_streaming AS "isStreaming", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projection_thread_messages - WHERE thread_id = ${threadId} - ORDER BY created_at ASC, message_id ASC - `, - }); - - const deleteProjectionThreadMessageRows = SqlSchema.void({ - Request: DeleteProjectionThreadMessagesInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_thread_messages - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadMessageRepositoryShape["upsert"] = (row) => - upsertProjectionThreadMessageRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), - ); - - const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => - getProjectionThreadMessageRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadMessageRepository.getByMessageId:query"), - ), - Effect.map(Option.map(toProjectionThreadMessage)), - ); - - const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => - listProjectionThreadMessageRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"), - ), - Effect.map((rows) => rows.map(toProjectionThreadMessage)), - ); - - const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => - deleteProjectionThreadMessageRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - getByMessageId, - listByThreadId, - deleteByThreadId, - } satisfies ProjectionThreadMessageRepositoryShape; -}); - -export const ProjectionThreadMessageRepositoryLive = Layer.effect( - ProjectionThreadMessageRepository, - makeProjectionThreadMessageRepository, -); +export { layer as ProjectionThreadMessageRepositoryLive } from "../ProjectionThreadMessages.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 63aed1a1670..47ad093546c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -1,111 +1 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { toPersistenceSqlError } from "../Errors.ts"; -import { - DeleteProjectionThreadProposedPlansInput, - ListProjectionThreadProposedPlansInput, - ProjectionThreadProposedPlan, - ProjectionThreadProposedPlanRepository, - type ProjectionThreadProposedPlanRepositoryShape, -} from "../Services/ProjectionThreadProposedPlans.ts"; - -const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadProposedPlanRow = SqlSchema.void({ - Request: ProjectionThreadProposedPlan, - execute: (row) => sql` - INSERT INTO projection_thread_proposed_plans ( - plan_id, - thread_id, - turn_id, - plan_markdown, - implemented_at, - implementation_thread_id, - created_at, - updated_at - ) - VALUES ( - ${row.planId}, - ${row.threadId}, - ${row.turnId}, - ${row.planMarkdown}, - ${row.implementedAt}, - ${row.implementationThreadId}, - ${row.createdAt}, - ${row.updatedAt} - ) - ON CONFLICT (plan_id) - DO UPDATE SET - thread_id = excluded.thread_id, - turn_id = excluded.turn_id, - plan_markdown = excluded.plan_markdown, - implemented_at = excluded.implemented_at, - implementation_thread_id = excluded.implementation_thread_id, - created_at = excluded.created_at, - updated_at = excluded.updated_at - `, - }); - - const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ - Request: ListProjectionThreadProposedPlansInput, - Result: ProjectionThreadProposedPlan, - execute: ({ threadId }) => sql` - SELECT - plan_id AS "planId", - thread_id AS "threadId", - turn_id AS "turnId", - plan_markdown AS "planMarkdown", - implemented_at AS "implementedAt", - implementation_thread_id AS "implementationThreadId", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projection_thread_proposed_plans - WHERE thread_id = ${threadId} - ORDER BY created_at ASC, plan_id ASC - `, - }); - - const deleteProjectionThreadProposedPlanRows = SqlSchema.void({ - Request: DeleteProjectionThreadProposedPlansInput, - execute: ({ threadId }) => sql` - DELETE FROM projection_thread_proposed_plans - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => - upsertProjectionThreadProposedPlanRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), - ); - - const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => - listProjectionThreadProposedPlanRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadProposedPlanRepository.listByThreadId:query"), - ), - ); - - const deleteByThreadId: ProjectionThreadProposedPlanRepositoryShape["deleteByThreadId"] = ( - input, - ) => - deleteProjectionThreadProposedPlanRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadProposedPlanRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - listByThreadId, - deleteByThreadId, - } satisfies ProjectionThreadProposedPlanRepositoryShape; -}); - -export const ProjectionThreadProposedPlanRepositoryLive = Layer.effect( - ProjectionThreadProposedPlanRepository, - makeProjectionThreadProposedPlanRepository, -); +export { layer as ProjectionThreadProposedPlanRepositoryLive } from "../ProjectionThreadProposedPlans.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index dcb750983a0..b330c87ee9d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -1,112 +1 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { toPersistenceSqlError } from "../Errors.ts"; - -import { - ProjectionThreadSession, - ProjectionThreadSessionRepository, - type ProjectionThreadSessionRepositoryShape, - DeleteProjectionThreadSessionInput, - GetProjectionThreadSessionInput, -} from "../Services/ProjectionThreadSessions.ts"; - -const makeProjectionThreadSessionRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadSessionRow = SqlSchema.void({ - Request: ProjectionThreadSession, - execute: (row) => - sql` - INSERT INTO projection_thread_sessions ( - thread_id, - status, - provider_name, - provider_instance_id, - runtime_mode, - active_turn_id, - last_error, - updated_at - ) - VALUES ( - ${row.threadId}, - ${row.status}, - ${row.providerName}, - ${row.providerInstanceId}, - ${row.runtimeMode}, - ${row.activeTurnId}, - ${row.lastError}, - ${row.updatedAt} - ) - ON CONFLICT (thread_id) - DO UPDATE SET - status = excluded.status, - provider_name = excluded.provider_name, - provider_instance_id = excluded.provider_instance_id, - runtime_mode = excluded.runtime_mode, - active_turn_id = excluded.active_turn_id, - last_error = excluded.last_error, - updated_at = excluded.updated_at - `, - }); - - const getProjectionThreadSessionRow = SqlSchema.findOneOption({ - Request: GetProjectionThreadSessionInput, - Result: ProjectionThreadSession, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - status, - provider_name AS "providerName", - provider_instance_id AS "providerInstanceId", - runtime_mode AS "runtimeMode", - active_turn_id AS "activeTurnId", - last_error AS "lastError", - updated_at AS "updatedAt" - FROM projection_thread_sessions - WHERE thread_id = ${threadId} - `, - }); - - const deleteProjectionThreadSessionRow = SqlSchema.void({ - Request: DeleteProjectionThreadSessionInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_thread_sessions - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadSessionRepositoryShape["upsert"] = (row) => - upsertProjectionThreadSessionRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadSessionRepository.upsert:query")), - ); - - const getByThreadId: ProjectionThreadSessionRepositoryShape["getByThreadId"] = (input) => - getProjectionThreadSessionRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadSessionRepository.getByThreadId:query"), - ), - ); - - const deleteByThreadId: ProjectionThreadSessionRepositoryShape["deleteByThreadId"] = (input) => - deleteProjectionThreadSessionRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadSessionRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - getByThreadId, - deleteByThreadId, - } satisfies ProjectionThreadSessionRepositoryShape; -}); - -export const ProjectionThreadSessionRepositoryLive = Layer.effect( - ProjectionThreadSessionRepository, - makeProjectionThreadSessionRepository, -); +export { layer as ProjectionThreadSessionRepositoryLive } from "../ProjectionThreadSessions.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..da850b52856 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,189 +1 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; - -import { toPersistenceSqlError } from "../Errors.ts"; -import { - DeleteProjectionThreadInput, - GetProjectionThreadInput, - ListProjectionThreadsByProjectInput, - ProjectionThread, - ProjectionThreadRepository, - type ProjectionThreadRepositoryShape, -} from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; - -const ProjectionThreadDbRow = ProjectionThread.mapFields( - Struct.assign({ - modelSelection: Schema.fromJsonString(ModelSelection), - }), -); -type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; - -const makeProjectionThreadRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadRow = SqlSchema.void({ - Request: ProjectionThread, - execute: (row) => - sql` - INSERT INTO projection_threads ( - thread_id, - project_id, - title, - model_selection_json, - runtime_mode, - interaction_mode, - branch, - worktree_path, - latest_turn_id, - created_at, - updated_at, - archived_at, - latest_user_message_at, - pending_approval_count, - pending_user_input_count, - has_actionable_proposed_plan, - deleted_at - ) - VALUES ( - ${row.threadId}, - ${row.projectId}, - ${row.title}, - ${JSON.stringify(row.modelSelection)}, - ${row.runtimeMode}, - ${row.interactionMode}, - ${row.branch}, - ${row.worktreePath}, - ${row.latestTurnId}, - ${row.createdAt}, - ${row.updatedAt}, - ${row.archivedAt}, - ${row.latestUserMessageAt}, - ${row.pendingApprovalCount}, - ${row.pendingUserInputCount}, - ${row.hasActionableProposedPlan}, - ${row.deletedAt} - ) - ON CONFLICT (thread_id) - DO UPDATE SET - project_id = excluded.project_id, - title = excluded.title, - model_selection_json = excluded.model_selection_json, - runtime_mode = excluded.runtime_mode, - interaction_mode = excluded.interaction_mode, - branch = excluded.branch, - worktree_path = excluded.worktree_path, - latest_turn_id = excluded.latest_turn_id, - created_at = excluded.created_at, - updated_at = excluded.updated_at, - archived_at = excluded.archived_at, - latest_user_message_at = excluded.latest_user_message_at, - pending_approval_count = excluded.pending_approval_count, - pending_user_input_count = excluded.pending_user_input_count, - has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, - deleted_at = excluded.deleted_at - `, - }); - - const getProjectionThreadRow = SqlSchema.findOneOption({ - Request: GetProjectionThreadInput, - Result: ProjectionThreadDbRow, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE thread_id = ${threadId} - `, - }); - - const listProjectionThreadRows = SqlSchema.findAll({ - Request: ListProjectionThreadsByProjectInput, - Result: ProjectionThreadDbRow, - execute: ({ projectId }) => - sql` - SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE project_id = ${projectId} - ORDER BY created_at ASC, thread_id ASC - `, - }); - - const deleteProjectionThreadRow = SqlSchema.void({ - Request: DeleteProjectionThreadInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_threads - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) => - upsertProjectionThreadRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), - ); - - const getById: ProjectionThreadRepositoryShape["getById"] = (input) => - getProjectionThreadRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), - ); - - const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => - listProjectionThreadRows(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), - ); - - const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => - deleteProjectionThreadRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), - ); - - return { - upsert, - getById, - listByProjectId, - deleteById, - } satisfies ProjectionThreadRepositoryShape; -}); - -export const ProjectionThreadRepositoryLive = Layer.effect( - ProjectionThreadRepository, - makeProjectionThreadRepository, -); +export { layer as ProjectionThreadRepositoryLive } from "../ProjectionThreads.ts"; diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index bd57a4eaa30..cead69bea8c 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -1,355 +1 @@ -import { OrchestrationCheckpointFile } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; - -import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; -import { - ClearCheckpointTurnConflictInput, - DeleteProjectionTurnsByThreadInput, - GetProjectionPendingTurnStartInput, - GetProjectionTurnByTurnIdInput, - ListProjectionTurnsByThreadInput, - ProjectionPendingTurnStart, - ProjectionTurn, - ProjectionTurnById, - ProjectionTurnRepository, - type ProjectionTurnRepositoryShape, -} from "../Services/ProjectionTurns.ts"; - -const ProjectionTurnDbRowSchema = ProjectionTurn.mapFields( - Struct.assign({ - checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), - }), -); - -const ProjectionTurnByIdDbRowSchema = ProjectionTurnById.mapFields( - Struct.assign({ - checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), - }), -); - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown) => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -const makeProjectionTurnRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionTurnById = SqlSchema.void({ - Request: ProjectionTurnByIdDbRowSchema, - execute: (row) => - sql` - INSERT INTO projection_turns ( - thread_id, - turn_id, - pending_message_id, - source_proposed_plan_thread_id, - source_proposed_plan_id, - assistant_message_id, - state, - requested_at, - started_at, - completed_at, - checkpoint_turn_count, - checkpoint_ref, - checkpoint_status, - checkpoint_files_json - ) - VALUES ( - ${row.threadId}, - ${row.turnId}, - ${row.pendingMessageId}, - ${row.sourceProposedPlanThreadId}, - ${row.sourceProposedPlanId}, - ${row.assistantMessageId}, - ${row.state}, - ${row.requestedAt}, - ${row.startedAt}, - ${row.completedAt}, - ${row.checkpointTurnCount}, - ${row.checkpointRef}, - ${row.checkpointStatus}, - ${row.checkpointFiles} - ) - ON CONFLICT (thread_id, turn_id) - DO UPDATE SET - pending_message_id = excluded.pending_message_id, - source_proposed_plan_thread_id = excluded.source_proposed_plan_thread_id, - source_proposed_plan_id = excluded.source_proposed_plan_id, - assistant_message_id = excluded.assistant_message_id, - state = excluded.state, - requested_at = excluded.requested_at, - started_at = excluded.started_at, - completed_at = excluded.completed_at, - checkpoint_turn_count = excluded.checkpoint_turn_count, - checkpoint_ref = excluded.checkpoint_ref, - checkpoint_status = excluded.checkpoint_status, - checkpoint_files_json = excluded.checkpoint_files_json - `, - }); - - const clearPendingProjectionTurnsByThread = SqlSchema.void({ - Request: DeleteProjectionTurnsByThreadInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id IS NULL - AND state = 'pending' - AND checkpoint_turn_count IS NULL - `, - }); - - const insertPendingProjectionTurn = SqlSchema.void({ - Request: ProjectionPendingTurnStart, - execute: (row) => - sql` - INSERT INTO projection_turns ( - thread_id, - turn_id, - pending_message_id, - source_proposed_plan_thread_id, - source_proposed_plan_id, - assistant_message_id, - state, - requested_at, - started_at, - completed_at, - checkpoint_turn_count, - checkpoint_ref, - checkpoint_status, - checkpoint_files_json - ) - VALUES ( - ${row.threadId}, - NULL, - ${row.messageId}, - ${row.sourceProposedPlanThreadId}, - ${row.sourceProposedPlanId}, - NULL, - 'pending', - ${row.requestedAt}, - NULL, - NULL, - NULL, - NULL, - NULL, - '[]' - ) - `, - }); - - const getPendingProjectionTurn = SqlSchema.findOneOption({ - Request: GetProjectionPendingTurnStartInput, - Result: ProjectionPendingTurnStart, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - pending_message_id AS "messageId", - source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", - source_proposed_plan_id AS "sourceProposedPlanId", - requested_at AS "requestedAt" - FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id IS NULL - AND state = 'pending' - AND pending_message_id IS NOT NULL - AND checkpoint_turn_count IS NULL - ORDER BY requested_at DESC - LIMIT 1 - `, - }); - - const listProjectionTurnsByThread = SqlSchema.findAll({ - Request: ListProjectionTurnsByThreadInput, - Result: ProjectionTurnDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - pending_message_id AS "pendingMessageId", - source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", - source_proposed_plan_id AS "sourceProposedPlanId", - assistant_message_id AS "assistantMessageId", - state, - requested_at AS "requestedAt", - started_at AS "startedAt", - completed_at AS "completedAt", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "checkpointStatus", - checkpoint_files_json AS "checkpointFiles" - FROM projection_turns - WHERE thread_id = ${threadId} - ORDER BY - CASE - WHEN checkpoint_turn_count IS NULL THEN 1 - ELSE 0 - END ASC, - checkpoint_turn_count ASC, - requested_at ASC, - turn_id ASC - `, - }); - - const getProjectionTurnByTurnId = SqlSchema.findOneOption({ - Request: GetProjectionTurnByTurnIdInput, - Result: ProjectionTurnByIdDbRowSchema, - execute: ({ threadId, turnId }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - pending_message_id AS "pendingMessageId", - source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", - source_proposed_plan_id AS "sourceProposedPlanId", - assistant_message_id AS "assistantMessageId", - state, - requested_at AS "requestedAt", - started_at AS "startedAt", - completed_at AS "completedAt", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "checkpointStatus", - checkpoint_files_json AS "checkpointFiles" - FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id = ${turnId} - LIMIT 1 - `, - }); - - const clearCheckpointTurnConflictRow = SqlSchema.void({ - Request: ClearCheckpointTurnConflictInput, - execute: ({ threadId, turnId, checkpointTurnCount }) => - sql` - UPDATE projection_turns - SET - checkpoint_turn_count = NULL, - checkpoint_ref = NULL, - checkpoint_status = NULL, - checkpoint_files_json = '[]' - WHERE thread_id = ${threadId} - AND checkpoint_turn_count = ${checkpointTurnCount} - AND (turn_id IS NULL OR turn_id <> ${turnId}) - `, - }); - - const deleteProjectionTurnsByThread = SqlSchema.void({ - Request: DeleteProjectionTurnsByThreadInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_turns - WHERE thread_id = ${threadId} - `, - }); - - const upsertByTurnId: ProjectionTurnRepositoryShape["upsertByTurnId"] = (row) => - upsertProjectionTurnById(row).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionTurnRepository.upsertByTurnId:query", - "ProjectionTurnRepository.upsertByTurnId:encodeRequest", - ), - ), - ); - - const replacePendingTurnStart: ProjectionTurnRepositoryShape["replacePendingTurnStart"] = (row) => - sql - .withTransaction( - clearPendingProjectionTurnsByThread({ threadId: row.threadId }).pipe( - Effect.flatMap(() => insertPendingProjectionTurn(row)), - ), - ) - .pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionTurnRepository.replacePendingTurnStart:query", - "ProjectionTurnRepository.replacePendingTurnStart:encodeRequest", - ), - ), - ); - - const getPendingTurnStartByThreadId: ProjectionTurnRepositoryShape["getPendingTurnStartByThreadId"] = - (input) => - getPendingProjectionTurn(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionTurnRepository.getPendingTurnStartByThreadId:query"), - ), - ); - - const deletePendingTurnStartByThreadId: ProjectionTurnRepositoryShape["deletePendingTurnStartByThreadId"] = - (input) => - clearPendingProjectionTurnsByThread(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionTurnRepository.deletePendingTurnStartByThreadId:query"), - ), - ); - - const listByThreadId: ProjectionTurnRepositoryShape["listByThreadId"] = (input) => - listProjectionTurnsByThread(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionTurnRepository.listByThreadId:query", - "ProjectionTurnRepository.listByThreadId:decodeRows", - ), - ), - Effect.map((rows) => rows as ReadonlyArray>), - ); - - const getByTurnId: ProjectionTurnRepositoryShape["getByTurnId"] = (input) => - getProjectionTurnByTurnId(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionTurnRepository.getByTurnId:query", - "ProjectionTurnRepository.getByTurnId:decodeRow", - ), - ), - Effect.flatMap((rowOption) => - Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - Effect.succeed(Option.some(row as Schema.Schema.Type)), - }), - ), - ); - - const clearCheckpointTurnConflict: ProjectionTurnRepositoryShape["clearCheckpointTurnConflict"] = - (input) => - clearCheckpointTurnConflictRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionTurnRepository.clearCheckpointTurnConflict:query"), - ), - ); - - const deleteByThreadId: ProjectionTurnRepositoryShape["deleteByThreadId"] = (input) => - deleteProjectionTurnsByThread(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionTurnRepository.deleteByThreadId:query")), - ); - - return { - upsertByTurnId, - replacePendingTurnStart, - getPendingTurnStartByThreadId, - deletePendingTurnStartByThreadId, - listByThreadId, - getByTurnId, - clearCheckpointTurnConflict, - deleteByThreadId, - } satisfies ProjectionTurnRepositoryShape; -}); - -export const ProjectionTurnRepositoryLive = Layer.effect( - ProjectionTurnRepository, - makeProjectionTurnRepository, -); +export { layer as ProjectionTurnRepositoryLive } from "../ProjectionTurns.ts"; diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 9ee5c82bb53..52e4f8f7408 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -1,208 +1,2 @@ -import { ThreadId } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; - -import { - toPersistenceDecodeError, - toPersistenceSqlError, - type ProviderSessionRuntimeRepositoryError, -} from "../Errors.ts"; -import { - ProviderSessionRuntime, - ProviderSessionRuntimeRepository, - type ProviderSessionRuntimeRepositoryShape, -} from "../Services/ProviderSessionRuntime.ts"; - -const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( - Struct.assign({ - resumeCursor: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - runtimePayload: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), - }), -); - -const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); - -const GetRuntimeRequestSchema = Schema.Struct({ - threadId: ThreadId, -}); - -const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown): ProviderSessionRuntimeRepositoryError => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -const makeProviderSessionRuntimeRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertRuntimeRow = SqlSchema.void({ - Request: ProviderSessionRuntimeDbRowSchema, - execute: (runtime) => - sql` - INSERT INTO provider_session_runtime ( - thread_id, - provider_name, - provider_instance_id, - adapter_key, - runtime_mode, - status, - last_seen_at, - resume_cursor_json, - runtime_payload_json - ) - VALUES ( - ${runtime.threadId}, - ${runtime.providerName}, - ${runtime.providerInstanceId}, - ${runtime.adapterKey}, - ${runtime.runtimeMode}, - ${runtime.status}, - ${runtime.lastSeenAt}, - ${runtime.resumeCursor}, - ${runtime.runtimePayload} - ) - ON CONFLICT (thread_id) - DO UPDATE SET - provider_name = excluded.provider_name, - provider_instance_id = excluded.provider_instance_id, - adapter_key = excluded.adapter_key, - runtime_mode = excluded.runtime_mode, - status = excluded.status, - last_seen_at = excluded.last_seen_at, - resume_cursor_json = excluded.resume_cursor_json, - runtime_payload_json = excluded.runtime_payload_json - `, - }); - - const getRuntimeRowByThreadId = SqlSchema.findOneOption({ - Request: GetRuntimeRequestSchema, - Result: ProviderSessionRuntimeDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - provider_name AS "providerName", - provider_instance_id AS "providerInstanceId", - adapter_key AS "adapterKey", - runtime_mode AS "runtimeMode", - status, - last_seen_at AS "lastSeenAt", - resume_cursor_json AS "resumeCursor", - runtime_payload_json AS "runtimePayload" - FROM provider_session_runtime - WHERE thread_id = ${threadId} - `, - }); - - const listRuntimeRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProviderSessionRuntimeDbRowSchema, - execute: () => - sql` - SELECT - thread_id AS "threadId", - provider_name AS "providerName", - provider_instance_id AS "providerInstanceId", - adapter_key AS "adapterKey", - runtime_mode AS "runtimeMode", - status, - last_seen_at AS "lastSeenAt", - resume_cursor_json AS "resumeCursor", - runtime_payload_json AS "runtimePayload" - FROM provider_session_runtime - ORDER BY last_seen_at ASC, thread_id ASC - `, - }); - - const deleteRuntimeByThreadId = SqlSchema.void({ - Request: DeleteRuntimeRequestSchema, - execute: ({ threadId }) => - sql` - DELETE FROM provider_session_runtime - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProviderSessionRuntimeRepositoryShape["upsert"] = (runtime) => - upsertRuntimeRow(runtime).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.upsert:query", - "ProviderSessionRuntimeRepository.upsert:encodeRequest", - ), - ), - ); - - const getByThreadId: ProviderSessionRuntimeRepositoryShape["getByThreadId"] = (input) => - getRuntimeRowByThreadId(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.getByThreadId:query", - "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", - ), - ), - Effect.flatMap((runtimeRowOption) => - Option.match(runtimeRowOption, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", - ), - ), - Effect.map((runtime) => Option.some(runtime)), - ), - }), - ), - ); - - const list: ProviderSessionRuntimeRepositoryShape["list"] = () => - listRuntimeRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProviderSessionRuntimeRepository.list:query", - "ProviderSessionRuntimeRepository.list:decodeRows", - ), - ), - Effect.flatMap((rows) => - Effect.forEach( - rows, - (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), - ), - ), - { concurrency: "unbounded" }, - ), - ), - ); - - const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) => - deleteRuntimeByThreadId(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - getByThreadId, - list, - deleteByThreadId, - } satisfies ProviderSessionRuntimeRepositoryShape; -}); - -export const ProviderSessionRuntimeRepositoryLive = Layer.effect( - ProviderSessionRuntimeRepository, - makeProviderSessionRuntimeRepository, -); +/** @deprecated Compatibility alias for the excluded orchestration integration harness. */ +export { layer as ProviderSessionRuntimeRepositoryLive } from "../ProviderSessionRuntime.ts"; diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 6b91b5bd07b..fd49edf0529 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -29,11 +29,6 @@ export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; export type TypeId = "~local/sqlite-node/SqliteClient"; -/** - * SqliteClient - Effect service tag for the sqlite SQL client. - */ -export const SqliteClient = Context.Service("t3/persistence/NodeSqliteClient"); - export interface SqliteClientConfig { readonly filename: string; readonly readonly?: boolean | undefined; @@ -251,25 +246,12 @@ const makeMemory = ( export const layerConfig = ( config: Config.Wrap, ): Layer.Layer => - Layer.effectContext( - Config.unwrap(config).pipe( - Effect.flatMap(make), - Effect.map((client) => - Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), - ), - ), - ).pipe(Layer.provide(Reactivity.layer)); + Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe( + Layer.provide(Reactivity.layer), + ); export const layer = (config: SqliteClientConfig): Layer.Layer => - Layer.effectContext( - Effect.map(make(config), (client) => - Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), - ), - ).pipe(Layer.provide(Reactivity.layer)); + Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer)); export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer => - Layer.effectContext( - Effect.map(makeMemory(config), (client) => - Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)), - ), - ).pipe(Layer.provide(Reactivity.layer)); + Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer)); diff --git a/apps/server/src/persistence/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/OrchestrationCommandReceipts.ts new file mode 100644 index 00000000000..c738345de95 --- /dev/null +++ b/apps/server/src/persistence/OrchestrationCommandReceipts.ts @@ -0,0 +1,150 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + CommandId, + IsoDateTime, + NonNegativeInt, + OrchestrationAggregateKind, + OrchestrationCommandReceiptStatus, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; + +import { + type OrchestrationCommandReceiptRepositoryError, + toPersistenceSqlError, +} from "./Errors.ts"; + +/** + * OrchestrationCommandReceiptRepository - Repository interface for command receipts. + * + * Owns persistence operations for deduplication and status tracking of + * orchestration command handling. + * + * @module OrchestrationCommandReceiptRepository + */ + +export const OrchestrationCommandReceipt = Schema.Struct({ + commandId: CommandId, + aggregateKind: OrchestrationAggregateKind, + aggregateId: Schema.Union([ProjectId, ThreadId]), + acceptedAt: IsoDateTime, + resultSequence: NonNegativeInt, + status: OrchestrationCommandReceiptStatus, + error: Schema.NullOr(Schema.String), +}); +export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; + +export const GetByCommandIdInput = Schema.Struct({ + commandId: CommandId, +}); +export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; + +/** + * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. + */ +export class OrchestrationCommandReceiptRepository extends Context.Service< + OrchestrationCommandReceiptRepository, + { + /** + * Insert or replace a command receipt row. + * + * Upserts by `commandId` for idempotent command-result tracking. + */ + readonly upsert: ( + receipt: OrchestrationCommandReceipt, + ) => Effect.Effect; + + /** + * Read a command receipt by command id. + */ + readonly getByCommandId: ( + input: GetByCommandIdInput, + ) => Effect.Effect< + Option.Option, + OrchestrationCommandReceiptRepositoryError + >; + } +>()("t3/persistence/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertReceiptRow = SqlSchema.void({ + Request: OrchestrationCommandReceipt, + execute: (receipt) => + sql` + INSERT INTO orchestration_command_receipts ( + command_id, + aggregate_kind, + aggregate_id, + accepted_at, + result_sequence, + status, + error + ) + VALUES ( + ${receipt.commandId}, + ${receipt.aggregateKind}, + ${receipt.aggregateId}, + ${receipt.acceptedAt}, + ${receipt.resultSequence}, + ${receipt.status}, + ${receipt.error} + ) + ON CONFLICT (command_id) + DO UPDATE SET + aggregate_kind = excluded.aggregate_kind, + aggregate_id = excluded.aggregate_id, + accepted_at = excluded.accepted_at, + result_sequence = excluded.result_sequence, + status = excluded.status, + error = excluded.error + `, + }); + + const findReceiptByCommandId = SqlSchema.findOneOption({ + Request: GetByCommandIdInput, + Result: OrchestrationCommandReceipt, + execute: ({ commandId }) => + sql` + SELECT + command_id AS "commandId", + aggregate_kind AS "aggregateKind", + aggregate_id AS "aggregateId", + accepted_at AS "acceptedAt", + result_sequence AS "resultSequence", + status, + error + FROM orchestration_command_receipts + WHERE command_id = ${commandId} + `, + }); + + const upsert: OrchestrationCommandReceiptRepository["Service"]["upsert"] = (receipt) => + upsertReceiptRow(receipt).pipe( + Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")), + ); + + const getByCommandId: OrchestrationCommandReceiptRepository["Service"]["getByCommandId"] = ( + input, + ) => + findReceiptByCommandId(input).pipe( + Effect.mapError( + toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), + ), + ); + + return { + upsert, + getByCommandId, + } satisfies OrchestrationCommandReceiptRepository["Service"]; +}); + +export const layer = Layer.effect(OrchestrationCommandReceiptRepository, make); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/OrchestrationEventStore.test.ts similarity index 88% rename from apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts rename to apps/server/src/persistence/OrchestrationEventStore.test.ts index 2bac5de920c..21c18e0e716 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.test.ts @@ -6,20 +6,19 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { PersistenceDecodeError } from "../Errors.ts"; -import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts"; -import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; -import { SqlitePersistenceMemory } from "./Sqlite.ts"; +import { PersistenceDecodeError } from "./Errors.ts"; +import * as OrchestrationEventStore from "./OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); const layer = it.layer( - OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + OrchestrationEventStore.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ); layer("OrchestrationEventStore", (it) => { it.effect("stores json columns as strings and replays decoded events", () => Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore; + const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; const now = "2026-01-01T00:00:00.000Z"; @@ -71,7 +70,7 @@ layer("OrchestrationEventStore", (it) => { it.effect("fails with PersistenceDecodeError when stored json is invalid", () => Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore; + const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts new file mode 100644 index 00000000000..abc3c3c5451 --- /dev/null +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -0,0 +1,329 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + CommandId, + EventId, + IsoDateTime, + NonNegativeInt, + OrchestrationActorKind, + OrchestrationAggregateKind, + OrchestrationEvent, + OrchestrationEventMetadata, + OrchestrationEventType, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; + +import { + type OrchestrationEventStoreError, + toPersistenceDecodeError, + toPersistenceSqlError, +} from "./Errors.ts"; + +/** + * OrchestrationEventStore - Event store interface for orchestration events. + * + * Owns durable append/replay access to the orchestration event stream. It does + * not reduce events into read models or apply command validation rules. + * + * Uses Effect `Context.Service` for dependency injection and exposes typed + * persistence/decode errors for event append and replay operations. + * + * @module OrchestrationEventStore + */ + +/** + * OrchestrationEventStore - Service tag for orchestration event persistence. + * + * @example + * ```ts + * const program = Effect.gen(function* () { + * const events = yield* OrchestrationEventStore + * return yield* Stream.runCollect(events.readAll()) + * }) + * ``` + */ +export class OrchestrationEventStore extends Context.Service< + OrchestrationEventStore, + { + /** + * Persist a new orchestration event. + * + * @param event - Event payload without sequence (assigned by storage). + * @returns Effect containing the stored event with assigned sequence. + * + * Actor kind is inferred from command/metadata before persistence. + */ + readonly append: ( + event: Omit, + ) => Effect.Effect; + + /** + * Replay events after the provided sequence. + * + * @param sequenceExclusive - Sequence cursor (exclusive). + * @param limit - Maximum number of events to emit. + * @returns Stream containing ordered events. + * + * Reads in fixed-size pages and normalizes non-integer/negative limits. + */ + readonly readFromSequence: ( + sequenceExclusive: number, + limit?: number, + ) => Stream.Stream; + + /** + * Read all events from the beginning of the stream. + * + * @returns Stream containing all stored events. + */ + readonly readAll: () => Stream.Stream; + } +>()("t3/persistence/OrchestrationEventStore") {} + +const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); + +const AppendEventRequestSchema = Schema.Struct({ + eventId: EventId, + aggregateKind: OrchestrationAggregateKind, + streamId: Schema.Union([ProjectId, ThreadId]), + type: OrchestrationEventType, + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + actorKind: OrchestrationActorKind, + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + payloadJson: UnknownFromJsonString, + metadataJson: EventMetadataFromJsonString, +}); + +const OrchestrationEventPersistedRowSchema = Schema.Struct({ + sequence: NonNegativeInt, + eventId: EventId, + type: OrchestrationEventType, + aggregateKind: OrchestrationAggregateKind, + aggregateId: Schema.Union([ProjectId, ThreadId]), + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + payload: UnknownFromJsonString, + metadata: EventMetadataFromJsonString, +}); + +const ReadFromSequenceRequestSchema = Schema.Struct({ + sequenceExclusive: NonNegativeInt, + limit: Schema.Number, +}); +const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; +const READ_PAGE_SIZE = 500; + +function inferActorKind( + event: Omit, +): Schema.Schema.Type { + if (event.commandId !== null && event.commandId.startsWith("provider:")) { + return "provider"; + } + if (event.commandId !== null && event.commandId.startsWith("server:")) { + return "server"; + } + if ( + event.metadata.providerTurnId !== undefined || + event.metadata.providerItemId !== undefined || + event.metadata.adapterKey !== undefined + ) { + return "provider"; + } + if (event.commandId === null) { + return "server"; + } + return "client"; +} + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): OrchestrationEventStoreError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const appendEventRow = SqlSchema.findOne({ + Request: AppendEventRequestSchema, + Result: OrchestrationEventPersistedRowSchema, + execute: (request) => + sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${request.eventId}, + ${request.aggregateKind}, + ${request.streamId}, + COALESCE( + ( + SELECT stream_version + 1 + FROM orchestration_events + WHERE aggregate_kind = ${request.aggregateKind} + AND stream_id = ${request.streamId} + ORDER BY stream_version DESC + LIMIT 1 + ), + 0 + ), + ${request.type}, + ${request.occurredAt}, + ${request.commandId}, + ${request.causationEventId}, + ${request.correlationId}, + ${request.actorKind}, + ${request.payloadJson}, + ${request.metadataJson} + ) + RETURNING + sequence, + event_id AS "eventId", + event_type AS "type", + aggregate_kind AS "aggregateKind", + stream_id AS "aggregateId", + occurred_at AS "occurredAt", + command_id AS "commandId", + causation_event_id AS "causationEventId", + correlation_id AS "correlationId", + payload_json AS "payload", + metadata_json AS "metadata" + `, + }); + + const readEventRowsFromSequence = SqlSchema.findAll({ + Request: ReadFromSequenceRequestSchema, + Result: OrchestrationEventPersistedRowSchema, + execute: (request) => + sql` + SELECT + sequence, + event_id AS "eventId", + event_type AS "type", + aggregate_kind AS "aggregateKind", + stream_id AS "aggregateId", + occurred_at AS "occurredAt", + command_id AS "commandId", + causation_event_id AS "causationEventId", + correlation_id AS "correlationId", + payload_json AS "payload", + metadata_json AS "metadata" + FROM orchestration_events + WHERE sequence > ${request.sequenceExclusive} + ORDER BY sequence ASC + LIMIT ${request.limit} + `, + }); + + const append: OrchestrationEventStore["Service"]["append"] = (event) => + appendEventRow({ + eventId: event.eventId, + aggregateKind: event.aggregateKind, + streamId: event.aggregateId, + type: event.type, + causationEventId: event.causationEventId, + correlationId: event.correlationId, + actorKind: inferActorKind(event), + occurredAt: event.occurredAt, + commandId: event.commandId, + payloadJson: event.payload, + metadataJson: event.metadata, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.append:insert", + "OrchestrationEventStore.append:decodeRow", + ), + ), + Effect.flatMap((row) => + decodeEvent(row).pipe( + Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")), + ), + ), + ); + + const readFromSequence: OrchestrationEventStore["Service"]["readFromSequence"] = ( + sequenceExclusive, + limit = DEFAULT_READ_FROM_SEQUENCE_LIMIT, + ) => { + const normalizedLimit = Math.max(0, Math.floor(limit)); + if (normalizedLimit === 0) { + return Stream.empty; + } + const readPage = ( + cursor: number, + remaining: number, + ): Stream.Stream => + Stream.fromEffect( + readEventRowsFromSequence({ + sequenceExclusive: cursor, + limit: Math.min(remaining, READ_PAGE_SIZE), + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.readFromSequence:query", + "OrchestrationEventStore.readFromSequence:decodeRows", + ), + ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeEvent(row).pipe( + Effect.mapError( + toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"), + ), + ), + ), + ), + ), + ).pipe( + Stream.flatMap((events) => { + if (events.length === 0) { + return Stream.empty; + } + const nextRemaining = remaining - events.length; + if (nextRemaining <= 0) { + return Stream.fromIterable(events); + } + return Stream.concat( + Stream.fromIterable(events), + readPage(events[events.length - 1]!.sequence, nextRemaining), + ); + }), + ); + + return readPage(sequenceExclusive, normalizedLimit); + }; + + return { + append, + readFromSequence, + readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), + } satisfies OrchestrationEventStore["Service"]; +}); + +export const layer = Layer.effect(OrchestrationEventStore, make); diff --git a/apps/server/src/persistence/ProjectionCheckpoints.ts b/apps/server/src/persistence/ProjectionCheckpoints.ts new file mode 100644 index 00000000000..a165dec8d8f --- /dev/null +++ b/apps/server/src/persistence/ProjectionCheckpoints.ts @@ -0,0 +1,295 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + CheckpointRef, + IsoDateTime, + MessageId, + NonNegativeInt, + OrchestrationCheckpointFile, + OrchestrationCheckpointStatus, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +import { + type ProjectionRepositoryError, + toPersistenceDecodeError, + toPersistenceSqlError, +} from "./Errors.ts"; + +/** + * ProjectionCheckpointRepository - Projection repository interface for checkpoints. + * + * Owns persistence operations for projected checkpoint summaries in thread + * timelines. + * + * @module ProjectionCheckpointRepository + */ + +export const ProjectionCheckpoint = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + checkpointTurnCount: NonNegativeInt, + checkpointRef: CheckpointRef, + status: OrchestrationCheckpointStatus, + files: Schema.Array(OrchestrationCheckpointFile), + assistantMessageId: Schema.NullOr(MessageId), + completedAt: IsoDateTime, +}); +export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type; + +export const ListByThreadIdInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListByThreadIdInput = typeof ListByThreadIdInput.Type; + +export const GetByThreadAndTurnCountInput = Schema.Struct({ + threadId: ThreadId, + checkpointTurnCount: NonNegativeInt, +}); +export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type; + +export const DeleteByThreadIdInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type; + +/** + * ProjectionCheckpointRepository - Service tag for checkpoint projection persistence. + */ +export class ProjectionCheckpointRepository extends Context.Service< + ProjectionCheckpointRepository, + { + /** + * Insert or replace a projected checkpoint row. + * + * Upserts by composite key `(threadId, checkpointTurnCount)`. + */ + readonly upsert: (row: ProjectionCheckpoint) => Effect.Effect; + + /** + * List projected checkpoints for a thread. + * + * Returned in ascending checkpoint turn-count order. + */ + readonly listByThreadId: ( + input: ListByThreadIdInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a projected checkpoint by thread and turn-count key. + */ + readonly getByThreadAndTurnCount: ( + input: GetByThreadAndTurnCountInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete projected checkpoint rows by thread. + */ + readonly deleteByThreadId: ( + input: DeleteByThreadIdInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionCheckpoints/ProjectionCheckpointRepository") {} + +const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( + Struct.assign({ + files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const clearCheckpointConflict = SqlSchema.void({ + Request: GetByThreadAndTurnCountInput, + execute: ({ threadId, checkpointTurnCount }) => + sql` + UPDATE projection_turns + SET + checkpoint_turn_count = NULL, + checkpoint_ref = NULL, + checkpoint_status = NULL, + checkpoint_files_json = '[]' + WHERE thread_id = ${threadId} + AND checkpoint_turn_count = ${checkpointTurnCount} + `, + }); + + const upsertProjectionCheckpointRow = SqlSchema.void({ + Request: ProjectionCheckpointDbRowSchema, + execute: (row) => + sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${row.threadId}, + ${row.turnId}, + NULL, + ${row.assistantMessageId}, + ${row.status === "error" ? "error" : "completed"}, + ${row.completedAt}, + ${row.completedAt}, + ${row.completedAt}, + ${row.checkpointTurnCount}, + ${row.checkpointRef}, + ${row.status}, + ${row.files} + ) + ON CONFLICT (thread_id, turn_id) + DO UPDATE SET + assistant_message_id = excluded.assistant_message_id, + state = excluded.state, + completed_at = excluded.completed_at, + checkpoint_turn_count = excluded.checkpoint_turn_count, + checkpoint_ref = excluded.checkpoint_ref, + checkpoint_status = excluded.checkpoint_status, + checkpoint_files_json = excluded.checkpoint_files_json + `, + }); + + const listProjectionCheckpointRows = SqlSchema.findAll({ + Request: ListByThreadIdInput, + Result: ProjectionCheckpointDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "status", + checkpoint_files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM projection_turns + WHERE thread_id = ${threadId} + AND checkpoint_turn_count IS NOT NULL + ORDER BY checkpoint_turn_count ASC + `, + }); + + const getProjectionCheckpointRow = SqlSchema.findOneOption({ + Request: GetByThreadAndTurnCountInput, + Result: ProjectionCheckpointDbRowSchema, + execute: ({ threadId, checkpointTurnCount }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "status", + checkpoint_files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM projection_turns + WHERE thread_id = ${threadId} + AND checkpoint_turn_count = ${checkpointTurnCount} + `, + }); + + const deleteProjectionCheckpointRows = SqlSchema.void({ + Request: DeleteByThreadIdInput, + execute: ({ threadId }) => + sql` + UPDATE projection_turns + SET + checkpoint_turn_count = NULL, + checkpoint_ref = NULL, + checkpoint_status = NULL, + checkpoint_files_json = '[]' + WHERE thread_id = ${threadId} + AND checkpoint_turn_count IS NOT NULL + `, + }); + + const upsertCheckpointRow = (row: Schema.Schema.Type) => + sql.withTransaction( + clearCheckpointConflict({ + threadId: row.threadId, + checkpointTurnCount: row.checkpointTurnCount, + }).pipe(Effect.flatMap(() => upsertProjectionCheckpointRow(row))), + ); + + const upsert: ProjectionCheckpointRepository["Service"]["upsert"] = (row) => + upsertCheckpointRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionCheckpointRepository.upsert:query", + "ProjectionCheckpointRepository.upsert:encodeRequest", + ), + ), + ); + + const listByThreadId: ProjectionCheckpointRepository["Service"]["listByThreadId"] = (input) => + listProjectionCheckpointRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionCheckpointRepository.listByThreadId:query", + "ProjectionCheckpointRepository.listByThreadId:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + const getByThreadAndTurnCount: ProjectionCheckpointRepository["Service"]["getByThreadAndTurnCount"] = + (input) => + getProjectionCheckpointRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionCheckpointRepository.getByThreadAndTurnCount:query", + "ProjectionCheckpointRepository.getByThreadAndTurnCount:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const deleteByThreadId: ProjectionCheckpointRepository["Service"]["deleteByThreadId"] = (input) => + deleteProjectionCheckpointRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionCheckpointRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + getByThreadAndTurnCount, + deleteByThreadId, + } satisfies ProjectionCheckpointRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionCheckpointRepository, make); diff --git a/apps/server/src/persistence/ProjectionPendingApprovals.ts b/apps/server/src/persistence/ProjectionPendingApprovals.ts new file mode 100644 index 00000000000..de4726d3545 --- /dev/null +++ b/apps/server/src/persistence/ProjectionPendingApprovals.ts @@ -0,0 +1,217 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + ApprovalRequestId, + IsoDateTime, + ProjectionPendingApprovalDecision, + ProjectionPendingApprovalStatus, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; + +/** + * ProjectionPendingApprovalRepository - Repository interface for pending approvals. + * + * Owns persistence operations for projected approval requests awaiting user + * decisions. + * + * @module ProjectionPendingApprovalRepository + */ + +export const ProjectionPendingApproval = Schema.Struct({ + requestId: ApprovalRequestId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + status: ProjectionPendingApprovalStatus, + decision: ProjectionPendingApprovalDecision, + createdAt: IsoDateTime, + resolvedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionPendingApproval = typeof ProjectionPendingApproval.Type; + +export const ListProjectionPendingApprovalsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionPendingApprovalsInput = typeof ListProjectionPendingApprovalsInput.Type; + +export const GetProjectionPendingApprovalInput = Schema.Struct({ + requestId: ApprovalRequestId, +}); +export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type; + +export const DeleteProjectionPendingApprovalInput = Schema.Struct({ + requestId: ApprovalRequestId, +}); +export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type; + +/** + * ProjectionPendingApprovalRepository - Service tag for pending approval persistence. + */ +export class ProjectionPendingApprovalRepository extends Context.Service< + ProjectionPendingApprovalRepository, + { + /** + * Insert or replace a projected pending approval row. + * + * Upserts by `requestId`. + */ + readonly upsert: ( + row: ProjectionPendingApproval, + ) => Effect.Effect; + + /** + * List pending approvals for a thread. + * + * Returned in ascending creation order. + */ + readonly listByThreadId: ( + input: ListProjectionPendingApprovalsInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a pending approval row by request id. + */ + readonly getByRequestId: ( + input: GetProjectionPendingApprovalInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete a pending approval row by request id. + */ + readonly deleteByRequestId: ( + input: DeleteProjectionPendingApprovalInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionPendingApprovals/ProjectionPendingApprovalRepository") {} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionPendingApprovalRow = SqlSchema.void({ + Request: ProjectionPendingApproval, + execute: (row) => + sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES ( + ${row.requestId}, + ${row.threadId}, + ${row.turnId}, + ${row.status}, + ${row.decision}, + ${row.createdAt}, + ${row.resolvedAt} + ) + ON CONFLICT (request_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + status = excluded.status, + decision = excluded.decision, + created_at = excluded.created_at, + resolved_at = excluded.resolved_at + `, + }); + + const listProjectionPendingApprovalRows = SqlSchema.findAll({ + Request: ListProjectionPendingApprovalsInput, + Result: ProjectionPendingApproval, + execute: ({ threadId }) => + sql` + SELECT + request_id AS "requestId", + thread_id AS "threadId", + turn_id AS "turnId", + status, + decision, + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, request_id ASC + `, + }); + + const getProjectionPendingApprovalRow = SqlSchema.findOneOption({ + Request: GetProjectionPendingApprovalInput, + Result: ProjectionPendingApproval, + execute: ({ requestId }) => + sql` + SELECT + request_id AS "requestId", + thread_id AS "threadId", + turn_id AS "turnId", + status, + decision, + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = ${requestId} + `, + }); + + const deleteProjectionPendingApprovalRow = SqlSchema.void({ + Request: DeleteProjectionPendingApprovalInput, + execute: ({ requestId }) => + sql` + DELETE FROM projection_pending_approvals + WHERE request_id = ${requestId} + `, + }); + + const upsert: ProjectionPendingApprovalRepository["Service"]["upsert"] = (row) => + upsertProjectionPendingApprovalRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")), + ); + + const listByThreadId: ProjectionPendingApprovalRepository["Service"]["listByThreadId"] = ( + input, + ) => + listProjectionPendingApprovalRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.listByThreadId:query"), + ), + ); + + const getByRequestId: ProjectionPendingApprovalRepository["Service"]["getByRequestId"] = ( + input, + ) => + getProjectionPendingApprovalRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.getByRequestId:query"), + ), + ); + + const deleteByRequestId: ProjectionPendingApprovalRepository["Service"]["deleteByRequestId"] = ( + input, + ) => + deleteProjectionPendingApprovalRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"), + ), + ); + + return { + upsert, + listByThreadId, + getByRequestId, + deleteByRequestId, + } satisfies ProjectionPendingApprovalRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionPendingApprovalRepository, make); diff --git a/apps/server/src/persistence/ProjectionProjects.ts b/apps/server/src/persistence/ProjectionProjects.ts new file mode 100644 index 00000000000..100ff344b51 --- /dev/null +++ b/apps/server/src/persistence/ProjectionProjects.ts @@ -0,0 +1,206 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; + +import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; + +/** + * ProjectionProjectRepository - Projection repository interface for projects. + * + * Owns persistence operations for project rows in the orchestration projection + * read model. + * + * @module ProjectionProjectRepository + */ + +export const ProjectionProject = Schema.Struct({ + projectId: ProjectId, + title: Schema.String, + workspaceRoot: Schema.String, + defaultModelSelection: Schema.NullOr(ModelSelection), + scripts: Schema.Array(ProjectScript), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionProject = typeof ProjectionProject.Type; + +export const GetProjectionProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type; + +export const DeleteProjectionProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type; + +/** + * ProjectionProjectRepository - Service tag for project projection persistence. + */ +export class ProjectionProjectRepository extends Context.Service< + ProjectionProjectRepository, + { + /** + * Insert or replace a projected project row. + * + * Upserts by `projectId` and persists scripts through JSON encoding. + */ + readonly upsert: (row: ProjectionProject) => Effect.Effect; + + /** + * Read a projected project row by id. + */ + readonly getById: ( + input: GetProjectionProjectInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List all projected project rows. + * + * Returned in deterministic creation order. + */ + readonly listAll: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + + /** + * Soft-delete a projected project row by id. + */ + readonly deleteById: ( + input: DeleteProjectionProjectInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionProjects/ProjectionProjectRepository") {} + +const ProjectionProjectDbRow = ProjectionProject.mapFields( + Struct.assign({ + defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + }), +); +type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionProjectRow = SqlSchema.void({ + Request: ProjectionProject, + execute: (row) => + sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${row.projectId}, + ${row.title}, + ${row.workspaceRoot}, + ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, + ${JSON.stringify(row.scripts)}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.deletedAt} + ) + ON CONFLICT (project_id) + DO UPDATE SET + title = excluded.title, + workspace_root = excluded.workspace_root, + default_model_selection_json = excluded.default_model_selection_json, + scripts_json = excluded.scripts_json, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `, + }); + + const getProjectionProjectRow = SqlSchema.findOneOption({ + Request: GetProjectionProjectInput, + Result: ProjectionProjectDbRow, + execute: ({ projectId }) => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model_selection_json AS "defaultModelSelection", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + WHERE project_id = ${projectId} + `, + }); + + const listProjectionProjectRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionProjectDbRow, + execute: () => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model_selection_json AS "defaultModelSelection", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + ORDER BY created_at ASC, project_id ASC + `, + }); + + const deleteProjectionProjectRow = SqlSchema.void({ + Request: DeleteProjectionProjectInput, + execute: ({ projectId }) => + sql` + DELETE FROM projection_projects + WHERE project_id = ${projectId} + `, + }); + + const upsert: ProjectionProjectRepository["Service"]["upsert"] = (row) => + upsertProjectionProjectRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")), + ); + + const getById: ProjectionProjectRepository["Service"]["getById"] = (input) => + getProjectionProjectRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), + ); + + const listAll: ProjectionProjectRepository["Service"]["listAll"] = () => + listProjectionProjectRows().pipe( + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), + ); + + const deleteById: ProjectionProjectRepository["Service"]["deleteById"] = (input) => + deleteProjectionProjectRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.deleteById:query")), + ); + + return { + upsert, + getById, + listAll, + deleteById, + } satisfies ProjectionProjectRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionProjectRepository, make); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/ProjectionRepositories.test.ts similarity index 67% rename from apps/server/src/persistence/Layers/ProjectionRepositories.test.ts rename to apps/server/src/persistence/ProjectionRepositories.test.ts index a2069e62a14..347d9c54582 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/ProjectionRepositories.test.ts @@ -3,18 +3,22 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { SqlitePersistenceMemory } from "./Sqlite.ts"; -import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; -import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; -import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; -import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +import { PersistenceDecodeError } from "./Errors.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; +import * as ProjectionProjects from "./ProjectionProjects.ts"; +import * as ProjectionThreads from "./ProjectionThreads.ts"; +import * as ProjectionTurns from "./ProjectionTurns.ts"; + +const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); const projectionRepositoriesLayer = it.layer( Layer.mergeAll( - ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), - ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionProjects.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreads.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionTurns.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), SqlitePersistenceMemory, ), ); @@ -22,7 +26,7 @@ const projectionRepositoriesLayer = it.layer( projectionRepositoriesLayer("Projection repositories", (it) => { it.effect("stores SQL NULL for missing project model options", () => Effect.gen(function* () { - const projects = yield* ProjectionProjectRepository; + const projects = yield* ProjectionProjects.ProjectionProjectRepository; const sql = yield* SqlClient.SqlClient; yield* projects.upsert({ @@ -72,7 +76,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { it.effect("stores JSON for thread model options", () => Effect.gen(function* () { - const threads = yield* ProjectionThreadRepository; + const threads = yield* ProjectionThreads.ProjectionThreadRepository; const sql = yield* SqlClient.SqlClient; yield* threads.upsert({ @@ -128,4 +132,39 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); }), ); + + it.effect("reports invalid pending turn rows as decode failures", () => + Effect.gen(function* () { + const turns = yield* ProjectionTurns.ProjectionTurnRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-invalid-pending-turn"); + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + pending_message_id, + state, + requested_at, + checkpoint_files_json + ) + VALUES ( + ${threadId}, + X'01', + 'pending', + '2026-06-20T00:00:00.000Z', + '[]' + ) + `; + + const result = yield* Effect.result(turns.getPendingTurnStartByThreadId({ threadId })); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.ok(isPersistenceDecodeError(result.failure)); + assert.equal( + result.failure.operation, + "ProjectionTurnRepository.getPendingTurnStartByThreadId:decodeRow", + ); + } + }), + ); }); diff --git a/apps/server/src/persistence/ProjectionState.ts b/apps/server/src/persistence/ProjectionState.ts new file mode 100644 index 00000000000..0e6c55c2dd5 --- /dev/null +++ b/apps/server/src/persistence/ProjectionState.ts @@ -0,0 +1,170 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts"; + +import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; + +/** + * ProjectionStateRepository - Projection repository interface for projector cursors. + * + * Owns persistence operations for projection cursor state used to resume + * incremental event projection. + * + * @module ProjectionStateRepository + */ + +export const ProjectionState = Schema.Struct({ + projector: Schema.String, + lastAppliedSequence: NonNegativeInt, + updatedAt: IsoDateTime, +}); +export type ProjectionState = typeof ProjectionState.Type; + +export const GetProjectionStateInput = Schema.Struct({ + projector: Schema.String, +}); +export type GetProjectionStateInput = typeof GetProjectionStateInput.Type; + +/** + * ProjectionStateRepository - Service tag for projection cursor persistence. + */ +export class ProjectionStateRepository extends Context.Service< + ProjectionStateRepository, + { + /** + * Insert or replace a projection cursor row. + * + * Upserts by projector name. + */ + readonly upsert: (row: ProjectionState) => Effect.Effect; + + /** + * Read projection cursor state for a projector key. + */ + readonly getByProjector: ( + input: GetProjectionStateInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List all projector cursor rows. + */ + readonly listAll: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + + /** + * Read the minimum applied sequence across all projectors. + * + * Returns `null` when no projector state rows exist. + */ + readonly minLastAppliedSequence: () => Effect.Effect; + } +>()("t3/persistence/ProjectionState/ProjectionStateRepository") {} + +const MinLastAppliedSequenceRowSchema = Schema.Struct({ + minLastAppliedSequence: Schema.NullOr(NonNegativeInt), +}); + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionStateRow = SqlSchema.void({ + Request: ProjectionState, + execute: (row) => + sql` + INSERT INTO projection_state ( + projector, + last_applied_sequence, + updated_at + ) + VALUES ( + ${row.projector}, + ${row.lastAppliedSequence}, + ${row.updatedAt} + ) + ON CONFLICT (projector) + DO UPDATE SET + last_applied_sequence = excluded.last_applied_sequence, + updated_at = excluded.updated_at + `, + }); + + const getProjectionStateRow = SqlSchema.findOneOption({ + Request: GetProjectionStateInput, + Result: ProjectionState, + execute: ({ projector }) => + sql` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence", + updated_at AS "updatedAt" + FROM projection_state + WHERE projector = ${projector} + `, + }); + + const listProjectionStateRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionState, + execute: () => + sql` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence", + updated_at AS "updatedAt" + FROM projection_state + ORDER BY projector ASC + `, + }); + + const readMinLastAppliedSequence = SqlSchema.findOne({ + Request: Schema.Void, + Result: MinLastAppliedSequenceRowSchema, + execute: () => + sql` + SELECT + MIN(last_applied_sequence) AS "minLastAppliedSequence" + FROM projection_state + `, + }); + + const upsert: ProjectionStateRepository["Service"]["upsert"] = (row) => + upsertProjectionStateRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")), + ); + + const getByProjector: ProjectionStateRepository["Service"]["getByProjector"] = (input) => + getProjectionStateRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.getByProjector:query")), + ); + + const listAll: ProjectionStateRepository["Service"]["listAll"] = () => + listProjectionStateRows(undefined).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")), + ); + + const minLastAppliedSequence: ProjectionStateRepository["Service"]["minLastAppliedSequence"] = + () => + readMinLastAppliedSequence(undefined).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionStateRepository.minLastAppliedSequence:query"), + ), + Effect.map((row) => row.minLastAppliedSequence), + ); + + return { + upsert, + getByProjector, + listAll, + minLastAppliedSequence, + } satisfies ProjectionStateRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionStateRepository, make); diff --git a/apps/server/src/persistence/ProjectionThreadActivities.ts b/apps/server/src/persistence/ProjectionThreadActivities.ts new file mode 100644 index 00000000000..f4b34a742f1 --- /dev/null +++ b/apps/server/src/persistence/ProjectionThreadActivities.ts @@ -0,0 +1,230 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + EventId, + IsoDateTime, + NonNegativeInt, + OrchestrationThreadActivityTone, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +import { + type ProjectionRepositoryError, + toPersistenceDecodeError, + toPersistenceSqlError, +} from "./Errors.ts"; + +/** + * ProjectionThreadActivityRepository - Projection repository interface for thread activity. + * + * Owns persistence operations for activity timeline entries projected from + * orchestration events. + * + * @module ProjectionThreadActivityRepository + */ + +export const ProjectionThreadActivity = Schema.Struct({ + activityId: EventId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + tone: OrchestrationThreadActivityTone, + kind: Schema.String, + summary: Schema.String, + payload: Schema.Unknown, + sequence: Schema.optional(NonNegativeInt), + createdAt: IsoDateTime, +}); +export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; + +export const ListProjectionThreadActivitiesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; + +export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadActivitiesInput = + typeof DeleteProjectionThreadActivitiesInput.Type; + +/** + * ProjectionThreadActivityRepository - Service tag for thread activity persistence. + */ +export class ProjectionThreadActivityRepository extends Context.Service< + ProjectionThreadActivityRepository, + { + /** + * Insert or replace a projected thread activity row. + * + * Upserts by `activityId` and JSON-encodes payload. + */ + readonly upsert: ( + row: ProjectionThreadActivity, + ) => Effect.Effect; + + /** + * List projected thread activity rows for a thread. + * + * Returned in ascending runtime sequence order (or creation order when + * sequence is unavailable). + */ + readonly listByThreadId: ( + input: ListProjectionThreadActivitiesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete projected thread activity rows by thread. + */ + readonly deleteByThreadId: ( + input: DeleteProjectionThreadActivitiesInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionThreadActivities/ProjectionThreadActivityRepository") {} + +const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( + Struct.assign({ + payload: Schema.fromJsonString(Schema.Unknown), + sequence: Schema.NullOr(NonNegativeInt), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadActivityRow = SqlSchema.void({ + Request: ProjectionThreadActivity, + execute: (row) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + ${row.activityId}, + ${row.threadId}, + ${row.turnId}, + ${row.tone}, + ${row.kind}, + ${row.summary}, + ${JSON.stringify(row.payload)}, + ${row.sequence ?? null}, + ${row.createdAt} + ) + ON CONFLICT (activity_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + tone = excluded.tone, + kind = excluded.kind, + summary = excluded.summary, + payload_json = excluded.payload_json, + sequence = excluded.sequence, + created_at = excluded.created_at + `, + }); + + const listProjectionThreadActivityRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + + const deleteProjectionThreadActivityRows = SqlSchema.void({ + Request: DeleteProjectionThreadActivitiesInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_activities + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadActivityRepository["Service"]["upsert"] = (row) => + upsertProjectionThreadActivityRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.upsert:query", + "ProjectionThreadActivityRepository.upsert:encodeRequest", + ), + ), + ); + + const listByThreadId: ProjectionThreadActivityRepository["Service"]["listByThreadId"] = (input) => + listProjectionThreadActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listByThreadId:query", + "ProjectionThreadActivityRepository.listByThreadId:decodeRows", + ), + ), + Effect.map((rows) => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })), + ), + ); + + const deleteByThreadId: ProjectionThreadActivityRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteProjectionThreadActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadActivityRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadActivityRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionThreadActivityRepository, make); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/ProjectionThreadMessages.test.ts similarity index 87% rename from apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts rename to apps/server/src/persistence/ProjectionThreadMessages.test.ts index b1f394a9e57..c0e469c606c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/ProjectionThreadMessages.test.ts @@ -3,18 +3,17 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts"; -import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts"; -import { SqlitePersistenceMemory } from "./Sqlite.ts"; +import * as ProjectionThreadMessages from "./ProjectionThreadMessages.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; const layer = it.layer( - ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadMessages.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ); layer("ProjectionThreadMessageRepository", (it) => { it.effect("preserves existing attachments when upsert omits attachments", () => Effect.gen(function* () { - const repository = yield* ProjectionThreadMessageRepository; + const repository = yield* ProjectionThreadMessages.ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-preserve-attachments"); const messageId = MessageId.make("message-preserve-attachments"); const createdAt = "2026-02-28T19:00:00.000Z"; @@ -68,7 +67,7 @@ layer("ProjectionThreadMessageRepository", (it) => { it.effect("allows explicit attachment clearing with an empty array", () => Effect.gen(function* () { - const repository = yield* ProjectionThreadMessageRepository; + const repository = yield* ProjectionThreadMessages.ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-clear-attachments"); const messageId = MessageId.make("message-clear-attachments"); const createdAt = "2026-02-28T19:10:00.000Z"; diff --git a/apps/server/src/persistence/ProjectionThreadMessages.ts b/apps/server/src/persistence/ProjectionThreadMessages.ts new file mode 100644 index 00000000000..77264790898 --- /dev/null +++ b/apps/server/src/persistence/ProjectionThreadMessages.ts @@ -0,0 +1,265 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + ChatAttachment, + IsoDateTime, + MessageId, + OrchestrationMessageRole, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; + +/** + * ProjectionThreadMessageRepository - Projection repository interface for messages. + * + * Owns persistence operations for projected thread messages rendered in the + * orchestration read model. + * + * @module ProjectionThreadMessageRepository + */ + +export const ProjectionThreadMessage = Schema.Struct({ + messageId: MessageId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + role: OrchestrationMessageRole, + text: Schema.String, + attachments: Schema.optional(Schema.Array(ChatAttachment)), + isStreaming: Schema.Boolean, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; + +export const ListProjectionThreadMessagesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; + +export const GetProjectionThreadMessageInput = Schema.Struct({ + messageId: MessageId, +}); +export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; + +export const DeleteProjectionThreadMessagesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type; + +/** + * ProjectionThreadMessageRepository - Service tag for message projection persistence. + */ +export class ProjectionThreadMessageRepository extends Context.Service< + ProjectionThreadMessageRepository, + { + /** + * Insert or replace a projected thread message row. + * + * Upserts by `messageId`. + */ + readonly upsert: ( + message: ProjectionThreadMessage, + ) => Effect.Effect; + + /** + * Read a projected thread message by id. + */ + readonly getByMessageId: ( + input: GetProjectionThreadMessageInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List projected thread messages for a thread. + * + * Returned in ascending creation order. + */ + readonly listByThreadId: ( + input: ListProjectionThreadMessagesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete projected thread messages by thread. + */ + readonly deleteByThreadId: ( + input: DeleteProjectionThreadMessagesInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionThreadMessages/ProjectionThreadMessageRepository") {} + +const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( + Struct.assign({ + isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + }), +); + +function toProjectionThreadMessage( + row: Schema.Schema.Type, +): ProjectionThreadMessage { + return { + messageId: row.messageId, + threadId: row.threadId, + turnId: row.turnId, + role: row.role, + text: row.text, + isStreaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + }; +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadMessageRow = SqlSchema.void({ + Request: ProjectionThreadMessage, + execute: (row) => { + const nextAttachmentsJson = + row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.turnId}, + ${row.role}, + ${row.text}, + COALESCE( + ${nextAttachmentsJson}, + ( + SELECT attachments_json + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), + ${row.isStreaming ? 1 : 0}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + role = excluded.role, + text = excluded.text, + attachments_json = COALESCE( + excluded.attachments_json, + projection_thread_messages.attachments_json + ), + is_streaming = excluded.is_streaming, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `; + }, + }); + + const getProjectionThreadMessageRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadMessageInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ messageId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE message_id = ${messageId} + LIMIT 1 + `, + }); + + const listProjectionThreadMessageRows = SqlSchema.findAll({ + Request: ListProjectionThreadMessagesInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, message_id ASC + `, + }); + + const deleteProjectionThreadMessageRows = SqlSchema.void({ + Request: DeleteProjectionThreadMessagesInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_messages + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadMessageRepository["Service"]["upsert"] = (row) => + upsertProjectionThreadMessageRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), + ); + + const getByMessageId: ProjectionThreadMessageRepository["Service"]["getByMessageId"] = (input) => + getProjectionThreadMessageRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.getByMessageId:query"), + ), + Effect.map(Option.map(toProjectionThreadMessage)), + ); + + const listByThreadId: ProjectionThreadMessageRepository["Service"]["listByThreadId"] = (input) => + listProjectionThreadMessageRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"), + ), + Effect.map((rows) => rows.map(toProjectionThreadMessage)), + ); + + const deleteByThreadId: ProjectionThreadMessageRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteProjectionThreadMessageRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + getByMessageId, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadMessageRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionThreadMessageRepository, make); diff --git a/apps/server/src/persistence/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/ProjectionThreadProposedPlans.ts new file mode 100644 index 00000000000..e5e56d4c291 --- /dev/null +++ b/apps/server/src/persistence/ProjectionThreadProposedPlans.ts @@ -0,0 +1,152 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + IsoDateTime, + OrchestrationProposedPlanId, + ThreadId, + TrimmedNonEmptyString, + TurnId, +} from "@t3tools/contracts"; + +import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; + +export const ProjectionThreadProposedPlan = Schema.Struct({ + planId: OrchestrationProposedPlanId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + planMarkdown: TrimmedNonEmptyString, + implementedAt: Schema.NullOr(IsoDateTime), + implementationThreadId: Schema.NullOr(ThreadId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectionThreadProposedPlan = typeof ProjectionThreadProposedPlan.Type; + +export const ListProjectionThreadProposedPlansInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadProposedPlansInput = + typeof ListProjectionThreadProposedPlansInput.Type; + +export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadProposedPlansInput = + typeof DeleteProjectionThreadProposedPlansInput.Type; + +export class ProjectionThreadProposedPlanRepository extends Context.Service< + ProjectionThreadProposedPlanRepository, + { + readonly upsert: ( + proposedPlan: ProjectionThreadProposedPlan, + ) => Effect.Effect; + readonly listByThreadId: ( + input: ListProjectionThreadProposedPlansInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByThreadId: ( + input: DeleteProjectionThreadProposedPlansInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionThreadProposedPlans/ProjectionThreadProposedPlanRepository") {} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadProposedPlanRow = SqlSchema.void({ + Request: ProjectionThreadProposedPlan, + execute: (row) => sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + implemented_at, + implementation_thread_id, + created_at, + updated_at + ) + VALUES ( + ${row.planId}, + ${row.threadId}, + ${row.turnId}, + ${row.planMarkdown}, + ${row.implementedAt}, + ${row.implementationThreadId}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (plan_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + plan_markdown = excluded.plan_markdown, + implemented_at = excluded.implemented_at, + implementation_thread_id = excluded.implementation_thread_id, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `, + }); + + const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ + Request: ListProjectionThreadProposedPlansInput, + Result: ProjectionThreadProposedPlan, + execute: ({ threadId }) => sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const deleteProjectionThreadProposedPlanRows = SqlSchema.void({ + Request: DeleteProjectionThreadProposedPlansInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadProposedPlanRepository["Service"]["upsert"] = (row) => + upsertProjectionThreadProposedPlanRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), + ); + + const listByThreadId: ProjectionThreadProposedPlanRepository["Service"]["listByThreadId"] = ( + input, + ) => + listProjectionThreadProposedPlanRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.listByThreadId:query"), + ), + ); + + const deleteByThreadId: ProjectionThreadProposedPlanRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteProjectionThreadProposedPlanRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadProposedPlanRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionThreadProposedPlanRepository, make); diff --git a/apps/server/src/persistence/ProjectionThreadSessions.ts b/apps/server/src/persistence/ProjectionThreadSessions.ts new file mode 100644 index 00000000000..ee386862f68 --- /dev/null +++ b/apps/server/src/persistence/ProjectionThreadSessions.ts @@ -0,0 +1,177 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + IsoDateTime, + OrchestrationSessionStatus, + ProviderInstanceId, + RuntimeMode, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; + +/** + * ProjectionThreadSessionRepository - Repository interface for thread sessions. + * + * Owns persistence operations for projected provider-session linkage and + * runtime status for each thread. + * + * @module ProjectionThreadSessionRepository + */ + +export const ProjectionThreadSession = Schema.Struct({ + threadId: ThreadId, + status: OrchestrationSessionStatus, + providerName: Schema.NullOr(Schema.String), + providerInstanceId: Schema.NullOr(ProviderInstanceId), + runtimeMode: RuntimeMode, + activeTurnId: Schema.NullOr(TurnId), + lastError: Schema.NullOr(Schema.String), + updatedAt: IsoDateTime, +}); +export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; + +export const GetProjectionThreadSessionInput = Schema.Struct({ + threadId: ThreadId, +}); +export type GetProjectionThreadSessionInput = typeof GetProjectionThreadSessionInput.Type; + +export const DeleteProjectionThreadSessionInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadSessionInput = typeof DeleteProjectionThreadSessionInput.Type; + +/** + * ProjectionThreadSessionRepository - Service tag for thread-session persistence. + */ +export class ProjectionThreadSessionRepository extends Context.Service< + ProjectionThreadSessionRepository, + { + /** + * Insert or replace a projected thread-session row. + * + * Upserts by `threadId`. + */ + readonly upsert: ( + row: ProjectionThreadSession, + ) => Effect.Effect; + + /** + * Read projected thread-session state by thread id. + */ + readonly getByThreadId: ( + input: GetProjectionThreadSessionInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete projected thread-session state by thread id. + */ + readonly deleteByThreadId: ( + input: DeleteProjectionThreadSessionInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionThreadSessions/ProjectionThreadSessionRepository") {} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadSessionRow = SqlSchema.void({ + Request: ProjectionThreadSession, + execute: (row) => + sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_instance_id, + runtime_mode, + active_turn_id, + last_error, + updated_at + ) + VALUES ( + ${row.threadId}, + ${row.status}, + ${row.providerName}, + ${row.providerInstanceId}, + ${row.runtimeMode}, + ${row.activeTurnId}, + ${row.lastError}, + ${row.updatedAt} + ) + ON CONFLICT (thread_id) + DO UPDATE SET + status = excluded.status, + provider_name = excluded.provider_name, + provider_instance_id = excluded.provider_instance_id, + runtime_mode = excluded.runtime_mode, + active_turn_id = excluded.active_turn_id, + last_error = excluded.last_error, + updated_at = excluded.updated_at + `, + }); + + const getProjectionThreadSessionRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadSessionInput, + Result: ProjectionThreadSession, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + status, + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", + runtime_mode AS "runtimeMode", + active_turn_id AS "activeTurnId", + last_error AS "lastError", + updated_at AS "updatedAt" + FROM projection_thread_sessions + WHERE thread_id = ${threadId} + `, + }); + + const deleteProjectionThreadSessionRow = SqlSchema.void({ + Request: DeleteProjectionThreadSessionInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_sessions + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadSessionRepository["Service"]["upsert"] = (row) => + upsertProjectionThreadSessionRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadSessionRepository.upsert:query")), + ); + + const getByThreadId: ProjectionThreadSessionRepository["Service"]["getByThreadId"] = (input) => + getProjectionThreadSessionRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadSessionRepository.getByThreadId:query"), + ), + ); + + const deleteByThreadId: ProjectionThreadSessionRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteProjectionThreadSessionRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadSessionRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + getByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadSessionRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionThreadSessionRepository, make); diff --git a/apps/server/src/persistence/ProjectionThreads.ts b/apps/server/src/persistence/ProjectionThreads.ts new file mode 100644 index 00000000000..05231dfabdd --- /dev/null +++ b/apps/server/src/persistence/ProjectionThreads.ts @@ -0,0 +1,273 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + IsoDateTime, + ModelSelection, + NonNegativeInt, + ProjectId, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; + +/** + * ProjectionThreadRepository - Projection repository interface for threads. + * + * Owns persistence operations for projected thread records in the + * orchestration read model. + * + * @module ProjectionThreadRepository + */ + +export const ProjectionThread = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + title: Schema.String, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + latestTurnId: Schema.NullOr(TurnId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime), + latestUserMessageAt: Schema.NullOr(IsoDateTime), + pendingApprovalCount: NonNegativeInt, + pendingUserInputCount: NonNegativeInt, + hasActionableProposedPlan: NonNegativeInt, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionThread = typeof ProjectionThread.Type; + +export const GetProjectionThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type; + +export const DeleteProjectionThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type; + +export const ListProjectionThreadsByProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; + +/** + * ProjectionThreadRepository - Service tag for thread projection persistence. + */ +export class ProjectionThreadRepository extends Context.Service< + ProjectionThreadRepository, + { + /** + * Insert or replace a projected thread row. + * + * Upserts by `threadId`. + */ + readonly upsert: (thread: ProjectionThread) => Effect.Effect; + + /** + * Read a projected thread row by id. + */ + readonly getById: ( + input: GetProjectionThreadInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List projected threads for a project. + * + * Returned in deterministic creation order. + */ + readonly listByProjectId: ( + input: ListProjectionThreadsByProjectInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Soft-delete a projected thread row by id. + */ + readonly deleteById: ( + input: DeleteProjectionThreadInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionThreads/ProjectionThreadRepository") {} + +const ProjectionThreadDbRow = ProjectionThread.mapFields( + Struct.assign({ + modelSelection: Schema.fromJsonString(ModelSelection), + }), +); +type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadRow = SqlSchema.void({ + Request: ProjectionThread, + execute: (row) => + sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + ${row.threadId}, + ${row.projectId}, + ${row.title}, + ${JSON.stringify(row.modelSelection)}, + ${row.runtimeMode}, + ${row.interactionMode}, + ${row.branch}, + ${row.worktreePath}, + ${row.latestTurnId}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.archivedAt}, + ${row.latestUserMessageAt}, + ${row.pendingApprovalCount}, + ${row.pendingUserInputCount}, + ${row.hasActionableProposedPlan}, + ${row.deletedAt} + ) + ON CONFLICT (thread_id) + DO UPDATE SET + project_id = excluded.project_id, + title = excluded.title, + model_selection_json = excluded.model_selection_json, + runtime_mode = excluded.runtime_mode, + interaction_mode = excluded.interaction_mode, + branch = excluded.branch, + worktree_path = excluded.worktree_path, + latest_turn_id = excluded.latest_turn_id, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + archived_at = excluded.archived_at, + latest_user_message_at = excluded.latest_user_message_at, + pending_approval_count = excluded.pending_approval_count, + pending_user_input_count = excluded.pending_user_input_count, + has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, + deleted_at = excluded.deleted_at + `, + }); + + const getProjectionThreadRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadInput, + Result: ProjectionThreadDbRow, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `, + }); + + const listProjectionThreadRows = SqlSchema.findAll({ + Request: ListProjectionThreadsByProjectInput, + Result: ProjectionThreadDbRow, + execute: ({ projectId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE project_id = ${projectId} + ORDER BY created_at ASC, thread_id ASC + `, + }); + + const deleteProjectionThreadRow = SqlSchema.void({ + Request: DeleteProjectionThreadInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_threads + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadRepository["Service"]["upsert"] = (row) => + upsertProjectionThreadRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), + ); + + const getById: ProjectionThreadRepository["Service"]["getById"] = (input) => + getProjectionThreadRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), + ); + + const listByProjectId: ProjectionThreadRepository["Service"]["listByProjectId"] = (input) => + listProjectionThreadRows(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), + ); + + const deleteById: ProjectionThreadRepository["Service"]["deleteById"] = (input) => + deleteProjectionThreadRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), + ); + + return { + upsert, + getById, + listByProjectId, + deleteById, + } satisfies ProjectionThreadRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionThreadRepository, make); diff --git a/apps/server/src/persistence/ProjectionTurns.ts b/apps/server/src/persistence/ProjectionTurns.ts new file mode 100644 index 00000000000..ab4cc60d652 --- /dev/null +++ b/apps/server/src/persistence/ProjectionTurns.ts @@ -0,0 +1,513 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + CheckpointRef, + IsoDateTime, + MessageId, + NonNegativeInt, + OrchestrationCheckpointFile, + OrchestrationCheckpointStatus, + OrchestrationProposedPlanId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +import { + type ProjectionRepositoryError, + toPersistenceDecodeError, + toPersistenceSqlError, +} from "./Errors.ts"; + +/** + * ProjectionTurnRepository - Projection repository interface for unified turn state. + * + * Owns persistence operations for pending starts, running/completed turn lifecycle, + * and checkpoint metadata in a single projection table. + * + * @module ProjectionTurnRepository + */ + +export const ProjectionTurnState = Schema.Literals([ + "pending", + "running", + "interrupted", + "completed", + "error", +]); +export type ProjectionTurnState = typeof ProjectionTurnState.Type; + +export const ProjectionTurn = Schema.Struct({ + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + pendingMessageId: Schema.NullOr(MessageId), + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), + assistantMessageId: Schema.NullOr(MessageId), + state: ProjectionTurnState, + requestedAt: IsoDateTime, + startedAt: Schema.NullOr(IsoDateTime), + completedAt: Schema.NullOr(IsoDateTime), + checkpointTurnCount: Schema.NullOr(NonNegativeInt), + checkpointRef: Schema.NullOr(CheckpointRef), + checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), + checkpointFiles: Schema.Array(OrchestrationCheckpointFile), +}); +export type ProjectionTurn = typeof ProjectionTurn.Type; + +export const ProjectionTurnById = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + pendingMessageId: Schema.NullOr(MessageId), + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), + assistantMessageId: Schema.NullOr(MessageId), + state: ProjectionTurnState, + requestedAt: IsoDateTime, + startedAt: Schema.NullOr(IsoDateTime), + completedAt: Schema.NullOr(IsoDateTime), + checkpointTurnCount: Schema.NullOr(NonNegativeInt), + checkpointRef: Schema.NullOr(CheckpointRef), + checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), + checkpointFiles: Schema.Array(OrchestrationCheckpointFile), +}); +export type ProjectionTurnById = typeof ProjectionTurnById.Type; + +export const ProjectionPendingTurnStart = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), + requestedAt: IsoDateTime, +}); +export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type; + +export const ListProjectionTurnsByThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionTurnsByThreadInput = typeof ListProjectionTurnsByThreadInput.Type; + +export const GetProjectionTurnByTurnIdInput = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, +}); +export type GetProjectionTurnByTurnIdInput = typeof GetProjectionTurnByTurnIdInput.Type; + +export const GetProjectionPendingTurnStartInput = Schema.Struct({ + threadId: ThreadId, +}); +export type GetProjectionPendingTurnStartInput = typeof GetProjectionPendingTurnStartInput.Type; + +export const DeleteProjectionTurnsByThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionTurnsByThreadInput = typeof DeleteProjectionTurnsByThreadInput.Type; + +export const ClearCheckpointTurnConflictInput = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + checkpointTurnCount: NonNegativeInt, +}); +export type ClearCheckpointTurnConflictInput = typeof ClearCheckpointTurnConflictInput.Type; + +export class ProjectionTurnRepository extends Context.Service< + ProjectionTurnRepository, + { + /** + * Inserts or updates the canonical row for a concrete `{threadId, turnId}` turn lifecycle state. + */ + readonly upsertByTurnId: ( + row: ProjectionTurnById, + ) => Effect.Effect; + + /** + * Replaces any existing pending-start placeholder rows for a thread with exactly one latest pending-start row. + */ + readonly replacePendingTurnStart: ( + row: ProjectionPendingTurnStart, + ) => Effect.Effect; + + /** + * Returns the newest pending-start placeholder for a thread; this is expected to be at most one row after replacement writes. + */ + readonly getPendingTurnStartByThreadId: ( + input: GetProjectionPendingTurnStartInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Deletes only pending-start placeholder rows (`turnId = null`) for a thread and leaves concrete turn rows untouched. + */ + readonly deletePendingTurnStartByThreadId: ( + input: GetProjectionPendingTurnStartInput, + ) => Effect.Effect; + + /** + * Lists all projection rows for a thread, including pending placeholders, with checkpoint rows ordered before non-checkpoint rows. + */ + readonly listByThreadId: ( + input: ListProjectionTurnsByThreadInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Looks up a concrete turn row by `{threadId, turnId}` and never returns pending placeholder rows. + */ + readonly getByTurnId: ( + input: GetProjectionTurnByTurnIdInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Clears checkpoint fields on conflicting rows that reuse the same checkpoint turn count in a thread, excluding the provided turn. + */ + readonly clearCheckpointTurnConflict: ( + input: ClearCheckpointTurnConflictInput, + ) => Effect.Effect; + + /** + * Hard-deletes all projection rows for a thread, including pending-start placeholders and checkpoint metadata rows. + */ + readonly deleteByThreadId: ( + input: DeleteProjectionTurnsByThreadInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionTurns/ProjectionTurnRepository") {} + +const ProjectionTurnDbRowSchema = ProjectionTurn.mapFields( + Struct.assign({ + checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), + }), +); + +const ProjectionTurnByIdDbRowSchema = ProjectionTurnById.mapFields( + Struct.assign({ + checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionTurnById = SqlSchema.void({ + Request: ProjectionTurnByIdDbRowSchema, + execute: (row) => + sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${row.threadId}, + ${row.turnId}, + ${row.pendingMessageId}, + ${row.sourceProposedPlanThreadId}, + ${row.sourceProposedPlanId}, + ${row.assistantMessageId}, + ${row.state}, + ${row.requestedAt}, + ${row.startedAt}, + ${row.completedAt}, + ${row.checkpointTurnCount}, + ${row.checkpointRef}, + ${row.checkpointStatus}, + ${row.checkpointFiles} + ) + ON CONFLICT (thread_id, turn_id) + DO UPDATE SET + pending_message_id = excluded.pending_message_id, + source_proposed_plan_thread_id = excluded.source_proposed_plan_thread_id, + source_proposed_plan_id = excluded.source_proposed_plan_id, + assistant_message_id = excluded.assistant_message_id, + state = excluded.state, + requested_at = excluded.requested_at, + started_at = excluded.started_at, + completed_at = excluded.completed_at, + checkpoint_turn_count = excluded.checkpoint_turn_count, + checkpoint_ref = excluded.checkpoint_ref, + checkpoint_status = excluded.checkpoint_status, + checkpoint_files_json = excluded.checkpoint_files_json + `, + }); + + const clearPendingProjectionTurnsByThread = SqlSchema.void({ + Request: DeleteProjectionTurnsByThreadInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NULL + AND state = 'pending' + AND checkpoint_turn_count IS NULL + `, + }); + + const insertPendingProjectionTurn = SqlSchema.void({ + Request: ProjectionPendingTurnStart, + execute: (row) => + sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${row.threadId}, + NULL, + ${row.messageId}, + ${row.sourceProposedPlanThreadId}, + ${row.sourceProposedPlanId}, + NULL, + 'pending', + ${row.requestedAt}, + NULL, + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `, + }); + + const getPendingProjectionTurn = SqlSchema.findOneOption({ + Request: GetProjectionPendingTurnStartInput, + Result: ProjectionPendingTurnStart, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + pending_message_id AS "messageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + requested_at AS "requestedAt" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NULL + AND state = 'pending' + AND pending_message_id IS NOT NULL + AND checkpoint_turn_count IS NULL + ORDER BY requested_at DESC + LIMIT 1 + `, + }); + + const listProjectionTurnsByThread = SqlSchema.findAll({ + Request: ListProjectionTurnsByThreadInput, + Result: ProjectionTurnDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + pending_message_id AS "pendingMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + assistant_message_id AS "assistantMessageId", + state, + requested_at AS "requestedAt", + started_at AS "startedAt", + completed_at AS "completedAt", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "checkpointStatus", + checkpoint_files_json AS "checkpointFiles" + FROM projection_turns + WHERE thread_id = ${threadId} + ORDER BY + CASE + WHEN checkpoint_turn_count IS NULL THEN 1 + ELSE 0 + END ASC, + checkpoint_turn_count ASC, + requested_at ASC, + turn_id ASC + `, + }); + + const getProjectionTurnByTurnId = SqlSchema.findOneOption({ + Request: GetProjectionTurnByTurnIdInput, + Result: ProjectionTurnByIdDbRowSchema, + execute: ({ threadId, turnId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + pending_message_id AS "pendingMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + assistant_message_id AS "assistantMessageId", + state, + requested_at AS "requestedAt", + started_at AS "startedAt", + completed_at AS "completedAt", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "checkpointStatus", + checkpoint_files_json AS "checkpointFiles" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id = ${turnId} + LIMIT 1 + `, + }); + + const clearCheckpointTurnConflictRow = SqlSchema.void({ + Request: ClearCheckpointTurnConflictInput, + execute: ({ threadId, turnId, checkpointTurnCount }) => + sql` + UPDATE projection_turns + SET + checkpoint_turn_count = NULL, + checkpoint_ref = NULL, + checkpoint_status = NULL, + checkpoint_files_json = '[]' + WHERE thread_id = ${threadId} + AND checkpoint_turn_count = ${checkpointTurnCount} + AND (turn_id IS NULL OR turn_id <> ${turnId}) + `, + }); + + const deleteProjectionTurnsByThread = SqlSchema.void({ + Request: DeleteProjectionTurnsByThreadInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_turns + WHERE thread_id = ${threadId} + `, + }); + + const upsertByTurnId: ProjectionTurnRepository["Service"]["upsertByTurnId"] = (row) => + upsertProjectionTurnById(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionTurnRepository.upsertByTurnId:query", + "ProjectionTurnRepository.upsertByTurnId:encodeRequest", + ), + ), + ); + + const replacePendingTurnStart: ProjectionTurnRepository["Service"]["replacePendingTurnStart"] = ( + row, + ) => + sql + .withTransaction( + clearPendingProjectionTurnsByThread({ threadId: row.threadId }).pipe( + Effect.flatMap(() => insertPendingProjectionTurn(row)), + ), + ) + .pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionTurnRepository.replacePendingTurnStart:query", + "ProjectionTurnRepository.replacePendingTurnStart:encodeRequest", + ), + ), + ); + + const getPendingTurnStartByThreadId: ProjectionTurnRepository["Service"]["getPendingTurnStartByThreadId"] = + (input) => + getPendingProjectionTurn(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionTurnRepository.getPendingTurnStartByThreadId:query", + "ProjectionTurnRepository.getPendingTurnStartByThreadId:decodeRow", + ), + ), + ); + + const deletePendingTurnStartByThreadId: ProjectionTurnRepository["Service"]["deletePendingTurnStartByThreadId"] = + (input) => + clearPendingProjectionTurnsByThread(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionTurnRepository.deletePendingTurnStartByThreadId:query"), + ), + ); + + const listByThreadId: ProjectionTurnRepository["Service"]["listByThreadId"] = (input) => + listProjectionTurnsByThread(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionTurnRepository.listByThreadId:query", + "ProjectionTurnRepository.listByThreadId:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + const getByTurnId: ProjectionTurnRepository["Service"]["getByTurnId"] = (input) => + getProjectionTurnByTurnId(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionTurnRepository.getByTurnId:query", + "ProjectionTurnRepository.getByTurnId:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const clearCheckpointTurnConflict: ProjectionTurnRepository["Service"]["clearCheckpointTurnConflict"] = + (input) => + clearCheckpointTurnConflictRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionTurnRepository.clearCheckpointTurnConflict:query"), + ), + ); + + const deleteByThreadId: ProjectionTurnRepository["Service"]["deleteByThreadId"] = (input) => + deleteProjectionTurnsByThread(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionTurnRepository.deleteByThreadId:query")), + ); + + return { + upsertByTurnId, + replacePendingTurnStart, + getPendingTurnStartByThreadId, + deletePendingTurnStartByThreadId, + listByThreadId, + getByTurnId, + clearCheckpointTurnConflict, + deleteByThreadId, + } satisfies ProjectionTurnRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionTurnRepository, make); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts new file mode 100644 index 00000000000..6bbbfbd4e19 --- /dev/null +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -0,0 +1,288 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + IsoDateTime, + ProviderInstanceId, + ProviderSessionRuntimeStatus, + RuntimeMode, + ThreadId, +} from "@t3tools/contracts"; + +import { + type ProviderSessionRuntimeRepositoryError, + toPersistenceDecodeError, + toPersistenceSqlError, +} from "./Errors.ts"; + +/** + * ProviderSessionRuntimeRepository - Repository interface for provider runtime sessions. + * + * Owns persistence operations for provider runtime metadata and resume cursors. + * + * @module ProviderSessionRuntimeRepository + */ + +export const ProviderSessionRuntime = Schema.Struct({ + threadId: ThreadId, + providerName: Schema.String, + /** + * User-defined routing key for the configured provider instance that + * owns this session. Nullable only at the storage/migration boundary: + * rows persisted before the driver/instance split carry only + * `providerName`. Repository consumers must materialize a concrete + * instance id before routing. + */ + providerInstanceId: Schema.NullOr(ProviderInstanceId), + adapterKey: Schema.String, + runtimeMode: RuntimeMode, + status: ProviderSessionRuntimeStatus, + lastSeenAt: IsoDateTime, + resumeCursor: Schema.NullOr(Schema.Unknown), + runtimePayload: Schema.NullOr(Schema.Unknown), +}); +export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; + +export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); +export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; + +export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); +export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; + +/** + * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. + */ +export class ProviderSessionRuntimeRepository extends Context.Service< + ProviderSessionRuntimeRepository, + { + /** + * Insert or replace a provider runtime row. + * + * Upserts by canonical `threadId`, including JSON payload/cursor fields. + */ + readonly upsert: ( + runtime: ProviderSessionRuntime, + ) => Effect.Effect; + + /** + * Read provider runtime state by canonical thread id. + */ + readonly getByThreadId: ( + input: GetProviderSessionRuntimeInput, + ) => Effect.Effect< + Option.Option, + ProviderSessionRuntimeRepositoryError + >; + + /** + * List all provider runtime rows. + * + * Returned in ascending last-seen order. + */ + readonly list: () => Effect.Effect< + ReadonlyArray, + ProviderSessionRuntimeRepositoryError + >; + + /** + * Delete provider runtime state by canonical thread id. + */ + readonly deleteByThreadId: ( + input: DeleteProviderSessionRuntimeInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProviderSessionRuntime/ProviderSessionRuntimeRepository") {} + +const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( + Struct.assign({ + resumeCursor: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + runtimePayload: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + }), +); + +const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); + +const GetRuntimeRequestSchema = Schema.Struct({ + threadId: ThreadId, +}); + +const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): ProviderSessionRuntimeRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRuntimeRow = SqlSchema.void({ + Request: ProviderSessionRuntimeDbRowSchema, + execute: (runtime) => + sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${runtime.threadId}, + ${runtime.providerName}, + ${runtime.providerInstanceId}, + ${runtime.adapterKey}, + ${runtime.runtimeMode}, + ${runtime.status}, + ${runtime.lastSeenAt}, + ${runtime.resumeCursor}, + ${runtime.runtimePayload} + ) + ON CONFLICT (thread_id) + DO UPDATE SET + provider_name = excluded.provider_name, + provider_instance_id = excluded.provider_instance_id, + adapter_key = excluded.adapter_key, + runtime_mode = excluded.runtime_mode, + status = excluded.status, + last_seen_at = excluded.last_seen_at, + resume_cursor_json = excluded.resume_cursor_json, + runtime_payload_json = excluded.runtime_payload_json + `, + }); + + const getRuntimeRowByThreadId = SqlSchema.findOneOption({ + Request: GetRuntimeRequestSchema, + Result: ProviderSessionRuntimeDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", + adapter_key AS "adapterKey", + runtime_mode AS "runtimeMode", + status, + last_seen_at AS "lastSeenAt", + resume_cursor_json AS "resumeCursor", + runtime_payload_json AS "runtimePayload" + FROM provider_session_runtime + WHERE thread_id = ${threadId} + `, + }); + + const listRuntimeRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProviderSessionRuntimeDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", + adapter_key AS "adapterKey", + runtime_mode AS "runtimeMode", + status, + last_seen_at AS "lastSeenAt", + resume_cursor_json AS "resumeCursor", + runtime_payload_json AS "runtimePayload" + FROM provider_session_runtime + ORDER BY last_seen_at ASC, thread_id ASC + `, + }); + + const deleteRuntimeByThreadId = SqlSchema.void({ + Request: DeleteRuntimeRequestSchema, + execute: ({ threadId }) => + sql` + DELETE FROM provider_session_runtime + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => + upsertRuntimeRow(runtime).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.upsert:query", + "ProviderSessionRuntimeRepository.upsert:encodeRequest", + ), + ), + ); + + const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => + getRuntimeRowByThreadId(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.getByThreadId:query", + "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", + ), + ), + Effect.flatMap((runtimeRowOption) => + Option.match(runtimeRowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeRuntime(row).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", + ), + ), + Effect.map((runtime) => Option.some(runtime)), + ), + }), + ), + ); + + const list: ProviderSessionRuntimeRepository["Service"]["list"] = () => + listRuntimeRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.list:query", + "ProviderSessionRuntimeRepository.list:decodeRows", + ), + ), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => + decodeRuntime(row).pipe( + Effect.mapError( + toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), + ), + ), + { concurrency: "unbounded" }, + ), + ), + ); + + const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteRuntimeByThreadId(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + getByThreadId, + list, + deleteByThreadId, + } satisfies ProviderSessionRuntimeRepository["Service"]; +}); + +export const layer = Layer.effect(ProviderSessionRuntimeRepository, make); diff --git a/apps/server/src/persistence/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts deleted file mode 100644 index c8745982d29..00000000000 --- a/apps/server/src/persistence/Services/AuthPairingLinks.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import { AuthEnvironmentScopes } from "@t3tools/contracts"; - -import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; - -export const AuthPairingLinkRecord = Schema.Struct({ - id: Schema.String, - credential: Schema.String, - method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), - scopes: Schema.fromJsonString(AuthEnvironmentScopes), - subject: Schema.String, - label: Schema.NullOr(Schema.String), - proofKeyThumbprint: Schema.NullOr(Schema.String), - createdAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, - consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), - revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), -}); -export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; - -export const CreateAuthPairingLinkInput = Schema.Struct({ - id: Schema.String, - credential: Schema.String, - method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), - scopes: AuthEnvironmentScopes, - subject: Schema.String, - label: Schema.NullOr(Schema.String), - proofKeyThumbprint: Schema.NullOr(Schema.String), - createdAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, -}); -export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; - -export const ConsumeAuthPairingLinkInput = Schema.Struct({ - credential: Schema.String, - proofKeyThumbprint: Schema.NullOr(Schema.String), - consumedAt: Schema.DateTimeUtcFromString, - now: Schema.DateTimeUtcFromString, -}); -export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type; - -export const ListActiveAuthPairingLinksInput = Schema.Struct({ - now: Schema.DateTimeUtcFromString, -}); -export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type; - -export const RevokeAuthPairingLinkInput = Schema.Struct({ - id: Schema.String, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; - -export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ - credential: Schema.String, -}); -export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; - -export interface AuthPairingLinkRepositoryShape { - readonly create: ( - input: CreateAuthPairingLinkInput, - ) => Effect.Effect; - readonly consumeAvailable: ( - input: ConsumeAuthPairingLinkInput, - ) => Effect.Effect, AuthPairingLinkRepositoryError>; - readonly listActive: ( - input: ListActiveAuthPairingLinksInput, - ) => Effect.Effect, AuthPairingLinkRepositoryError>; - readonly revoke: ( - input: RevokeAuthPairingLinkInput, - ) => Effect.Effect; - readonly getByCredential: ( - input: GetAuthPairingLinkByCredentialInput, - ) => Effect.Effect, AuthPairingLinkRepositoryError>; -} - -export class AuthPairingLinkRepository extends Context.Service< - AuthPairingLinkRepository, - AuthPairingLinkRepositoryShape ->()("t3/persistence/Services/AuthPairingLinks/AuthPairingLinkRepository") {} diff --git a/apps/server/src/persistence/Services/AuthSessions.ts b/apps/server/src/persistence/Services/AuthSessions.ts deleted file mode 100644 index c08956bdd71..00000000000 --- a/apps/server/src/persistence/Services/AuthSessions.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - AuthClientMetadataDeviceType, - AuthEnvironmentScopes, - AuthSessionId, - ServerAuthSessionMethod, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { AuthSessionRepositoryError } from "../Errors.ts"; - -export const AuthSessionClientMetadataRecord = Schema.Struct({ - label: Schema.NullOr(Schema.String), - ipAddress: Schema.NullOr(Schema.String), - userAgent: Schema.NullOr(Schema.String), - deviceType: AuthClientMetadataDeviceType, - os: Schema.NullOr(Schema.String), - browser: Schema.NullOr(Schema.String), -}); -export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type; - -export const AuthSessionRecord = Schema.Struct({ - sessionId: AuthSessionId, - subject: Schema.String, - scopes: AuthEnvironmentScopes, - method: ServerAuthSessionMethod, - client: AuthSessionClientMetadataRecord, - issuedAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, - lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), - revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), -}); -export type AuthSessionRecord = typeof AuthSessionRecord.Type; - -export const CreateAuthSessionInput = Schema.Struct({ - sessionId: AuthSessionId, - subject: Schema.String, - scopes: AuthEnvironmentScopes, - method: ServerAuthSessionMethod, - client: AuthSessionClientMetadataRecord, - issuedAt: Schema.DateTimeUtcFromString, - expiresAt: Schema.DateTimeUtcFromString, -}); -export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; - -export const GetAuthSessionByIdInput = Schema.Struct({ - sessionId: AuthSessionId, -}); -export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; - -export const ListActiveAuthSessionsInput = Schema.Struct({ - now: Schema.DateTimeUtcFromString, -}); -export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; - -export const RevokeAuthSessionInput = Schema.Struct({ - sessionId: AuthSessionId, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; - -export const RevokeOtherAuthSessionsInput = Schema.Struct({ - currentSessionId: AuthSessionId, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; - -export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ - sessionId: AuthSessionId, - lastConnectedAt: Schema.DateTimeUtcFromString, -}); -export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; - -export interface AuthSessionRepositoryShape { - readonly create: ( - input: CreateAuthSessionInput, - ) => Effect.Effect; - readonly getById: ( - input: GetAuthSessionByIdInput, - ) => Effect.Effect, AuthSessionRepositoryError>; - readonly listActive: ( - input: ListActiveAuthSessionsInput, - ) => Effect.Effect, AuthSessionRepositoryError>; - readonly revoke: ( - input: RevokeAuthSessionInput, - ) => Effect.Effect; - readonly revokeAllExcept: ( - input: RevokeOtherAuthSessionsInput, - ) => Effect.Effect, AuthSessionRepositoryError>; - readonly setLastConnectedAt: ( - input: SetAuthSessionLastConnectedAtInput, - ) => Effect.Effect; -} - -export class AuthSessionRepository extends Context.Service< - AuthSessionRepository, - AuthSessionRepositoryShape ->()("t3/persistence/Services/AuthSessions/AuthSessionRepository") {} diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index 1498984827e..96a000b1402 100644 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -1,71 +1 @@ -/** - * OrchestrationCommandReceiptRepository - Repository interface for command receipts. - * - * Owns persistence operations for deduplication and status tracking of - * orchestration command handling. - * - * @module OrchestrationCommandReceiptRepository - */ -import { - CommandId, - IsoDateTime, - NonNegativeInt, - OrchestrationAggregateKind, - OrchestrationCommandReceiptStatus, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; - -export const OrchestrationCommandReceipt = Schema.Struct({ - commandId: CommandId, - aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), - acceptedAt: IsoDateTime, - resultSequence: NonNegativeInt, - status: OrchestrationCommandReceiptStatus, - error: Schema.NullOr(Schema.String), -}); -export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; - -export const GetByCommandIdInput = Schema.Struct({ - commandId: CommandId, -}); -export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; - -/** - * OrchestrationCommandReceiptRepositoryShape - Service API for command receipts. - */ -export interface OrchestrationCommandReceiptRepositoryShape { - /** - * Insert or replace a command receipt row. - * - * Upserts by `commandId` for idempotent command-result tracking. - */ - readonly upsert: ( - receipt: OrchestrationCommandReceipt, - ) => Effect.Effect; - - /** - * Read a command receipt by command id. - */ - readonly getByCommandId: ( - input: GetByCommandIdInput, - ) => Effect.Effect< - Option.Option, - OrchestrationCommandReceiptRepositoryError - >; -} - -/** - * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. - */ -export class OrchestrationCommandReceiptRepository extends Context.Service< - OrchestrationCommandReceiptRepository, - OrchestrationCommandReceiptRepositoryShape ->()("t3/persistence/Services/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} +export { OrchestrationCommandReceiptRepository } from "../OrchestrationCommandReceipts.ts"; diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 8b465e7713e..e5cbf0276c2 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -1,71 +1,10 @@ -/** - * OrchestrationEventStore - Event store interface for orchestration events. - * - * Owns durable append/replay access to the orchestration event stream. It does - * not reduce events into read models or apply command validation rules. - * - * Uses Effect `Context.Service` for dependency injection and exposes typed - * persistence/decode errors for event append and replay operations. - * - * @module OrchestrationEventStore - */ -import { OrchestrationEvent } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; -import type * as Stream from "effect/Stream"; - -import type { OrchestrationEventStoreError } from "../Errors.ts"; - -/** - * OrchestrationEventStoreShape - Service API for orchestration event persistence. - */ -export interface OrchestrationEventStoreShape { - /** - * Persist a new orchestration event. - * - * @param event - Event payload without sequence (assigned by storage). - * @returns Effect containing the stored event with assigned sequence. - * - * Actor kind is inferred from command/metadata before persistence. - */ - readonly append: ( - event: Omit, - ) => Effect.Effect; - - /** - * Replay events after the provided sequence. - * - * @param sequenceExclusive - Sequence cursor (exclusive). - * @param limit - Maximum number of events to emit. - * @returns Stream containing ordered events. - * - * Reads in fixed-size pages and normalizes non-integer/negative limits. - */ - readonly readFromSequence: ( - sequenceExclusive: number, - limit?: number, - ) => Stream.Stream; +import type * as OrchestrationEventStoreModule from "../OrchestrationEventStore.ts"; - /** - * Read all events from the beginning of the stream. - * - * @returns Stream containing all stored events. - */ - readonly readAll: () => Stream.Stream; -} +export { OrchestrationEventStore } from "../OrchestrationEventStore.ts"; /** - * OrchestrationEventStore - Service tag for orchestration event persistence. - * - * @example - * ```ts - * const program = Effect.gen(function* () { - * const events = yield* OrchestrationEventStore - * return yield* Stream.runCollect(events.readAll()) - * }) - * ``` + * @deprecated Compatibility alias for the excluded orchestration tests only. + * In-scope consumers must use `OrchestrationEventStore["Service"]`. */ -export class OrchestrationEventStore extends Context.Service< - OrchestrationEventStore, - OrchestrationEventStoreShape ->()("t3/persistence/Services/OrchestrationEventStore") {} +export type OrchestrationEventStoreShape = + OrchestrationEventStoreModule.OrchestrationEventStore["Service"]; diff --git a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts index 7796ebec2a8..4b6514e451b 100644 --- a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts @@ -1,95 +1,5 @@ /** - * ProjectionCheckpointRepository - Projection repository interface for checkpoints. - * - * Owns persistence operations for projected checkpoint summaries in thread - * timelines. - * - * @module ProjectionCheckpointRepository + * @deprecated Compatibility exports for the excluded orchestration modules and integration + * harness. In-scope consumers must import the canonical module namespace. */ -import { - CheckpointRef, - IsoDateTime, - MessageId, - NonNegativeInt, - OrchestrationCheckpointFile, - OrchestrationCheckpointStatus, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Context from "effect/Context"; -import * as Schema from "effect/Schema"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionCheckpoint = Schema.Struct({ - threadId: ThreadId, - turnId: TurnId, - checkpointTurnCount: NonNegativeInt, - checkpointRef: CheckpointRef, - status: OrchestrationCheckpointStatus, - files: Schema.Array(OrchestrationCheckpointFile), - assistantMessageId: Schema.NullOr(MessageId), - completedAt: IsoDateTime, -}); -export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type; - -export const ListByThreadIdInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListByThreadIdInput = typeof ListByThreadIdInput.Type; - -export const GetByThreadAndTurnCountInput = Schema.Struct({ - threadId: ThreadId, - checkpointTurnCount: NonNegativeInt, -}); -export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type; - -export const DeleteByThreadIdInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type; - -/** - * ProjectionCheckpointRepositoryShape - Service API for projected checkpoints. - */ -export interface ProjectionCheckpointRepositoryShape { - /** - * Insert or replace a projected checkpoint row. - * - * Upserts by composite key `(threadId, checkpointTurnCount)`. - */ - readonly upsert: (row: ProjectionCheckpoint) => Effect.Effect; - - /** - * List projected checkpoints for a thread. - * - * Returned in ascending checkpoint turn-count order. - */ - readonly listByThreadId: ( - input: ListByThreadIdInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Read a projected checkpoint by thread and turn-count key. - */ - readonly getByThreadAndTurnCount: ( - input: GetByThreadAndTurnCountInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete projected checkpoint rows by thread. - */ - readonly deleteByThreadId: ( - input: DeleteByThreadIdInput, - ) => Effect.Effect; -} - -/** - * ProjectionCheckpointRepository - Service tag for checkpoint projection persistence. - */ -export class ProjectionCheckpointRepository extends Context.Service< - ProjectionCheckpointRepository, - ProjectionCheckpointRepositoryShape ->()("t3/persistence/Services/ProjectionCheckpoints/ProjectionCheckpointRepository") {} +export { ProjectionCheckpoint, ProjectionCheckpointRepository } from "../ProjectionCheckpoints.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts index 967e6da9d3a..87005833d3c 100644 --- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -1,93 +1 @@ -/** - * ProjectionPendingApprovalRepository - Repository interface for pending approvals. - * - * Owns persistence operations for projected approval requests awaiting user - * decisions. - * - * @module ProjectionPendingApprovalRepository - */ -import { - ApprovalRequestId, - IsoDateTime, - ProjectionPendingApprovalDecision, - ProjectionPendingApprovalStatus, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionPendingApproval = Schema.Struct({ - requestId: ApprovalRequestId, - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - status: ProjectionPendingApprovalStatus, - decision: ProjectionPendingApprovalDecision, - createdAt: IsoDateTime, - resolvedAt: Schema.NullOr(IsoDateTime), -}); -export type ProjectionPendingApproval = typeof ProjectionPendingApproval.Type; - -export const ListProjectionPendingApprovalsInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionPendingApprovalsInput = typeof ListProjectionPendingApprovalsInput.Type; - -export const GetProjectionPendingApprovalInput = Schema.Struct({ - requestId: ApprovalRequestId, -}); -export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type; - -export const DeleteProjectionPendingApprovalInput = Schema.Struct({ - requestId: ApprovalRequestId, -}); -export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type; - -/** - * ProjectionPendingApprovalRepositoryShape - Service API for pending approvals. - */ -export interface ProjectionPendingApprovalRepositoryShape { - /** - * Insert or replace a projected pending approval row. - * - * Upserts by `requestId`. - */ - readonly upsert: ( - row: ProjectionPendingApproval, - ) => Effect.Effect; - - /** - * List pending approvals for a thread. - * - * Returned in ascending creation order. - */ - readonly listByThreadId: ( - input: ListProjectionPendingApprovalsInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Read a pending approval row by request id. - */ - readonly getByRequestId: ( - input: GetProjectionPendingApprovalInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete a pending approval row by request id. - */ - readonly deleteByRequestId: ( - input: DeleteProjectionPendingApprovalInput, - ) => Effect.Effect; -} - -/** - * ProjectionPendingApprovalRepository - Service tag for pending approval persistence. - */ -export class ProjectionPendingApprovalRepository extends Context.Service< - ProjectionPendingApprovalRepository, - ProjectionPendingApprovalRepositoryShape ->()("t3/persistence/Services/ProjectionPendingApprovals/ProjectionPendingApprovalRepository") {} +export { ProjectionPendingApprovalRepository } from "../ProjectionPendingApprovals.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 5632205a269..7e1d9eb45a8 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -1,81 +1 @@ -/** - * ProjectionProjectRepository - Projection repository interface for projects. - * - * Owns persistence operations for project rows in the orchestration projection - * read model. - * - * @module ProjectionProjectRepository - */ -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionProject = Schema.Struct({ - projectId: ProjectId, - title: Schema.String, - workspaceRoot: Schema.String, - defaultModelSelection: Schema.NullOr(ModelSelection), - scripts: Schema.Array(ProjectScript), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, - deletedAt: Schema.NullOr(IsoDateTime), -}); -export type ProjectionProject = typeof ProjectionProject.Type; - -export const GetProjectionProjectInput = Schema.Struct({ - projectId: ProjectId, -}); -export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type; - -export const DeleteProjectionProjectInput = Schema.Struct({ - projectId: ProjectId, -}); -export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type; - -/** - * ProjectionProjectRepositoryShape - Service API for projected project records. - */ -export interface ProjectionProjectRepositoryShape { - /** - * Insert or replace a projected project row. - * - * Upserts by `projectId` and persists scripts through JSON encoding. - */ - readonly upsert: (row: ProjectionProject) => Effect.Effect; - - /** - * Read a projected project row by id. - */ - readonly getById: ( - input: GetProjectionProjectInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * List all projected project rows. - * - * Returned in deterministic creation order. - */ - readonly listAll: () => Effect.Effect< - ReadonlyArray, - ProjectionRepositoryError - >; - - /** - * Soft-delete a projected project row by id. - */ - readonly deleteById: ( - input: DeleteProjectionProjectInput, - ) => Effect.Effect; -} - -/** - * ProjectionProjectRepository - Service tag for project projection persistence. - */ -export class ProjectionProjectRepository extends Context.Service< - ProjectionProjectRepository, - ProjectionProjectRepositoryShape ->()("t3/persistence/Services/ProjectionProjects/ProjectionProjectRepository") {} +export { ProjectionProject, ProjectionProjectRepository } from "../ProjectionProjects.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionState.ts b/apps/server/src/persistence/Services/ProjectionState.ts index ba1ca151736..774ade7e5fa 100644 --- a/apps/server/src/persistence/Services/ProjectionState.ts +++ b/apps/server/src/persistence/Services/ProjectionState.ts @@ -1,66 +1 @@ -/** - * ProjectionStateRepository - Projection repository interface for projector cursors. - * - * Owns persistence operations for projection cursor state used to resume - * incremental event projection. - * - * @module ProjectionStateRepository - */ -import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionState = Schema.Struct({ - projector: Schema.String, - lastAppliedSequence: NonNegativeInt, - updatedAt: IsoDateTime, -}); -export type ProjectionState = typeof ProjectionState.Type; - -export const GetProjectionStateInput = Schema.Struct({ - projector: Schema.String, -}); -export type GetProjectionStateInput = typeof GetProjectionStateInput.Type; - -/** - * ProjectionStateRepositoryShape - Service API for projector state records. - */ -export interface ProjectionStateRepositoryShape { - /** - * Insert or replace a projection cursor row. - * - * Upserts by projector name. - */ - readonly upsert: (row: ProjectionState) => Effect.Effect; - - /** - * Read projection cursor state for a projector key. - */ - readonly getByProjector: ( - input: GetProjectionStateInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * List all projector cursor rows. - */ - readonly listAll: () => Effect.Effect, ProjectionRepositoryError>; - - /** - * Read the minimum applied sequence across all projectors. - * - * Returns `null` when no projector state rows exist. - */ - readonly minLastAppliedSequence: () => Effect.Effect; -} - -/** - * ProjectionStateRepository - Service tag for projection cursor persistence. - */ -export class ProjectionStateRepository extends Context.Service< - ProjectionStateRepository, - ProjectionStateRepositoryShape ->()("t3/persistence/Services/ProjectionState/ProjectionStateRepository") {} +export { ProjectionState, ProjectionStateRepository } from "../ProjectionState.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 47cb6073c47..1bdb2f7dcf6 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -1,84 +1,4 @@ -/** - * ProjectionThreadActivityRepository - Projection repository interface for thread activity. - * - * Owns persistence operations for activity timeline entries projected from - * orchestration events. - * - * @module ProjectionThreadActivityRepository - */ -import { - EventId, - IsoDateTime, - NonNegativeInt, - OrchestrationThreadActivityTone, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionThreadActivity = Schema.Struct({ - activityId: EventId, - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - tone: OrchestrationThreadActivityTone, - kind: Schema.String, - summary: Schema.String, - payload: Schema.Unknown, - sequence: Schema.optional(NonNegativeInt), - createdAt: IsoDateTime, -}); -export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; - -export const ListProjectionThreadActivitiesInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; - -export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadActivitiesInput = - typeof DeleteProjectionThreadActivitiesInput.Type; - -/** - * ProjectionThreadActivityRepositoryShape - Service API for projected thread activity. - */ -export interface ProjectionThreadActivityRepositoryShape { - /** - * Insert or replace a projected thread activity row. - * - * Upserts by `activityId` and JSON-encodes payload. - */ - readonly upsert: ( - row: ProjectionThreadActivity, - ) => Effect.Effect; - - /** - * List projected thread activity rows for a thread. - * - * Returned in ascending runtime sequence order (or creation order when - * sequence is unavailable). - */ - readonly listByThreadId: ( - input: ListProjectionThreadActivitiesInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete projected thread activity rows by thread. - */ - readonly deleteByThreadId: ( - input: DeleteProjectionThreadActivitiesInput, - ) => Effect.Effect; -} - -/** - * ProjectionThreadActivityRepository - Service tag for thread activity persistence. - */ -export class ProjectionThreadActivityRepository extends Context.Service< +export { + ProjectionThreadActivity, ProjectionThreadActivityRepository, - ProjectionThreadActivityRepositoryShape ->()("t3/persistence/Services/ProjectionThreadActivities/ProjectionThreadActivityRepository") {} +} from "../ProjectionThreadActivities.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff320256..fa90abc853d 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -1,95 +1,4 @@ -/** - * ProjectionThreadMessageRepository - Projection repository interface for messages. - * - * Owns persistence operations for projected thread messages rendered in the - * orchestration read model. - * - * @module ProjectionThreadMessageRepository - */ -import { - ChatAttachment, - MessageId, - OrchestrationMessageRole, - ThreadId, - TurnId, - IsoDateTime, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Option from "effect/Option"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionThreadMessage = Schema.Struct({ - messageId: MessageId, - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - role: OrchestrationMessageRole, - text: Schema.String, - attachments: Schema.optional(Schema.Array(ChatAttachment)), - isStreaming: Schema.Boolean, - createdAt: IsoDateTime, - updatedAt: IsoDateTime, -}); -export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; - -export const ListProjectionThreadMessagesInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; - -export const GetProjectionThreadMessageInput = Schema.Struct({ - messageId: MessageId, -}); -export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; - -export const DeleteProjectionThreadMessagesInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type; - -/** - * ProjectionThreadMessageRepositoryShape - Service API for projected thread messages. - */ -export interface ProjectionThreadMessageRepositoryShape { - /** - * Insert or replace a projected thread message row. - * - * Upserts by `messageId`. - */ - readonly upsert: ( - message: ProjectionThreadMessage, - ) => Effect.Effect; - - /** - * Read a projected thread message by id. - */ - readonly getByMessageId: ( - input: GetProjectionThreadMessageInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * List projected thread messages for a thread. - * - * Returned in ascending creation order. - */ - readonly listByThreadId: ( - input: ListProjectionThreadMessagesInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete projected thread messages by thread. - */ - readonly deleteByThreadId: ( - input: DeleteProjectionThreadMessagesInput, - ) => Effect.Effect; -} - -/** - * ProjectionThreadMessageRepository - Service tag for message projection persistence. - */ -export class ProjectionThreadMessageRepository extends Context.Service< +export { + ProjectionThreadMessage, ProjectionThreadMessageRepository, - ProjectionThreadMessageRepositoryShape ->()("t3/persistence/Services/ProjectionThreadMessages/ProjectionThreadMessageRepository") {} +} from "../ProjectionThreadMessages.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index b4bc2bcc328..5ab37bf0d3b 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -1,55 +1,4 @@ -import { - IsoDateTime, - OrchestrationProposedPlanId, - ThreadId, - TrimmedNonEmptyString, - TurnId, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionThreadProposedPlan = Schema.Struct({ - planId: OrchestrationProposedPlanId, - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - planMarkdown: TrimmedNonEmptyString, - implementedAt: Schema.NullOr(IsoDateTime), - implementationThreadId: Schema.NullOr(ThreadId), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, -}); -export type ProjectionThreadProposedPlan = typeof ProjectionThreadProposedPlan.Type; - -export const ListProjectionThreadProposedPlansInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionThreadProposedPlansInput = - typeof ListProjectionThreadProposedPlansInput.Type; - -export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadProposedPlansInput = - typeof DeleteProjectionThreadProposedPlansInput.Type; - -export interface ProjectionThreadProposedPlanRepositoryShape { - readonly upsert: ( - proposedPlan: ProjectionThreadProposedPlan, - ) => Effect.Effect; - readonly listByThreadId: ( - input: ListProjectionThreadProposedPlansInput, - ) => Effect.Effect, ProjectionRepositoryError>; - readonly deleteByThreadId: ( - input: DeleteProjectionThreadProposedPlansInput, - ) => Effect.Effect; -} - -export class ProjectionThreadProposedPlanRepository extends Context.Service< +export { + ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, - ProjectionThreadProposedPlanRepositoryShape ->()( - "t3/persistence/Services/ProjectionThreadProposedPlans/ProjectionThreadProposedPlanRepository", -) {} +} from "../ProjectionThreadProposedPlans.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 7cecac33eb6..7898c92a1fd 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -1,78 +1,4 @@ -/** - * ProjectionThreadSessionRepository - Repository interface for thread sessions. - * - * Owns persistence operations for projected provider-session linkage and - * runtime status for each thread. - * - * @module ProjectionThreadSessionRepository - */ -import { - RuntimeMode, - IsoDateTime, - OrchestrationSessionStatus, - ProviderInstanceId, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionThreadSession = Schema.Struct({ - threadId: ThreadId, - status: OrchestrationSessionStatus, - providerName: Schema.NullOr(Schema.String), - providerInstanceId: Schema.NullOr(ProviderInstanceId), - runtimeMode: RuntimeMode, - activeTurnId: Schema.NullOr(TurnId), - lastError: Schema.NullOr(Schema.String), - updatedAt: IsoDateTime, -}); -export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; - -export const GetProjectionThreadSessionInput = Schema.Struct({ - threadId: ThreadId, -}); -export type GetProjectionThreadSessionInput = typeof GetProjectionThreadSessionInput.Type; - -export const DeleteProjectionThreadSessionInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadSessionInput = typeof DeleteProjectionThreadSessionInput.Type; - -/** - * ProjectionThreadSessionRepositoryShape - Service API for projected thread sessions. - */ -export interface ProjectionThreadSessionRepositoryShape { - /** - * Insert or replace a projected thread-session row. - * - * Upserts by `threadId`. - */ - readonly upsert: (row: ProjectionThreadSession) => Effect.Effect; - - /** - * Read projected thread-session state by thread id. - */ - readonly getByThreadId: ( - input: GetProjectionThreadSessionInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete projected thread-session state by thread id. - */ - readonly deleteByThreadId: ( - input: DeleteProjectionThreadSessionInput, - ) => Effect.Effect; -} - -/** - * ProjectionThreadSessionRepository - Service tag for thread-session persistence. - */ -export class ProjectionThreadSessionRepository extends Context.Service< +export { + ProjectionThreadSession, ProjectionThreadSessionRepository, - ProjectionThreadSessionRepositoryShape ->()("t3/persistence/Services/ProjectionThreadSessions/ProjectionThreadSessionRepository") {} +} from "../ProjectionThreadSessions.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..f417e53fc3c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -1,103 +1 @@ -/** - * ProjectionThreadRepository - Projection repository interface for threads. - * - * Owns persistence operations for projected thread records in the - * orchestration read model. - * - * @module ProjectionThreadRepository - */ -import { - IsoDateTime, - ModelSelection, - NonNegativeInt, - ProjectId, - ProviderInteractionMode, - RuntimeMode, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionThread = Schema.Struct({ - threadId: ThreadId, - projectId: ProjectId, - title: Schema.String, - modelSelection: ModelSelection, - runtimeMode: RuntimeMode, - interactionMode: ProviderInteractionMode, - branch: Schema.NullOr(Schema.String), - worktreePath: Schema.NullOr(Schema.String), - latestTurnId: Schema.NullOr(TurnId), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, - archivedAt: Schema.NullOr(IsoDateTime), - latestUserMessageAt: Schema.NullOr(IsoDateTime), - pendingApprovalCount: NonNegativeInt, - pendingUserInputCount: NonNegativeInt, - hasActionableProposedPlan: NonNegativeInt, - deletedAt: Schema.NullOr(IsoDateTime), -}); -export type ProjectionThread = typeof ProjectionThread.Type; - -export const GetProjectionThreadInput = Schema.Struct({ - threadId: ThreadId, -}); -export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type; - -export const DeleteProjectionThreadInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type; - -export const ListProjectionThreadsByProjectInput = Schema.Struct({ - projectId: ProjectId, -}); -export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; - -/** - * ProjectionThreadRepositoryShape - Service API for projected thread records. - */ -export interface ProjectionThreadRepositoryShape { - /** - * Insert or replace a projected thread row. - * - * Upserts by `threadId`. - */ - readonly upsert: (thread: ProjectionThread) => Effect.Effect; - - /** - * Read a projected thread row by id. - */ - readonly getById: ( - input: GetProjectionThreadInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * List projected threads for a project. - * - * Returned in deterministic creation order. - */ - readonly listByProjectId: ( - input: ListProjectionThreadsByProjectInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Soft-delete a projected thread row by id. - */ - readonly deleteById: ( - input: DeleteProjectionThreadInput, - ) => Effect.Effect; -} - -/** - * ProjectionThreadRepository - Service tag for thread projection persistence. - */ -export class ProjectionThreadRepository extends Context.Service< - ProjectionThreadRepository, - ProjectionThreadRepositoryShape ->()("t3/persistence/Services/ProjectionThreads/ProjectionThreadRepository") {} +export { ProjectionThread, ProjectionThreadRepository } from "../ProjectionThreads.ts"; diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index f3d5d5e4706..894dfb267ba 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -1,170 +1 @@ -/** - * ProjectionTurnRepository - Projection repository interface for unified turn state. - * - * Owns persistence operations for pending starts, running/completed turn lifecycle, - * and checkpoint metadata in a single projection table. - * - * @module ProjectionTurnRepository - */ -import { - CheckpointRef, - IsoDateTime, - MessageId, - NonNegativeInt, - OrchestrationProposedPlanId, - OrchestrationCheckpointFile, - OrchestrationCheckpointStatus, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProjectionRepositoryError } from "../Errors.ts"; - -export const ProjectionTurnState = Schema.Literals([ - "pending", - "running", - "interrupted", - "completed", - "error", -]); -export type ProjectionTurnState = typeof ProjectionTurnState.Type; - -export const ProjectionTurn = Schema.Struct({ - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - pendingMessageId: Schema.NullOr(MessageId), - sourceProposedPlanThreadId: Schema.NullOr(ThreadId), - sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), - assistantMessageId: Schema.NullOr(MessageId), - state: ProjectionTurnState, - requestedAt: IsoDateTime, - startedAt: Schema.NullOr(IsoDateTime), - completedAt: Schema.NullOr(IsoDateTime), - checkpointTurnCount: Schema.NullOr(NonNegativeInt), - checkpointRef: Schema.NullOr(CheckpointRef), - checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), - checkpointFiles: Schema.Array(OrchestrationCheckpointFile), -}); -export type ProjectionTurn = typeof ProjectionTurn.Type; - -export const ProjectionTurnById = Schema.Struct({ - threadId: ThreadId, - turnId: TurnId, - pendingMessageId: Schema.NullOr(MessageId), - sourceProposedPlanThreadId: Schema.NullOr(ThreadId), - sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), - assistantMessageId: Schema.NullOr(MessageId), - state: ProjectionTurnState, - requestedAt: IsoDateTime, - startedAt: Schema.NullOr(IsoDateTime), - completedAt: Schema.NullOr(IsoDateTime), - checkpointTurnCount: Schema.NullOr(NonNegativeInt), - checkpointRef: Schema.NullOr(CheckpointRef), - checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), - checkpointFiles: Schema.Array(OrchestrationCheckpointFile), -}); -export type ProjectionTurnById = typeof ProjectionTurnById.Type; - -export const ProjectionPendingTurnStart = Schema.Struct({ - threadId: ThreadId, - messageId: MessageId, - sourceProposedPlanThreadId: Schema.NullOr(ThreadId), - sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), - requestedAt: IsoDateTime, -}); -export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type; - -export const ListProjectionTurnsByThreadInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionTurnsByThreadInput = typeof ListProjectionTurnsByThreadInput.Type; - -export const GetProjectionTurnByTurnIdInput = Schema.Struct({ - threadId: ThreadId, - turnId: TurnId, -}); -export type GetProjectionTurnByTurnIdInput = typeof GetProjectionTurnByTurnIdInput.Type; - -export const GetProjectionPendingTurnStartInput = Schema.Struct({ - threadId: ThreadId, -}); -export type GetProjectionPendingTurnStartInput = typeof GetProjectionPendingTurnStartInput.Type; - -export const DeleteProjectionTurnsByThreadInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionTurnsByThreadInput = typeof DeleteProjectionTurnsByThreadInput.Type; - -export const ClearCheckpointTurnConflictInput = Schema.Struct({ - threadId: ThreadId, - turnId: TurnId, - checkpointTurnCount: NonNegativeInt, -}); -export type ClearCheckpointTurnConflictInput = typeof ClearCheckpointTurnConflictInput.Type; - -export interface ProjectionTurnRepositoryShape { - /** - * Inserts or updates the canonical row for a concrete `{threadId, turnId}` turn lifecycle state. - */ - readonly upsertByTurnId: ( - row: ProjectionTurnById, - ) => Effect.Effect; - - /** - * Replaces any existing pending-start placeholder rows for a thread with exactly one latest pending-start row. - */ - readonly replacePendingTurnStart: ( - row: ProjectionPendingTurnStart, - ) => Effect.Effect; - - /** - * Returns the newest pending-start placeholder for a thread; this is expected to be at most one row after replacement writes. - */ - readonly getPendingTurnStartByThreadId: ( - input: GetProjectionPendingTurnStartInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Deletes only pending-start placeholder rows (`turnId = null`) for a thread and leaves concrete turn rows untouched. - */ - readonly deletePendingTurnStartByThreadId: ( - input: GetProjectionPendingTurnStartInput, - ) => Effect.Effect; - - /** - * Lists all projection rows for a thread, including pending placeholders, with checkpoint rows ordered before non-checkpoint rows. - */ - readonly listByThreadId: ( - input: ListProjectionTurnsByThreadInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Looks up a concrete turn row by `{threadId, turnId}` and never returns pending placeholder rows. - */ - readonly getByTurnId: ( - input: GetProjectionTurnByTurnIdInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Clears checkpoint fields on conflicting rows that reuse the same checkpoint turn count in a thread, excluding the provided turn. - */ - readonly clearCheckpointTurnConflict: ( - input: ClearCheckpointTurnConflictInput, - ) => Effect.Effect; - - /** - * Hard-deletes all projection rows for a thread, including pending-start placeholders and checkpoint metadata rows. - */ - readonly deleteByThreadId: ( - input: DeleteProjectionTurnsByThreadInput, - ) => Effect.Effect; -} - -export class ProjectionTurnRepository extends Context.Service< - ProjectionTurnRepository, - ProjectionTurnRepositoryShape ->()("t3/persistence/Services/ProjectionTurns/ProjectionTurnRepository") {} +export { ProjectionTurn, ProjectionTurnRepository } from "../ProjectionTurns.ts"; diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts deleted file mode 100644 index 125f4fa5bbf..00000000000 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * ProviderSessionRuntimeRepository - Repository interface for provider runtime sessions. - * - * Owns persistence operations for provider runtime metadata and resume cursors. - * - * @module ProviderSessionRuntimeRepository - */ -import { - IsoDateTime, - ProviderInstanceId, - ProviderSessionRuntimeStatus, - RuntimeMode, - ThreadId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; - -export const ProviderSessionRuntime = Schema.Struct({ - threadId: ThreadId, - providerName: Schema.String, - /** - * User-defined routing key for the configured provider instance that - * owns this session. Nullable only at the storage/migration boundary: - * rows persisted before the driver/instance split carry only - * `providerName`. Repository consumers must materialize a concrete - * instance id before routing. - */ - providerInstanceId: Schema.NullOr(ProviderInstanceId), - adapterKey: Schema.String, - runtimeMode: RuntimeMode, - status: ProviderSessionRuntimeStatus, - lastSeenAt: IsoDateTime, - resumeCursor: Schema.NullOr(Schema.Unknown), - runtimePayload: Schema.NullOr(Schema.Unknown), -}); -export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; - -export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); -export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; - -export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); -export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; - -/** - * ProviderSessionRuntimeRepositoryShape - Service API for provider runtime records. - */ -export interface ProviderSessionRuntimeRepositoryShape { - /** - * Insert or replace a provider runtime row. - * - * Upserts by canonical `threadId`, including JSON payload/cursor fields. - */ - readonly upsert: ( - runtime: ProviderSessionRuntime, - ) => Effect.Effect; - - /** - * Read provider runtime state by canonical thread id. - */ - readonly getByThreadId: ( - input: GetProviderSessionRuntimeInput, - ) => Effect.Effect, ProviderSessionRuntimeRepositoryError>; - - /** - * List all provider runtime rows. - * - * Returned in ascending last-seen order. - */ - readonly list: () => Effect.Effect< - ReadonlyArray, - ProviderSessionRuntimeRepositoryError - >; - - /** - * Delete provider runtime state by canonical thread id. - */ - readonly deleteByThreadId: ( - input: DeleteProviderSessionRuntimeInput, - ) => Effect.Effect; -} - -/** - * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. - */ -export class ProviderSessionRuntimeRepository extends Context.Service< - ProviderSessionRuntimeRepository, - ProviderSessionRuntimeRepositoryShape ->()("t3/persistence/Services/ProviderSessionRuntime/ProviderSessionRuntimeRepository") {} diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 6a72bf69941..8581a11213b 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -53,8 +53,7 @@ import { makeProviderServiceLive } from "./ProviderService.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory, @@ -282,7 +281,7 @@ function makeProviderServiceLayer() { }); const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -327,7 +326,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => [CODEX_DRIVER]: codex.adapter, }); const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -379,7 +378,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () : registryBase.getInstanceInfo(instanceId), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -453,7 +452,7 @@ it.effect( }, }, }); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe( @@ -517,7 +516,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance ), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -557,7 +556,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se const registry = makeAdapterRegistryMock({ [ProviderDriverKind.make("codex")]: codex.adapter, }); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -612,7 +611,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); @@ -643,7 +642,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( assert.equal(persistedProvider, "codex"); const runtime = yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; return yield* repository.getByThreadId({ threadId: asThreadId("thread-stale"), }); @@ -671,7 +670,7 @@ it.effect( const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -717,7 +716,7 @@ it.effect( }).pipe(Effect.provide(firstProviderLayer)); const persistedAfterStopAll = yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; return yield* repository.getByThreadId({ threadId: startedSession.threadId, }); @@ -909,7 +908,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("preserves the persisted binding when stopping a session", () => Effect.gen(function* () { const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { provider: ProviderDriverKind.make("codex"), @@ -1179,7 +1178,7 @@ routing.layer("ProviderServiceLive routing", (it) => { it.effect("persists runtime status transitions in provider_session_runtime", () => Effect.gen(function* () { const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = asThreadId("thread-runtime-status"); const session = yield* provider.startSession(threadId, { @@ -1226,7 +1225,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-start-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -1316,7 +1315,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(persistenceLayer), ); @@ -1761,7 +1760,7 @@ validation.layer("ProviderServiceLive validation", (it) => { it.effect("accepts startSession when adapter has not emitted provider thread id yet", () => Effect.gen(function* () { const provider = yield* ProviderService; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => Effect.sync(() => { diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index f9793ca9d1f..c5d60a69a22 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -16,15 +16,12 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; function makeDirectoryLayer(persistenceLayer: Layer.Layer) { - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( - Layer.provide(persistenceLayer), - ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( runtimeRepositoryLayer, ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), @@ -36,7 +33,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initialThreadId = ThreadId.make("thread-1"); @@ -83,7 +80,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-runtime"); @@ -128,7 +125,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const olderThreadId = ThreadId.make("thread-runtime-older"); const newerThreadId = ThreadId.make("thread-runtime-newer"); @@ -202,7 +199,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-provider-change"); yield* runtimeRepository.upsert({ diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 0508f6c8cb3..23075bd9a06 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -5,8 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory, @@ -59,7 +58,7 @@ function mergeRuntimePayload( } function toRuntimeBinding( - runtime: ProviderSessionRuntime, + runtime: ProviderSessionRuntime.ProviderSessionRuntime, operation: string, ): Effect.Effect { return decodeProviderDriverKind(runtime.providerName, operation).pipe( @@ -85,7 +84,7 @@ function toRuntimeBinding( } const makeProviderSessionDirectory = Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const getBinding = (threadId: ThreadId) => repository.getByThreadId({ threadId }).pipe( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 18e6166c1cd..e976c183a43 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -19,8 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderValidationError } from "../Errors.ts"; import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; @@ -118,7 +117,7 @@ function makeReadModel( describe("ProviderSessionReaper", () => { let runtime: ManagedRuntime.ManagedRuntime< - ProviderSessionReaper | ProviderSessionRuntimeRepository, + ProviderSessionReaper | ProviderSessionRuntime.ProviderSessionRuntimeRepository, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -176,7 +175,7 @@ describe("ProviderSessionReaper", () => { streamEvents: Stream.empty, }; - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( @@ -238,7 +237,9 @@ describe("ProviderSessionReaper", () => { }, ]), }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -286,7 +287,9 @@ describe("ProviderSessionReaper", () => { }, ]), }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -333,7 +336,9 @@ describe("ProviderSessionReaper", () => { }, ]), }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -380,7 +385,9 @@ describe("ProviderSessionReaper", () => { }, ]), }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -449,7 +456,9 @@ describe("ProviderSessionReaper", () => { ) : Effect.void, }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ @@ -530,7 +539,9 @@ describe("ProviderSessionReaper", () => { ? Effect.die(new Error("simulated stop defect")) : Effect.void, }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); await runtime!.runPromise( repository.upsert({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 373dc61bad8..f1e900c0b5a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -19,7 +19,7 @@ import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; @@ -166,7 +166,7 @@ const ReactorLayerLive = Layer.empty.pipe( ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), ); // `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter From d22eeb55bbcd3b693460b5f0acc38dced5ce7d11 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 21:03:41 -0700 Subject: [PATCH 2/2] scope persistence refactor outside orchestration Co-authored-by: codex --- .../Layers/OrchestrationCommandReceipts.ts | 91 +++- .../OrchestrationEventStore.test.ts | 13 +- .../Layers/OrchestrationEventStore.ts | 271 ++++++++- .../Layers/ProjectionCheckpoints.ts | 219 +++++++- .../Layers/ProjectionPendingApprovals.ts | 138 ++++- .../persistence/Layers/ProjectionProjects.ts | 144 ++++- .../ProjectionRepositories.test.ts | 57 +- .../src/persistence/Layers/ProjectionState.ts | 119 +++- .../Layers/ProjectionThreadActivities.ts | 160 +++++- .../ProjectionThreadMessages.test.ts | 11 +- .../Layers/ProjectionThreadMessages.ts | 189 ++++++- .../Layers/ProjectionThreadProposedPlans.ts | 112 +++- .../Layers/ProjectionThreadSessions.ts | 113 +++- .../persistence/Layers/ProjectionThreads.ts | 190 ++++++- .../src/persistence/Layers/ProjectionTurns.ts | 356 +++++++++++- .../OrchestrationCommandReceipts.ts | 150 ----- .../persistence/OrchestrationEventStore.ts | 329 ----------- .../src/persistence/ProjectionCheckpoints.ts | 295 ---------- .../persistence/ProjectionPendingApprovals.ts | 217 -------- .../src/persistence/ProjectionProjects.ts | 206 ------- .../server/src/persistence/ProjectionState.ts | 170 ------ .../persistence/ProjectionThreadActivities.ts | 230 -------- .../persistence/ProjectionThreadMessages.ts | 265 --------- .../ProjectionThreadProposedPlans.ts | 152 ------ .../persistence/ProjectionThreadSessions.ts | 177 ------ .../src/persistence/ProjectionThreads.ts | 273 ---------- .../server/src/persistence/ProjectionTurns.ts | 513 ------------------ .../Services/OrchestrationCommandReceipts.ts | 72 ++- .../Services/OrchestrationEventStore.ts | 73 ++- .../Services/ProjectionCheckpoints.ts | 96 +++- .../Services/ProjectionPendingApprovals.ts | 94 +++- .../Services/ProjectionProjects.ts | 82 ++- .../persistence/Services/ProjectionState.ts | 67 ++- .../Services/ProjectionThreadActivities.ts | 86 ++- .../Services/ProjectionThreadMessages.ts | 97 +++- .../Services/ProjectionThreadProposedPlans.ts | 57 +- .../Services/ProjectionThreadSessions.ts | 80 ++- .../persistence/Services/ProjectionThreads.ts | 104 +++- .../persistence/Services/ProjectionTurns.ts | 171 +++++- 39 files changed, 3163 insertions(+), 3076 deletions(-) rename apps/server/src/persistence/{ => Layers}/OrchestrationEventStore.test.ts (88%) rename apps/server/src/persistence/{ => Layers}/ProjectionRepositories.test.ts (67%) rename apps/server/src/persistence/{ => Layers}/ProjectionThreadMessages.test.ts (87%) delete mode 100644 apps/server/src/persistence/OrchestrationCommandReceipts.ts delete mode 100644 apps/server/src/persistence/OrchestrationEventStore.ts delete mode 100644 apps/server/src/persistence/ProjectionCheckpoints.ts delete mode 100644 apps/server/src/persistence/ProjectionPendingApprovals.ts delete mode 100644 apps/server/src/persistence/ProjectionProjects.ts delete mode 100644 apps/server/src/persistence/ProjectionState.ts delete mode 100644 apps/server/src/persistence/ProjectionThreadActivities.ts delete mode 100644 apps/server/src/persistence/ProjectionThreadMessages.ts delete mode 100644 apps/server/src/persistence/ProjectionThreadProposedPlans.ts delete mode 100644 apps/server/src/persistence/ProjectionThreadSessions.ts delete mode 100644 apps/server/src/persistence/ProjectionThreads.ts delete mode 100644 apps/server/src/persistence/ProjectionTurns.ts diff --git a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts index 355111aa6a7..989f5d78f8d 100644 --- a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts @@ -1 +1,90 @@ -export { layer as OrchestrationCommandReceiptRepositoryLive } from "../OrchestrationCommandReceipts.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { toPersistenceSqlError } from "../Errors.ts"; + +import { + GetByCommandIdInput, + OrchestrationCommandReceipt, + OrchestrationCommandReceiptRepository, + type OrchestrationCommandReceiptRepositoryShape, +} from "../Services/OrchestrationCommandReceipts.ts"; + +const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertReceiptRow = SqlSchema.void({ + Request: OrchestrationCommandReceipt, + execute: (receipt) => + sql` + INSERT INTO orchestration_command_receipts ( + command_id, + aggregate_kind, + aggregate_id, + accepted_at, + result_sequence, + status, + error + ) + VALUES ( + ${receipt.commandId}, + ${receipt.aggregateKind}, + ${receipt.aggregateId}, + ${receipt.acceptedAt}, + ${receipt.resultSequence}, + ${receipt.status}, + ${receipt.error} + ) + ON CONFLICT (command_id) + DO UPDATE SET + aggregate_kind = excluded.aggregate_kind, + aggregate_id = excluded.aggregate_id, + accepted_at = excluded.accepted_at, + result_sequence = excluded.result_sequence, + status = excluded.status, + error = excluded.error + `, + }); + + const findReceiptByCommandId = SqlSchema.findOneOption({ + Request: GetByCommandIdInput, + Result: OrchestrationCommandReceipt, + execute: ({ commandId }) => + sql` + SELECT + command_id AS "commandId", + aggregate_kind AS "aggregateKind", + aggregate_id AS "aggregateId", + accepted_at AS "acceptedAt", + result_sequence AS "resultSequence", + status, + error + FROM orchestration_command_receipts + WHERE command_id = ${commandId} + `, + }); + + const upsert: OrchestrationCommandReceiptRepositoryShape["upsert"] = (receipt) => + upsertReceiptRow(receipt).pipe( + Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")), + ); + + const getByCommandId: OrchestrationCommandReceiptRepositoryShape["getByCommandId"] = (input) => + findReceiptByCommandId(input).pipe( + Effect.mapError( + toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), + ), + ); + + return { + upsert, + getByCommandId, + } satisfies OrchestrationCommandReceiptRepositoryShape; +}); + +export const OrchestrationCommandReceiptRepositoryLive = Layer.effect( + OrchestrationCommandReceiptRepository, + makeOrchestrationCommandReceiptRepository, +); diff --git a/apps/server/src/persistence/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts similarity index 88% rename from apps/server/src/persistence/OrchestrationEventStore.test.ts rename to apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 21c18e0e716..2bac5de920c 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -6,19 +6,20 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { PersistenceDecodeError } from "./Errors.ts"; -import * as OrchestrationEventStore from "./OrchestrationEventStore.ts"; -import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; +import { PersistenceDecodeError } from "../Errors.ts"; +import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts"; +import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); const layer = it.layer( - OrchestrationEventStore.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ); layer("OrchestrationEventStore", (it) => { it.effect("stores json columns as strings and replays decoded events", () => Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; + const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; const now = "2026-01-01T00:00:00.000Z"; @@ -70,7 +71,7 @@ layer("OrchestrationEventStore", (it) => { it.effect("fails with PersistenceDecodeError when stored json is invalid", () => Effect.gen(function* () { - const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; + const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 68a446c4c15..18d0e9aa578 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -1 +1,270 @@ -export { layer as OrchestrationEventStoreLive } from "../OrchestrationEventStore.ts"; +import { + CommandId, + EventId, + IsoDateTime, + NonNegativeInt, + OrchestrationActorKind, + OrchestrationAggregateKind, + OrchestrationEvent, + OrchestrationEventMetadata, + OrchestrationEventType, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type OrchestrationEventStoreError, +} from "../Errors.ts"; +import { + OrchestrationEventStore, + type OrchestrationEventStoreShape, +} from "../Services/OrchestrationEventStore.ts"; + +const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); + +const AppendEventRequestSchema = Schema.Struct({ + eventId: EventId, + aggregateKind: OrchestrationAggregateKind, + streamId: Schema.Union([ProjectId, ThreadId]), + type: OrchestrationEventType, + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + actorKind: OrchestrationActorKind, + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + payloadJson: UnknownFromJsonString, + metadataJson: EventMetadataFromJsonString, +}); + +const OrchestrationEventPersistedRowSchema = Schema.Struct({ + sequence: NonNegativeInt, + eventId: EventId, + type: OrchestrationEventType, + aggregateKind: OrchestrationAggregateKind, + aggregateId: Schema.Union([ProjectId, ThreadId]), + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + payload: UnknownFromJsonString, + metadata: EventMetadataFromJsonString, +}); + +const ReadFromSequenceRequestSchema = Schema.Struct({ + sequenceExclusive: NonNegativeInt, + limit: Schema.Number, +}); +const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; +const READ_PAGE_SIZE = 500; + +function inferActorKind( + event: Omit, +): Schema.Schema.Type { + if (event.commandId !== null && event.commandId.startsWith("provider:")) { + return "provider"; + } + if (event.commandId !== null && event.commandId.startsWith("server:")) { + return "server"; + } + if ( + event.metadata.providerTurnId !== undefined || + event.metadata.providerItemId !== undefined || + event.metadata.adapterKey !== undefined + ) { + return "provider"; + } + if (event.commandId === null) { + return "server"; + } + return "client"; +} + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): OrchestrationEventStoreError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeEventStore = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const appendEventRow = SqlSchema.findOne({ + Request: AppendEventRequestSchema, + Result: OrchestrationEventPersistedRowSchema, + execute: (request) => + sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${request.eventId}, + ${request.aggregateKind}, + ${request.streamId}, + COALESCE( + ( + SELECT stream_version + 1 + FROM orchestration_events + WHERE aggregate_kind = ${request.aggregateKind} + AND stream_id = ${request.streamId} + ORDER BY stream_version DESC + LIMIT 1 + ), + 0 + ), + ${request.type}, + ${request.occurredAt}, + ${request.commandId}, + ${request.causationEventId}, + ${request.correlationId}, + ${request.actorKind}, + ${request.payloadJson}, + ${request.metadataJson} + ) + RETURNING + sequence, + event_id AS "eventId", + event_type AS "type", + aggregate_kind AS "aggregateKind", + stream_id AS "aggregateId", + occurred_at AS "occurredAt", + command_id AS "commandId", + causation_event_id AS "causationEventId", + correlation_id AS "correlationId", + payload_json AS "payload", + metadata_json AS "metadata" + `, + }); + + const readEventRowsFromSequence = SqlSchema.findAll({ + Request: ReadFromSequenceRequestSchema, + Result: OrchestrationEventPersistedRowSchema, + execute: (request) => + sql` + SELECT + sequence, + event_id AS "eventId", + event_type AS "type", + aggregate_kind AS "aggregateKind", + stream_id AS "aggregateId", + occurred_at AS "occurredAt", + command_id AS "commandId", + causation_event_id AS "causationEventId", + correlation_id AS "correlationId", + payload_json AS "payload", + metadata_json AS "metadata" + FROM orchestration_events + WHERE sequence > ${request.sequenceExclusive} + ORDER BY sequence ASC + LIMIT ${request.limit} + `, + }); + + const append: OrchestrationEventStoreShape["append"] = (event) => + appendEventRow({ + eventId: event.eventId, + aggregateKind: event.aggregateKind, + streamId: event.aggregateId, + type: event.type, + causationEventId: event.causationEventId, + correlationId: event.correlationId, + actorKind: inferActorKind(event), + occurredAt: event.occurredAt, + commandId: event.commandId, + payloadJson: event.payload, + metadataJson: event.metadata, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.append:insert", + "OrchestrationEventStore.append:decodeRow", + ), + ), + Effect.flatMap((row) => + decodeEvent(row).pipe( + Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")), + ), + ), + ); + + const readFromSequence: OrchestrationEventStoreShape["readFromSequence"] = ( + sequenceExclusive, + limit = DEFAULT_READ_FROM_SEQUENCE_LIMIT, + ) => { + const normalizedLimit = Math.max(0, Math.floor(limit)); + if (normalizedLimit === 0) { + return Stream.empty; + } + const readPage = ( + cursor: number, + remaining: number, + ): Stream.Stream => + Stream.fromEffect( + readEventRowsFromSequence({ + sequenceExclusive: cursor, + limit: Math.min(remaining, READ_PAGE_SIZE), + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.readFromSequence:query", + "OrchestrationEventStore.readFromSequence:decodeRows", + ), + ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeEvent(row).pipe( + Effect.mapError( + toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"), + ), + ), + ), + ), + ), + ).pipe( + Stream.flatMap((events) => { + if (events.length === 0) { + return Stream.empty; + } + const nextRemaining = remaining - events.length; + if (nextRemaining <= 0) { + return Stream.fromIterable(events); + } + return Stream.concat( + Stream.fromIterable(events), + readPage(events[events.length - 1]!.sequence, nextRemaining), + ); + }), + ); + + return readPage(sequenceExclusive, normalizedLimit); + }; + + return { + append, + readFromSequence, + readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), + } satisfies OrchestrationEventStoreShape; +}); + +export const OrchestrationEventStoreLive = Layer.effect(OrchestrationEventStore, makeEventStore); diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts index 63441735116..cfa5658d0e9 100644 --- a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts @@ -1,2 +1,217 @@ -/** @deprecated Compatibility alias for the excluded orchestration integration harness. */ -export { layer as ProjectionCheckpointRepositoryLive } from "../ProjectionCheckpoints.ts"; +import { OrchestrationCheckpointFile } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteByThreadIdInput, + GetByThreadAndTurnCountInput, + ListByThreadIdInput, + ProjectionCheckpoint, + ProjectionCheckpointRepository, + type ProjectionCheckpointRepositoryShape, +} from "../Services/ProjectionCheckpoints.ts"; + +const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( + Struct.assign({ + files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeProjectionCheckpointRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const clearCheckpointConflict = SqlSchema.void({ + Request: GetByThreadAndTurnCountInput, + execute: ({ threadId, checkpointTurnCount }) => + sql` + UPDATE projection_turns + SET + checkpoint_turn_count = NULL, + checkpoint_ref = NULL, + checkpoint_status = NULL, + checkpoint_files_json = '[]' + WHERE thread_id = ${threadId} + AND checkpoint_turn_count = ${checkpointTurnCount} + `, + }); + + const upsertProjectionCheckpointRow = SqlSchema.void({ + Request: ProjectionCheckpointDbRowSchema, + execute: (row) => + sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${row.threadId}, + ${row.turnId}, + NULL, + ${row.assistantMessageId}, + ${row.status === "error" ? "error" : "completed"}, + ${row.completedAt}, + ${row.completedAt}, + ${row.completedAt}, + ${row.checkpointTurnCount}, + ${row.checkpointRef}, + ${row.status}, + ${row.files} + ) + ON CONFLICT (thread_id, turn_id) + DO UPDATE SET + assistant_message_id = excluded.assistant_message_id, + state = excluded.state, + completed_at = excluded.completed_at, + checkpoint_turn_count = excluded.checkpoint_turn_count, + checkpoint_ref = excluded.checkpoint_ref, + checkpoint_status = excluded.checkpoint_status, + checkpoint_files_json = excluded.checkpoint_files_json + `, + }); + + const listProjectionCheckpointRows = SqlSchema.findAll({ + Request: ListByThreadIdInput, + Result: ProjectionCheckpointDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "status", + checkpoint_files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM projection_turns + WHERE thread_id = ${threadId} + AND checkpoint_turn_count IS NOT NULL + ORDER BY checkpoint_turn_count ASC + `, + }); + + const getProjectionCheckpointRow = SqlSchema.findOneOption({ + Request: GetByThreadAndTurnCountInput, + Result: ProjectionCheckpointDbRowSchema, + execute: ({ threadId, checkpointTurnCount }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "status", + checkpoint_files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM projection_turns + WHERE thread_id = ${threadId} + AND checkpoint_turn_count = ${checkpointTurnCount} + `, + }); + + const deleteProjectionCheckpointRows = SqlSchema.void({ + Request: DeleteByThreadIdInput, + execute: ({ threadId }) => + sql` + UPDATE projection_turns + SET + checkpoint_turn_count = NULL, + checkpoint_ref = NULL, + checkpoint_status = NULL, + checkpoint_files_json = '[]' + WHERE thread_id = ${threadId} + AND checkpoint_turn_count IS NOT NULL + `, + }); + + const upsertCheckpointRow = (row: Schema.Schema.Type) => + sql.withTransaction( + clearCheckpointConflict({ + threadId: row.threadId, + checkpointTurnCount: row.checkpointTurnCount, + }).pipe(Effect.flatMap(() => upsertProjectionCheckpointRow(row))), + ); + + const upsert: ProjectionCheckpointRepositoryShape["upsert"] = (row) => + upsertCheckpointRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionCheckpointRepository.upsert:query", + "ProjectionCheckpointRepository.upsert:encodeRequest", + ), + ), + ); + + const listByThreadId: ProjectionCheckpointRepositoryShape["listByThreadId"] = (input) => + listProjectionCheckpointRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionCheckpointRepository.listByThreadId:query", + "ProjectionCheckpointRepository.listByThreadId:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + const getByThreadAndTurnCount: ProjectionCheckpointRepositoryShape["getByThreadAndTurnCount"] = ( + input, + ) => + getProjectionCheckpointRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionCheckpointRepository.getByThreadAndTurnCount:query", + "ProjectionCheckpointRepository.getByThreadAndTurnCount:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const deleteByThreadId: ProjectionCheckpointRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionCheckpointRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionCheckpointRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + getByThreadAndTurnCount, + deleteByThreadId, + } satisfies ProjectionCheckpointRepositoryShape; +}); + +export const ProjectionCheckpointRepositoryLive = Layer.effect( + ProjectionCheckpointRepository, + makeProjectionCheckpointRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts index 76be9684d47..253f6e13b97 100644 --- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts @@ -1 +1,137 @@ -export { layer as ProjectionPendingApprovalRepositoryLive } from "../ProjectionPendingApprovals.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + GetProjectionPendingApprovalInput, + DeleteProjectionPendingApprovalInput, + ListProjectionPendingApprovalsInput, + ProjectionPendingApproval, + ProjectionPendingApprovalRepository, + type ProjectionPendingApprovalRepositoryShape, +} from "../Services/ProjectionPendingApprovals.ts"; + +const makeProjectionPendingApprovalRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionPendingApprovalRow = SqlSchema.void({ + Request: ProjectionPendingApproval, + execute: (row) => + sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES ( + ${row.requestId}, + ${row.threadId}, + ${row.turnId}, + ${row.status}, + ${row.decision}, + ${row.createdAt}, + ${row.resolvedAt} + ) + ON CONFLICT (request_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + status = excluded.status, + decision = excluded.decision, + created_at = excluded.created_at, + resolved_at = excluded.resolved_at + `, + }); + + const listProjectionPendingApprovalRows = SqlSchema.findAll({ + Request: ListProjectionPendingApprovalsInput, + Result: ProjectionPendingApproval, + execute: ({ threadId }) => + sql` + SELECT + request_id AS "requestId", + thread_id AS "threadId", + turn_id AS "turnId", + status, + decision, + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, request_id ASC + `, + }); + + const getProjectionPendingApprovalRow = SqlSchema.findOneOption({ + Request: GetProjectionPendingApprovalInput, + Result: ProjectionPendingApproval, + execute: ({ requestId }) => + sql` + SELECT + request_id AS "requestId", + thread_id AS "threadId", + turn_id AS "turnId", + status, + decision, + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = ${requestId} + `, + }); + + const deleteProjectionPendingApprovalRow = SqlSchema.void({ + Request: DeleteProjectionPendingApprovalInput, + execute: ({ requestId }) => + sql` + DELETE FROM projection_pending_approvals + WHERE request_id = ${requestId} + `, + }); + + const upsert: ProjectionPendingApprovalRepositoryShape["upsert"] = (row) => + upsertProjectionPendingApprovalRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")), + ); + + const listByThreadId: ProjectionPendingApprovalRepositoryShape["listByThreadId"] = (input) => + listProjectionPendingApprovalRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.listByThreadId:query"), + ), + ); + + const getByRequestId: ProjectionPendingApprovalRepositoryShape["getByRequestId"] = (input) => + getProjectionPendingApprovalRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.getByRequestId:query"), + ), + ); + + const deleteByRequestId: ProjectionPendingApprovalRepositoryShape["deleteByRequestId"] = ( + input, + ) => + deleteProjectionPendingApprovalRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"), + ), + ); + + return { + upsert, + listByThreadId, + getByRequestId, + deleteByRequestId, + } satisfies ProjectionPendingApprovalRepositoryShape; +}); + +export const ProjectionPendingApprovalRepositoryLive = Layer.effect( + ProjectionPendingApprovalRepository, + makeProjectionPendingApprovalRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index de348f97543..c1ca6d3104e 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -1 +1,143 @@ -export { layer as ProjectionProjectRepositoryLive } from "../ProjectionProjects.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; + +import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionProjectInput, + GetProjectionProjectInput, + ProjectionProject, + ProjectionProjectRepository, + type ProjectionProjectRepositoryShape, +} from "../Services/ProjectionProjects.ts"; + +const ProjectionProjectDbRow = ProjectionProject.mapFields( + Struct.assign({ + defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + }), +); +type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; + +const makeProjectionProjectRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionProjectRow = SqlSchema.void({ + Request: ProjectionProject, + execute: (row) => + sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${row.projectId}, + ${row.title}, + ${row.workspaceRoot}, + ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, + ${JSON.stringify(row.scripts)}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.deletedAt} + ) + ON CONFLICT (project_id) + DO UPDATE SET + title = excluded.title, + workspace_root = excluded.workspace_root, + default_model_selection_json = excluded.default_model_selection_json, + scripts_json = excluded.scripts_json, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `, + }); + + const getProjectionProjectRow = SqlSchema.findOneOption({ + Request: GetProjectionProjectInput, + Result: ProjectionProjectDbRow, + execute: ({ projectId }) => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model_selection_json AS "defaultModelSelection", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + WHERE project_id = ${projectId} + `, + }); + + const listProjectionProjectRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionProjectDbRow, + execute: () => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model_selection_json AS "defaultModelSelection", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + ORDER BY created_at ASC, project_id ASC + `, + }); + + const deleteProjectionProjectRow = SqlSchema.void({ + Request: DeleteProjectionProjectInput, + execute: ({ projectId }) => + sql` + DELETE FROM projection_projects + WHERE project_id = ${projectId} + `, + }); + + const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) => + upsertProjectionProjectRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")), + ); + + const getById: ProjectionProjectRepositoryShape["getById"] = (input) => + getProjectionProjectRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), + ); + + const listAll: ProjectionProjectRepositoryShape["listAll"] = () => + listProjectionProjectRows().pipe( + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), + ); + + const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) => + deleteProjectionProjectRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.deleteById:query")), + ); + + return { + upsert, + getById, + listAll, + deleteById, + } satisfies ProjectionProjectRepositoryShape; +}); + +export const ProjectionProjectRepositoryLive = Layer.effect( + ProjectionProjectRepository, + makeProjectionProjectRepository, +); diff --git a/apps/server/src/persistence/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts similarity index 67% rename from apps/server/src/persistence/ProjectionRepositories.test.ts rename to apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 347d9c54582..a2069e62a14 100644 --- a/apps/server/src/persistence/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -3,22 +3,18 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { PersistenceDecodeError } from "./Errors.ts"; -import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; -import * as ProjectionProjects from "./ProjectionProjects.ts"; -import * as ProjectionThreads from "./ProjectionThreads.ts"; -import * as ProjectionTurns from "./ProjectionTurns.ts"; - -const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); +import { SqlitePersistenceMemory } from "./Sqlite.ts"; +import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; +import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; +import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; +import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; const projectionRepositoriesLayer = it.layer( Layer.mergeAll( - ProjectionProjects.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), - ProjectionThreads.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), - ProjectionTurns.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), SqlitePersistenceMemory, ), ); @@ -26,7 +22,7 @@ const projectionRepositoriesLayer = it.layer( projectionRepositoriesLayer("Projection repositories", (it) => { it.effect("stores SQL NULL for missing project model options", () => Effect.gen(function* () { - const projects = yield* ProjectionProjects.ProjectionProjectRepository; + const projects = yield* ProjectionProjectRepository; const sql = yield* SqlClient.SqlClient; yield* projects.upsert({ @@ -76,7 +72,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { it.effect("stores JSON for thread model options", () => Effect.gen(function* () { - const threads = yield* ProjectionThreads.ProjectionThreadRepository; + const threads = yield* ProjectionThreadRepository; const sql = yield* SqlClient.SqlClient; yield* threads.upsert({ @@ -132,39 +128,4 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); }), ); - - it.effect("reports invalid pending turn rows as decode failures", () => - Effect.gen(function* () { - const turns = yield* ProjectionTurns.ProjectionTurnRepository; - const sql = yield* SqlClient.SqlClient; - const threadId = ThreadId.make("thread-invalid-pending-turn"); - - yield* sql` - INSERT INTO projection_turns ( - thread_id, - pending_message_id, - state, - requested_at, - checkpoint_files_json - ) - VALUES ( - ${threadId}, - X'01', - 'pending', - '2026-06-20T00:00:00.000Z', - '[]' - ) - `; - - const result = yield* Effect.result(turns.getPendingTurnStartByThreadId({ threadId })); - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.ok(isPersistenceDecodeError(result.failure)); - assert.equal( - result.failure.operation, - "ProjectionTurnRepository.getPendingTurnStartByThreadId:decodeRow", - ); - } - }), - ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionState.ts b/apps/server/src/persistence/Layers/ProjectionState.ts index 81d24e88ca8..f630fe51e03 100644 --- a/apps/server/src/persistence/Layers/ProjectionState.ts +++ b/apps/server/src/persistence/Layers/ProjectionState.ts @@ -1 +1,118 @@ -export { layer as ProjectionStateRepositoryLive } from "../ProjectionState.ts"; +import { NonNegativeInt } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { toPersistenceSqlError } from "../Errors.ts"; + +import { + ProjectionStateRepository, + type ProjectionStateRepositoryShape, + GetProjectionStateInput, + ProjectionState, +} from "../Services/ProjectionState.ts"; + +const MinLastAppliedSequenceRowSchema = Schema.Struct({ + minLastAppliedSequence: Schema.NullOr(NonNegativeInt), +}); + +const makeProjectionStateRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionStateRow = SqlSchema.void({ + Request: ProjectionState, + execute: (row) => + sql` + INSERT INTO projection_state ( + projector, + last_applied_sequence, + updated_at + ) + VALUES ( + ${row.projector}, + ${row.lastAppliedSequence}, + ${row.updatedAt} + ) + ON CONFLICT (projector) + DO UPDATE SET + last_applied_sequence = excluded.last_applied_sequence, + updated_at = excluded.updated_at + `, + }); + + const getProjectionStateRow = SqlSchema.findOneOption({ + Request: GetProjectionStateInput, + Result: ProjectionState, + execute: ({ projector }) => + sql` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence", + updated_at AS "updatedAt" + FROM projection_state + WHERE projector = ${projector} + `, + }); + + const listProjectionStateRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionState, + execute: () => + sql` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence", + updated_at AS "updatedAt" + FROM projection_state + ORDER BY projector ASC + `, + }); + + const readMinLastAppliedSequence = SqlSchema.findOne({ + Request: Schema.Void, + Result: MinLastAppliedSequenceRowSchema, + execute: () => + sql` + SELECT + MIN(last_applied_sequence) AS "minLastAppliedSequence" + FROM projection_state + `, + }); + + const upsert: ProjectionStateRepositoryShape["upsert"] = (row) => + upsertProjectionStateRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")), + ); + + const getByProjector: ProjectionStateRepositoryShape["getByProjector"] = (input) => + getProjectionStateRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.getByProjector:query")), + ); + + const listAll: ProjectionStateRepositoryShape["listAll"] = () => + listProjectionStateRows(undefined).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")), + ); + + const minLastAppliedSequence: ProjectionStateRepositoryShape["minLastAppliedSequence"] = () => + readMinLastAppliedSequence(undefined).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionStateRepository.minLastAppliedSequence:query"), + ), + Effect.map((row) => row.minLastAppliedSequence), + ); + + return { + upsert, + getByProjector, + listAll, + minLastAppliedSequence, + } satisfies ProjectionStateRepositoryShape; +}); + +export const ProjectionStateRepositoryLive = Layer.effect( + ProjectionStateRepository, + makeProjectionStateRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index df6e1d5ec73..2f4815f9654 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -1 +1,159 @@ -export { layer as ProjectionThreadActivityRepositoryLive } from "../ProjectionThreadActivities.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { NonNegativeInt } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; + +import { + DeleteProjectionThreadActivitiesInput, + ListProjectionThreadActivitiesInput, + ProjectionThreadActivity, + ProjectionThreadActivityRepository, + type ProjectionThreadActivityRepositoryShape, +} from "../Services/ProjectionThreadActivities.ts"; + +const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( + Struct.assign({ + payload: Schema.fromJsonString(Schema.Unknown), + sequence: Schema.NullOr(NonNegativeInt), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeProjectionThreadActivityRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadActivityRow = SqlSchema.void({ + Request: ProjectionThreadActivity, + execute: (row) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + ${row.activityId}, + ${row.threadId}, + ${row.turnId}, + ${row.tone}, + ${row.kind}, + ${row.summary}, + ${JSON.stringify(row.payload)}, + ${row.sequence ?? null}, + ${row.createdAt} + ) + ON CONFLICT (activity_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + tone = excluded.tone, + kind = excluded.kind, + summary = excluded.summary, + payload_json = excluded.payload_json, + sequence = excluded.sequence, + created_at = excluded.created_at + `, + }); + + const listProjectionThreadActivityRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + + const deleteProjectionThreadActivityRows = SqlSchema.void({ + Request: DeleteProjectionThreadActivitiesInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_activities + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadActivityRepositoryShape["upsert"] = (row) => + upsertProjectionThreadActivityRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.upsert:query", + "ProjectionThreadActivityRepository.upsert:encodeRequest", + ), + ), + ); + + const listByThreadId: ProjectionThreadActivityRepositoryShape["listByThreadId"] = (input) => + listProjectionThreadActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listByThreadId:query", + "ProjectionThreadActivityRepository.listByThreadId:decodeRows", + ), + ), + Effect.map((rows) => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })), + ), + ); + + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionThreadActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadActivityRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadActivityRepositoryShape; +}); + +export const ProjectionThreadActivityRepositoryLive = Layer.effect( + ProjectionThreadActivityRepository, + makeProjectionThreadActivityRepository, +); diff --git a/apps/server/src/persistence/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts similarity index 87% rename from apps/server/src/persistence/ProjectionThreadMessages.test.ts rename to apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index c0e469c606c..b1f394a9e57 100644 --- a/apps/server/src/persistence/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -3,17 +3,18 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as ProjectionThreadMessages from "./ProjectionThreadMessages.ts"; -import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; +import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; const layer = it.layer( - ProjectionThreadMessages.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ); layer("ProjectionThreadMessageRepository", (it) => { it.effect("preserves existing attachments when upsert omits attachments", () => Effect.gen(function* () { - const repository = yield* ProjectionThreadMessages.ProjectionThreadMessageRepository; + const repository = yield* ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-preserve-attachments"); const messageId = MessageId.make("message-preserve-attachments"); const createdAt = "2026-02-28T19:00:00.000Z"; @@ -67,7 +68,7 @@ layer("ProjectionThreadMessageRepository", (it) => { it.effect("allows explicit attachment clearing with an empty array", () => Effect.gen(function* () { - const repository = yield* ProjectionThreadMessages.ProjectionThreadMessageRepository; + const repository = yield* ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-clear-attachments"); const messageId = MessageId.make("message-clear-attachments"); const createdAt = "2026-02-28T19:10:00.000Z"; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index e0f41326067..71919166886 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -1 +1,188 @@ -export { layer as ProjectionThreadMessageRepositoryLive } from "../ProjectionThreadMessages.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import { ChatAttachment } from "@t3tools/contracts"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + GetProjectionThreadMessageInput, + ProjectionThreadMessageRepository, + type ProjectionThreadMessageRepositoryShape, + DeleteProjectionThreadMessagesInput, + ListProjectionThreadMessagesInput, + ProjectionThreadMessage, +} from "../Services/ProjectionThreadMessages.ts"; + +const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( + Struct.assign({ + isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + }), +); + +function toProjectionThreadMessage( + row: Schema.Schema.Type, +): ProjectionThreadMessage { + return { + messageId: row.messageId, + threadId: row.threadId, + turnId: row.turnId, + role: row.role, + text: row.text, + isStreaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + }; +} + +const makeProjectionThreadMessageRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadMessageRow = SqlSchema.void({ + Request: ProjectionThreadMessage, + execute: (row) => { + const nextAttachmentsJson = + row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.turnId}, + ${row.role}, + ${row.text}, + COALESCE( + ${nextAttachmentsJson}, + ( + SELECT attachments_json + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), + ${row.isStreaming ? 1 : 0}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + role = excluded.role, + text = excluded.text, + attachments_json = COALESCE( + excluded.attachments_json, + projection_thread_messages.attachments_json + ), + is_streaming = excluded.is_streaming, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `; + }, + }); + + const getProjectionThreadMessageRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadMessageInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ messageId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE message_id = ${messageId} + LIMIT 1 + `, + }); + + const listProjectionThreadMessageRows = SqlSchema.findAll({ + Request: ListProjectionThreadMessagesInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, message_id ASC + `, + }); + + const deleteProjectionThreadMessageRows = SqlSchema.void({ + Request: DeleteProjectionThreadMessagesInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_messages + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadMessageRepositoryShape["upsert"] = (row) => + upsertProjectionThreadMessageRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), + ); + + const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => + getProjectionThreadMessageRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.getByMessageId:query"), + ), + Effect.map(Option.map(toProjectionThreadMessage)), + ); + + const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => + listProjectionThreadMessageRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"), + ), + Effect.map((rows) => rows.map(toProjectionThreadMessage)), + ); + + const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionThreadMessageRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + getByMessageId, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadMessageRepositoryShape; +}); + +export const ProjectionThreadMessageRepositoryLive = Layer.effect( + ProjectionThreadMessageRepository, + makeProjectionThreadMessageRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 47ad093546c..63aed1a1670 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -1 +1,111 @@ -export { layer as ProjectionThreadProposedPlanRepositoryLive } from "../ProjectionThreadProposedPlans.ts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionThreadProposedPlansInput, + ListProjectionThreadProposedPlansInput, + ProjectionThreadProposedPlan, + ProjectionThreadProposedPlanRepository, + type ProjectionThreadProposedPlanRepositoryShape, +} from "../Services/ProjectionThreadProposedPlans.ts"; + +const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadProposedPlanRow = SqlSchema.void({ + Request: ProjectionThreadProposedPlan, + execute: (row) => sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + implemented_at, + implementation_thread_id, + created_at, + updated_at + ) + VALUES ( + ${row.planId}, + ${row.threadId}, + ${row.turnId}, + ${row.planMarkdown}, + ${row.implementedAt}, + ${row.implementationThreadId}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (plan_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + plan_markdown = excluded.plan_markdown, + implemented_at = excluded.implemented_at, + implementation_thread_id = excluded.implementation_thread_id, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `, + }); + + const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ + Request: ListProjectionThreadProposedPlansInput, + Result: ProjectionThreadProposedPlan, + execute: ({ threadId }) => sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const deleteProjectionThreadProposedPlanRows = SqlSchema.void({ + Request: DeleteProjectionThreadProposedPlansInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => + upsertProjectionThreadProposedPlanRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), + ); + + const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => + listProjectionThreadProposedPlanRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.listByThreadId:query"), + ), + ); + + const deleteByThreadId: ProjectionThreadProposedPlanRepositoryShape["deleteByThreadId"] = ( + input, + ) => + deleteProjectionThreadProposedPlanRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadProposedPlanRepositoryShape; +}); + +export const ProjectionThreadProposedPlanRepositoryLive = Layer.effect( + ProjectionThreadProposedPlanRepository, + makeProjectionThreadProposedPlanRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index b330c87ee9d..dcb750983a0 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -1 +1,112 @@ -export { layer as ProjectionThreadSessionRepositoryLive } from "../ProjectionThreadSessions.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { toPersistenceSqlError } from "../Errors.ts"; + +import { + ProjectionThreadSession, + ProjectionThreadSessionRepository, + type ProjectionThreadSessionRepositoryShape, + DeleteProjectionThreadSessionInput, + GetProjectionThreadSessionInput, +} from "../Services/ProjectionThreadSessions.ts"; + +const makeProjectionThreadSessionRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadSessionRow = SqlSchema.void({ + Request: ProjectionThreadSession, + execute: (row) => + sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_instance_id, + runtime_mode, + active_turn_id, + last_error, + updated_at + ) + VALUES ( + ${row.threadId}, + ${row.status}, + ${row.providerName}, + ${row.providerInstanceId}, + ${row.runtimeMode}, + ${row.activeTurnId}, + ${row.lastError}, + ${row.updatedAt} + ) + ON CONFLICT (thread_id) + DO UPDATE SET + status = excluded.status, + provider_name = excluded.provider_name, + provider_instance_id = excluded.provider_instance_id, + runtime_mode = excluded.runtime_mode, + active_turn_id = excluded.active_turn_id, + last_error = excluded.last_error, + updated_at = excluded.updated_at + `, + }); + + const getProjectionThreadSessionRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadSessionInput, + Result: ProjectionThreadSession, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + status, + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", + runtime_mode AS "runtimeMode", + active_turn_id AS "activeTurnId", + last_error AS "lastError", + updated_at AS "updatedAt" + FROM projection_thread_sessions + WHERE thread_id = ${threadId} + `, + }); + + const deleteProjectionThreadSessionRow = SqlSchema.void({ + Request: DeleteProjectionThreadSessionInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_sessions + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadSessionRepositoryShape["upsert"] = (row) => + upsertProjectionThreadSessionRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadSessionRepository.upsert:query")), + ); + + const getByThreadId: ProjectionThreadSessionRepositoryShape["getByThreadId"] = (input) => + getProjectionThreadSessionRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadSessionRepository.getByThreadId:query"), + ), + ); + + const deleteByThreadId: ProjectionThreadSessionRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionThreadSessionRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadSessionRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + getByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadSessionRepositoryShape; +}); + +export const ProjectionThreadSessionRepositoryLive = Layer.effect( + ProjectionThreadSessionRepository, + makeProjectionThreadSessionRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index da850b52856..1baeb375c15 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1 +1,189 @@ -export { layer as ProjectionThreadRepositoryLive } from "../ProjectionThreads.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionThreadInput, + GetProjectionThreadInput, + ListProjectionThreadsByProjectInput, + ProjectionThread, + ProjectionThreadRepository, + type ProjectionThreadRepositoryShape, +} from "../Services/ProjectionThreads.ts"; +import { ModelSelection } from "@t3tools/contracts"; + +const ProjectionThreadDbRow = ProjectionThread.mapFields( + Struct.assign({ + modelSelection: Schema.fromJsonString(ModelSelection), + }), +); +type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; + +const makeProjectionThreadRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadRow = SqlSchema.void({ + Request: ProjectionThread, + execute: (row) => + sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + ${row.threadId}, + ${row.projectId}, + ${row.title}, + ${JSON.stringify(row.modelSelection)}, + ${row.runtimeMode}, + ${row.interactionMode}, + ${row.branch}, + ${row.worktreePath}, + ${row.latestTurnId}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.archivedAt}, + ${row.latestUserMessageAt}, + ${row.pendingApprovalCount}, + ${row.pendingUserInputCount}, + ${row.hasActionableProposedPlan}, + ${row.deletedAt} + ) + ON CONFLICT (thread_id) + DO UPDATE SET + project_id = excluded.project_id, + title = excluded.title, + model_selection_json = excluded.model_selection_json, + runtime_mode = excluded.runtime_mode, + interaction_mode = excluded.interaction_mode, + branch = excluded.branch, + worktree_path = excluded.worktree_path, + latest_turn_id = excluded.latest_turn_id, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + archived_at = excluded.archived_at, + latest_user_message_at = excluded.latest_user_message_at, + pending_approval_count = excluded.pending_approval_count, + pending_user_input_count = excluded.pending_user_input_count, + has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, + deleted_at = excluded.deleted_at + `, + }); + + const getProjectionThreadRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadInput, + Result: ProjectionThreadDbRow, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `, + }); + + const listProjectionThreadRows = SqlSchema.findAll({ + Request: ListProjectionThreadsByProjectInput, + Result: ProjectionThreadDbRow, + execute: ({ projectId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE project_id = ${projectId} + ORDER BY created_at ASC, thread_id ASC + `, + }); + + const deleteProjectionThreadRow = SqlSchema.void({ + Request: DeleteProjectionThreadInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_threads + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) => + upsertProjectionThreadRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), + ); + + const getById: ProjectionThreadRepositoryShape["getById"] = (input) => + getProjectionThreadRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), + ); + + const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => + listProjectionThreadRows(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), + ); + + const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => + deleteProjectionThreadRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), + ); + + return { + upsert, + getById, + listByProjectId, + deleteById, + } satisfies ProjectionThreadRepositoryShape; +}); + +export const ProjectionThreadRepositoryLive = Layer.effect( + ProjectionThreadRepository, + makeProjectionThreadRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index cead69bea8c..bd57a4eaa30 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -1 +1,355 @@ -export { layer as ProjectionTurnRepositoryLive } from "../ProjectionTurns.ts"; +import { OrchestrationCheckpointFile } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { + ClearCheckpointTurnConflictInput, + DeleteProjectionTurnsByThreadInput, + GetProjectionPendingTurnStartInput, + GetProjectionTurnByTurnIdInput, + ListProjectionTurnsByThreadInput, + ProjectionPendingTurnStart, + ProjectionTurn, + ProjectionTurnById, + ProjectionTurnRepository, + type ProjectionTurnRepositoryShape, +} from "../Services/ProjectionTurns.ts"; + +const ProjectionTurnDbRowSchema = ProjectionTurn.mapFields( + Struct.assign({ + checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), + }), +); + +const ProjectionTurnByIdDbRowSchema = ProjectionTurnById.mapFields( + Struct.assign({ + checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeProjectionTurnRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionTurnById = SqlSchema.void({ + Request: ProjectionTurnByIdDbRowSchema, + execute: (row) => + sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${row.threadId}, + ${row.turnId}, + ${row.pendingMessageId}, + ${row.sourceProposedPlanThreadId}, + ${row.sourceProposedPlanId}, + ${row.assistantMessageId}, + ${row.state}, + ${row.requestedAt}, + ${row.startedAt}, + ${row.completedAt}, + ${row.checkpointTurnCount}, + ${row.checkpointRef}, + ${row.checkpointStatus}, + ${row.checkpointFiles} + ) + ON CONFLICT (thread_id, turn_id) + DO UPDATE SET + pending_message_id = excluded.pending_message_id, + source_proposed_plan_thread_id = excluded.source_proposed_plan_thread_id, + source_proposed_plan_id = excluded.source_proposed_plan_id, + assistant_message_id = excluded.assistant_message_id, + state = excluded.state, + requested_at = excluded.requested_at, + started_at = excluded.started_at, + completed_at = excluded.completed_at, + checkpoint_turn_count = excluded.checkpoint_turn_count, + checkpoint_ref = excluded.checkpoint_ref, + checkpoint_status = excluded.checkpoint_status, + checkpoint_files_json = excluded.checkpoint_files_json + `, + }); + + const clearPendingProjectionTurnsByThread = SqlSchema.void({ + Request: DeleteProjectionTurnsByThreadInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NULL + AND state = 'pending' + AND checkpoint_turn_count IS NULL + `, + }); + + const insertPendingProjectionTurn = SqlSchema.void({ + Request: ProjectionPendingTurnStart, + execute: (row) => + sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${row.threadId}, + NULL, + ${row.messageId}, + ${row.sourceProposedPlanThreadId}, + ${row.sourceProposedPlanId}, + NULL, + 'pending', + ${row.requestedAt}, + NULL, + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `, + }); + + const getPendingProjectionTurn = SqlSchema.findOneOption({ + Request: GetProjectionPendingTurnStartInput, + Result: ProjectionPendingTurnStart, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + pending_message_id AS "messageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + requested_at AS "requestedAt" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NULL + AND state = 'pending' + AND pending_message_id IS NOT NULL + AND checkpoint_turn_count IS NULL + ORDER BY requested_at DESC + LIMIT 1 + `, + }); + + const listProjectionTurnsByThread = SqlSchema.findAll({ + Request: ListProjectionTurnsByThreadInput, + Result: ProjectionTurnDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + pending_message_id AS "pendingMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + assistant_message_id AS "assistantMessageId", + state, + requested_at AS "requestedAt", + started_at AS "startedAt", + completed_at AS "completedAt", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "checkpointStatus", + checkpoint_files_json AS "checkpointFiles" + FROM projection_turns + WHERE thread_id = ${threadId} + ORDER BY + CASE + WHEN checkpoint_turn_count IS NULL THEN 1 + ELSE 0 + END ASC, + checkpoint_turn_count ASC, + requested_at ASC, + turn_id ASC + `, + }); + + const getProjectionTurnByTurnId = SqlSchema.findOneOption({ + Request: GetProjectionTurnByTurnIdInput, + Result: ProjectionTurnByIdDbRowSchema, + execute: ({ threadId, turnId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + pending_message_id AS "pendingMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + assistant_message_id AS "assistantMessageId", + state, + requested_at AS "requestedAt", + started_at AS "startedAt", + completed_at AS "completedAt", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "checkpointStatus", + checkpoint_files_json AS "checkpointFiles" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id = ${turnId} + LIMIT 1 + `, + }); + + const clearCheckpointTurnConflictRow = SqlSchema.void({ + Request: ClearCheckpointTurnConflictInput, + execute: ({ threadId, turnId, checkpointTurnCount }) => + sql` + UPDATE projection_turns + SET + checkpoint_turn_count = NULL, + checkpoint_ref = NULL, + checkpoint_status = NULL, + checkpoint_files_json = '[]' + WHERE thread_id = ${threadId} + AND checkpoint_turn_count = ${checkpointTurnCount} + AND (turn_id IS NULL OR turn_id <> ${turnId}) + `, + }); + + const deleteProjectionTurnsByThread = SqlSchema.void({ + Request: DeleteProjectionTurnsByThreadInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_turns + WHERE thread_id = ${threadId} + `, + }); + + const upsertByTurnId: ProjectionTurnRepositoryShape["upsertByTurnId"] = (row) => + upsertProjectionTurnById(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionTurnRepository.upsertByTurnId:query", + "ProjectionTurnRepository.upsertByTurnId:encodeRequest", + ), + ), + ); + + const replacePendingTurnStart: ProjectionTurnRepositoryShape["replacePendingTurnStart"] = (row) => + sql + .withTransaction( + clearPendingProjectionTurnsByThread({ threadId: row.threadId }).pipe( + Effect.flatMap(() => insertPendingProjectionTurn(row)), + ), + ) + .pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionTurnRepository.replacePendingTurnStart:query", + "ProjectionTurnRepository.replacePendingTurnStart:encodeRequest", + ), + ), + ); + + const getPendingTurnStartByThreadId: ProjectionTurnRepositoryShape["getPendingTurnStartByThreadId"] = + (input) => + getPendingProjectionTurn(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionTurnRepository.getPendingTurnStartByThreadId:query"), + ), + ); + + const deletePendingTurnStartByThreadId: ProjectionTurnRepositoryShape["deletePendingTurnStartByThreadId"] = + (input) => + clearPendingProjectionTurnsByThread(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionTurnRepository.deletePendingTurnStartByThreadId:query"), + ), + ); + + const listByThreadId: ProjectionTurnRepositoryShape["listByThreadId"] = (input) => + listProjectionTurnsByThread(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionTurnRepository.listByThreadId:query", + "ProjectionTurnRepository.listByThreadId:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + const getByTurnId: ProjectionTurnRepositoryShape["getByTurnId"] = (input) => + getProjectionTurnByTurnId(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionTurnRepository.getByTurnId:query", + "ProjectionTurnRepository.getByTurnId:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const clearCheckpointTurnConflict: ProjectionTurnRepositoryShape["clearCheckpointTurnConflict"] = + (input) => + clearCheckpointTurnConflictRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionTurnRepository.clearCheckpointTurnConflict:query"), + ), + ); + + const deleteByThreadId: ProjectionTurnRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionTurnsByThread(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionTurnRepository.deleteByThreadId:query")), + ); + + return { + upsertByTurnId, + replacePendingTurnStart, + getPendingTurnStartByThreadId, + deletePendingTurnStartByThreadId, + listByThreadId, + getByTurnId, + clearCheckpointTurnConflict, + deleteByThreadId, + } satisfies ProjectionTurnRepositoryShape; +}); + +export const ProjectionTurnRepositoryLive = Layer.effect( + ProjectionTurnRepository, + makeProjectionTurnRepository, +); diff --git a/apps/server/src/persistence/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/OrchestrationCommandReceipts.ts deleted file mode 100644 index c738345de95..00000000000 --- a/apps/server/src/persistence/OrchestrationCommandReceipts.ts +++ /dev/null @@ -1,150 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - CommandId, - IsoDateTime, - NonNegativeInt, - OrchestrationAggregateKind, - OrchestrationCommandReceiptStatus, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; - -import { - type OrchestrationCommandReceiptRepositoryError, - toPersistenceSqlError, -} from "./Errors.ts"; - -/** - * OrchestrationCommandReceiptRepository - Repository interface for command receipts. - * - * Owns persistence operations for deduplication and status tracking of - * orchestration command handling. - * - * @module OrchestrationCommandReceiptRepository - */ - -export const OrchestrationCommandReceipt = Schema.Struct({ - commandId: CommandId, - aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), - acceptedAt: IsoDateTime, - resultSequence: NonNegativeInt, - status: OrchestrationCommandReceiptStatus, - error: Schema.NullOr(Schema.String), -}); -export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; - -export const GetByCommandIdInput = Schema.Struct({ - commandId: CommandId, -}); -export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; - -/** - * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. - */ -export class OrchestrationCommandReceiptRepository extends Context.Service< - OrchestrationCommandReceiptRepository, - { - /** - * Insert or replace a command receipt row. - * - * Upserts by `commandId` for idempotent command-result tracking. - */ - readonly upsert: ( - receipt: OrchestrationCommandReceipt, - ) => Effect.Effect; - - /** - * Read a command receipt by command id. - */ - readonly getByCommandId: ( - input: GetByCommandIdInput, - ) => Effect.Effect< - Option.Option, - OrchestrationCommandReceiptRepositoryError - >; - } ->()("t3/persistence/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertReceiptRow = SqlSchema.void({ - Request: OrchestrationCommandReceipt, - execute: (receipt) => - sql` - INSERT INTO orchestration_command_receipts ( - command_id, - aggregate_kind, - aggregate_id, - accepted_at, - result_sequence, - status, - error - ) - VALUES ( - ${receipt.commandId}, - ${receipt.aggregateKind}, - ${receipt.aggregateId}, - ${receipt.acceptedAt}, - ${receipt.resultSequence}, - ${receipt.status}, - ${receipt.error} - ) - ON CONFLICT (command_id) - DO UPDATE SET - aggregate_kind = excluded.aggregate_kind, - aggregate_id = excluded.aggregate_id, - accepted_at = excluded.accepted_at, - result_sequence = excluded.result_sequence, - status = excluded.status, - error = excluded.error - `, - }); - - const findReceiptByCommandId = SqlSchema.findOneOption({ - Request: GetByCommandIdInput, - Result: OrchestrationCommandReceipt, - execute: ({ commandId }) => - sql` - SELECT - command_id AS "commandId", - aggregate_kind AS "aggregateKind", - aggregate_id AS "aggregateId", - accepted_at AS "acceptedAt", - result_sequence AS "resultSequence", - status, - error - FROM orchestration_command_receipts - WHERE command_id = ${commandId} - `, - }); - - const upsert: OrchestrationCommandReceiptRepository["Service"]["upsert"] = (receipt) => - upsertReceiptRow(receipt).pipe( - Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")), - ); - - const getByCommandId: OrchestrationCommandReceiptRepository["Service"]["getByCommandId"] = ( - input, - ) => - findReceiptByCommandId(input).pipe( - Effect.mapError( - toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), - ), - ); - - return { - upsert, - getByCommandId, - } satisfies OrchestrationCommandReceiptRepository["Service"]; -}); - -export const layer = Layer.effect(OrchestrationCommandReceiptRepository, make); diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts deleted file mode 100644 index abc3c3c5451..00000000000 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ /dev/null @@ -1,329 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - CommandId, - EventId, - IsoDateTime, - NonNegativeInt, - OrchestrationActorKind, - OrchestrationAggregateKind, - OrchestrationEvent, - OrchestrationEventMetadata, - OrchestrationEventType, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; - -import { - type OrchestrationEventStoreError, - toPersistenceDecodeError, - toPersistenceSqlError, -} from "./Errors.ts"; - -/** - * OrchestrationEventStore - Event store interface for orchestration events. - * - * Owns durable append/replay access to the orchestration event stream. It does - * not reduce events into read models or apply command validation rules. - * - * Uses Effect `Context.Service` for dependency injection and exposes typed - * persistence/decode errors for event append and replay operations. - * - * @module OrchestrationEventStore - */ - -/** - * OrchestrationEventStore - Service tag for orchestration event persistence. - * - * @example - * ```ts - * const program = Effect.gen(function* () { - * const events = yield* OrchestrationEventStore - * return yield* Stream.runCollect(events.readAll()) - * }) - * ``` - */ -export class OrchestrationEventStore extends Context.Service< - OrchestrationEventStore, - { - /** - * Persist a new orchestration event. - * - * @param event - Event payload without sequence (assigned by storage). - * @returns Effect containing the stored event with assigned sequence. - * - * Actor kind is inferred from command/metadata before persistence. - */ - readonly append: ( - event: Omit, - ) => Effect.Effect; - - /** - * Replay events after the provided sequence. - * - * @param sequenceExclusive - Sequence cursor (exclusive). - * @param limit - Maximum number of events to emit. - * @returns Stream containing ordered events. - * - * Reads in fixed-size pages and normalizes non-integer/negative limits. - */ - readonly readFromSequence: ( - sequenceExclusive: number, - limit?: number, - ) => Stream.Stream; - - /** - * Read all events from the beginning of the stream. - * - * @returns Stream containing all stored events. - */ - readonly readAll: () => Stream.Stream; - } ->()("t3/persistence/OrchestrationEventStore") {} - -const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); -const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); -const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); - -const AppendEventRequestSchema = Schema.Struct({ - eventId: EventId, - aggregateKind: OrchestrationAggregateKind, - streamId: Schema.Union([ProjectId, ThreadId]), - type: OrchestrationEventType, - causationEventId: Schema.NullOr(EventId), - correlationId: Schema.NullOr(CommandId), - actorKind: OrchestrationActorKind, - occurredAt: IsoDateTime, - commandId: Schema.NullOr(CommandId), - payloadJson: UnknownFromJsonString, - metadataJson: EventMetadataFromJsonString, -}); - -const OrchestrationEventPersistedRowSchema = Schema.Struct({ - sequence: NonNegativeInt, - eventId: EventId, - type: OrchestrationEventType, - aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), - occurredAt: IsoDateTime, - commandId: Schema.NullOr(CommandId), - causationEventId: Schema.NullOr(EventId), - correlationId: Schema.NullOr(CommandId), - payload: UnknownFromJsonString, - metadata: EventMetadataFromJsonString, -}); - -const ReadFromSequenceRequestSchema = Schema.Struct({ - sequenceExclusive: NonNegativeInt, - limit: Schema.Number, -}); -const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; -const READ_PAGE_SIZE = 500; - -function inferActorKind( - event: Omit, -): Schema.Schema.Type { - if (event.commandId !== null && event.commandId.startsWith("provider:")) { - return "provider"; - } - if (event.commandId !== null && event.commandId.startsWith("server:")) { - return "server"; - } - if ( - event.metadata.providerTurnId !== undefined || - event.metadata.providerItemId !== undefined || - event.metadata.adapterKey !== undefined - ) { - return "provider"; - } - if (event.commandId === null) { - return "server"; - } - return "client"; -} - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown): OrchestrationEventStoreError => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const appendEventRow = SqlSchema.findOne({ - Request: AppendEventRequestSchema, - Result: OrchestrationEventPersistedRowSchema, - execute: (request) => - sql` - INSERT INTO orchestration_events ( - event_id, - aggregate_kind, - stream_id, - stream_version, - event_type, - occurred_at, - command_id, - causation_event_id, - correlation_id, - actor_kind, - payload_json, - metadata_json - ) - VALUES ( - ${request.eventId}, - ${request.aggregateKind}, - ${request.streamId}, - COALESCE( - ( - SELECT stream_version + 1 - FROM orchestration_events - WHERE aggregate_kind = ${request.aggregateKind} - AND stream_id = ${request.streamId} - ORDER BY stream_version DESC - LIMIT 1 - ), - 0 - ), - ${request.type}, - ${request.occurredAt}, - ${request.commandId}, - ${request.causationEventId}, - ${request.correlationId}, - ${request.actorKind}, - ${request.payloadJson}, - ${request.metadataJson} - ) - RETURNING - sequence, - event_id AS "eventId", - event_type AS "type", - aggregate_kind AS "aggregateKind", - stream_id AS "aggregateId", - occurred_at AS "occurredAt", - command_id AS "commandId", - causation_event_id AS "causationEventId", - correlation_id AS "correlationId", - payload_json AS "payload", - metadata_json AS "metadata" - `, - }); - - const readEventRowsFromSequence = SqlSchema.findAll({ - Request: ReadFromSequenceRequestSchema, - Result: OrchestrationEventPersistedRowSchema, - execute: (request) => - sql` - SELECT - sequence, - event_id AS "eventId", - event_type AS "type", - aggregate_kind AS "aggregateKind", - stream_id AS "aggregateId", - occurred_at AS "occurredAt", - command_id AS "commandId", - causation_event_id AS "causationEventId", - correlation_id AS "correlationId", - payload_json AS "payload", - metadata_json AS "metadata" - FROM orchestration_events - WHERE sequence > ${request.sequenceExclusive} - ORDER BY sequence ASC - LIMIT ${request.limit} - `, - }); - - const append: OrchestrationEventStore["Service"]["append"] = (event) => - appendEventRow({ - eventId: event.eventId, - aggregateKind: event.aggregateKind, - streamId: event.aggregateId, - type: event.type, - causationEventId: event.causationEventId, - correlationId: event.correlationId, - actorKind: inferActorKind(event), - occurredAt: event.occurredAt, - commandId: event.commandId, - payloadJson: event.payload, - metadataJson: event.metadata, - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "OrchestrationEventStore.append:insert", - "OrchestrationEventStore.append:decodeRow", - ), - ), - Effect.flatMap((row) => - decodeEvent(row).pipe( - Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")), - ), - ), - ); - - const readFromSequence: OrchestrationEventStore["Service"]["readFromSequence"] = ( - sequenceExclusive, - limit = DEFAULT_READ_FROM_SEQUENCE_LIMIT, - ) => { - const normalizedLimit = Math.max(0, Math.floor(limit)); - if (normalizedLimit === 0) { - return Stream.empty; - } - const readPage = ( - cursor: number, - remaining: number, - ): Stream.Stream => - Stream.fromEffect( - readEventRowsFromSequence({ - sequenceExclusive: cursor, - limit: Math.min(remaining, READ_PAGE_SIZE), - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "OrchestrationEventStore.readFromSequence:query", - "OrchestrationEventStore.readFromSequence:decodeRows", - ), - ), - Effect.flatMap((rows) => - Effect.forEach(rows, (row) => - decodeEvent(row).pipe( - Effect.mapError( - toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"), - ), - ), - ), - ), - ), - ).pipe( - Stream.flatMap((events) => { - if (events.length === 0) { - return Stream.empty; - } - const nextRemaining = remaining - events.length; - if (nextRemaining <= 0) { - return Stream.fromIterable(events); - } - return Stream.concat( - Stream.fromIterable(events), - readPage(events[events.length - 1]!.sequence, nextRemaining), - ); - }), - ); - - return readPage(sequenceExclusive, normalizedLimit); - }; - - return { - append, - readFromSequence, - readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), - } satisfies OrchestrationEventStore["Service"]; -}); - -export const layer = Layer.effect(OrchestrationEventStore, make); diff --git a/apps/server/src/persistence/ProjectionCheckpoints.ts b/apps/server/src/persistence/ProjectionCheckpoints.ts deleted file mode 100644 index a165dec8d8f..00000000000 --- a/apps/server/src/persistence/ProjectionCheckpoints.ts +++ /dev/null @@ -1,295 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - CheckpointRef, - IsoDateTime, - MessageId, - NonNegativeInt, - OrchestrationCheckpointFile, - OrchestrationCheckpointStatus, - ThreadId, - TurnId, -} from "@t3tools/contracts"; - -import { - type ProjectionRepositoryError, - toPersistenceDecodeError, - toPersistenceSqlError, -} from "./Errors.ts"; - -/** - * ProjectionCheckpointRepository - Projection repository interface for checkpoints. - * - * Owns persistence operations for projected checkpoint summaries in thread - * timelines. - * - * @module ProjectionCheckpointRepository - */ - -export const ProjectionCheckpoint = Schema.Struct({ - threadId: ThreadId, - turnId: TurnId, - checkpointTurnCount: NonNegativeInt, - checkpointRef: CheckpointRef, - status: OrchestrationCheckpointStatus, - files: Schema.Array(OrchestrationCheckpointFile), - assistantMessageId: Schema.NullOr(MessageId), - completedAt: IsoDateTime, -}); -export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type; - -export const ListByThreadIdInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListByThreadIdInput = typeof ListByThreadIdInput.Type; - -export const GetByThreadAndTurnCountInput = Schema.Struct({ - threadId: ThreadId, - checkpointTurnCount: NonNegativeInt, -}); -export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type; - -export const DeleteByThreadIdInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type; - -/** - * ProjectionCheckpointRepository - Service tag for checkpoint projection persistence. - */ -export class ProjectionCheckpointRepository extends Context.Service< - ProjectionCheckpointRepository, - { - /** - * Insert or replace a projected checkpoint row. - * - * Upserts by composite key `(threadId, checkpointTurnCount)`. - */ - readonly upsert: (row: ProjectionCheckpoint) => Effect.Effect; - - /** - * List projected checkpoints for a thread. - * - * Returned in ascending checkpoint turn-count order. - */ - readonly listByThreadId: ( - input: ListByThreadIdInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Read a projected checkpoint by thread and turn-count key. - */ - readonly getByThreadAndTurnCount: ( - input: GetByThreadAndTurnCountInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete projected checkpoint rows by thread. - */ - readonly deleteByThreadId: ( - input: DeleteByThreadIdInput, - ) => Effect.Effect; - } ->()("t3/persistence/ProjectionCheckpoints/ProjectionCheckpointRepository") {} - -const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( - Struct.assign({ - files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), - }), -); - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown) => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const clearCheckpointConflict = SqlSchema.void({ - Request: GetByThreadAndTurnCountInput, - execute: ({ threadId, checkpointTurnCount }) => - sql` - UPDATE projection_turns - SET - checkpoint_turn_count = NULL, - checkpoint_ref = NULL, - checkpoint_status = NULL, - checkpoint_files_json = '[]' - WHERE thread_id = ${threadId} - AND checkpoint_turn_count = ${checkpointTurnCount} - `, - }); - - const upsertProjectionCheckpointRow = SqlSchema.void({ - Request: ProjectionCheckpointDbRowSchema, - execute: (row) => - sql` - INSERT INTO projection_turns ( - thread_id, - turn_id, - pending_message_id, - assistant_message_id, - state, - requested_at, - started_at, - completed_at, - checkpoint_turn_count, - checkpoint_ref, - checkpoint_status, - checkpoint_files_json - ) - VALUES ( - ${row.threadId}, - ${row.turnId}, - NULL, - ${row.assistantMessageId}, - ${row.status === "error" ? "error" : "completed"}, - ${row.completedAt}, - ${row.completedAt}, - ${row.completedAt}, - ${row.checkpointTurnCount}, - ${row.checkpointRef}, - ${row.status}, - ${row.files} - ) - ON CONFLICT (thread_id, turn_id) - DO UPDATE SET - assistant_message_id = excluded.assistant_message_id, - state = excluded.state, - completed_at = excluded.completed_at, - checkpoint_turn_count = excluded.checkpoint_turn_count, - checkpoint_ref = excluded.checkpoint_ref, - checkpoint_status = excluded.checkpoint_status, - checkpoint_files_json = excluded.checkpoint_files_json - `, - }); - - const listProjectionCheckpointRows = SqlSchema.findAll({ - Request: ListByThreadIdInput, - Result: ProjectionCheckpointDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "status", - checkpoint_files_json AS "files", - assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" - FROM projection_turns - WHERE thread_id = ${threadId} - AND checkpoint_turn_count IS NOT NULL - ORDER BY checkpoint_turn_count ASC - `, - }); - - const getProjectionCheckpointRow = SqlSchema.findOneOption({ - Request: GetByThreadAndTurnCountInput, - Result: ProjectionCheckpointDbRowSchema, - execute: ({ threadId, checkpointTurnCount }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "status", - checkpoint_files_json AS "files", - assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" - FROM projection_turns - WHERE thread_id = ${threadId} - AND checkpoint_turn_count = ${checkpointTurnCount} - `, - }); - - const deleteProjectionCheckpointRows = SqlSchema.void({ - Request: DeleteByThreadIdInput, - execute: ({ threadId }) => - sql` - UPDATE projection_turns - SET - checkpoint_turn_count = NULL, - checkpoint_ref = NULL, - checkpoint_status = NULL, - checkpoint_files_json = '[]' - WHERE thread_id = ${threadId} - AND checkpoint_turn_count IS NOT NULL - `, - }); - - const upsertCheckpointRow = (row: Schema.Schema.Type) => - sql.withTransaction( - clearCheckpointConflict({ - threadId: row.threadId, - checkpointTurnCount: row.checkpointTurnCount, - }).pipe(Effect.flatMap(() => upsertProjectionCheckpointRow(row))), - ); - - const upsert: ProjectionCheckpointRepository["Service"]["upsert"] = (row) => - upsertCheckpointRow(row).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionCheckpointRepository.upsert:query", - "ProjectionCheckpointRepository.upsert:encodeRequest", - ), - ), - ); - - const listByThreadId: ProjectionCheckpointRepository["Service"]["listByThreadId"] = (input) => - listProjectionCheckpointRows(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionCheckpointRepository.listByThreadId:query", - "ProjectionCheckpointRepository.listByThreadId:decodeRows", - ), - ), - Effect.map((rows) => rows as ReadonlyArray>), - ); - - const getByThreadAndTurnCount: ProjectionCheckpointRepository["Service"]["getByThreadAndTurnCount"] = - (input) => - getProjectionCheckpointRow(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionCheckpointRepository.getByThreadAndTurnCount:query", - "ProjectionCheckpointRepository.getByThreadAndTurnCount:decodeRow", - ), - ), - Effect.flatMap((rowOption) => - Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - Effect.succeed(Option.some(row as Schema.Schema.Type)), - }), - ), - ); - - const deleteByThreadId: ProjectionCheckpointRepository["Service"]["deleteByThreadId"] = (input) => - deleteProjectionCheckpointRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionCheckpointRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - listByThreadId, - getByThreadAndTurnCount, - deleteByThreadId, - } satisfies ProjectionCheckpointRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionCheckpointRepository, make); diff --git a/apps/server/src/persistence/ProjectionPendingApprovals.ts b/apps/server/src/persistence/ProjectionPendingApprovals.ts deleted file mode 100644 index de4726d3545..00000000000 --- a/apps/server/src/persistence/ProjectionPendingApprovals.ts +++ /dev/null @@ -1,217 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - ApprovalRequestId, - IsoDateTime, - ProjectionPendingApprovalDecision, - ProjectionPendingApprovalStatus, - ThreadId, - TurnId, -} from "@t3tools/contracts"; - -import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; - -/** - * ProjectionPendingApprovalRepository - Repository interface for pending approvals. - * - * Owns persistence operations for projected approval requests awaiting user - * decisions. - * - * @module ProjectionPendingApprovalRepository - */ - -export const ProjectionPendingApproval = Schema.Struct({ - requestId: ApprovalRequestId, - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - status: ProjectionPendingApprovalStatus, - decision: ProjectionPendingApprovalDecision, - createdAt: IsoDateTime, - resolvedAt: Schema.NullOr(IsoDateTime), -}); -export type ProjectionPendingApproval = typeof ProjectionPendingApproval.Type; - -export const ListProjectionPendingApprovalsInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionPendingApprovalsInput = typeof ListProjectionPendingApprovalsInput.Type; - -export const GetProjectionPendingApprovalInput = Schema.Struct({ - requestId: ApprovalRequestId, -}); -export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type; - -export const DeleteProjectionPendingApprovalInput = Schema.Struct({ - requestId: ApprovalRequestId, -}); -export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type; - -/** - * ProjectionPendingApprovalRepository - Service tag for pending approval persistence. - */ -export class ProjectionPendingApprovalRepository extends Context.Service< - ProjectionPendingApprovalRepository, - { - /** - * Insert or replace a projected pending approval row. - * - * Upserts by `requestId`. - */ - readonly upsert: ( - row: ProjectionPendingApproval, - ) => Effect.Effect; - - /** - * List pending approvals for a thread. - * - * Returned in ascending creation order. - */ - readonly listByThreadId: ( - input: ListProjectionPendingApprovalsInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Read a pending approval row by request id. - */ - readonly getByRequestId: ( - input: GetProjectionPendingApprovalInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete a pending approval row by request id. - */ - readonly deleteByRequestId: ( - input: DeleteProjectionPendingApprovalInput, - ) => Effect.Effect; - } ->()("t3/persistence/ProjectionPendingApprovals/ProjectionPendingApprovalRepository") {} - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionPendingApprovalRow = SqlSchema.void({ - Request: ProjectionPendingApproval, - execute: (row) => - sql` - INSERT INTO projection_pending_approvals ( - request_id, - thread_id, - turn_id, - status, - decision, - created_at, - resolved_at - ) - VALUES ( - ${row.requestId}, - ${row.threadId}, - ${row.turnId}, - ${row.status}, - ${row.decision}, - ${row.createdAt}, - ${row.resolvedAt} - ) - ON CONFLICT (request_id) - DO UPDATE SET - thread_id = excluded.thread_id, - turn_id = excluded.turn_id, - status = excluded.status, - decision = excluded.decision, - created_at = excluded.created_at, - resolved_at = excluded.resolved_at - `, - }); - - const listProjectionPendingApprovalRows = SqlSchema.findAll({ - Request: ListProjectionPendingApprovalsInput, - Result: ProjectionPendingApproval, - execute: ({ threadId }) => - sql` - SELECT - request_id AS "requestId", - thread_id AS "threadId", - turn_id AS "turnId", - status, - decision, - created_at AS "createdAt", - resolved_at AS "resolvedAt" - FROM projection_pending_approvals - WHERE thread_id = ${threadId} - ORDER BY created_at ASC, request_id ASC - `, - }); - - const getProjectionPendingApprovalRow = SqlSchema.findOneOption({ - Request: GetProjectionPendingApprovalInput, - Result: ProjectionPendingApproval, - execute: ({ requestId }) => - sql` - SELECT - request_id AS "requestId", - thread_id AS "threadId", - turn_id AS "turnId", - status, - decision, - created_at AS "createdAt", - resolved_at AS "resolvedAt" - FROM projection_pending_approvals - WHERE request_id = ${requestId} - `, - }); - - const deleteProjectionPendingApprovalRow = SqlSchema.void({ - Request: DeleteProjectionPendingApprovalInput, - execute: ({ requestId }) => - sql` - DELETE FROM projection_pending_approvals - WHERE request_id = ${requestId} - `, - }); - - const upsert: ProjectionPendingApprovalRepository["Service"]["upsert"] = (row) => - upsertProjectionPendingApprovalRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")), - ); - - const listByThreadId: ProjectionPendingApprovalRepository["Service"]["listByThreadId"] = ( - input, - ) => - listProjectionPendingApprovalRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionPendingApprovalRepository.listByThreadId:query"), - ), - ); - - const getByRequestId: ProjectionPendingApprovalRepository["Service"]["getByRequestId"] = ( - input, - ) => - getProjectionPendingApprovalRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionPendingApprovalRepository.getByRequestId:query"), - ), - ); - - const deleteByRequestId: ProjectionPendingApprovalRepository["Service"]["deleteByRequestId"] = ( - input, - ) => - deleteProjectionPendingApprovalRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"), - ), - ); - - return { - upsert, - listByThreadId, - getByRequestId, - deleteByRequestId, - } satisfies ProjectionPendingApprovalRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionPendingApprovalRepository, make); diff --git a/apps/server/src/persistence/ProjectionProjects.ts b/apps/server/src/persistence/ProjectionProjects.ts deleted file mode 100644 index 100ff344b51..00000000000 --- a/apps/server/src/persistence/ProjectionProjects.ts +++ /dev/null @@ -1,206 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; - -import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; - -/** - * ProjectionProjectRepository - Projection repository interface for projects. - * - * Owns persistence operations for project rows in the orchestration projection - * read model. - * - * @module ProjectionProjectRepository - */ - -export const ProjectionProject = Schema.Struct({ - projectId: ProjectId, - title: Schema.String, - workspaceRoot: Schema.String, - defaultModelSelection: Schema.NullOr(ModelSelection), - scripts: Schema.Array(ProjectScript), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, - deletedAt: Schema.NullOr(IsoDateTime), -}); -export type ProjectionProject = typeof ProjectionProject.Type; - -export const GetProjectionProjectInput = Schema.Struct({ - projectId: ProjectId, -}); -export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type; - -export const DeleteProjectionProjectInput = Schema.Struct({ - projectId: ProjectId, -}); -export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type; - -/** - * ProjectionProjectRepository - Service tag for project projection persistence. - */ -export class ProjectionProjectRepository extends Context.Service< - ProjectionProjectRepository, - { - /** - * Insert or replace a projected project row. - * - * Upserts by `projectId` and persists scripts through JSON encoding. - */ - readonly upsert: (row: ProjectionProject) => Effect.Effect; - - /** - * Read a projected project row by id. - */ - readonly getById: ( - input: GetProjectionProjectInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * List all projected project rows. - * - * Returned in deterministic creation order. - */ - readonly listAll: () => Effect.Effect< - ReadonlyArray, - ProjectionRepositoryError - >; - - /** - * Soft-delete a projected project row by id. - */ - readonly deleteById: ( - input: DeleteProjectionProjectInput, - ) => Effect.Effect; - } ->()("t3/persistence/ProjectionProjects/ProjectionProjectRepository") {} - -const ProjectionProjectDbRow = ProjectionProject.mapFields( - Struct.assign({ - defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), - scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), - }), -); -type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionProjectRow = SqlSchema.void({ - Request: ProjectionProject, - execute: (row) => - sql` - INSERT INTO projection_projects ( - project_id, - title, - workspace_root, - default_model_selection_json, - scripts_json, - created_at, - updated_at, - deleted_at - ) - VALUES ( - ${row.projectId}, - ${row.title}, - ${row.workspaceRoot}, - ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, - ${JSON.stringify(row.scripts)}, - ${row.createdAt}, - ${row.updatedAt}, - ${row.deletedAt} - ) - ON CONFLICT (project_id) - DO UPDATE SET - title = excluded.title, - workspace_root = excluded.workspace_root, - default_model_selection_json = excluded.default_model_selection_json, - scripts_json = excluded.scripts_json, - created_at = excluded.created_at, - updated_at = excluded.updated_at, - deleted_at = excluded.deleted_at - `, - }); - - const getProjectionProjectRow = SqlSchema.findOneOption({ - Request: GetProjectionProjectInput, - Result: ProjectionProjectDbRow, - execute: ({ projectId }) => - sql` - SELECT - project_id AS "projectId", - title, - workspace_root AS "workspaceRoot", - default_model_selection_json AS "defaultModelSelection", - scripts_json AS "scripts", - created_at AS "createdAt", - updated_at AS "updatedAt", - deleted_at AS "deletedAt" - FROM projection_projects - WHERE project_id = ${projectId} - `, - }); - - const listProjectionProjectRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProjectionProjectDbRow, - execute: () => - sql` - SELECT - project_id AS "projectId", - title, - workspace_root AS "workspaceRoot", - default_model_selection_json AS "defaultModelSelection", - scripts_json AS "scripts", - created_at AS "createdAt", - updated_at AS "updatedAt", - deleted_at AS "deletedAt" - FROM projection_projects - ORDER BY created_at ASC, project_id ASC - `, - }); - - const deleteProjectionProjectRow = SqlSchema.void({ - Request: DeleteProjectionProjectInput, - execute: ({ projectId }) => - sql` - DELETE FROM projection_projects - WHERE project_id = ${projectId} - `, - }); - - const upsert: ProjectionProjectRepository["Service"]["upsert"] = (row) => - upsertProjectionProjectRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")), - ); - - const getById: ProjectionProjectRepository["Service"]["getById"] = (input) => - getProjectionProjectRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), - ); - - const listAll: ProjectionProjectRepository["Service"]["listAll"] = () => - listProjectionProjectRows().pipe( - Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), - ); - - const deleteById: ProjectionProjectRepository["Service"]["deleteById"] = (input) => - deleteProjectionProjectRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.deleteById:query")), - ); - - return { - upsert, - getById, - listAll, - deleteById, - } satisfies ProjectionProjectRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionProjectRepository, make); diff --git a/apps/server/src/persistence/ProjectionState.ts b/apps/server/src/persistence/ProjectionState.ts deleted file mode 100644 index 0e6c55c2dd5..00000000000 --- a/apps/server/src/persistence/ProjectionState.ts +++ /dev/null @@ -1,170 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts"; - -import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; - -/** - * ProjectionStateRepository - Projection repository interface for projector cursors. - * - * Owns persistence operations for projection cursor state used to resume - * incremental event projection. - * - * @module ProjectionStateRepository - */ - -export const ProjectionState = Schema.Struct({ - projector: Schema.String, - lastAppliedSequence: NonNegativeInt, - updatedAt: IsoDateTime, -}); -export type ProjectionState = typeof ProjectionState.Type; - -export const GetProjectionStateInput = Schema.Struct({ - projector: Schema.String, -}); -export type GetProjectionStateInput = typeof GetProjectionStateInput.Type; - -/** - * ProjectionStateRepository - Service tag for projection cursor persistence. - */ -export class ProjectionStateRepository extends Context.Service< - ProjectionStateRepository, - { - /** - * Insert or replace a projection cursor row. - * - * Upserts by projector name. - */ - readonly upsert: (row: ProjectionState) => Effect.Effect; - - /** - * Read projection cursor state for a projector key. - */ - readonly getByProjector: ( - input: GetProjectionStateInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * List all projector cursor rows. - */ - readonly listAll: () => Effect.Effect< - ReadonlyArray, - ProjectionRepositoryError - >; - - /** - * Read the minimum applied sequence across all projectors. - * - * Returns `null` when no projector state rows exist. - */ - readonly minLastAppliedSequence: () => Effect.Effect; - } ->()("t3/persistence/ProjectionState/ProjectionStateRepository") {} - -const MinLastAppliedSequenceRowSchema = Schema.Struct({ - minLastAppliedSequence: Schema.NullOr(NonNegativeInt), -}); - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionStateRow = SqlSchema.void({ - Request: ProjectionState, - execute: (row) => - sql` - INSERT INTO projection_state ( - projector, - last_applied_sequence, - updated_at - ) - VALUES ( - ${row.projector}, - ${row.lastAppliedSequence}, - ${row.updatedAt} - ) - ON CONFLICT (projector) - DO UPDATE SET - last_applied_sequence = excluded.last_applied_sequence, - updated_at = excluded.updated_at - `, - }); - - const getProjectionStateRow = SqlSchema.findOneOption({ - Request: GetProjectionStateInput, - Result: ProjectionState, - execute: ({ projector }) => - sql` - SELECT - projector, - last_applied_sequence AS "lastAppliedSequence", - updated_at AS "updatedAt" - FROM projection_state - WHERE projector = ${projector} - `, - }); - - const listProjectionStateRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProjectionState, - execute: () => - sql` - SELECT - projector, - last_applied_sequence AS "lastAppliedSequence", - updated_at AS "updatedAt" - FROM projection_state - ORDER BY projector ASC - `, - }); - - const readMinLastAppliedSequence = SqlSchema.findOne({ - Request: Schema.Void, - Result: MinLastAppliedSequenceRowSchema, - execute: () => - sql` - SELECT - MIN(last_applied_sequence) AS "minLastAppliedSequence" - FROM projection_state - `, - }); - - const upsert: ProjectionStateRepository["Service"]["upsert"] = (row) => - upsertProjectionStateRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")), - ); - - const getByProjector: ProjectionStateRepository["Service"]["getByProjector"] = (input) => - getProjectionStateRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.getByProjector:query")), - ); - - const listAll: ProjectionStateRepository["Service"]["listAll"] = () => - listProjectionStateRows(undefined).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")), - ); - - const minLastAppliedSequence: ProjectionStateRepository["Service"]["minLastAppliedSequence"] = - () => - readMinLastAppliedSequence(undefined).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionStateRepository.minLastAppliedSequence:query"), - ), - Effect.map((row) => row.minLastAppliedSequence), - ); - - return { - upsert, - getByProjector, - listAll, - minLastAppliedSequence, - } satisfies ProjectionStateRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionStateRepository, make); diff --git a/apps/server/src/persistence/ProjectionThreadActivities.ts b/apps/server/src/persistence/ProjectionThreadActivities.ts deleted file mode 100644 index f4b34a742f1..00000000000 --- a/apps/server/src/persistence/ProjectionThreadActivities.ts +++ /dev/null @@ -1,230 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - EventId, - IsoDateTime, - NonNegativeInt, - OrchestrationThreadActivityTone, - ThreadId, - TurnId, -} from "@t3tools/contracts"; - -import { - type ProjectionRepositoryError, - toPersistenceDecodeError, - toPersistenceSqlError, -} from "./Errors.ts"; - -/** - * ProjectionThreadActivityRepository - Projection repository interface for thread activity. - * - * Owns persistence operations for activity timeline entries projected from - * orchestration events. - * - * @module ProjectionThreadActivityRepository - */ - -export const ProjectionThreadActivity = Schema.Struct({ - activityId: EventId, - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - tone: OrchestrationThreadActivityTone, - kind: Schema.String, - summary: Schema.String, - payload: Schema.Unknown, - sequence: Schema.optional(NonNegativeInt), - createdAt: IsoDateTime, -}); -export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; - -export const ListProjectionThreadActivitiesInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; - -export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadActivitiesInput = - typeof DeleteProjectionThreadActivitiesInput.Type; - -/** - * ProjectionThreadActivityRepository - Service tag for thread activity persistence. - */ -export class ProjectionThreadActivityRepository extends Context.Service< - ProjectionThreadActivityRepository, - { - /** - * Insert or replace a projected thread activity row. - * - * Upserts by `activityId` and JSON-encodes payload. - */ - readonly upsert: ( - row: ProjectionThreadActivity, - ) => Effect.Effect; - - /** - * List projected thread activity rows for a thread. - * - * Returned in ascending runtime sequence order (or creation order when - * sequence is unavailable). - */ - readonly listByThreadId: ( - input: ListProjectionThreadActivitiesInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete projected thread activity rows by thread. - */ - readonly deleteByThreadId: ( - input: DeleteProjectionThreadActivitiesInput, - ) => Effect.Effect; - } ->()("t3/persistence/ProjectionThreadActivities/ProjectionThreadActivityRepository") {} - -const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( - Struct.assign({ - payload: Schema.fromJsonString(Schema.Unknown), - sequence: Schema.NullOr(NonNegativeInt), - }), -); - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown) => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadActivityRow = SqlSchema.void({ - Request: ProjectionThreadActivity, - execute: (row) => - sql` - INSERT INTO projection_thread_activities ( - activity_id, - thread_id, - turn_id, - tone, - kind, - summary, - payload_json, - sequence, - created_at - ) - VALUES ( - ${row.activityId}, - ${row.threadId}, - ${row.turnId}, - ${row.tone}, - ${row.kind}, - ${row.summary}, - ${JSON.stringify(row.payload)}, - ${row.sequence ?? null}, - ${row.createdAt} - ) - ON CONFLICT (activity_id) - DO UPDATE SET - thread_id = excluded.thread_id, - turn_id = excluded.turn_id, - tone = excluded.tone, - kind = excluded.kind, - summary = excluded.summary, - payload_json = excluded.payload_json, - sequence = excluded.sequence, - created_at = excluded.created_at - `, - }); - - const listProjectionThreadActivityRows = SqlSchema.findAll({ - Request: ListProjectionThreadActivitiesInput, - Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - activity_id AS "activityId", - thread_id AS "threadId", - turn_id AS "turnId", - tone, - kind, - summary, - payload_json AS "payload", - sequence, - created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} - ORDER BY - CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, - sequence ASC, - created_at ASC, - activity_id ASC - `, - }); - - const deleteProjectionThreadActivityRows = SqlSchema.void({ - Request: DeleteProjectionThreadActivitiesInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_thread_activities - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadActivityRepository["Service"]["upsert"] = (row) => - upsertProjectionThreadActivityRow(row).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionThreadActivityRepository.upsert:query", - "ProjectionThreadActivityRepository.upsert:encodeRequest", - ), - ), - ); - - const listByThreadId: ProjectionThreadActivityRepository["Service"]["listByThreadId"] = (input) => - listProjectionThreadActivityRows(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionThreadActivityRepository.listByThreadId:query", - "ProjectionThreadActivityRepository.listByThreadId:decodeRows", - ), - ), - Effect.map((rows) => - rows.map((row) => ({ - activityId: row.activityId, - threadId: row.threadId, - turnId: row.turnId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - })), - ), - ); - - const deleteByThreadId: ProjectionThreadActivityRepository["Service"]["deleteByThreadId"] = ( - input, - ) => - deleteProjectionThreadActivityRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadActivityRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - listByThreadId, - deleteByThreadId, - } satisfies ProjectionThreadActivityRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionThreadActivityRepository, make); diff --git a/apps/server/src/persistence/ProjectionThreadMessages.ts b/apps/server/src/persistence/ProjectionThreadMessages.ts deleted file mode 100644 index 77264790898..00000000000 --- a/apps/server/src/persistence/ProjectionThreadMessages.ts +++ /dev/null @@ -1,265 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - ChatAttachment, - IsoDateTime, - MessageId, - OrchestrationMessageRole, - ThreadId, - TurnId, -} from "@t3tools/contracts"; - -import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; - -/** - * ProjectionThreadMessageRepository - Projection repository interface for messages. - * - * Owns persistence operations for projected thread messages rendered in the - * orchestration read model. - * - * @module ProjectionThreadMessageRepository - */ - -export const ProjectionThreadMessage = Schema.Struct({ - messageId: MessageId, - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - role: OrchestrationMessageRole, - text: Schema.String, - attachments: Schema.optional(Schema.Array(ChatAttachment)), - isStreaming: Schema.Boolean, - createdAt: IsoDateTime, - updatedAt: IsoDateTime, -}); -export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; - -export const ListProjectionThreadMessagesInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; - -export const GetProjectionThreadMessageInput = Schema.Struct({ - messageId: MessageId, -}); -export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; - -export const DeleteProjectionThreadMessagesInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type; - -/** - * ProjectionThreadMessageRepository - Service tag for message projection persistence. - */ -export class ProjectionThreadMessageRepository extends Context.Service< - ProjectionThreadMessageRepository, - { - /** - * Insert or replace a projected thread message row. - * - * Upserts by `messageId`. - */ - readonly upsert: ( - message: ProjectionThreadMessage, - ) => Effect.Effect; - - /** - * Read a projected thread message by id. - */ - readonly getByMessageId: ( - input: GetProjectionThreadMessageInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * List projected thread messages for a thread. - * - * Returned in ascending creation order. - */ - readonly listByThreadId: ( - input: ListProjectionThreadMessagesInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete projected thread messages by thread. - */ - readonly deleteByThreadId: ( - input: DeleteProjectionThreadMessagesInput, - ) => Effect.Effect; - } ->()("t3/persistence/ProjectionThreadMessages/ProjectionThreadMessageRepository") {} - -const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( - Struct.assign({ - isStreaming: Schema.Number, - attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), - }), -); - -function toProjectionThreadMessage( - row: Schema.Schema.Type, -): ProjectionThreadMessage { - return { - messageId: row.messageId, - threadId: row.threadId, - turnId: row.turnId, - role: row.role, - text: row.text, - isStreaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - ...(row.attachments !== null ? { attachments: row.attachments } : {}), - }; -} - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadMessageRow = SqlSchema.void({ - Request: ProjectionThreadMessage, - execute: (row) => { - const nextAttachmentsJson = - row.attachments !== undefined ? JSON.stringify(row.attachments) : null; - return sql` - INSERT INTO projection_thread_messages ( - message_id, - thread_id, - turn_id, - role, - text, - attachments_json, - is_streaming, - created_at, - updated_at - ) - VALUES ( - ${row.messageId}, - ${row.threadId}, - ${row.turnId}, - ${row.role}, - ${row.text}, - COALESCE( - ${nextAttachmentsJson}, - ( - SELECT attachments_json - FROM projection_thread_messages - WHERE message_id = ${row.messageId} - ) - ), - ${row.isStreaming ? 1 : 0}, - ${row.createdAt}, - ${row.updatedAt} - ) - ON CONFLICT (message_id) - DO UPDATE SET - thread_id = excluded.thread_id, - turn_id = excluded.turn_id, - role = excluded.role, - text = excluded.text, - attachments_json = COALESCE( - excluded.attachments_json, - projection_thread_messages.attachments_json - ), - is_streaming = excluded.is_streaming, - created_at = excluded.created_at, - updated_at = excluded.updated_at - `; - }, - }); - - const getProjectionThreadMessageRow = SqlSchema.findOneOption({ - Request: GetProjectionThreadMessageInput, - Result: ProjectionThreadMessageDbRowSchema, - execute: ({ messageId }) => - sql` - SELECT - message_id AS "messageId", - thread_id AS "threadId", - turn_id AS "turnId", - role, - text, - attachments_json AS "attachments", - is_streaming AS "isStreaming", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projection_thread_messages - WHERE message_id = ${messageId} - LIMIT 1 - `, - }); - - const listProjectionThreadMessageRows = SqlSchema.findAll({ - Request: ListProjectionThreadMessagesInput, - Result: ProjectionThreadMessageDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - message_id AS "messageId", - thread_id AS "threadId", - turn_id AS "turnId", - role, - text, - attachments_json AS "attachments", - is_streaming AS "isStreaming", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projection_thread_messages - WHERE thread_id = ${threadId} - ORDER BY created_at ASC, message_id ASC - `, - }); - - const deleteProjectionThreadMessageRows = SqlSchema.void({ - Request: DeleteProjectionThreadMessagesInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_thread_messages - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadMessageRepository["Service"]["upsert"] = (row) => - upsertProjectionThreadMessageRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), - ); - - const getByMessageId: ProjectionThreadMessageRepository["Service"]["getByMessageId"] = (input) => - getProjectionThreadMessageRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadMessageRepository.getByMessageId:query"), - ), - Effect.map(Option.map(toProjectionThreadMessage)), - ); - - const listByThreadId: ProjectionThreadMessageRepository["Service"]["listByThreadId"] = (input) => - listProjectionThreadMessageRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"), - ), - Effect.map((rows) => rows.map(toProjectionThreadMessage)), - ); - - const deleteByThreadId: ProjectionThreadMessageRepository["Service"]["deleteByThreadId"] = ( - input, - ) => - deleteProjectionThreadMessageRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - getByMessageId, - listByThreadId, - deleteByThreadId, - } satisfies ProjectionThreadMessageRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionThreadMessageRepository, make); diff --git a/apps/server/src/persistence/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/ProjectionThreadProposedPlans.ts deleted file mode 100644 index e5e56d4c291..00000000000 --- a/apps/server/src/persistence/ProjectionThreadProposedPlans.ts +++ /dev/null @@ -1,152 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - IsoDateTime, - OrchestrationProposedPlanId, - ThreadId, - TrimmedNonEmptyString, - TurnId, -} from "@t3tools/contracts"; - -import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; - -export const ProjectionThreadProposedPlan = Schema.Struct({ - planId: OrchestrationProposedPlanId, - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - planMarkdown: TrimmedNonEmptyString, - implementedAt: Schema.NullOr(IsoDateTime), - implementationThreadId: Schema.NullOr(ThreadId), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, -}); -export type ProjectionThreadProposedPlan = typeof ProjectionThreadProposedPlan.Type; - -export const ListProjectionThreadProposedPlansInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionThreadProposedPlansInput = - typeof ListProjectionThreadProposedPlansInput.Type; - -export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadProposedPlansInput = - typeof DeleteProjectionThreadProposedPlansInput.Type; - -export class ProjectionThreadProposedPlanRepository extends Context.Service< - ProjectionThreadProposedPlanRepository, - { - readonly upsert: ( - proposedPlan: ProjectionThreadProposedPlan, - ) => Effect.Effect; - readonly listByThreadId: ( - input: ListProjectionThreadProposedPlansInput, - ) => Effect.Effect, ProjectionRepositoryError>; - readonly deleteByThreadId: ( - input: DeleteProjectionThreadProposedPlansInput, - ) => Effect.Effect; - } ->()("t3/persistence/ProjectionThreadProposedPlans/ProjectionThreadProposedPlanRepository") {} - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadProposedPlanRow = SqlSchema.void({ - Request: ProjectionThreadProposedPlan, - execute: (row) => sql` - INSERT INTO projection_thread_proposed_plans ( - plan_id, - thread_id, - turn_id, - plan_markdown, - implemented_at, - implementation_thread_id, - created_at, - updated_at - ) - VALUES ( - ${row.planId}, - ${row.threadId}, - ${row.turnId}, - ${row.planMarkdown}, - ${row.implementedAt}, - ${row.implementationThreadId}, - ${row.createdAt}, - ${row.updatedAt} - ) - ON CONFLICT (plan_id) - DO UPDATE SET - thread_id = excluded.thread_id, - turn_id = excluded.turn_id, - plan_markdown = excluded.plan_markdown, - implemented_at = excluded.implemented_at, - implementation_thread_id = excluded.implementation_thread_id, - created_at = excluded.created_at, - updated_at = excluded.updated_at - `, - }); - - const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ - Request: ListProjectionThreadProposedPlansInput, - Result: ProjectionThreadProposedPlan, - execute: ({ threadId }) => sql` - SELECT - plan_id AS "planId", - thread_id AS "threadId", - turn_id AS "turnId", - plan_markdown AS "planMarkdown", - implemented_at AS "implementedAt", - implementation_thread_id AS "implementationThreadId", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projection_thread_proposed_plans - WHERE thread_id = ${threadId} - ORDER BY created_at ASC, plan_id ASC - `, - }); - - const deleteProjectionThreadProposedPlanRows = SqlSchema.void({ - Request: DeleteProjectionThreadProposedPlansInput, - execute: ({ threadId }) => sql` - DELETE FROM projection_thread_proposed_plans - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadProposedPlanRepository["Service"]["upsert"] = (row) => - upsertProjectionThreadProposedPlanRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), - ); - - const listByThreadId: ProjectionThreadProposedPlanRepository["Service"]["listByThreadId"] = ( - input, - ) => - listProjectionThreadProposedPlanRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadProposedPlanRepository.listByThreadId:query"), - ), - ); - - const deleteByThreadId: ProjectionThreadProposedPlanRepository["Service"]["deleteByThreadId"] = ( - input, - ) => - deleteProjectionThreadProposedPlanRows(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadProposedPlanRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - listByThreadId, - deleteByThreadId, - } satisfies ProjectionThreadProposedPlanRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionThreadProposedPlanRepository, make); diff --git a/apps/server/src/persistence/ProjectionThreadSessions.ts b/apps/server/src/persistence/ProjectionThreadSessions.ts deleted file mode 100644 index ee386862f68..00000000000 --- a/apps/server/src/persistence/ProjectionThreadSessions.ts +++ /dev/null @@ -1,177 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - IsoDateTime, - OrchestrationSessionStatus, - ProviderInstanceId, - RuntimeMode, - ThreadId, - TurnId, -} from "@t3tools/contracts"; - -import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; - -/** - * ProjectionThreadSessionRepository - Repository interface for thread sessions. - * - * Owns persistence operations for projected provider-session linkage and - * runtime status for each thread. - * - * @module ProjectionThreadSessionRepository - */ - -export const ProjectionThreadSession = Schema.Struct({ - threadId: ThreadId, - status: OrchestrationSessionStatus, - providerName: Schema.NullOr(Schema.String), - providerInstanceId: Schema.NullOr(ProviderInstanceId), - runtimeMode: RuntimeMode, - activeTurnId: Schema.NullOr(TurnId), - lastError: Schema.NullOr(Schema.String), - updatedAt: IsoDateTime, -}); -export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; - -export const GetProjectionThreadSessionInput = Schema.Struct({ - threadId: ThreadId, -}); -export type GetProjectionThreadSessionInput = typeof GetProjectionThreadSessionInput.Type; - -export const DeleteProjectionThreadSessionInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadSessionInput = typeof DeleteProjectionThreadSessionInput.Type; - -/** - * ProjectionThreadSessionRepository - Service tag for thread-session persistence. - */ -export class ProjectionThreadSessionRepository extends Context.Service< - ProjectionThreadSessionRepository, - { - /** - * Insert or replace a projected thread-session row. - * - * Upserts by `threadId`. - */ - readonly upsert: ( - row: ProjectionThreadSession, - ) => Effect.Effect; - - /** - * Read projected thread-session state by thread id. - */ - readonly getByThreadId: ( - input: GetProjectionThreadSessionInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Delete projected thread-session state by thread id. - */ - readonly deleteByThreadId: ( - input: DeleteProjectionThreadSessionInput, - ) => Effect.Effect; - } ->()("t3/persistence/ProjectionThreadSessions/ProjectionThreadSessionRepository") {} - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadSessionRow = SqlSchema.void({ - Request: ProjectionThreadSession, - execute: (row) => - sql` - INSERT INTO projection_thread_sessions ( - thread_id, - status, - provider_name, - provider_instance_id, - runtime_mode, - active_turn_id, - last_error, - updated_at - ) - VALUES ( - ${row.threadId}, - ${row.status}, - ${row.providerName}, - ${row.providerInstanceId}, - ${row.runtimeMode}, - ${row.activeTurnId}, - ${row.lastError}, - ${row.updatedAt} - ) - ON CONFLICT (thread_id) - DO UPDATE SET - status = excluded.status, - provider_name = excluded.provider_name, - provider_instance_id = excluded.provider_instance_id, - runtime_mode = excluded.runtime_mode, - active_turn_id = excluded.active_turn_id, - last_error = excluded.last_error, - updated_at = excluded.updated_at - `, - }); - - const getProjectionThreadSessionRow = SqlSchema.findOneOption({ - Request: GetProjectionThreadSessionInput, - Result: ProjectionThreadSession, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - status, - provider_name AS "providerName", - provider_instance_id AS "providerInstanceId", - runtime_mode AS "runtimeMode", - active_turn_id AS "activeTurnId", - last_error AS "lastError", - updated_at AS "updatedAt" - FROM projection_thread_sessions - WHERE thread_id = ${threadId} - `, - }); - - const deleteProjectionThreadSessionRow = SqlSchema.void({ - Request: DeleteProjectionThreadSessionInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_thread_sessions - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadSessionRepository["Service"]["upsert"] = (row) => - upsertProjectionThreadSessionRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadSessionRepository.upsert:query")), - ); - - const getByThreadId: ProjectionThreadSessionRepository["Service"]["getByThreadId"] = (input) => - getProjectionThreadSessionRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadSessionRepository.getByThreadId:query"), - ), - ); - - const deleteByThreadId: ProjectionThreadSessionRepository["Service"]["deleteByThreadId"] = ( - input, - ) => - deleteProjectionThreadSessionRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionThreadSessionRepository.deleteByThreadId:query"), - ), - ); - - return { - upsert, - getByThreadId, - deleteByThreadId, - } satisfies ProjectionThreadSessionRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionThreadSessionRepository, make); diff --git a/apps/server/src/persistence/ProjectionThreads.ts b/apps/server/src/persistence/ProjectionThreads.ts deleted file mode 100644 index 05231dfabdd..00000000000 --- a/apps/server/src/persistence/ProjectionThreads.ts +++ /dev/null @@ -1,273 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - IsoDateTime, - ModelSelection, - NonNegativeInt, - ProjectId, - ProviderInteractionMode, - RuntimeMode, - ThreadId, - TurnId, -} from "@t3tools/contracts"; - -import { type ProjectionRepositoryError, toPersistenceSqlError } from "./Errors.ts"; - -/** - * ProjectionThreadRepository - Projection repository interface for threads. - * - * Owns persistence operations for projected thread records in the - * orchestration read model. - * - * @module ProjectionThreadRepository - */ - -export const ProjectionThread = Schema.Struct({ - threadId: ThreadId, - projectId: ProjectId, - title: Schema.String, - modelSelection: ModelSelection, - runtimeMode: RuntimeMode, - interactionMode: ProviderInteractionMode, - branch: Schema.NullOr(Schema.String), - worktreePath: Schema.NullOr(Schema.String), - latestTurnId: Schema.NullOr(TurnId), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, - archivedAt: Schema.NullOr(IsoDateTime), - latestUserMessageAt: Schema.NullOr(IsoDateTime), - pendingApprovalCount: NonNegativeInt, - pendingUserInputCount: NonNegativeInt, - hasActionableProposedPlan: NonNegativeInt, - deletedAt: Schema.NullOr(IsoDateTime), -}); -export type ProjectionThread = typeof ProjectionThread.Type; - -export const GetProjectionThreadInput = Schema.Struct({ - threadId: ThreadId, -}); -export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type; - -export const DeleteProjectionThreadInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type; - -export const ListProjectionThreadsByProjectInput = Schema.Struct({ - projectId: ProjectId, -}); -export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; - -/** - * ProjectionThreadRepository - Service tag for thread projection persistence. - */ -export class ProjectionThreadRepository extends Context.Service< - ProjectionThreadRepository, - { - /** - * Insert or replace a projected thread row. - * - * Upserts by `threadId`. - */ - readonly upsert: (thread: ProjectionThread) => Effect.Effect; - - /** - * Read a projected thread row by id. - */ - readonly getById: ( - input: GetProjectionThreadInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * List projected threads for a project. - * - * Returned in deterministic creation order. - */ - readonly listByProjectId: ( - input: ListProjectionThreadsByProjectInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Soft-delete a projected thread row by id. - */ - readonly deleteById: ( - input: DeleteProjectionThreadInput, - ) => Effect.Effect; - } ->()("t3/persistence/ProjectionThreads/ProjectionThreadRepository") {} - -const ProjectionThreadDbRow = ProjectionThread.mapFields( - Struct.assign({ - modelSelection: Schema.fromJsonString(ModelSelection), - }), -); -type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionThreadRow = SqlSchema.void({ - Request: ProjectionThread, - execute: (row) => - sql` - INSERT INTO projection_threads ( - thread_id, - project_id, - title, - model_selection_json, - runtime_mode, - interaction_mode, - branch, - worktree_path, - latest_turn_id, - created_at, - updated_at, - archived_at, - latest_user_message_at, - pending_approval_count, - pending_user_input_count, - has_actionable_proposed_plan, - deleted_at - ) - VALUES ( - ${row.threadId}, - ${row.projectId}, - ${row.title}, - ${JSON.stringify(row.modelSelection)}, - ${row.runtimeMode}, - ${row.interactionMode}, - ${row.branch}, - ${row.worktreePath}, - ${row.latestTurnId}, - ${row.createdAt}, - ${row.updatedAt}, - ${row.archivedAt}, - ${row.latestUserMessageAt}, - ${row.pendingApprovalCount}, - ${row.pendingUserInputCount}, - ${row.hasActionableProposedPlan}, - ${row.deletedAt} - ) - ON CONFLICT (thread_id) - DO UPDATE SET - project_id = excluded.project_id, - title = excluded.title, - model_selection_json = excluded.model_selection_json, - runtime_mode = excluded.runtime_mode, - interaction_mode = excluded.interaction_mode, - branch = excluded.branch, - worktree_path = excluded.worktree_path, - latest_turn_id = excluded.latest_turn_id, - created_at = excluded.created_at, - updated_at = excluded.updated_at, - archived_at = excluded.archived_at, - latest_user_message_at = excluded.latest_user_message_at, - pending_approval_count = excluded.pending_approval_count, - pending_user_input_count = excluded.pending_user_input_count, - has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, - deleted_at = excluded.deleted_at - `, - }); - - const getProjectionThreadRow = SqlSchema.findOneOption({ - Request: GetProjectionThreadInput, - Result: ProjectionThreadDbRow, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE thread_id = ${threadId} - `, - }); - - const listProjectionThreadRows = SqlSchema.findAll({ - Request: ListProjectionThreadsByProjectInput, - Result: ProjectionThreadDbRow, - execute: ({ projectId }) => - sql` - SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE project_id = ${projectId} - ORDER BY created_at ASC, thread_id ASC - `, - }); - - const deleteProjectionThreadRow = SqlSchema.void({ - Request: DeleteProjectionThreadInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_threads - WHERE thread_id = ${threadId} - `, - }); - - const upsert: ProjectionThreadRepository["Service"]["upsert"] = (row) => - upsertProjectionThreadRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), - ); - - const getById: ProjectionThreadRepository["Service"]["getById"] = (input) => - getProjectionThreadRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), - ); - - const listByProjectId: ProjectionThreadRepository["Service"]["listByProjectId"] = (input) => - listProjectionThreadRows(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), - ); - - const deleteById: ProjectionThreadRepository["Service"]["deleteById"] = (input) => - deleteProjectionThreadRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), - ); - - return { - upsert, - getById, - listByProjectId, - deleteById, - } satisfies ProjectionThreadRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionThreadRepository, make); diff --git a/apps/server/src/persistence/ProjectionTurns.ts b/apps/server/src/persistence/ProjectionTurns.ts deleted file mode 100644 index ab4cc60d652..00000000000 --- a/apps/server/src/persistence/ProjectionTurns.ts +++ /dev/null @@ -1,513 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; - -import { - CheckpointRef, - IsoDateTime, - MessageId, - NonNegativeInt, - OrchestrationCheckpointFile, - OrchestrationCheckpointStatus, - OrchestrationProposedPlanId, - ThreadId, - TurnId, -} from "@t3tools/contracts"; - -import { - type ProjectionRepositoryError, - toPersistenceDecodeError, - toPersistenceSqlError, -} from "./Errors.ts"; - -/** - * ProjectionTurnRepository - Projection repository interface for unified turn state. - * - * Owns persistence operations for pending starts, running/completed turn lifecycle, - * and checkpoint metadata in a single projection table. - * - * @module ProjectionTurnRepository - */ - -export const ProjectionTurnState = Schema.Literals([ - "pending", - "running", - "interrupted", - "completed", - "error", -]); -export type ProjectionTurnState = typeof ProjectionTurnState.Type; - -export const ProjectionTurn = Schema.Struct({ - threadId: ThreadId, - turnId: Schema.NullOr(TurnId), - pendingMessageId: Schema.NullOr(MessageId), - sourceProposedPlanThreadId: Schema.NullOr(ThreadId), - sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), - assistantMessageId: Schema.NullOr(MessageId), - state: ProjectionTurnState, - requestedAt: IsoDateTime, - startedAt: Schema.NullOr(IsoDateTime), - completedAt: Schema.NullOr(IsoDateTime), - checkpointTurnCount: Schema.NullOr(NonNegativeInt), - checkpointRef: Schema.NullOr(CheckpointRef), - checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), - checkpointFiles: Schema.Array(OrchestrationCheckpointFile), -}); -export type ProjectionTurn = typeof ProjectionTurn.Type; - -export const ProjectionTurnById = Schema.Struct({ - threadId: ThreadId, - turnId: TurnId, - pendingMessageId: Schema.NullOr(MessageId), - sourceProposedPlanThreadId: Schema.NullOr(ThreadId), - sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), - assistantMessageId: Schema.NullOr(MessageId), - state: ProjectionTurnState, - requestedAt: IsoDateTime, - startedAt: Schema.NullOr(IsoDateTime), - completedAt: Schema.NullOr(IsoDateTime), - checkpointTurnCount: Schema.NullOr(NonNegativeInt), - checkpointRef: Schema.NullOr(CheckpointRef), - checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), - checkpointFiles: Schema.Array(OrchestrationCheckpointFile), -}); -export type ProjectionTurnById = typeof ProjectionTurnById.Type; - -export const ProjectionPendingTurnStart = Schema.Struct({ - threadId: ThreadId, - messageId: MessageId, - sourceProposedPlanThreadId: Schema.NullOr(ThreadId), - sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), - requestedAt: IsoDateTime, -}); -export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type; - -export const ListProjectionTurnsByThreadInput = Schema.Struct({ - threadId: ThreadId, -}); -export type ListProjectionTurnsByThreadInput = typeof ListProjectionTurnsByThreadInput.Type; - -export const GetProjectionTurnByTurnIdInput = Schema.Struct({ - threadId: ThreadId, - turnId: TurnId, -}); -export type GetProjectionTurnByTurnIdInput = typeof GetProjectionTurnByTurnIdInput.Type; - -export const GetProjectionPendingTurnStartInput = Schema.Struct({ - threadId: ThreadId, -}); -export type GetProjectionPendingTurnStartInput = typeof GetProjectionPendingTurnStartInput.Type; - -export const DeleteProjectionTurnsByThreadInput = Schema.Struct({ - threadId: ThreadId, -}); -export type DeleteProjectionTurnsByThreadInput = typeof DeleteProjectionTurnsByThreadInput.Type; - -export const ClearCheckpointTurnConflictInput = Schema.Struct({ - threadId: ThreadId, - turnId: TurnId, - checkpointTurnCount: NonNegativeInt, -}); -export type ClearCheckpointTurnConflictInput = typeof ClearCheckpointTurnConflictInput.Type; - -export class ProjectionTurnRepository extends Context.Service< - ProjectionTurnRepository, - { - /** - * Inserts or updates the canonical row for a concrete `{threadId, turnId}` turn lifecycle state. - */ - readonly upsertByTurnId: ( - row: ProjectionTurnById, - ) => Effect.Effect; - - /** - * Replaces any existing pending-start placeholder rows for a thread with exactly one latest pending-start row. - */ - readonly replacePendingTurnStart: ( - row: ProjectionPendingTurnStart, - ) => Effect.Effect; - - /** - * Returns the newest pending-start placeholder for a thread; this is expected to be at most one row after replacement writes. - */ - readonly getPendingTurnStartByThreadId: ( - input: GetProjectionPendingTurnStartInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Deletes only pending-start placeholder rows (`turnId = null`) for a thread and leaves concrete turn rows untouched. - */ - readonly deletePendingTurnStartByThreadId: ( - input: GetProjectionPendingTurnStartInput, - ) => Effect.Effect; - - /** - * Lists all projection rows for a thread, including pending placeholders, with checkpoint rows ordered before non-checkpoint rows. - */ - readonly listByThreadId: ( - input: ListProjectionTurnsByThreadInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Looks up a concrete turn row by `{threadId, turnId}` and never returns pending placeholder rows. - */ - readonly getByTurnId: ( - input: GetProjectionTurnByTurnIdInput, - ) => Effect.Effect, ProjectionRepositoryError>; - - /** - * Clears checkpoint fields on conflicting rows that reuse the same checkpoint turn count in a thread, excluding the provided turn. - */ - readonly clearCheckpointTurnConflict: ( - input: ClearCheckpointTurnConflictInput, - ) => Effect.Effect; - - /** - * Hard-deletes all projection rows for a thread, including pending-start placeholders and checkpoint metadata rows. - */ - readonly deleteByThreadId: ( - input: DeleteProjectionTurnsByThreadInput, - ) => Effect.Effect; - } ->()("t3/persistence/ProjectionTurns/ProjectionTurnRepository") {} - -const ProjectionTurnDbRowSchema = ProjectionTurn.mapFields( - Struct.assign({ - checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), - }), -); - -const ProjectionTurnByIdDbRowSchema = ProjectionTurnById.mapFields( - Struct.assign({ - checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), - }), -); - -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { - return (cause: unknown) => - Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); -} - -export const make = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertProjectionTurnById = SqlSchema.void({ - Request: ProjectionTurnByIdDbRowSchema, - execute: (row) => - sql` - INSERT INTO projection_turns ( - thread_id, - turn_id, - pending_message_id, - source_proposed_plan_thread_id, - source_proposed_plan_id, - assistant_message_id, - state, - requested_at, - started_at, - completed_at, - checkpoint_turn_count, - checkpoint_ref, - checkpoint_status, - checkpoint_files_json - ) - VALUES ( - ${row.threadId}, - ${row.turnId}, - ${row.pendingMessageId}, - ${row.sourceProposedPlanThreadId}, - ${row.sourceProposedPlanId}, - ${row.assistantMessageId}, - ${row.state}, - ${row.requestedAt}, - ${row.startedAt}, - ${row.completedAt}, - ${row.checkpointTurnCount}, - ${row.checkpointRef}, - ${row.checkpointStatus}, - ${row.checkpointFiles} - ) - ON CONFLICT (thread_id, turn_id) - DO UPDATE SET - pending_message_id = excluded.pending_message_id, - source_proposed_plan_thread_id = excluded.source_proposed_plan_thread_id, - source_proposed_plan_id = excluded.source_proposed_plan_id, - assistant_message_id = excluded.assistant_message_id, - state = excluded.state, - requested_at = excluded.requested_at, - started_at = excluded.started_at, - completed_at = excluded.completed_at, - checkpoint_turn_count = excluded.checkpoint_turn_count, - checkpoint_ref = excluded.checkpoint_ref, - checkpoint_status = excluded.checkpoint_status, - checkpoint_files_json = excluded.checkpoint_files_json - `, - }); - - const clearPendingProjectionTurnsByThread = SqlSchema.void({ - Request: DeleteProjectionTurnsByThreadInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id IS NULL - AND state = 'pending' - AND checkpoint_turn_count IS NULL - `, - }); - - const insertPendingProjectionTurn = SqlSchema.void({ - Request: ProjectionPendingTurnStart, - execute: (row) => - sql` - INSERT INTO projection_turns ( - thread_id, - turn_id, - pending_message_id, - source_proposed_plan_thread_id, - source_proposed_plan_id, - assistant_message_id, - state, - requested_at, - started_at, - completed_at, - checkpoint_turn_count, - checkpoint_ref, - checkpoint_status, - checkpoint_files_json - ) - VALUES ( - ${row.threadId}, - NULL, - ${row.messageId}, - ${row.sourceProposedPlanThreadId}, - ${row.sourceProposedPlanId}, - NULL, - 'pending', - ${row.requestedAt}, - NULL, - NULL, - NULL, - NULL, - NULL, - '[]' - ) - `, - }); - - const getPendingProjectionTurn = SqlSchema.findOneOption({ - Request: GetProjectionPendingTurnStartInput, - Result: ProjectionPendingTurnStart, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - pending_message_id AS "messageId", - source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", - source_proposed_plan_id AS "sourceProposedPlanId", - requested_at AS "requestedAt" - FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id IS NULL - AND state = 'pending' - AND pending_message_id IS NOT NULL - AND checkpoint_turn_count IS NULL - ORDER BY requested_at DESC - LIMIT 1 - `, - }); - - const listProjectionTurnsByThread = SqlSchema.findAll({ - Request: ListProjectionTurnsByThreadInput, - Result: ProjectionTurnDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - pending_message_id AS "pendingMessageId", - source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", - source_proposed_plan_id AS "sourceProposedPlanId", - assistant_message_id AS "assistantMessageId", - state, - requested_at AS "requestedAt", - started_at AS "startedAt", - completed_at AS "completedAt", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "checkpointStatus", - checkpoint_files_json AS "checkpointFiles" - FROM projection_turns - WHERE thread_id = ${threadId} - ORDER BY - CASE - WHEN checkpoint_turn_count IS NULL THEN 1 - ELSE 0 - END ASC, - checkpoint_turn_count ASC, - requested_at ASC, - turn_id ASC - `, - }); - - const getProjectionTurnByTurnId = SqlSchema.findOneOption({ - Request: GetProjectionTurnByTurnIdInput, - Result: ProjectionTurnByIdDbRowSchema, - execute: ({ threadId, turnId }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - pending_message_id AS "pendingMessageId", - source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", - source_proposed_plan_id AS "sourceProposedPlanId", - assistant_message_id AS "assistantMessageId", - state, - requested_at AS "requestedAt", - started_at AS "startedAt", - completed_at AS "completedAt", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "checkpointStatus", - checkpoint_files_json AS "checkpointFiles" - FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id = ${turnId} - LIMIT 1 - `, - }); - - const clearCheckpointTurnConflictRow = SqlSchema.void({ - Request: ClearCheckpointTurnConflictInput, - execute: ({ threadId, turnId, checkpointTurnCount }) => - sql` - UPDATE projection_turns - SET - checkpoint_turn_count = NULL, - checkpoint_ref = NULL, - checkpoint_status = NULL, - checkpoint_files_json = '[]' - WHERE thread_id = ${threadId} - AND checkpoint_turn_count = ${checkpointTurnCount} - AND (turn_id IS NULL OR turn_id <> ${turnId}) - `, - }); - - const deleteProjectionTurnsByThread = SqlSchema.void({ - Request: DeleteProjectionTurnsByThreadInput, - execute: ({ threadId }) => - sql` - DELETE FROM projection_turns - WHERE thread_id = ${threadId} - `, - }); - - const upsertByTurnId: ProjectionTurnRepository["Service"]["upsertByTurnId"] = (row) => - upsertProjectionTurnById(row).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionTurnRepository.upsertByTurnId:query", - "ProjectionTurnRepository.upsertByTurnId:encodeRequest", - ), - ), - ); - - const replacePendingTurnStart: ProjectionTurnRepository["Service"]["replacePendingTurnStart"] = ( - row, - ) => - sql - .withTransaction( - clearPendingProjectionTurnsByThread({ threadId: row.threadId }).pipe( - Effect.flatMap(() => insertPendingProjectionTurn(row)), - ), - ) - .pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionTurnRepository.replacePendingTurnStart:query", - "ProjectionTurnRepository.replacePendingTurnStart:encodeRequest", - ), - ), - ); - - const getPendingTurnStartByThreadId: ProjectionTurnRepository["Service"]["getPendingTurnStartByThreadId"] = - (input) => - getPendingProjectionTurn(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionTurnRepository.getPendingTurnStartByThreadId:query", - "ProjectionTurnRepository.getPendingTurnStartByThreadId:decodeRow", - ), - ), - ); - - const deletePendingTurnStartByThreadId: ProjectionTurnRepository["Service"]["deletePendingTurnStartByThreadId"] = - (input) => - clearPendingProjectionTurnsByThread(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionTurnRepository.deletePendingTurnStartByThreadId:query"), - ), - ); - - const listByThreadId: ProjectionTurnRepository["Service"]["listByThreadId"] = (input) => - listProjectionTurnsByThread(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionTurnRepository.listByThreadId:query", - "ProjectionTurnRepository.listByThreadId:decodeRows", - ), - ), - Effect.map((rows) => rows as ReadonlyArray>), - ); - - const getByTurnId: ProjectionTurnRepository["Service"]["getByTurnId"] = (input) => - getProjectionTurnByTurnId(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionTurnRepository.getByTurnId:query", - "ProjectionTurnRepository.getByTurnId:decodeRow", - ), - ), - Effect.flatMap((rowOption) => - Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - Effect.succeed(Option.some(row as Schema.Schema.Type)), - }), - ), - ); - - const clearCheckpointTurnConflict: ProjectionTurnRepository["Service"]["clearCheckpointTurnConflict"] = - (input) => - clearCheckpointTurnConflictRow(input).pipe( - Effect.mapError( - toPersistenceSqlError("ProjectionTurnRepository.clearCheckpointTurnConflict:query"), - ), - ); - - const deleteByThreadId: ProjectionTurnRepository["Service"]["deleteByThreadId"] = (input) => - deleteProjectionTurnsByThread(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionTurnRepository.deleteByThreadId:query")), - ); - - return { - upsertByTurnId, - replacePendingTurnStart, - getPendingTurnStartByThreadId, - deletePendingTurnStartByThreadId, - listByThreadId, - getByTurnId, - clearCheckpointTurnConflict, - deleteByThreadId, - } satisfies ProjectionTurnRepository["Service"]; -}); - -export const layer = Layer.effect(ProjectionTurnRepository, make); diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index 96a000b1402..1498984827e 100644 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -1 +1,71 @@ -export { OrchestrationCommandReceiptRepository } from "../OrchestrationCommandReceipts.ts"; +/** + * OrchestrationCommandReceiptRepository - Repository interface for command receipts. + * + * Owns persistence operations for deduplication and status tracking of + * orchestration command handling. + * + * @module OrchestrationCommandReceiptRepository + */ +import { + CommandId, + IsoDateTime, + NonNegativeInt, + OrchestrationAggregateKind, + OrchestrationCommandReceiptStatus, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; + +export const OrchestrationCommandReceipt = Schema.Struct({ + commandId: CommandId, + aggregateKind: OrchestrationAggregateKind, + aggregateId: Schema.Union([ProjectId, ThreadId]), + acceptedAt: IsoDateTime, + resultSequence: NonNegativeInt, + status: OrchestrationCommandReceiptStatus, + error: Schema.NullOr(Schema.String), +}); +export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; + +export const GetByCommandIdInput = Schema.Struct({ + commandId: CommandId, +}); +export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; + +/** + * OrchestrationCommandReceiptRepositoryShape - Service API for command receipts. + */ +export interface OrchestrationCommandReceiptRepositoryShape { + /** + * Insert or replace a command receipt row. + * + * Upserts by `commandId` for idempotent command-result tracking. + */ + readonly upsert: ( + receipt: OrchestrationCommandReceipt, + ) => Effect.Effect; + + /** + * Read a command receipt by command id. + */ + readonly getByCommandId: ( + input: GetByCommandIdInput, + ) => Effect.Effect< + Option.Option, + OrchestrationCommandReceiptRepositoryError + >; +} + +/** + * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. + */ +export class OrchestrationCommandReceiptRepository extends Context.Service< + OrchestrationCommandReceiptRepository, + OrchestrationCommandReceiptRepositoryShape +>()("t3/persistence/Services/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index e5cbf0276c2..8b465e7713e 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -1,10 +1,71 @@ -import type * as OrchestrationEventStoreModule from "../OrchestrationEventStore.ts"; +/** + * OrchestrationEventStore - Event store interface for orchestration events. + * + * Owns durable append/replay access to the orchestration event stream. It does + * not reduce events into read models or apply command validation rules. + * + * Uses Effect `Context.Service` for dependency injection and exposes typed + * persistence/decode errors for event append and replay operations. + * + * @module OrchestrationEventStore + */ +import { OrchestrationEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +import type { OrchestrationEventStoreError } from "../Errors.ts"; + +/** + * OrchestrationEventStoreShape - Service API for orchestration event persistence. + */ +export interface OrchestrationEventStoreShape { + /** + * Persist a new orchestration event. + * + * @param event - Event payload without sequence (assigned by storage). + * @returns Effect containing the stored event with assigned sequence. + * + * Actor kind is inferred from command/metadata before persistence. + */ + readonly append: ( + event: Omit, + ) => Effect.Effect; + + /** + * Replay events after the provided sequence. + * + * @param sequenceExclusive - Sequence cursor (exclusive). + * @param limit - Maximum number of events to emit. + * @returns Stream containing ordered events. + * + * Reads in fixed-size pages and normalizes non-integer/negative limits. + */ + readonly readFromSequence: ( + sequenceExclusive: number, + limit?: number, + ) => Stream.Stream; -export { OrchestrationEventStore } from "../OrchestrationEventStore.ts"; + /** + * Read all events from the beginning of the stream. + * + * @returns Stream containing all stored events. + */ + readonly readAll: () => Stream.Stream; +} /** - * @deprecated Compatibility alias for the excluded orchestration tests only. - * In-scope consumers must use `OrchestrationEventStore["Service"]`. + * OrchestrationEventStore - Service tag for orchestration event persistence. + * + * @example + * ```ts + * const program = Effect.gen(function* () { + * const events = yield* OrchestrationEventStore + * return yield* Stream.runCollect(events.readAll()) + * }) + * ``` */ -export type OrchestrationEventStoreShape = - OrchestrationEventStoreModule.OrchestrationEventStore["Service"]; +export class OrchestrationEventStore extends Context.Service< + OrchestrationEventStore, + OrchestrationEventStoreShape +>()("t3/persistence/Services/OrchestrationEventStore") {} diff --git a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts index 4b6514e451b..7796ebec2a8 100644 --- a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts @@ -1,5 +1,95 @@ /** - * @deprecated Compatibility exports for the excluded orchestration modules and integration - * harness. In-scope consumers must import the canonical module namespace. + * ProjectionCheckpointRepository - Projection repository interface for checkpoints. + * + * Owns persistence operations for projected checkpoint summaries in thread + * timelines. + * + * @module ProjectionCheckpointRepository */ -export { ProjectionCheckpoint, ProjectionCheckpointRepository } from "../ProjectionCheckpoints.ts"; +import { + CheckpointRef, + IsoDateTime, + MessageId, + NonNegativeInt, + OrchestrationCheckpointFile, + OrchestrationCheckpointStatus, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Context from "effect/Context"; +import * as Schema from "effect/Schema"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionCheckpoint = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + checkpointTurnCount: NonNegativeInt, + checkpointRef: CheckpointRef, + status: OrchestrationCheckpointStatus, + files: Schema.Array(OrchestrationCheckpointFile), + assistantMessageId: Schema.NullOr(MessageId), + completedAt: IsoDateTime, +}); +export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type; + +export const ListByThreadIdInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListByThreadIdInput = typeof ListByThreadIdInput.Type; + +export const GetByThreadAndTurnCountInput = Schema.Struct({ + threadId: ThreadId, + checkpointTurnCount: NonNegativeInt, +}); +export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type; + +export const DeleteByThreadIdInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type; + +/** + * ProjectionCheckpointRepositoryShape - Service API for projected checkpoints. + */ +export interface ProjectionCheckpointRepositoryShape { + /** + * Insert or replace a projected checkpoint row. + * + * Upserts by composite key `(threadId, checkpointTurnCount)`. + */ + readonly upsert: (row: ProjectionCheckpoint) => Effect.Effect; + + /** + * List projected checkpoints for a thread. + * + * Returned in ascending checkpoint turn-count order. + */ + readonly listByThreadId: ( + input: ListByThreadIdInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a projected checkpoint by thread and turn-count key. + */ + readonly getByThreadAndTurnCount: ( + input: GetByThreadAndTurnCountInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete projected checkpoint rows by thread. + */ + readonly deleteByThreadId: ( + input: DeleteByThreadIdInput, + ) => Effect.Effect; +} + +/** + * ProjectionCheckpointRepository - Service tag for checkpoint projection persistence. + */ +export class ProjectionCheckpointRepository extends Context.Service< + ProjectionCheckpointRepository, + ProjectionCheckpointRepositoryShape +>()("t3/persistence/Services/ProjectionCheckpoints/ProjectionCheckpointRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts index 87005833d3c..967e6da9d3a 100644 --- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -1 +1,93 @@ -export { ProjectionPendingApprovalRepository } from "../ProjectionPendingApprovals.ts"; +/** + * ProjectionPendingApprovalRepository - Repository interface for pending approvals. + * + * Owns persistence operations for projected approval requests awaiting user + * decisions. + * + * @module ProjectionPendingApprovalRepository + */ +import { + ApprovalRequestId, + IsoDateTime, + ProjectionPendingApprovalDecision, + ProjectionPendingApprovalStatus, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionPendingApproval = Schema.Struct({ + requestId: ApprovalRequestId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + status: ProjectionPendingApprovalStatus, + decision: ProjectionPendingApprovalDecision, + createdAt: IsoDateTime, + resolvedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionPendingApproval = typeof ProjectionPendingApproval.Type; + +export const ListProjectionPendingApprovalsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionPendingApprovalsInput = typeof ListProjectionPendingApprovalsInput.Type; + +export const GetProjectionPendingApprovalInput = Schema.Struct({ + requestId: ApprovalRequestId, +}); +export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type; + +export const DeleteProjectionPendingApprovalInput = Schema.Struct({ + requestId: ApprovalRequestId, +}); +export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type; + +/** + * ProjectionPendingApprovalRepositoryShape - Service API for pending approvals. + */ +export interface ProjectionPendingApprovalRepositoryShape { + /** + * Insert or replace a projected pending approval row. + * + * Upserts by `requestId`. + */ + readonly upsert: ( + row: ProjectionPendingApproval, + ) => Effect.Effect; + + /** + * List pending approvals for a thread. + * + * Returned in ascending creation order. + */ + readonly listByThreadId: ( + input: ListProjectionPendingApprovalsInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a pending approval row by request id. + */ + readonly getByRequestId: ( + input: GetProjectionPendingApprovalInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete a pending approval row by request id. + */ + readonly deleteByRequestId: ( + input: DeleteProjectionPendingApprovalInput, + ) => Effect.Effect; +} + +/** + * ProjectionPendingApprovalRepository - Service tag for pending approval persistence. + */ +export class ProjectionPendingApprovalRepository extends Context.Service< + ProjectionPendingApprovalRepository, + ProjectionPendingApprovalRepositoryShape +>()("t3/persistence/Services/ProjectionPendingApprovals/ProjectionPendingApprovalRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 7e1d9eb45a8..5632205a269 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -1 +1,81 @@ -export { ProjectionProject, ProjectionProjectRepository } from "../ProjectionProjects.ts"; +/** + * ProjectionProjectRepository - Projection repository interface for projects. + * + * Owns persistence operations for project rows in the orchestration projection + * read model. + * + * @module ProjectionProjectRepository + */ +import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionProject = Schema.Struct({ + projectId: ProjectId, + title: Schema.String, + workspaceRoot: Schema.String, + defaultModelSelection: Schema.NullOr(ModelSelection), + scripts: Schema.Array(ProjectScript), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionProject = typeof ProjectionProject.Type; + +export const GetProjectionProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type; + +export const DeleteProjectionProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type; + +/** + * ProjectionProjectRepositoryShape - Service API for projected project records. + */ +export interface ProjectionProjectRepositoryShape { + /** + * Insert or replace a projected project row. + * + * Upserts by `projectId` and persists scripts through JSON encoding. + */ + readonly upsert: (row: ProjectionProject) => Effect.Effect; + + /** + * Read a projected project row by id. + */ + readonly getById: ( + input: GetProjectionProjectInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List all projected project rows. + * + * Returned in deterministic creation order. + */ + readonly listAll: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + + /** + * Soft-delete a projected project row by id. + */ + readonly deleteById: ( + input: DeleteProjectionProjectInput, + ) => Effect.Effect; +} + +/** + * ProjectionProjectRepository - Service tag for project projection persistence. + */ +export class ProjectionProjectRepository extends Context.Service< + ProjectionProjectRepository, + ProjectionProjectRepositoryShape +>()("t3/persistence/Services/ProjectionProjects/ProjectionProjectRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionState.ts b/apps/server/src/persistence/Services/ProjectionState.ts index 774ade7e5fa..ba1ca151736 100644 --- a/apps/server/src/persistence/Services/ProjectionState.ts +++ b/apps/server/src/persistence/Services/ProjectionState.ts @@ -1 +1,66 @@ -export { ProjectionState, ProjectionStateRepository } from "../ProjectionState.ts"; +/** + * ProjectionStateRepository - Projection repository interface for projector cursors. + * + * Owns persistence operations for projection cursor state used to resume + * incremental event projection. + * + * @module ProjectionStateRepository + */ +import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionState = Schema.Struct({ + projector: Schema.String, + lastAppliedSequence: NonNegativeInt, + updatedAt: IsoDateTime, +}); +export type ProjectionState = typeof ProjectionState.Type; + +export const GetProjectionStateInput = Schema.Struct({ + projector: Schema.String, +}); +export type GetProjectionStateInput = typeof GetProjectionStateInput.Type; + +/** + * ProjectionStateRepositoryShape - Service API for projector state records. + */ +export interface ProjectionStateRepositoryShape { + /** + * Insert or replace a projection cursor row. + * + * Upserts by projector name. + */ + readonly upsert: (row: ProjectionState) => Effect.Effect; + + /** + * Read projection cursor state for a projector key. + */ + readonly getByProjector: ( + input: GetProjectionStateInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List all projector cursor rows. + */ + readonly listAll: () => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read the minimum applied sequence across all projectors. + * + * Returns `null` when no projector state rows exist. + */ + readonly minLastAppliedSequence: () => Effect.Effect; +} + +/** + * ProjectionStateRepository - Service tag for projection cursor persistence. + */ +export class ProjectionStateRepository extends Context.Service< + ProjectionStateRepository, + ProjectionStateRepositoryShape +>()("t3/persistence/Services/ProjectionState/ProjectionStateRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 1bdb2f7dcf6..47cb6073c47 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -1,4 +1,84 @@ -export { - ProjectionThreadActivity, +/** + * ProjectionThreadActivityRepository - Projection repository interface for thread activity. + * + * Owns persistence operations for activity timeline entries projected from + * orchestration events. + * + * @module ProjectionThreadActivityRepository + */ +import { + EventId, + IsoDateTime, + NonNegativeInt, + OrchestrationThreadActivityTone, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadActivity = Schema.Struct({ + activityId: EventId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + tone: OrchestrationThreadActivityTone, + kind: Schema.String, + summary: Schema.String, + payload: Schema.Unknown, + sequence: Schema.optional(NonNegativeInt), + createdAt: IsoDateTime, +}); +export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; + +export const ListProjectionThreadActivitiesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; + +export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadActivitiesInput = + typeof DeleteProjectionThreadActivitiesInput.Type; + +/** + * ProjectionThreadActivityRepositoryShape - Service API for projected thread activity. + */ +export interface ProjectionThreadActivityRepositoryShape { + /** + * Insert or replace a projected thread activity row. + * + * Upserts by `activityId` and JSON-encodes payload. + */ + readonly upsert: ( + row: ProjectionThreadActivity, + ) => Effect.Effect; + + /** + * List projected thread activity rows for a thread. + * + * Returned in ascending runtime sequence order (or creation order when + * sequence is unavailable). + */ + readonly listByThreadId: ( + input: ListProjectionThreadActivitiesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete projected thread activity rows by thread. + */ + readonly deleteByThreadId: ( + input: DeleteProjectionThreadActivitiesInput, + ) => Effect.Effect; +} + +/** + * ProjectionThreadActivityRepository - Service tag for thread activity persistence. + */ +export class ProjectionThreadActivityRepository extends Context.Service< ProjectionThreadActivityRepository, -} from "../ProjectionThreadActivities.ts"; + ProjectionThreadActivityRepositoryShape +>()("t3/persistence/Services/ProjectionThreadActivities/ProjectionThreadActivityRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index fa90abc853d..d50ff320256 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -1,4 +1,95 @@ -export { - ProjectionThreadMessage, +/** + * ProjectionThreadMessageRepository - Projection repository interface for messages. + * + * Owns persistence operations for projected thread messages rendered in the + * orchestration read model. + * + * @module ProjectionThreadMessageRepository + */ +import { + ChatAttachment, + MessageId, + OrchestrationMessageRole, + ThreadId, + TurnId, + IsoDateTime, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Option from "effect/Option"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadMessage = Schema.Struct({ + messageId: MessageId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + role: OrchestrationMessageRole, + text: Schema.String, + attachments: Schema.optional(Schema.Array(ChatAttachment)), + isStreaming: Schema.Boolean, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; + +export const ListProjectionThreadMessagesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; + +export const GetProjectionThreadMessageInput = Schema.Struct({ + messageId: MessageId, +}); +export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; + +export const DeleteProjectionThreadMessagesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type; + +/** + * ProjectionThreadMessageRepositoryShape - Service API for projected thread messages. + */ +export interface ProjectionThreadMessageRepositoryShape { + /** + * Insert or replace a projected thread message row. + * + * Upserts by `messageId`. + */ + readonly upsert: ( + message: ProjectionThreadMessage, + ) => Effect.Effect; + + /** + * Read a projected thread message by id. + */ + readonly getByMessageId: ( + input: GetProjectionThreadMessageInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List projected thread messages for a thread. + * + * Returned in ascending creation order. + */ + readonly listByThreadId: ( + input: ListProjectionThreadMessagesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete projected thread messages by thread. + */ + readonly deleteByThreadId: ( + input: DeleteProjectionThreadMessagesInput, + ) => Effect.Effect; +} + +/** + * ProjectionThreadMessageRepository - Service tag for message projection persistence. + */ +export class ProjectionThreadMessageRepository extends Context.Service< ProjectionThreadMessageRepository, -} from "../ProjectionThreadMessages.ts"; + ProjectionThreadMessageRepositoryShape +>()("t3/persistence/Services/ProjectionThreadMessages/ProjectionThreadMessageRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index 5ab37bf0d3b..b4bc2bcc328 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -1,4 +1,55 @@ -export { - ProjectionThreadProposedPlan, +import { + IsoDateTime, + OrchestrationProposedPlanId, + ThreadId, + TrimmedNonEmptyString, + TurnId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadProposedPlan = Schema.Struct({ + planId: OrchestrationProposedPlanId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + planMarkdown: TrimmedNonEmptyString, + implementedAt: Schema.NullOr(IsoDateTime), + implementationThreadId: Schema.NullOr(ThreadId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectionThreadProposedPlan = typeof ProjectionThreadProposedPlan.Type; + +export const ListProjectionThreadProposedPlansInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadProposedPlansInput = + typeof ListProjectionThreadProposedPlansInput.Type; + +export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadProposedPlansInput = + typeof DeleteProjectionThreadProposedPlansInput.Type; + +export interface ProjectionThreadProposedPlanRepositoryShape { + readonly upsert: ( + proposedPlan: ProjectionThreadProposedPlan, + ) => Effect.Effect; + readonly listByThreadId: ( + input: ListProjectionThreadProposedPlansInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByThreadId: ( + input: DeleteProjectionThreadProposedPlansInput, + ) => Effect.Effect; +} + +export class ProjectionThreadProposedPlanRepository extends Context.Service< ProjectionThreadProposedPlanRepository, -} from "../ProjectionThreadProposedPlans.ts"; + ProjectionThreadProposedPlanRepositoryShape +>()( + "t3/persistence/Services/ProjectionThreadProposedPlans/ProjectionThreadProposedPlanRepository", +) {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 7898c92a1fd..7cecac33eb6 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -1,4 +1,78 @@ -export { - ProjectionThreadSession, +/** + * ProjectionThreadSessionRepository - Repository interface for thread sessions. + * + * Owns persistence operations for projected provider-session linkage and + * runtime status for each thread. + * + * @module ProjectionThreadSessionRepository + */ +import { + RuntimeMode, + IsoDateTime, + OrchestrationSessionStatus, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadSession = Schema.Struct({ + threadId: ThreadId, + status: OrchestrationSessionStatus, + providerName: Schema.NullOr(Schema.String), + providerInstanceId: Schema.NullOr(ProviderInstanceId), + runtimeMode: RuntimeMode, + activeTurnId: Schema.NullOr(TurnId), + lastError: Schema.NullOr(Schema.String), + updatedAt: IsoDateTime, +}); +export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; + +export const GetProjectionThreadSessionInput = Schema.Struct({ + threadId: ThreadId, +}); +export type GetProjectionThreadSessionInput = typeof GetProjectionThreadSessionInput.Type; + +export const DeleteProjectionThreadSessionInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadSessionInput = typeof DeleteProjectionThreadSessionInput.Type; + +/** + * ProjectionThreadSessionRepositoryShape - Service API for projected thread sessions. + */ +export interface ProjectionThreadSessionRepositoryShape { + /** + * Insert or replace a projected thread-session row. + * + * Upserts by `threadId`. + */ + readonly upsert: (row: ProjectionThreadSession) => Effect.Effect; + + /** + * Read projected thread-session state by thread id. + */ + readonly getByThreadId: ( + input: GetProjectionThreadSessionInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Delete projected thread-session state by thread id. + */ + readonly deleteByThreadId: ( + input: DeleteProjectionThreadSessionInput, + ) => Effect.Effect; +} + +/** + * ProjectionThreadSessionRepository - Service tag for thread-session persistence. + */ +export class ProjectionThreadSessionRepository extends Context.Service< ProjectionThreadSessionRepository, -} from "../ProjectionThreadSessions.ts"; + ProjectionThreadSessionRepositoryShape +>()("t3/persistence/Services/ProjectionThreadSessions/ProjectionThreadSessionRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index f417e53fc3c..44fdc147a4a 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -1 +1,103 @@ -export { ProjectionThread, ProjectionThreadRepository } from "../ProjectionThreads.ts"; +/** + * ProjectionThreadRepository - Projection repository interface for threads. + * + * Owns persistence operations for projected thread records in the + * orchestration read model. + * + * @module ProjectionThreadRepository + */ +import { + IsoDateTime, + ModelSelection, + NonNegativeInt, + ProjectId, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThread = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + title: Schema.String, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + latestTurnId: Schema.NullOr(TurnId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime), + latestUserMessageAt: Schema.NullOr(IsoDateTime), + pendingApprovalCount: NonNegativeInt, + pendingUserInputCount: NonNegativeInt, + hasActionableProposedPlan: NonNegativeInt, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionThread = typeof ProjectionThread.Type; + +export const GetProjectionThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type; + +export const DeleteProjectionThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type; + +export const ListProjectionThreadsByProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; + +/** + * ProjectionThreadRepositoryShape - Service API for projected thread records. + */ +export interface ProjectionThreadRepositoryShape { + /** + * Insert or replace a projected thread row. + * + * Upserts by `threadId`. + */ + readonly upsert: (thread: ProjectionThread) => Effect.Effect; + + /** + * Read a projected thread row by id. + */ + readonly getById: ( + input: GetProjectionThreadInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * List projected threads for a project. + * + * Returned in deterministic creation order. + */ + readonly listByProjectId: ( + input: ListProjectionThreadsByProjectInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Soft-delete a projected thread row by id. + */ + readonly deleteById: ( + input: DeleteProjectionThreadInput, + ) => Effect.Effect; +} + +/** + * ProjectionThreadRepository - Service tag for thread projection persistence. + */ +export class ProjectionThreadRepository extends Context.Service< + ProjectionThreadRepository, + ProjectionThreadRepositoryShape +>()("t3/persistence/Services/ProjectionThreads/ProjectionThreadRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index 894dfb267ba..f3d5d5e4706 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -1 +1,170 @@ -export { ProjectionTurn, ProjectionTurnRepository } from "../ProjectionTurns.ts"; +/** + * ProjectionTurnRepository - Projection repository interface for unified turn state. + * + * Owns persistence operations for pending starts, running/completed turn lifecycle, + * and checkpoint metadata in a single projection table. + * + * @module ProjectionTurnRepository + */ +import { + CheckpointRef, + IsoDateTime, + MessageId, + NonNegativeInt, + OrchestrationProposedPlanId, + OrchestrationCheckpointFile, + OrchestrationCheckpointStatus, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionTurnState = Schema.Literals([ + "pending", + "running", + "interrupted", + "completed", + "error", +]); +export type ProjectionTurnState = typeof ProjectionTurnState.Type; + +export const ProjectionTurn = Schema.Struct({ + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + pendingMessageId: Schema.NullOr(MessageId), + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), + assistantMessageId: Schema.NullOr(MessageId), + state: ProjectionTurnState, + requestedAt: IsoDateTime, + startedAt: Schema.NullOr(IsoDateTime), + completedAt: Schema.NullOr(IsoDateTime), + checkpointTurnCount: Schema.NullOr(NonNegativeInt), + checkpointRef: Schema.NullOr(CheckpointRef), + checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), + checkpointFiles: Schema.Array(OrchestrationCheckpointFile), +}); +export type ProjectionTurn = typeof ProjectionTurn.Type; + +export const ProjectionTurnById = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + pendingMessageId: Schema.NullOr(MessageId), + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), + assistantMessageId: Schema.NullOr(MessageId), + state: ProjectionTurnState, + requestedAt: IsoDateTime, + startedAt: Schema.NullOr(IsoDateTime), + completedAt: Schema.NullOr(IsoDateTime), + checkpointTurnCount: Schema.NullOr(NonNegativeInt), + checkpointRef: Schema.NullOr(CheckpointRef), + checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), + checkpointFiles: Schema.Array(OrchestrationCheckpointFile), +}); +export type ProjectionTurnById = typeof ProjectionTurnById.Type; + +export const ProjectionPendingTurnStart = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), + requestedAt: IsoDateTime, +}); +export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type; + +export const ListProjectionTurnsByThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionTurnsByThreadInput = typeof ListProjectionTurnsByThreadInput.Type; + +export const GetProjectionTurnByTurnIdInput = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, +}); +export type GetProjectionTurnByTurnIdInput = typeof GetProjectionTurnByTurnIdInput.Type; + +export const GetProjectionPendingTurnStartInput = Schema.Struct({ + threadId: ThreadId, +}); +export type GetProjectionPendingTurnStartInput = typeof GetProjectionPendingTurnStartInput.Type; + +export const DeleteProjectionTurnsByThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionTurnsByThreadInput = typeof DeleteProjectionTurnsByThreadInput.Type; + +export const ClearCheckpointTurnConflictInput = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + checkpointTurnCount: NonNegativeInt, +}); +export type ClearCheckpointTurnConflictInput = typeof ClearCheckpointTurnConflictInput.Type; + +export interface ProjectionTurnRepositoryShape { + /** + * Inserts or updates the canonical row for a concrete `{threadId, turnId}` turn lifecycle state. + */ + readonly upsertByTurnId: ( + row: ProjectionTurnById, + ) => Effect.Effect; + + /** + * Replaces any existing pending-start placeholder rows for a thread with exactly one latest pending-start row. + */ + readonly replacePendingTurnStart: ( + row: ProjectionPendingTurnStart, + ) => Effect.Effect; + + /** + * Returns the newest pending-start placeholder for a thread; this is expected to be at most one row after replacement writes. + */ + readonly getPendingTurnStartByThreadId: ( + input: GetProjectionPendingTurnStartInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Deletes only pending-start placeholder rows (`turnId = null`) for a thread and leaves concrete turn rows untouched. + */ + readonly deletePendingTurnStartByThreadId: ( + input: GetProjectionPendingTurnStartInput, + ) => Effect.Effect; + + /** + * Lists all projection rows for a thread, including pending placeholders, with checkpoint rows ordered before non-checkpoint rows. + */ + readonly listByThreadId: ( + input: ListProjectionTurnsByThreadInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Looks up a concrete turn row by `{threadId, turnId}` and never returns pending placeholder rows. + */ + readonly getByTurnId: ( + input: GetProjectionTurnByTurnIdInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Clears checkpoint fields on conflicting rows that reuse the same checkpoint turn count in a thread, excluding the provided turn. + */ + readonly clearCheckpointTurnConflict: ( + input: ClearCheckpointTurnConflictInput, + ) => Effect.Effect; + + /** + * Hard-deletes all projection rows for a thread, including pending-start placeholders and checkpoint metadata rows. + */ + readonly deleteByThreadId: ( + input: DeleteProjectionTurnsByThreadInput, + ) => Effect.Effect; +} + +export class ProjectionTurnRepository extends Context.Service< + ProjectionTurnRepository, + ProjectionTurnRepositoryShape +>()("t3/persistence/Services/ProjectionTurns/ProjectionTurnRepository") {}